diff --git a/lib/private/repair.php b/lib/private/repair.php index c4f057b53a..d9fd99707e 100644 --- a/lib/private/repair.php +++ b/lib/private/repair.php @@ -11,6 +11,7 @@ namespace OC; use OC\Hooks\BasicEmitter; use OC\Hooks\Emitter; use OC\Repair\AssetCache; +use OC\Repair\CleanTags; use OC\Repair\Collation; use OC\Repair\FillETags; use OC\Repair\InnoDB; @@ -81,7 +82,8 @@ class Repair extends BasicEmitter { new RepairLegacyStorages(\OC::$server->getConfig(), \OC_DB::getConnection()), new RepairConfig(), new AssetCache(), - new FillETags(\OC_DB::getConnection()) + new FillETags(\OC_DB::getConnection()), + new CleanTags(\OC_DB::getConnection()), ); } diff --git a/lib/repair/cleantags.php b/lib/repair/cleantags.php new file mode 100644 index 0000000000..6aa325df0b --- /dev/null +++ b/lib/repair/cleantags.php @@ -0,0 +1,114 @@ + + * This file is licensed under the Affero General Public License version 3 or + * later. + * See the COPYING-README file. + */ + +namespace OC\Repair; + +use OC\DB\Connection; +use OC\Hooks\BasicEmitter; +use OC\RepairStep; + +/** + * Class RepairConfig + * + * @package OC\Repair + */ +class CleanTags extends BasicEmitter implements RepairStep { + + /** @var Connection */ + protected $connection; + + /** + * @param Connection $connection + */ + public function __construct(Connection $connection) { + $this->connection = $connection; + } + + /** + * @return string + */ + public function getName() { + return 'Clean tags and favorites'; + } + + /** + * Updates the configuration after running an update + */ + public function run() { + + // Delete tag entries for deleted files + $this->deleteOrphanEntries( + '%d tags for delete files have been removed.', + '*PREFIX*vcategory_to_object', 'objid', + '*PREFIX*filecache', 'fileid', 'fileid' + ); + + // Delete tag entries for deleted tags + $this->deleteOrphanEntries( + '%d tag entries for deleted tags have been removed.', + '*PREFIX*vcategory_to_object', 'categoryid', + '*PREFIX*vcategory', 'id', 'uid' + ); + + // Delete tags that have no entries + $this->deleteOrphanEntries( + '%d tags with no entries have been removed.', + '*PREFIX*vcategory', 'id', + '*PREFIX*vcategory_to_object', 'categoryid', 'type' + ); + } + + /** + * Deletes all entries from $deleteTable that do not have a matching entry in $sourceTable + * + * A query joins $deleteTable.$deleteId = $sourceTable.$sourceId and checks + * whether $sourceNullColumn is null. If it is null, the entry in $deleteTable + * is being deleted. + * + * @param string $repairInfo + * @param string $deleteTable + * @param string $deleteId + * @param string $sourceTable + * @param string $sourceId + * @param string $sourceNullColumn If this column is null in the source table, + * the entry is deleted in the $deleteTable + */ + protected function deleteOrphanEntries($repairInfo, $deleteTable, $deleteId, $sourceTable, $sourceId, $sourceNullColumn) { + $qb = $this->connection->createQueryBuilder(); + + $qb->select('d.' . $deleteId) + ->from($deleteTable, 'd') + ->leftJoin('d', $sourceTable, 's', 'd.' . $deleteId . ' = s.' . $sourceId) + ->where( + 'd.type = ' . $qb->expr()->literal('files') + ) + ->andWhere( + $qb->expr()->isNull('s.' . $sourceNullColumn) + ); + $result = $qb->execute(); + + $orphanItems = array(); + while ($row = $result->fetch()) { + $orphanItems[] = (int) $row[$deleteId]; + } + + if (!empty($orphanItems)) { + $orphanItemsBatch = array_chunk($orphanItems, 200); + foreach ($orphanItemsBatch as $items) { + $qb->delete($deleteTable) + ->where($qb->expr()->in($deleteId, ':ids')); + $qb->setParameter('ids', $items, \Doctrine\DBAL\Connection::PARAM_INT_ARRAY); + $qb->execute(); + } + } + + if ($repairInfo) { + $this->emit('\OC\Repair', 'info', array(sprintf($repairInfo, sizeof($orphanItems)))); + } + } +} diff --git a/tests/lib/repair/cleantags.php b/tests/lib/repair/cleantags.php new file mode 100644 index 0000000000..29a1a8b432 --- /dev/null +++ b/tests/lib/repair/cleantags.php @@ -0,0 +1,143 @@ + + * This file is licensed under the Affero General Public License version 3 or + * later. + * See the COPYING-README file. + */ + +namespace Test\Repair; + +/** + * Tests for the cleaning the tags tables + * + * @see \OC\Repair\CleanTags + */ +class CleanTags extends \Test\TestCase { + + /** @var \OC\RepairStep */ + private $repair; + + /** @var \Doctrine\DBAL\Connection */ + private $connection; + + /** @var array */ + protected $tagCategories; + + /** @var int */ + protected $createdFile; + + protected function setUp() { + parent::setUp(); + + $this->connection = \OC::$server->getDatabaseConnection(); + $this->repair = new \OC\Repair\CleanTags($this->connection); + } + + protected function tearDown() { + $qb = $this->connection->createQueryBuilder(); + $qb->delete('*PREFIX*vcategory') + ->where('uid = ' . $qb->createNamedParameter('TestRepairCleanTags')) + ->execute(); + + $qb->delete('*PREFIX*vcategory_to_object') + ->where($qb->expr()->in('categoryid', ':ids')); + $qb->setParameter('ids', $this->tagCategories, \Doctrine\DBAL\Connection::PARAM_INT_ARRAY); + $qb->execute(); + + $qb->delete('*PREFIX*filecache') + ->where('fileid = ' . $qb->createNamedParameter($this->createdFile, \PDO::PARAM_INT)) + ->execute(); + + parent::tearDown(); + } + + public function testRun() { + $cat1 = $this->addTagCategory('TestRepairCleanTags', 'files'); // Retained + $cat2 = $this->addTagCategory('TestRepairCleanTags2', 'files'); // Deleted: Category is empty + $cat3 = $this->addTagCategory('TestRepairCleanTags', 'contacts'); // Retained + $file = $this->getFileID(); + + $this->addTagEntry($file, $cat2, 'files'); // Retained + $this->addTagEntry($file + 1, $cat1, 'files'); // Deleted: File is NULL + $this->addTagEntry(9999999, $cat3, 'contacts'); // Retained + $this->addTagEntry($file, $cat3 + 1, 'files'); // Deleted: Category is NULL + + $this->assertEntryCount('*PREFIX*vcategory', 3, 'Assert tag categories count before repair step'); + $this->assertEntryCount('*PREFIX*vcategory_to_object', 4, 'Assert tag entries count before repair step'); + $this->repair->run(); + $this->assertEntryCount('*PREFIX*vcategory', 2, 'Assert tag categories count after repair step'); + $this->assertEntryCount('*PREFIX*vcategory_to_object', 2, 'Assert tag entries count after repair step'); + } + + /** + * @param string $tableName + * @param int $expected + * @param string $message + */ + protected function assertEntryCount($tableName, $expected, $message = '') { + $qb = $this->connection->createQueryBuilder(); + $result = $qb->select('COUNT(*)') + ->from($tableName) + ->execute(); + + $this->assertEquals($expected, $result->fetchColumn(), $message); + } + + /** + * Adds a new tag category to the database + * + * @param string $category + * @param string $type + * @return int + */ + protected function addTagCategory($category, $type) { + $qb = $this->connection->createQueryBuilder(); + $qb->insert('*PREFIX*vcategory') + ->values([ + 'uid' => $qb->createNamedParameter('TestRepairCleanTags'), + 'category' => $qb->createNamedParameter($category), + 'type' => $qb->createNamedParameter($type), + ]) + ->execute(); + + $id = (int) $this->connection->lastInsertId(); + $this->tagCategories[] = $id; + return $id; + } + + /** + * Adds a new tag entry to the database + * @param int $objectId + * @param int $category + * @param string $type + */ + protected function addTagEntry($objectId, $category, $type) { + $qb = $this->connection->createQueryBuilder(); + $qb->insert('*PREFIX*vcategory_to_object') + ->values([ + 'objid' => $qb->createNamedParameter($objectId, \PDO::PARAM_INT), + 'categoryid' => $qb->createNamedParameter($category, \PDO::PARAM_INT), + 'type' => $qb->createNamedParameter($type), + ]) + ->execute(); + } + + /** + * Gets the last fileid from the file cache + * + * @return int + */ + protected function getFileID() { + $qb = $this->connection->createQueryBuilder(); + + // We create a new file entry and delete it after the test again + $qb->insert('*PREFIX*filecache') + ->values([ + 'path' => $qb->createNamedParameter('TestRepairCleanTags'), + ]) + ->execute(); + $this->createdFile = (int) $this->connection->lastInsertId(); + return $this->createdFile; + } +}