diff --git a/src/VersionsCleanup/ChangeSetCleanupTask.php b/src/VersionsCleanup/ChangeSetCleanupTask.php new file mode 100644 index 00000000..46afef41 --- /dev/null +++ b/src/VersionsCleanup/ChangeSetCleanupTask.php @@ -0,0 +1,102 @@ +get('table_name'); + $itemTableRaw = ChangeSetItem::config()->get('table_name'); + $relationTableRaw = $itemTableRaw . '_ReferencedBy'; + $mainTable = sprintf('"%s"', $mainTableRaw); + $itemTable = sprintf('"%s"', $itemTableRaw); + $relationTable = sprintf('"%s"', $relationTableRaw); + $deletionDate = DBDatetime::now() + ->modify(sprintf('- %d days', self::DELETION_LIFETIME)) + ->Rfc2822(); + + $ids = ChangeSet::get() + ->filter(['LastEdited:LessThan' => $deletionDate]) + ->sort('ID', 'ASC') + ->limit(self::DELETION_LIMIT) + ->columnUnique('ID'); + + if (count($ids) === 0) { + echo 'Nothing to delete.' . PHP_EOL; + + return; + } + + $query = SQLDelete::create( + [ + $mainTable, + ], + [ + sprintf($mainTable . '."ID" IN (%s)', DB::placeholders($ids)) => $ids, + ], + [ + $mainTable, + $itemTable, + $relationTable, + ], + ); + + $query + ->addLeftJoin($itemTableRaw, sprintf('%s."ID" = %s."ChangeSetID"', $mainTable, $itemTable)) + ->addLeftJoin($relationTableRaw, sprintf('%s."ID" = %s."ChangeSetItemID"', $itemTable, $relationTable)); + + $result = $query->execute(); + + if ($result === null) { + echo 'Failed to execute deletion.' . PHP_EOL; + + return; + } + + echo sprintf('Deleted %d records.', DB::affected_rows()) . PHP_EOL; + } +} diff --git a/src/VersionsCleanup/CleanupJob.php b/src/VersionsCleanup/CleanupJob.php new file mode 100644 index 00000000..1c62015a --- /dev/null +++ b/src/VersionsCleanup/CleanupJob.php @@ -0,0 +1,66 @@ +versions = $versions; + } + + public function getTitle(): string + { + return 'Delete old version records'; + } + + public function getJobType(): int + { + return QueuedJob::QUEUED; + } + + public function setup(): void + { + $this->remainingVersions = $this->versions; + $this->totalSteps = count($this->versions); + } + + /** + * @throws Exception + */ + public function process(): void + { + $remaining = $this->remainingVersions; + + if (count($remaining) > 0) { + $item = array_shift($remaining); + $id = $item['id']; + $class = $item['class']; + $versions = $item['versions']; + + $count = CleanupService::singleton()->deleteVersions($class, $id, $versions); + $this->addMessage(sprintf('Deleted %d version records', $count)); + + // Mark item as processed + $this->currentStep += 1; + $this->remainingVersions = $remaining; + } + + if (count($this->remainingVersions) > 0) { + return; + } + + $this->isComplete = true; + } +} diff --git a/src/VersionsCleanup/CleanupService.php b/src/VersionsCleanup/CleanupService.php new file mode 100644 index 00000000..1c718e05 --- /dev/null +++ b/src/VersionsCleanup/CleanupService.php @@ -0,0 +1,564 @@ +getBaseClasses(); + + foreach ($classes as $class) { + // Process only one class at a time so we don't exhaust memory + $records = $this->getRecordsForDeletion([$class]); + $versions = $this->getVersionsForDeletion($records); + $this->queueDeletionJobsForVersionGroups($versions); + } + } + + /** + * Get list of valid base classes that we need to process + * + * @return array + */ + public function getBaseClasses(): array + { + $classes = $this->config()->get('base_classes'); + + return array_filter($classes, static function ($class) { + $singleton = DataObject::singleton($class); + + if (!$singleton->hasExtension(Versioned::class)) { + // Skip non-versioned classes as there are no old version records to delete + return false; + } + + if ($class !== $singleton->baseClass()) { + // Skip non-base-class as subclasses are covered automatically + return false; + } + + return true; + }); + } + + /** + * Determine which records have versions that can be deleted + * + * @param array $classes + * @return array + */ + public function getRecordsForDeletion(array $classes): array + { + $keepLimit = (int) $this->config()->get('keep_limit'); + $recordLimit = (int) $this->config()->get('deletion_record_limit'); + $deletionDate = DBDatetime::create_field('Datetime', DBDatetime::now()->Rfc2822()) + ->modify(sprintf('- %d days', $this->config()->get('keep_lifetime'))) + ->Rfc2822(); + $records = []; + + foreach ($classes as $class) { + /** @var DataObject|FluentExtension $singleton */ + $singleton = DataObject::singleton($class); + $isLocalised = $singleton->hasExtension(FluentVersionedExtension::class) + && count($singleton->getLocalisedFields()) > 0; + + $mainTable = $this->getTableNameForClass($class); + $baseTableRaw = $this->getVersionTableName($mainTable); + $baseTable = sprintf('"%s"', $baseTableRaw); + $query = SQLSelect::create( + [ + // We need to identify the records which have old versions ready for deletion + $baseTable . '."RecordID"', + ], + $baseTable, + [ + // Include only versions older than specified date + $baseTable . '."LastEdited" <= ?' => $deletionDate, + // Include only draft edits versions + // as we don't want to delete publish versions because these drive isPublishedInLocale() + $baseTable . '."WasPublished"' => 0, + // Skip records without mandatory data + $baseTable . '."ClassName" IS NOT NULL', + $baseTable . '."ClassName" != ?' => '', + ], + [ + // Apply consistent ordering + $baseTable . '."RecordID"' => 'ASC', + ], + [ + // Grouping by Record ID as we want to get RecordID overview at this point + $baseTable . '."RecordID"', + ], + [ + // Need to have more old versions than the allowed limit + 'COUNT(*) > ?' => $keepLimit, + ], + $recordLimit + ); + + // Localised objects need additional join + if ($isLocalised) { + $localisedTableRaw = $this->getVersionLocalisedTableName($mainTable); + $localisedTable = sprintf('"%s"', $localisedTableRaw); + $query + // Join through to the localised table + // Version numbers map one to one + ->addInnerJoin( + $localisedTableRaw, + sprintf( + '%1$s."RecordID" = %2$s."RecordID" AND %1$s."Version" = %2$s."Version"', + $baseTable, + $localisedTable + ) + ) + ->addSelect([ + // We will need the locale information as well later on + $localisedTable . '."Locale"', + ]) + ->addGroupBy([ + // Grouping has to be extended to locales + // as we want to keep the minimum number of versions per locale + $localisedTable . '."Locale"', + ]) + ->addOrderBy([ + // Extends consistent ordering to locales + $localisedTable . '."Locale"', + ]); + } + + $results = $query->execute(); + + if ($results === null) { + continue; + } + + $data = []; + + while ($result = $results->next()) { + $item = [ + 'id' => (int) $result['RecordID'], + ]; + + if ($isLocalised) { + // Add additional locale data for localised records + $item['locale'] = $result['Locale']; + } + + $data[] = $item; + } + + if (count($data) === 0) { + continue; + } + + $records[$class] = $data; + } + + return $records; + } + + /** + * Determine which versions need to be deleted for specified records + * + * @param array $records + * @return array + */ + public function getVersionsForDeletion(array $records): array + { + $keepLimit = (int) $this->config()->get('keep_limit'); + $versionLimit = (int) $this->config()->get('deletion_version_limit'); + $deletionDate = DBDatetime::create_field('Datetime', DBDatetime::now()->Rfc2822()) + ->modify(sprintf('- %d days', $this->config()->get('keep_lifetime'))) + ->Rfc2822(); + $versions = []; + + foreach ($records as $baseClass => $items) { + $mainTable = $this->getTableNameForClass($baseClass); + $baseTableRaw = $this->getVersionTableName($mainTable); + $baseTable = sprintf('"%s"', $baseTableRaw); + + foreach ($items as $item) { + $recordId = $item['id']; + $locale = array_key_exists('locale', $item) + ? $item['locale'] + : null; + + $query = SQLSelect::create( + [ + // We need version number so we can delete it + $baseTable . '."Version"', + // We need class name as this drives which tables need to be joined during deletion + $baseTable . '."ClassName"', + ], + $baseTable, + [ + $baseTable . '."RecordID"' => $recordId, + // Include only versions older than specified date + $baseTable . '."LastEdited" <= ?' => $deletionDate, + // Include only draft edits versions + // as we don't want to delete publish versions because these drive isPublishedInLocale() + $baseTable . '."WasPublished"' => 0, + // Skip records without mandatory data + $baseTable . '."ClassName" IS NOT NULL', + $baseTable . '."ClassName" != ?' => '', + ], + [ + // Latest versions first so we can keep the minimum required versions easily + // This will cause the newer versions to be deleted first but it shouldn't matter + // as all old versions will get deleted eventually (order shouldn't matter) + $baseTable . '."Version"' => 'DESC', + ], + [], + [], + [ + // Make sure we skip the versions which need to be retained + // these will be at the start of the list because of our sorting order + 'limit' => $versionLimit, + 'start' => $keepLimit, + ] + ); + + // Localised objects need additional join + if ($locale) { + $localisedTableRaw = $this->getVersionLocalisedTableName($mainTable); + $localisedTable = sprintf('"%s"', $localisedTableRaw); + $query + // Join through to the localised table + // Version numbers map one to one + ->addInnerJoin( + $localisedTableRaw, + sprintf( + '%1$s."RecordID" = %2$s."RecordID" AND %1$s."Version" = %2$s."Version"', + $baseTable, + $localisedTable + ) + ) + ->addWhere([ + // Narrow down the search to specific locale, this ensures that we keep minimum + // required versions per locale + $localisedTable . '."Locale"' => $locale, + ]); + } + + $results = $query->execute(); + + if ($results === null) { + continue; + } + + $data = []; + + // Group versions by class so it's easier to process them later + while ($result = $results->next()) { + $version = (int) $result['Version']; + $class = $result['ClassName']; + + if (!array_key_exists($class, $data)) { + $data[$class] = []; + } + + $data[$class][] = $version; + } + + if (count($data) === 0) { + continue; + } + + $versions[$baseClass][$recordId] = $data; + } + } + + return $versions; + } + + /** + * Pack versions into jobs so we can delete them in smaller chunks + * + * @param array $groups + * @throws ValidationException + */ + public function queueDeletionJobsForVersionGroups(array $groups): void + { + // Format data into job specific format so it's easy to consume + $data = []; + + foreach ($groups as $records) { + foreach ($records as $recordId => $recordData) { + foreach ($recordData as $class => $versions) { + $data[] = [ + 'id' => $recordId, + 'class' => $class, + 'versions' => $versions, + ]; + } + } + } + + if (count($data) === 0) { + return; + } + + $batchSize = (int) $this->config()->get('deletion_batch_size'); + $data = $batchSize > 0 + ? array_chunk($data, $batchSize) + : [$data]; + + $service = QueuedJobService::singleton(); + + foreach ($data as $chunk) { + $job = new CleanupJob(); + $job->hydrate($chunk); + $service->queueJob($job); + } + } + + /** + * Execute deletion of specified versions for a record + * + * @param string $class + * @param int $recordId + * @param array $versions + * @return int + */ + public function deleteVersions(string $class, int $recordId, array $versions): int + { + if (count($versions) === 0) { + // Nothing to delete + return 0; + } + + $tables = $this->getTablesListForClass($class); + $baseTables = $tables['base']; + + if (count($baseTables) === 0) { + return 0; + } + + // We can assume first table is the base table + $baseTableRaw = $baseTables[0]; + $baseTablesRaw = $baseTables; + + $baseTable = sprintf('"%s"', $baseTableRaw); + array_walk($baseTables, static function (&$item): void { + $item = sprintf('"%s"', $item); + }); + + $query = SQLDelete::create( + [ + $baseTable, + ], + [ + // We are deleting specific versions for specific record + $baseTable . '."RecordID"' => $recordId, + sprintf($baseTable . '."Version" IN (%s)', DB::placeholders($versions)) => $versions, + ], + $baseTables, + ); + + // Join additional tables so we can delete all related data (avoid orphaned version data) + foreach ($baseTablesRaw as $table) { + // No need to join the base table as it's already present in the FROM + if ($table === $baseTableRaw) { + continue; + } + + $query->addLeftJoin( + $table, + sprintf('%1$s."RecordID" = "%2$s"."RecordID" AND %1$s."Version" = "%2$s"."Version"', $baseTable, $table) + ); + } + + $localisedTables = $tables['localised']; + + // Add localised table to the join and deletion + if (count($localisedTables) > 0) { + $localisedTablesRaw = $localisedTables; + + array_walk($localisedTables, static function (&$item): void { + $item = sprintf('"%s"', $item); + }); + + // Register localised tables for deletion so we delete records from it + $query->addDelete($localisedTables); + + foreach ($localisedTablesRaw as $table) { + $query->addLeftJoin( + $table, + sprintf( + '%1$s."RecordID" = "%2$s"."RecordID" AND %1$s."Version" = "%2$s"."Version"', + $baseTable, + $table + ) + ); + } + } + + $results = $query->execute(); + + if ($results === null) { + return 0; + } + + return DB::affected_rows(); + } + + /** + * Get list of all tables that the specified class has + * + * @param string $class + * @return array[] + */ + protected function getTablesListForClass(string $class): array + { + /** @var DataObject|FluentExtension $singleton */ + $singleton = DataObject::singleton($class); + $classes = ClassInfo::ancestry($class, true); + $tables = []; + + foreach ($classes as $currentClass) { + $tables[] = $this->getTableNameForClass($currentClass); + } + + $baseTables = []; + $localisedTables = []; + + foreach ($tables as $table) { + $baseTables[] = $this->getVersionTableName($table); + } + + // Include localised tables if needed + if ($singleton->hasExtension(FluentVersionedExtension::class)) { + $localisedDataTables = array_keys($singleton->getLocalisedTables()); + + foreach ($tables as $table) { + if (!in_array($table, $localisedDataTables)) { + // Skip any tables that do not contain localised data + continue; + } + + $localisedTables[] = $this->getVersionLocalisedTableName($table); + } + } + + return [ + 'base' => $baseTables, + 'localised' => $localisedTables, + ]; + } + + /** + * Determine name of table from class + * + * @param string $class + * @return string + */ + protected function getTableNameForClass(string $class): string + { + $table = DataObject::singleton($class) + ->config() + ->uninherited('table_name'); + + // Fallback to class name if no table name is specified + return $table ?: $class; + } + + /** + * Determine the name of version table + * + * @param string $table + * @return string + */ + protected function getVersionTableName(string $table): string + { + return $table . '_Versions'; + } + + /** + * Determine the name of localised version table + * + * @param string $table + * @return string + */ + protected function getVersionLocalisedTableName(string $table): string + { + return $table . '_Localised_Versions'; + } +} diff --git a/src/VersionsCleanup/CleanupTask.php b/src/VersionsCleanup/CleanupTask.php new file mode 100644 index 00000000..e6f1a305 --- /dev/null +++ b/src/VersionsCleanup/CleanupTask.php @@ -0,0 +1,48 @@ +VersionsCleanup) { + return; + } + + CleanupService::singleton()->processVersionsForDeletion(); + } +} diff --git a/tests/php/VersionsCleanup/CleanupServiceTest.php b/tests/php/VersionsCleanup/CleanupServiceTest.php new file mode 100644 index 00000000..078af6dd --- /dev/null +++ b/tests/php/VersionsCleanup/CleanupServiceTest.php @@ -0,0 +1,770 @@ + [ + FluentSiteTreeExtension::class, + ], + Ship::class => [ + Versioned::class, + ], + ]; + + protected function setUp(): void + { + FluentState::singleton()->withState(function (FluentState $state): void { + $state->setLocale(LocaleDefaultRecordsExtension::LOCALE_INTERNATIONAL_ENGLISH); + + DBDatetime::set_mock_now('2020-01-01 00:00:00'); + parent::setUp(); + }); + } + + /** + * @param array $classes + * @param array $expected + * @dataProvider baseClassesProvider + */ + public function testGetBaseClasses(array $classes, array $expected): void + { + CleanupService::config()->set('base_classes', $classes); + $this->assertSame($expected, CleanupService::singleton()->getBaseClasses()); + } + + public function baseClassesProvider(): array + { + return [ + 'Localised / Valid' => [ + [SiteTree::class], + [SiteTree::class], + ], + 'Not Localised / Valid' => [ + [Ship::class], + [Ship::class], + ], + 'Invalid' => [ + [SiteConfig::class], + [], + ], + ]; + } + + /** + * @param string $class + * @param string $now + * @param array $expected + * @throws ValidationException + * @dataProvider deletionRecordsProvider + */ + public function testGetRecordsForDeletion(string $class, string $id, string $now, array $expected): void + { + FluentState::singleton()->withState(function (FluentState $state) use ($class, $id, $now, $expected): void { + $state->setLocale(LocaleDefaultRecordsExtension::LOCALE_INTERNATIONAL_ENGLISH); + + $model = $this->objFromFixture($class, $id); + $this->createTestVersions($model); + $baseClass = $model->baseClass(); + DBDatetime::set_mock_now($now); + $records = CleanupService::singleton()->getRecordsForDeletion([$baseClass]); + + if (count($expected) === 0) { + $this->assertCount(0, $records); + + return; + } + + $this->assertArrayHasKey($baseClass, $records); + $this->assertSame($expected, $records[$baseClass]); + }); + } + + public function deletionRecordsProvider(): array + { + return [ + 'Not Localised / No versions passed lifetime' => [ + Ship::class, + 'ship1', + '2020-06-30 00:00:00', + [], + ], + 'Not Localised / Versions passed lifetime' => [ + Ship::class, + 'ship1', + '2020-07-01 00:00:00', + [ + [ + 'id' => 1, + ], + ], + ], + 'Localised / No versions passed lifetime' => [ + House::class, + 'house1', + '2020-06-29 00:00:00', + [], + ], + 'Localised / Versions passed lifetime' => [ + House::class, + 'house1', + '2020-06-30 00:00:00', + [ + [ + 'id' => 1, + 'locale' => LocaleDefaultRecordsExtension::LOCALE_INTERNATIONAL_ENGLISH, + ], + ], + ], + ]; + } + + /** + * @param string $class + * @param array $config + * @param int $expected + * @throws ValidationException + * @throws Exception + * @dataProvider deletionRecordLimitProvider + */ + public function testDeletionRecordLimit( + string $class, + array $config, + int $expected + ): void { + foreach ($config as $key => $value) { + CleanupService::config()->set($key, $value); + } + + FluentState::singleton()->withState(function (FluentState $state) use ($class, $expected): void { + $state->setLocale(LocaleDefaultRecordsExtension::LOCALE_INTERNATIONAL_ENGLISH); + + $models = DataObject::get($class); + + foreach ($models as $model) { + $this->createTestVersions($model); + } + + $baseClass = DataObject::singleton($class)->baseClass(); + DBDatetime::set_mock_now('2022-01-01 00:00:00'); + $records = CleanupService::singleton()->getRecordsForDeletion([$baseClass]); + + if ($expected === 0) { + $this->assertCount(0, $records); + + return; + } + + $this->assertArrayHasKey($baseClass, $records); + $this->assertCount($expected, $records[$baseClass]); + }); + } + + public function deletionRecordLimitProvider(): array + { + return [ + 'Not Localised / Limit 1' => [ + House::class, + ['deletion_record_limit' => 1], + 1, + ], + 'Not Localised / Limit 2' => [ + House::class, + ['deletion_record_limit' => 2], + 2, + ], + 'Localised / Limit 1' => [ + Ship::class, + ['deletion_record_limit' => 1], + 1, + ], + 'Localised / Limit 2' => [ + Ship::class, + ['deletion_record_limit' => 2], + 2, + ], + ]; + } + + /** + * @param string $class + * @param string $id + * @param string $now + * @param array $expected + * @param array $config + * @throws ValidationException + * @throws Exception + * @dataProvider deletionVersionsProvider + */ + public function testGetVersionsForDeletion( + string $class, + string $id, + string $now, + array $expected, + array $config = [] + ): void { + foreach ($config as $key => $value) { + CleanupService::config()->set($key, $value); + } + + FluentState::singleton()->withState(function (FluentState $state) use ($class, $id, $now, $expected): void { + $state->setLocale(LocaleDefaultRecordsExtension::LOCALE_INTERNATIONAL_ENGLISH); + + $model = $this->objFromFixture($class, $id); + $this->createTestVersions($model); + $baseClass = $model->baseClass(); + DBDatetime::set_mock_now($now); + $mockData = [ + 'id' => $model->ID, + ]; + + if ($model->hasExtension(FluentExtension::class)) { + $mockData['locale'] = LocaleDefaultRecordsExtension::LOCALE_INTERNATIONAL_ENGLISH; + } + + $versions = CleanupService::singleton()->getVersionsForDeletion([ + $baseClass => [ + $mockData, + ], + ]); + + if (count($expected) === 0) { + $this->assertCount(0, $versions); + + return; + } + + $this->assertArrayHasKey($baseClass, $versions); + $this->assertArrayHasKey($model->ID, $versions[$baseClass]); + $this->assertArrayHasKey($model->ClassName, $versions[$baseClass][$model->ID]); + $this->assertSame($expected, $versions[$baseClass][$model->ID][$model->ClassName]); + }); + } + + public function deletionVersionsProvider(): array + { + return [ + 'Not Localised / No versions passed lifetime' => [ + Ship::class, + 'ship1', + '2020-06-30 00:00:00', + [], + ], + 'Not Localised / Versions passed lifetime' => [ + Ship::class, + 'ship1', + '2020-07-01 00:00:00', + [ + 1, + ], + ], + 'Not Localised / Versions skips published' => [ + Ship::class, + 'ship1', + '2022-01-01 00:00:00', + [ + 11, + 10, + 8, + 7, + 6, + 4, + 3, + 2, + 1, + ], + ], + 'Not Localised / Versions deletion limit' => [ + Ship::class, + 'ship1', + '2022-01-01 00:00:00', + [ + 11, + ], + [ + 'deletion_version_limit' => 1, + ], + ], + 'Not Localised / Versions keep more versions' => [ + Ship::class, + 'ship1', + '2022-01-01 00:00:00', + [ + 7, + 6, + 4, + 3, + 2, + 1, + ], + [ + 'keep_limit' => 5, + ], + ], + 'Not Localised / Versions shorter lifetime' => [ + Ship::class, + 'ship1', + '2020-06-30 00:00:00', + [ + 1, + ], + [ + 'keep_lifetime' => 179, + ], + ], + 'Localised / No versions passed lifetime' => [ + House::class, + 'house1', + '2020-06-29 00:00:00', + [], + ], + 'Localised / Versions passed lifetime' => [ + House::class, + 'house1', + '2020-06-30 00:00:00', + [ + 1, + ], + ], + 'Localised / Versions deletion limit' => [ + House::class, + 'house1', + '2022-01-01 00:00:00', + [ + 12, + ], + [ + 'deletion_version_limit' => 1, + ], + ], + 'Localised / Versions skips published' => [ + House::class, + 'house1', + '2022-01-01 00:00:00', + [ + 12, + 11, + 9, + 8, + 7, + 5, + 4, + 3, + 2, + 1, + ], + ], + 'Localised / Versions keep more versions' => [ + House::class, + 'house1', + '2022-01-01 00:00:00', + [ + 8, + 7, + 5, + 4, + 3, + 2, + 1, + ], + [ + 'keep_limit' => 5, + ], + ], + 'Localised / Versions shorter lifetime' => [ + House::class, + 'house1', + '2020-06-29 00:00:00', + [ + 1, + ], + [ + 'keep_lifetime' => 179, + ], + ], + ]; + } + + /** + * @param string $class + * @param string $id + * @param array $versionsPreDeletion + * @param array $versionsPostDeletion + * @param array $tables + * @param array $versionsToDelete + * @throws ValidationException + * @throws Exception + * @dataProvider jobProcessProvider + */ + public function testQueueDeletionJobsForVersionGroupsProcessLocalised( + string $class, + string $id, + array $versionsPreDeletion, + array $versionsPostDeletion, + array $tables, + array $versionsToDelete + ): void { + FluentState::singleton()->withState( + function (FluentState $state) use ( + $class, + $id, + $versionsPreDeletion, + $versionsPostDeletion, + $tables, + $versionsToDelete + ): void { + $state->setLocale(LocaleDefaultRecordsExtension::LOCALE_INTERNATIONAL_ENGLISH); + + $model = $this->objFromFixture($class, $id); + $this->createTestVersions($model); + + foreach ($tables as $table) { + $versions = $this->getVersion($table, $model->ID); + $this->assertSame($versionsPreDeletion, $versions); + } + + $job = new CleanupJob(); + $job->hydrate($versionsToDelete); + $job->setup(); + $job->process(); + $this->assertTrue($job->jobFinished()); + + foreach ($tables as $table) { + $versions = $this->getVersion($table, $model->ID); + $this->assertSame($versionsPostDeletion, $versions); + } + } + ); + } + + public function jobProcessProvider(): array + { + return [ + 'Not Localised' => [ + Ship::class, + 'ship1', + [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + ], + [ + 1, + 2, + 3, + 4, + 5, + 7, + 8, + 9, + 11, + 12, + 13, + 14, + ], + [ + 'VersionsCleanup_Ship_Versions', + ], + [ + [ + 'id' => 1, + 'class' => Ship::class, + 'versions' => [6, 10], + ], + ], + ], + 'Localised' => [ + House::class, + 'house1', + [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + ], + [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 10, + 11, + 12, + 14, + 15, + ], + [ + 'SiteTree_Versions', + 'Page_Versions', + 'VersionsCleanup_House_Versions', + 'SiteTree_Localised_Versions', + 'Page_Localised_Versions', + 'VersionsCleanup_House_Localised_Versions', + ], + [ + [ + 'id' => 1, + 'class' => House::class, + 'versions' => [9, 13], + ], + ], + ], + ]; + } + + /** + * @throws ValidationException + */ + public function testQueueDeletionJobsForVersionGroupsData(): void + { + $versions = [ + SiteTree::class => [ + 1 => [ + House::class => [ + 9, + 10, + ], + ], + ], + ]; + + CleanupService::singleton()->queueDeletionJobsForVersionGroups($versions); + $jobs = QueuedJobDescriptor::get()->filter(['Implementation' => CleanupJob::class]); + $this->assertCount(1, $jobs); + + /** @var QueuedJobDescriptor $job */ + $job = $jobs->first(); + + $jobData = unserialize($job->SavedJobData); + $this->assertEquals( + [ + [ + 'id' => 1, + 'class' => House::class, + 'versions' => [9, 10], + ], + ], + $jobData->versions + ); + } + + /** + * @param array $versions + * @param array $config + * @param int $expected + * @throws ValidationException + * @dataProvider jobBatchSizeProvider + */ + public function testQueueDeletionJobsForVersionGroupsBatchSize(array $versions, array $config, int $expected): void + { + foreach ($config as $key => $value) { + CleanupService::config()->set($key, $value); + } + + CleanupService::singleton()->queueDeletionJobsForVersionGroups($versions); + $this->assertCount($expected, QueuedJobDescriptor::get()->filter(['Implementation' => CleanupJob::class])); + } + + public function jobBatchSizeProvider(): array + { + return [ + 'Single job' => [ + [ + Ship::class => [ + 1 => [ + Ship::class => [ + 2, + 3, + ], + ], + ], + SiteTree::class => [ + 1 => [ + House::class => [ + 9, + 10, + ], + ], + ], + ], + [ + 'deletion_batch_size' => 0, + ], + 1, + ], + 'Multiple jobs' => [ + [ + Ship::class => [ + 1 => [ + Ship::class => [ + 2, + 3, + ], + ], + ], + SiteTree::class => [ + 1 => [ + House::class => [ + 9, + 10, + ], + ], + ], + ], + [ + 'deletion_batch_size' => 1, + ], + 2, + ], + ]; + } + + /** + * @param string $class + * @param $expected + * @dataProvider tablesListProvider + */ + public function testGetTablesListForClass(string $class, array $expected): void + { + $method = new ReflectionMethod(CleanupService::class, 'getTablesListForClass'); + $method->setAccessible(true); + + $this->assertSame($expected, $method->invoke(CleanupService::singleton(), $class)); + } + + public function tablesListProvider(): array + { + return [ + 'Localised' => [ + House::class, + [ + 'base' => [ + 'SiteTree_Versions', + 'Page_Versions', + 'VersionsCleanup_House_Versions', + ], + 'localised' => [ + 'SiteTree_Localised_Versions', + 'Page_Localised_Versions', + 'VersionsCleanup_House_Localised_Versions', + ], + ], + ], + 'Not Localised' => [ + Ship::class, + [ + 'base' => [ + 'VersionsCleanup_Ship_Versions', + ], + 'localised' => [], + ], + ], + ]; + } + + /** + * @param DataObject|Versioned $model + * @throws ValidationException + * @throws Exception + */ + private function createTestVersions(DataObject $model): void + { + $mockRange = range(1, 10); + + foreach ($mockRange as $i) { + $mockDate = DBDatetime::create_field('Datetime', DBDatetime::now()->Rfc2822()) + ->modify(sprintf('+ %d days', $i)) + ->Rfc2822(); + + DBDatetime::withFixedNow($mockDate, static function () use ($model, $i): void { + $model->Title = 'Iteration ' . $i; + $model->write(); + + if (($i % 3) !== 0) { + return; + } + + $model->publishRecursive(); + }); + } + } + + private function getVersion(string $table, int $recordId): array + { + $query = SQLSelect::create( + [ + '"Version"', + ], + sprintf('"%s"', $table), + [ + '"RecordID"' => $recordId, + ], + [ + '"Version"' => 'ASC', + ], + ); + + return $query->execute()->column('Version'); + } +} diff --git a/tests/php/VersionsCleanup/CleanupServiceTest.yml b/tests/php/VersionsCleanup/CleanupServiceTest.yml new file mode 100644 index 00000000..baca030d --- /dev/null +++ b/tests/php/VersionsCleanup/CleanupServiceTest.yml @@ -0,0 +1,36 @@ +TractorCow\Fluent\Model\Locale: + nz: + Locale: en_001 + APIURLSegment: en + Title: 'International' + URLSegment: int + AlacrityMarket: en + us: + Locale: en_US + Title: 'United States' + URLSegment: us + AlacrityMarket: us + Fallbacks: + - =>TractorCow\Fluent\Model\Locale.nz + +App\Tests\VersionsCleanup\House: + house1: + Title: 'TestHouse1' + URLSegment: 'test-house1' + house2: + Title: 'TestHouse2' + URLSegment: 'test-house2' + house3: + Title: 'TestHouse3' + URLSegment: 'test-house3' + +App\Tests\VersionsCleanup\Ship: + ship1: + Title: 'TestShip1' + Address: 'TestAddress1' + ship2: + Title: 'TestShip2' + Address: 'TestAddress2' + ship3: + Title: 'TestShip3' + Address: 'TestAddress3' diff --git a/tests/php/VersionsCleanup/House.php b/tests/php/VersionsCleanup/House.php new file mode 100644 index 00000000..f900a2d6 --- /dev/null +++ b/tests/php/VersionsCleanup/House.php @@ -0,0 +1,21 @@ + 'Varchar', + ]; +} diff --git a/tests/php/VersionsCleanup/Ship.php b/tests/php/VersionsCleanup/Ship.php new file mode 100644 index 00000000..e24754b8 --- /dev/null +++ b/tests/php/VersionsCleanup/Ship.php @@ -0,0 +1,21 @@ + 'Varchar', + ]; +}