From 3700e137ce8ba8a155d9360e0c61772ad46c5708 Mon Sep 17 00:00:00 2001 From: Sabina Talipova Date: Wed, 20 Mar 2024 13:25:43 +1300 Subject: [PATCH] NEW LinkableMigrationTask for migrating sheadawson-linkable links --- docs/en/09_migrating/01_linkable-migration.md | 151 +++++ src/Tasks/GorriecoeMigrationTask.php | 509 +---------------- src/Tasks/LinkableMigrationTask.php | 115 ++++ src/Tasks/ModuleMigrationTaskTrait.php | 529 ++++++++++++++++++ .../CustomLinkableLink.php | 15 + .../HasManyLinkableLinkOwner.php | 15 + .../HasOneLinkableLinkOwner.php | 15 + tests/php/Tasks/LinkableMigrationTaskTest.php | 203 +++++++ tests/php/Tasks/LinkableMigrationTaskTest.yml | 92 +++ 9 files changed, 1137 insertions(+), 507 deletions(-) create mode 100644 docs/en/09_migrating/01_linkable-migration.md create mode 100644 src/Tasks/LinkableMigrationTask.php create mode 100644 src/Tasks/ModuleMigrationTaskTrait.php create mode 100644 tests/php/Tasks/LinkableMigrationTask/CustomLinkableLink.php create mode 100644 tests/php/Tasks/LinkableMigrationTask/HasManyLinkableLinkOwner.php create mode 100644 tests/php/Tasks/LinkableMigrationTask/HasOneLinkableLinkOwner.php create mode 100644 tests/php/Tasks/LinkableMigrationTaskTest.php create mode 100644 tests/php/Tasks/LinkableMigrationTaskTest.yml diff --git a/docs/en/09_migrating/01_linkable-migration.md b/docs/en/09_migrating/01_linkable-migration.md new file mode 100644 index 00000000..2bb4a308 --- /dev/null +++ b/docs/en/09_migrating/01_linkable-migration.md @@ -0,0 +1,151 @@ +--- +title: Migrating from Shae Dawson's Linkable module +summary: A guide for migrating from sheadawson/silverstripe-linkable to silverstripe/linkfield +--- + +# Migrating from Shae Dawson's Linkable module + +The [`sheadawson/silverstripe-linkable` module](https://github.com/sheadawson/silverstripe-linkable) was a much loved, and much used module. It is, unfortunately, no longer maintained. We have provided some steps and tasks that we hope can be used to migrate your project from Linkable to LinkField. + +> [!WARNING] +> This guide and the associated migration task assume all of the data for your links are in the base table for `Sheadawson\Linkable\Models\Link` or in automatically generated tables (e.g. join tables for `many_many` relations). +> If you have subclassed `Sheadawson\Linkable\Models\Link`, there may be additional steps you need to take to migrate the data for your subclass. + +## Preamble + +This migration process covers shifting data from the `Linkable` tables to the appropriate `LinkField` tables. This does not cover usages of `EmbeddedObject`. + +**Versioned:** If you have `Versioned` `Linkable`, then the expectation is that you will also `Version` `LinkField`. If you have not `Versioned` `Linkable`, then the expectation is that you will **not** `Version` `LinkField`. + +**No support for internal links with query params (GET params):** Please be aware that Linkfield does not support internal links with query params (`?`) out of the box, and therefor the migration task will **remove** any query params that are present in the Linkable's `Anchor` field. + +## Install Silvesrtripe Linkfield + +Install the Silverstripe Linkfield module: + +```bash +composer require silverstripe/linkfield 4 +``` + +Optionally, you can also remove the Linkable module (though, you might find it useful to keep around as a reference while you are upgrading your code). + +Do this step at whatever point makes sense to you. + +```bash +composer remove sheadawson/silverstripe-linkable +``` + +## Replace app usages + +You should review how you are using the original `Link` model and `LinkField`, but if you don't have any customisations, then replacing the old with the new **might** be quite simple. + +If you have used imports (`use` statements), then your first step might just be to search for `use [old];` and replace +with `use [new];` (since the class name references have not changed at all). +```diff +- Sheadawson\Linkable\Models\Link ++ SilverStripe\LinkField\Models\Link + +- Sheadawson\Linkable\Forms\LinkField ++ SilverStripe\LinkField\Form\LinkField + +``` + +If you have extensions, new fields, etc, then your replacements might need to be a bit more considered. + +The other key (less easy to automate) thing that you'll need to update is that the old `LinkField` required you to specify the related field with `ID` appended, whereas the new `LinkField` requires you to specify the field without `ID` appended. EG. +```diff +- LinkField::create('MyLinkID') ++ LinkField::create('MyLink') +``` +Search for instances of `LinkField::create` and `new LinkField`, and hopefully that should give you all of the places where you need to update field name references. + +### Configuration + +Be sure to check how the old module classes are referenced in config `yml` files (eg: `app/_config`). Update appropriately. + +### Populate module + +If you use the populate module, you will not be able to simply "replace" the namespace. Fixture definitions for the new Linkfield module are quite different. There are entirely different models for different link types, whereas before it was just a DB field to specify the type. + +See below for example before/after usage: + +#### Before + +```yml +Sheadawson\Linkable\Models\Link: + internal: + Title: Internal link + Type: SiteTree + SiteTreeID: 1 + OpenInNewWindow: true + external: + Title: External link + Type: URL + URL: https://example.org + OpenInNewWindow: true + file: + Title: File link + Type: File + File: =>SilverStripe\Assets\File.example + phone: + Title: Phone link + Type: Phone + Phone: +64 1 234 567 + email: + Title: Email link + Type: Email + Email: foo@example.org +``` + +#### After + +```yml +SilverStripe\LinkField\Models\SiteTreeLink: + internal: + LinkText: Internal link + Page: =>Page.home + OpenInNew: true +SilverStripe\LinkField\Models\ExternalLink: + external: + LinkText: External link + ExternalUrl: https://example.org + OpenInNew: true +SilverStripe\LinkField\Models\FileLink: + file: + LinkText: File link + File: =>SilverStripe\Assets\File.example +SilverStripe\LinkField\Models\PhoneLink: + phone: + LinkText: Phone link + Phone: +64 1 234 567 +SilverStripe\LinkField\Models\EmailLink: + email: + LinkText: Email link + Email: foo@example.org +``` + +## Replace template usages + +**Before:** You might have had references to `$LinkURL` or `$Link.LinkURL`. +**After:** These would need to be updated to `$URL` or `$Link.URL` respectively. + +**Before:** `$OpenInNewWindow` or `$Link.OpenInNewWindow`. +**After:** `$OpenInNew` or `$Link.OpenInNew` respectively. + +**Before:** `$Link.TargetAttr` or `$TargetAttr` would output the appropriate `target="xx"`. +**After:** There is no direct replacement. + +This is an area where you should spend some decent effort to make sure each implementation is outputting as you expect it to. There may be more "handy" methods that Linkable provided that no longer exist (that we haven't covered above). + +## Table structures + +It's important to understand that we are going from a single table in Linkable to multiple tables in LinkField. + +**Before:** We had 1 table with all data, and one of the field in there specified the type of the Link. +**After:** We have 1 table for each type of Link, with a base `Link` table for all record. + +## Migrating + +The migration process completely repeats the process of updating the old version of Linkfield to the new one, so please follow the instructions provided in the [Migrating section](./00_upgrading.md#migrating) making the following small changes to the configuration files. + +- Change Task class name from `SilverStripe\LinkField\Tasks\LinkFieldMigrationTask` to `SilverStripe\LinkField\Tasks\LinkableMigrationTask` in each yml configuration file. diff --git a/src/Tasks/GorriecoeMigrationTask.php b/src/Tasks/GorriecoeMigrationTask.php index 36eb26bf..45336816 100644 --- a/src/Tasks/GorriecoeMigrationTask.php +++ b/src/Tasks/GorriecoeMigrationTask.php @@ -24,6 +24,7 @@ class GorriecoeMigrationTask extends BuildTask { use MigrationTaskTrait; + use ModuleMigrationTaskTrait; private static $segment = 'gorriecoe-to-linkfield-migration-task'; @@ -154,514 +155,8 @@ class GorriecoeMigrationTask extends BuildTask private string $oldTableName; /** - * Perform the actual data migration and publish links as appropriate + * The old link model class name */ - public function performMigration(): void - { - $this->extend('beforePerformMigration'); - // Because we're using SQL INSERT with specific ID values, - // we can't perform the migration if there are existing links because there - // may be ID conflicts. - if (Link::get()->exists()) { - throw new RuntimeException('Cannot perform migration with existing silverstripe/linkfield link records.'); - } - - $this->insertBaseRows(); - $this->insertTypeSpecificRows(); - $this->updateSiteTreeRows(); - $this->migrateHasManyRelations(); - $this->migrateManyManyRelations(); - $this->setOwnerForHasOneLinks(); - - $this->print("Dropping old link table '{$this->oldTableName}'"); - DB::get_conn()->query("DROP TABLE \"{$this->oldTableName}\""); - - $this->print('-----------------'); - $this->print('Bulk data migration complete. All links should be correct (but unpublished) at this stage.'); - $this->print('-----------------'); - - $this->publishLinks(); - - $this->print('-----------------'); - $this->print('Migration completed successfully.'); - $this->print('-----------------'); - $this->extend('afterPerformMigration'); - } - - /** - * Check if we actually need to migrate anything, and if not give clear output as to why not. - */ - private function getNeedsMigration(): bool - { - $oldTableName = $this->getTableOrObsoleteTable(static::config()->get('old_link_table')); - if (!$oldTableName) { - $this->print('Nothing to migrate - old link table doesn\'t exist.'); - return false; - } - $this->oldTableName = $oldTableName; - return true; - } - - /** - * Insert a row into the base Link table for each link, mapping all of the columns - * that are shared across all link types. - */ - private function insertBaseRows(): void - { - $this->extend('beforeInsertBaseRows'); - $db = DB::get_conn(); - - // Get a full map of columns to migrate that applies to all link types - $baseTableColumnMap = $this->getBaseColumnMap(); - // ClassName will need to be handled per link type - unset($baseTableColumnMap['ClassName']); - - // Set the correct ClassName based on the type of link. - // Note that case statements have no abstraction, but are already used elsewhere - // so should be safe. See DataQuery::getFinalisedQuery() which is used for all - // DataList queries. - $classNameSelect = 'CASE '; - $typeColumn = $db->escapeIdentifier("{$this->oldTableName}.Type"); - foreach (static::config()->get('link_type_columns') as $type => $spec) { - $toClass = $db->quoteString($spec['class']); - $type = $db->quoteString($type); - $classNameSelect .= "WHEN {$typeColumn} = {$type} THEN {$toClass} "; - } - $classNameSelect .= 'ELSE ' . $db->quoteString(Link::class) . ' END AS ClassName'; - - // Insert rows - $baseTable = DataObject::getSchema()->baseDataTable(Link::class); - $quotedBaseTable = $db->escapeIdentifier($baseTable); - $baseColumns = implode(', ', array_values($baseTableColumnMap)); - $subQuery = SQLSelect::create( - array_keys($baseTableColumnMap), - $db->escapeIdentifier($this->oldTableName) - )->addSelect($classNameSelect)->sql(); - // We can't use the ORM to do INSERT with SELECT, but thankfully - // the syntax is generic enough that it should work for all SQL databases. - DB::query("INSERT INTO {$quotedBaseTable} ({$baseColumns}, ClassName) {$subQuery}"); - $this->extend('afterInsertBaseRows'); - } - - /** - * Insert rows for all link subclasses based on the type of the old link - */ - private function insertTypeSpecificRows(): void - { - $this->extend('beforeInsertTypeSpecificRows'); - $schema = DataObject::getSchema(); - $db = DB::get_conn(); - foreach (static::config()->get('link_type_columns') as $type => $spec) { - $type = $db->quoteString($type); - $toClass = $spec['class']; - $columnMap = $spec['fields']; - - $table = $schema->tableName($toClass); - $quotedTable = $db->escapeIdentifier($table); - $baseColumns = implode(', ', array_values($columnMap)); - $subQuery = SQLSelect::create( - ['ID', ...array_keys($columnMap)], - $db->escapeIdentifier($this->oldTableName), - [$db->escapeIdentifier("{$this->oldTableName}.Type") . " = {$type}"] - )->sql(); - // We can't use the ORM to do INSERT with SELECT, but thankfully - // the syntax is generic enough that it should work for all SQL databases. - DB::query("INSERT INTO {$quotedTable} (ID, {$baseColumns}) {$subQuery}"); - } - $this->extend('afterInsertTypeSpecificRows'); - } - - /** - * Update the Anchor column for SiteTreeLink - */ - private function updateSiteTreeRows(): void - { - $this->extend('beforeUpdateSiteTreeRows'); - // We have to split the Anchor column, which means we have to fetch and operate on the values. - $currentChunk = 0; - $chunkSize = static::config()->get('chunk_size'); - $count = $chunkSize; - $db = DB::get_conn(); - $schema = DataObject::getSchema(); - $siteTreeLinkTable = $schema->tableForField(SiteTreeLink::class, 'Anchor'); - // Keep looping until we run out of chunks - while ($count >= $chunkSize) { - // Get data about the old SiteTree links - $oldLinkRows = SQLSelect::create( - ['ID', 'Anchor'], - $db->escapeIdentifier($this->oldTableName), - [ - $db->escapeIdentifier($this->oldTableName . '.Type') => 'SiteTree', - $db->nullCheckClause($db->escapeIdentifier($this->oldTableName . '.Anchor'), false) - ] - )->setLimit($chunkSize, $chunkSize * $currentChunk)->execute(); - // Prepare for next iteration - $count = $oldLinkRows->numRecords(); - $currentChunk++; - - // Update all links which have an anchor - foreach ($oldLinkRows as $oldLink) { - // Get the query string and anchor separated - $queryString = null; - $anchor = null; - $oldAnchor = $oldLink['Anchor']; - if (str_starts_with($oldAnchor, '#')) { - $parts = explode('?', $oldAnchor, 2); - $anchor = ltrim($parts[0], '#'); - $queryString = ltrim($parts[1] ?? '', '?'); - } elseif (str_starts_with($oldAnchor, '?')) { - $parts = explode('#', $oldAnchor, 2); - $queryString = ltrim($parts[0], '?'); - $anchor = ltrim($parts[1] ?? '', '#'); - } else { - // Assume it's an anchor and they just forgot the # - // We don't need the # so just add it directly. - $anchor = $oldAnchor; - } - $this->extend('updateAnchorAndQueryString', $anchor, $queryString, $oldAnchor); - // Update the link with the correct anchor and query string - SQLUpdate::create( - $db->escapeIdentifier($siteTreeLinkTable), - [ - $schema->sqlColumnForField(SiteTreeLink::class, 'Anchor') => $anchor, - $schema->sqlColumnForField(SiteTreeLink::class, 'QueryString') => $queryString, - ], - [$db->escapeIdentifier($siteTreeLinkTable . '.ID') => $oldLink['ID']] - )->execute(); - } - - // If $chunkSize was null, we did everything in a single chunk - // but we need to break the loop artificially. - if ($chunkSize === null) { - break; - } - } - $this->extend('afterUpdateSiteTreeRows'); - } - - private function migrateHasManyRelations(): void - { - $this->extend('beforeMigrateHasManyRelations'); - $linksList = static::config()->get('has_many_links_data'); - - // Exit early if there's nothing to migrate - if (empty($linksList)) { - $this->print('No has_many relations to migrate.'); - $this->extend('afterMigrateHasManyRelations'); - return; - } - - $this->print('Migrating has_many relations.'); - $schema = DataObject::getSchema(); - $db = DB::get_conn(); - $oldTableFields = DB::field_list($this->oldTableName); - foreach ($linksList as $ownerClass => $relations) { - foreach ($relations as $hasManyRelation => $hasOneRelation) { - // Check if HasOneID column is in the old base Link table - if (!array_key_exists("{$hasOneRelation}ID", $oldTableFields)) { - // This is an unusual situation, and is difficult to do generically. - // We'll leave this scenario up to the developer to handle. - $this->extend('migrateHasOneForLinkSubclass', $linkClass, $ownerClass, $hasOneRelation, $hasManyRelation); - continue; - } - $linkTable = $schema->baseDataTable(Link::class); - $tables = [$linkTable]; - // Include versioned tables if link is versioned - if (Link::has_extension(Versioned::class)) { - $tables[] = "{$linkTable}_Versions"; - $tables[] = "{$linkTable}_Live"; - } - $wasPolyMorphic = array_key_exists("{$hasOneRelation}Class", $oldTableFields); - $wasMultiRelational = $wasPolyMorphic && array_key_exists("{$hasOneRelation}Relation", $oldTableFields); - // Migrate old has_one on link to the Owner relation. - foreach ($tables as $table) { - // Only set owner where the OwnerID is not already set - $ownerIdColumn = $db->escapeIdentifier($table . '.OwnerID'); - $nullCheck = $db->nullCheckClause($ownerIdColumn, true); - $whereClause = [ - "$ownerIdColumn = 0 OR $nullCheck", - $db->nullCheckClause($db->escapeIdentifier($table . '.OwnerRelation'), true), - ]; - if ($wasPolyMorphic) { - // For polymorphic relations, don't set the owner for records belonging - // to a different class hierarchy. - $validClasses = ClassInfo::subclassesFor($ownerClass, true); - $placeholders = DB::placeholders($validClasses); - $whereClause[] = [$db->escapeIdentifier("{$this->oldTableName}.{$hasOneRelation}Class") . " IN ($placeholders)" => $validClasses]; - if ($wasMultiRelational) { - $whereClause[] = [$db->escapeIdentifier("{$this->oldTableName}.{$hasOneRelation}Relation") => $hasManyRelation]; - } - } - $update = SQLUpdate::create( - $db->escapeIdentifier($table), - [ - $db->escapeIdentifier($table . '.OwnerID') => [$schema->sqlColumnForField($ownerClass, 'ID') => []], - $db->escapeIdentifier($table . '.OwnerClass') => [$schema->sqlColumnForField($ownerClass, 'ClassName') => []], - $db->escapeIdentifier($table . '.OwnerRelation') => $hasManyRelation, - ], - $whereClause - ) - ->addInnerJoin($this->oldTableName, $db->escapeIdentifier($this->oldTableName . '.ID') . ' = ' . $db->escapeIdentifier("{$table}.ID")) - ->addInnerJoin($schema->baseDataTable($ownerClass), $schema->sqlColumnForField($ownerClass, 'ID') . ' = ' . $db->escapeIdentifier("{$this->oldTableName}.{$hasOneRelation}ID")); - $update->execute(); - } - } - } - $this->extend('afterMigrateHasManyRelations'); - } - - private function migrateManyManyRelations(): void - { - $this->extend('beforeMigrateManyManyRelations'); - $linksList = static::config()->get('many_many_links_data'); - - // Exit early if there's nothing to migrate - if (empty($linksList)) { - $this->print('No many_many relations to migrate.'); - $this->extend('afterMigrateManyManyRelations'); - return; - } - - $this->print('Migrating many_many relations.'); - $schema = DataObject::getSchema(); - $db = DB::get_conn(); - $baseLinkTable = $schema->baseDataTable(Link::class); - $originalOldLinkTable = str_replace('_obsolete_', '', $this->oldTableName); - foreach ($linksList as $ownerClass => $relations) { - $ownerBaseTable = $schema->baseDataTable($ownerClass); - $ownerTable = $schema->tableName($ownerClass); - foreach ($relations as $manyManyRelation => $spec) { - $throughSpec = $spec['through'] ?? []; - if (!empty($throughSpec)) { - if (!isset($spec['table'])) { - throw new RuntimeException("Must declare the table name for many_many through relation '{$ownerClass}.{$manyManyRelation}'."); - } - $ownerIdField = $throughSpec['from'] . 'ID'; - $linkIdField = $throughSpec['to'] . 'ID'; - } else { - $ownerIdField = "{$ownerTable}ID"; - $linkIdField = "{$originalOldLinkTable}ID"; - } - $extraFields = $spec['extraFields'] ?? []; - $joinTable = $this->getTableOrObsoleteTable($spec['table'] ?? "{$ownerTable}_{$manyManyRelation}"); - - if ($joinTable === null) { - throw new RuntimeException("Couldn't find join table for many_many relation '{$ownerClass}.{$manyManyRelation}'."); - } - - $polymorphicWhereClause = []; - if (!empty($throughSpec)) { - $joinColumns = DB::field_list($joinTable); - if (array_key_exists($throughSpec['from'] . 'Class', $joinColumns)) { - // For polymorphic relations, don't set the owner for records belonging - // to a different class hierarchy. - $validClasses = ClassInfo::subclassesFor($ownerClass, true); - $placeholders = DB::placeholders($validClasses); - $polymorphicClassColumn = $throughSpec['from'] . 'Class'; - $polymorphicWhereClause = [$db->escapeIdentifier("{$joinTable}.{$polymorphicClassColumn}") . " IN ($placeholders)" => $validClasses]; - } - } - - // If the join table for many_many through still has an associated DataObject class, - // something is very weird and we should throw an error. - // Most likely the developer just forgot to delete it or didn't run dev/build before running this task. - if (!empty($throughSpec) && $schema->tableClass($joinTable) !== null) { - throw new RuntimeException("Join table '{$joinTable}' for many_many through relation '{$ownerClass}.{$manyManyRelation}' still has a DataObject class."); - } - - $this->copyDuplicatedLinksInThisRelation($manyManyRelation, $ownerBaseTable, $joinTable, $linkIdField, $ownerIdField, $extraFields, $polymorphicWhereClause); - - $tables = [$baseLinkTable]; - // Include versioned tables if link is versioned - if (Link::has_extension(Versioned::class)) { - $tables[] = "{$baseLinkTable}_Versions"; - $tables[] = "{$baseLinkTable}_Live"; - } - foreach ($tables as $table) { - $ownerIdColumn = $db->escapeIdentifier($table . '.OwnerID'); - $nullCheck = $db->nullCheckClause($ownerIdColumn, true); - - // Set owner fields - $assignments = [ - $ownerIdColumn => [$db->escapeIdentifier("{$ownerBaseTable}.ID") => []], - $db->escapeIdentifier("{$table}.OwnerClass") => [$db->escapeIdentifier("{$ownerBaseTable}.ClassName") => []], - $db->escapeIdentifier("{$table}.OwnerRelation") => $manyManyRelation, - ]; - // Set extra fields - foreach ($extraFields as $fromField => $toField) { - $assignments[$db->escapeIdentifier("{$table}.{$toField}")] = [$db->escapeIdentifier("{$joinTable}.{$fromField}") => []]; - } - - // Make the update, joining on the join table and base owner table - $update = SQLUpdate::create( - $db->escapeIdentifier($table), - $assignments, - [ - // Don't set if there's already an owner for that link - "$ownerIdColumn = 0 OR $nullCheck", - $db->nullCheckClause($db->escapeIdentifier($table . '.OwnerRelation'), true), - ...$polymorphicWhereClause, - ] - )->addInnerJoin($joinTable, $db->escapeIdentifier("{$joinTable}.{$linkIdField}") . ' = ' . $db->escapeIdentifier("{$table}.ID")) - ->addInnerJoin($ownerBaseTable, $db->escapeIdentifier("{$ownerBaseTable}.ID") . ' = ' . $db->escapeIdentifier("{$joinTable}.{$ownerIdField}")); - $update->execute(); - } - // Drop the join table - $this->print("Dropping old many_many join table '{$joinTable}'"); - DB::get_conn()->query("DROP TABLE \"{$joinTable}\""); - } - } - - $this->extend('afterMigrateManyManyRelations'); - } - - /** - * Duplicate any links which appear multiple times in a many_many relation - * and remove the duplicate rows from the join table - */ - private function copyDuplicatedLinksInThisRelation( - string $relationName, - string $ownerBaseTable, - string $joinTable, - string $linkIdField, - string $ownerIdField, - array $extraFields, - array $polymorphicWhereClause - ): void { - $db = DB::get_conn(); - $schema = DataObject::getSchema(); - $baseLinkTable = $schema->baseDataTable(Link::class); - $joinLinkIdColumn = $db->escapeIdentifier("{$joinTable}.{$linkIdField}"); - $joinOwnerIdColumn = $db->escapeIdentifier("{$joinTable}.{$ownerIdField}"); - $subclassLinkJoins = []; - - // Prepare subquery that identifies which rows are for duplicate links - $duplicates = SQLSelect::create( - $joinLinkIdColumn, - $db->escapeIdentifier($joinTable), - $polymorphicWhereClause, - groupby: $joinLinkIdColumn, - having: "COUNT({$joinLinkIdColumn}) > 1" - )->execute(); - - // Exit early if there's no duplicates - if ($duplicates->numRecords() < 1) { - return; - } - - // Get selection fields, aliased so they can be dropped straight into a link record - $selections = [ - 'ID' => $joinLinkIdColumn, - 'OwnerClass' => $db->escapeIdentifier("{$ownerBaseTable}.ClassName"), - 'OwnerID' => $db->escapeIdentifier("{$ownerBaseTable}.ID"), - ]; - // Select additional base columns except where they're mapped as extra fields (e.g. sort may come from manymany) - foreach ($this->getBaseColumnMap() as $baseField) { - if ($baseField !== 'ID' && !in_array($baseField, $extraFields)) { - $selections[$baseField] = $db->escapeIdentifier("{$baseLinkTable}.{$baseField}"); - } - } - // Select extra fields, aliased as appropriate - foreach ($extraFields as $fromField => $toField) { - $selections[$toField] = $db->escapeIdentifier("{$joinTable}.{$fromField}"); - } - // Select columns from subclasses (e.g. Email, Phone, etc) - foreach (static::config()->get('link_type_columns') as $spec) { - foreach ($spec['fields'] as $subclassField) { - $selections[$subclassField] = $schema->sqlColumnForField($spec['class'], $subclassField); - // Make sure we join the subclass table into the query - $subclassTable = $schema->tableForField($spec['class'], $subclassField); - if (!array_key_exists($subclassTable, $subclassLinkJoins)) { - $subclassLinkJoins[$subclassTable] = $db->escapeIdentifier("{$subclassTable}.ID") . ' = ' . $db->escapeIdentifier("{$baseLinkTable}.ID"); - } - } - } - - $toDelete = []; - $originalLinks = []; - $currentChunk = 0; - $chunkSize = static::config()->get('chunk_size'); - $count = $chunkSize; - $duplicateIDs = implode(', ', $duplicates->column()); - - // To ensure this scales well, we'll fetch and duplicate links in chunks. - while ($count >= $chunkSize) { - $select = SQLSelect::create( - $selections, - $db->escapeIdentifier($joinTable), - [ - "{$joinLinkIdColumn} in ({$duplicateIDs})", - ...$polymorphicWhereClause, - ] - ) - ->addInnerJoin($ownerBaseTable, $db->escapeIdentifier("{$ownerBaseTable}.ID") . " = {$joinOwnerIdColumn}") - ->addInnerJoin($baseLinkTable, $db->escapeIdentifier("{$baseLinkTable}.ID") . " = {$joinLinkIdColumn}"); - // Add joins for link subclasses - foreach ($subclassLinkJoins as $subclassTable => $onPredicate) { - if (!$select->isJoinedTo($subclassTable)) { - $select->addLeftJoin($subclassTable, $onPredicate); - } - } - $linkData = $select->setLimit($chunkSize, $chunkSize * $currentChunk)->execute(); - // Prepare for next iteration - $count = $linkData->numRecords(); - $currentChunk++; - - foreach ($linkData as $link) { - $ownerID = $link['OwnerID']; - $linkID = $link['ID']; - unset($link['ID']); - // Skip the first of each duplicate set (i.e. the original link) - if (!array_key_exists($linkID, $originalLinks)) { - $originalLinks[$linkID] = true; - continue; - } - // Mark duplicate join row for deletion - $toDelete[] = "{$joinOwnerIdColumn} = {$ownerID} AND {$joinLinkIdColumn} = {$linkID}"; - // Create the duplicate link - note it already has its correct owner relation and other necessary data - $link['OwnerRelation'] = $relationName; - $newLink = $link['ClassName']::create($link); - $this->extend('updateNewLink', $newLink, $link); - $newLink->write(); - } - - // If $chunkSize was null, we did everything in a single chunk - // but we need to break the loop artificially. - if ($chunkSize === null) { - break; - } - } - - // Delete the duplicate rows from the join table - SQLDelete::create($db->escapeIdentifier($joinTable), $polymorphicWhereClause)->addWhereAny($toDelete)->execute(); - } - - /** - * If the table exists, returns it. If it exists but is obsolete, returned the obsolete - * prefixed name. - * Returns null if the table doesn't exist at all. - */ - private function getTableOrObsoleteTable(string $tableName): ?string - { - $allTables = DB::table_list(); - if (!array_key_exists(strtolower($tableName), $allTables)) { - $tableName = '_obsolete_' . $tableName; - if (!array_key_exists(strtolower($tableName), $allTables)) { - return null; - } - } - return $tableName; - } - - private function getBaseColumnMap(): array - { - $baseColumnMap = static::config()->get('base_link_columns'); - foreach (array_keys(DataObject::config()->uninherited('fixed_fields')) as $fixedField) { - $baseColumnMap[$fixedField] = $fixedField; - } - return $baseColumnMap; - } - private function classIsOldLink(string $class): bool { return $class === 'gorriecoe\Link\Models\Link'; diff --git a/src/Tasks/LinkableMigrationTask.php b/src/Tasks/LinkableMigrationTask.php new file mode 100644 index 00000000..eab22854 --- /dev/null +++ b/src/Tasks/LinkableMigrationTask.php @@ -0,0 +1,115 @@ + 'OpenInNew', + 'Title' => 'LinkText', + ]; + + /** + * Mapping for different types of links, including the class to map to and + * database column mappings. + */ + private static array $link_type_columns = [ + 'URL' => [ + 'class' => ExternalLink::class, + 'fields' => [ + 'URL' => 'ExternalUrl', + ], + ], + 'Email' => [ + 'class' => EmailLink::class, + 'fields' => [ + 'Email' => 'Email', + ], + ], + 'Phone' => [ + 'class' => PhoneLink::class, + 'fields' => [ + 'Phone' => 'Phone', + ], + ], + 'File' => [ + 'class' => FileLink::class, + 'fields' => [ + 'FileID' => 'FileID', + ], + ], + 'SiteTree' => [ + 'class' => SiteTreeLink::class, + 'fields' => [ + 'SiteTreeID' => 'PageID', + ], + ], + ]; + + /** + * List any has_many relations that should be migrated. + */ + private static array $has_many_links_data = []; + + + /** + * List any many_many relations that should be migrated. + */ + private static array $many_many_links_data = []; + + /** + * The table name for the base link model. + */ + private string $oldTableName; + + /** + * The old link model class name + */ + private function classIsOldLink(string $class): bool + { + return $class === 'Sheadawson\Linkable\Models\Link'; + } +} diff --git a/src/Tasks/ModuleMigrationTaskTrait.php b/src/Tasks/ModuleMigrationTaskTrait.php new file mode 100644 index 00000000..d0f7253a --- /dev/null +++ b/src/Tasks/ModuleMigrationTaskTrait.php @@ -0,0 +1,529 @@ +extend('beforePerformMigration'); + // Because we're using SQL INSERT with specific ID values, + // we can't perform the migration if there are existing links because there + // may be ID conflicts. + if (Link::get()->exists()) { + throw new RuntimeException('Cannot perform migration with existing silverstripe/linkfield link records.'); + } + + $this->insertBaseRows(); + $this->insertTypeSpecificRows(); + $this->updateSiteTreeRows(); + $this->migrateHasManyRelations(); + $this->migrateManyManyRelations(); + $this->setOwnerForHasOneLinks(); + + $this->print("Dropping old link table '{$this->oldTableName}'"); + DB::get_conn()->query("DROP TABLE \"{$this->oldTableName}\""); + + $this->print('-----------------'); + $this->print('Bulk data migration complete. All links should be correct (but unpublished) at this stage.'); + $this->print('-----------------'); + + $this->publishLinks(); + + $this->print('-----------------'); + $this->print('Migration completed successfully.'); + $this->print('-----------------'); + $this->extend('afterPerformMigration'); + } + + /** + * Check if we actually need to migrate anything, and if not give clear output as to why not. + */ + private function getNeedsMigration(): bool + { + $oldTableName = $this->getTableOrObsoleteTable(static::config()->get('old_link_table')); + if (!$oldTableName) { + $this->print('Nothing to migrate - old link table doesn\'t exist.'); + return false; + } + $this->oldTableName = $oldTableName; + return true; + } + + /** + * Insert a row into the base Link table for each link, mapping all of the columns + * that are shared across all link types. + */ + private function insertBaseRows(): void + { + $this->extend('beforeInsertBaseRows'); + $db = DB::get_conn(); + + // Get a full map of columns to migrate that applies to all link types + $baseTableColumnMap = $this->getBaseColumnMap(); + // ClassName will need to be handled per link type + unset($baseTableColumnMap['ClassName']); + + // Set the correct ClassName based on the type of link. + // Note that case statements have no abstraction, but are already used elsewhere + // so should be safe. See DataQuery::getFinalisedQuery() which is used for all + // DataList queries. + $classNameSelect = 'CASE '; + $typeColumn = $db->escapeIdentifier("{$this->oldTableName}.Type"); + foreach (static::config()->get('link_type_columns') as $type => $spec) { + $toClass = $db->quoteString($spec['class']); + $type = $db->quoteString($type); + $classNameSelect .= "WHEN {$typeColumn} = {$type} THEN {$toClass} "; + } + $classNameSelect .= 'ELSE ' . $db->quoteString(Link::class) . ' END AS ClassName'; + + // Insert rows + $baseTable = DataObject::getSchema()->baseDataTable(Link::class); + $quotedBaseTable = $db->escapeIdentifier($baseTable); + $baseColumns = implode(', ', array_values($baseTableColumnMap)); + $subQuery = SQLSelect::create( + array_keys($baseTableColumnMap), + $db->escapeIdentifier($this->oldTableName) + )->addSelect($classNameSelect)->sql(); + // We can't use the ORM to do INSERT with SELECT, but thankfully + // the syntax is generic enough that it should work for all SQL databases. + DB::query("INSERT INTO {$quotedBaseTable} ({$baseColumns}, ClassName) {$subQuery}"); + $this->extend('afterInsertBaseRows'); + } + + /** + * Insert rows for all link subclasses based on the type of the old link + */ + private function insertTypeSpecificRows(): void + { + $this->extend('beforeInsertTypeSpecificRows'); + $schema = DataObject::getSchema(); + $db = DB::get_conn(); + foreach (static::config()->get('link_type_columns') as $type => $spec) { + $type = $db->quoteString($type); + $toClass = $spec['class']; + $columnMap = $spec['fields']; + + $table = $schema->tableName($toClass); + $quotedTable = $db->escapeIdentifier($table); + $baseColumns = implode(', ', array_values($columnMap)); + $subQuery = SQLSelect::create( + ['ID', ...array_keys($columnMap)], + $db->escapeIdentifier($this->oldTableName), + [$db->escapeIdentifier("{$this->oldTableName}.Type") . " = {$type}"] + )->sql(); + // We can't use the ORM to do INSERT with SELECT, but thankfully + // the syntax is generic enough that it should work for all SQL databases. + DB::query("INSERT INTO {$quotedTable} (ID, {$baseColumns}) {$subQuery}"); + } + $this->extend('afterInsertTypeSpecificRows'); + } + + /** + * Update the Anchor column for SiteTreeLink + */ + private function updateSiteTreeRows(): void + { + $this->extend('beforeUpdateSiteTreeRows'); + // We have to split the Anchor column, which means we have to fetch and operate on the values. + $currentChunk = 0; + $chunkSize = static::config()->get('chunk_size'); + $count = $chunkSize; + $db = DB::get_conn(); + $schema = DataObject::getSchema(); + $siteTreeLinkTable = $schema->tableForField(SiteTreeLink::class, 'Anchor'); + // Keep looping until we run out of chunks + while ($count >= $chunkSize) { + // Get data about the old SiteTree links + $oldLinkRows = SQLSelect::create( + ['ID', 'Anchor'], + $db->escapeIdentifier($this->oldTableName), + [ + $db->escapeIdentifier($this->oldTableName . '.Type') => 'SiteTree', + $db->nullCheckClause($db->escapeIdentifier($this->oldTableName . '.Anchor'), false) + ] + )->setLimit($chunkSize, $chunkSize * $currentChunk)->execute(); + // Prepare for next iteration + $count = $oldLinkRows->numRecords(); + $currentChunk++; + + // Update all links which have an anchor + foreach ($oldLinkRows as $oldLink) { + // Get the query string and anchor separated + $queryString = null; + $anchor = null; + $oldAnchor = $oldLink['Anchor']; + if (str_starts_with($oldAnchor, '#')) { + $parts = explode('?', $oldAnchor, 2); + $anchor = ltrim($parts[0], '#'); + $queryString = ltrim($parts[1] ?? '', '?'); + } elseif (str_starts_with($oldAnchor, '?')) { + $parts = explode('#', $oldAnchor, 2); + $queryString = ltrim($parts[0], '?'); + $anchor = ltrim($parts[1] ?? '', '#'); + } else { + // Assume it's an anchor and they just forgot the # + // We don't need the # so just add it directly. + $anchor = $oldAnchor; + } + $this->extend('updateAnchorAndQueryString', $anchor, $queryString, $oldAnchor); + // Update the link with the correct anchor and query string + SQLUpdate::create( + $db->escapeIdentifier($siteTreeLinkTable), + [ + $schema->sqlColumnForField(SiteTreeLink::class, 'Anchor') => $anchor, + $schema->sqlColumnForField(SiteTreeLink::class, 'QueryString') => $queryString, + ], + [$db->escapeIdentifier($siteTreeLinkTable . '.ID') => $oldLink['ID']] + )->execute(); + } + + // If $chunkSize was null, we did everything in a single chunk + // but we need to break the loop artificially. + if ($chunkSize === null) { + break; + } + } + $this->extend('afterUpdateSiteTreeRows'); + } + + private function migrateHasManyRelations(): void + { + $this->extend('beforeMigrateHasManyRelations'); + $linksList = static::config()->get('has_many_links_data'); + + // Exit early if there's nothing to migrate + if (empty($linksList)) { + $this->print('No has_many relations to migrate.'); + $this->extend('afterMigrateHasManyRelations'); + return; + } + + $this->print('Migrating has_many relations.'); + $schema = DataObject::getSchema(); + $db = DB::get_conn(); + $oldTableFields = DB::field_list($this->oldTableName); + foreach ($linksList as $ownerClass => $relations) { + foreach ($relations as $hasManyRelation => $hasOneRelation) { + // Check if HasOneID column is in the old base Link table + if (!array_key_exists("{$hasOneRelation}ID", $oldTableFields)) { + // This is an unusual situation, and is difficult to do generically. + // We'll leave this scenario up to the developer to handle. + $this->extend('migrateHasOneForLinkSubclass', $linkClass, $ownerClass, $hasOneRelation, $hasManyRelation); + continue; + } + $linkTable = $schema->baseDataTable(Link::class); + $tables = [$linkTable]; + // Include versioned tables if link is versioned + if (Link::has_extension(Versioned::class)) { + $tables[] = "{$linkTable}_Versions"; + $tables[] = "{$linkTable}_Live"; + } + $wasPolyMorphic = array_key_exists("{$hasOneRelation}Class", $oldTableFields); + $wasMultiRelational = $wasPolyMorphic && array_key_exists("{$hasOneRelation}Relation", $oldTableFields); + // Migrate old has_one on link to the Owner relation. + foreach ($tables as $table) { + // Only set owner where the OwnerID is not already set + $ownerIdColumn = $db->escapeIdentifier($table . '.OwnerID'); + $nullCheck = $db->nullCheckClause($ownerIdColumn, true); + $whereClause = [ + "$ownerIdColumn = 0 OR $nullCheck", + $db->nullCheckClause($db->escapeIdentifier($table . '.OwnerRelation'), true), + ]; + if ($wasPolyMorphic) { + // For polymorphic relations, don't set the owner for records belonging + // to a different class hierarchy. + $validClasses = ClassInfo::subclassesFor($ownerClass, true); + $placeholders = DB::placeholders($validClasses); + $whereClause[] = [$db->escapeIdentifier("{$this->oldTableName}.{$hasOneRelation}Class") . " IN ($placeholders)" => $validClasses]; + if ($wasMultiRelational) { + $whereClause[] = [$db->escapeIdentifier("{$this->oldTableName}.{$hasOneRelation}Relation") => $hasManyRelation]; + } + } + $update = SQLUpdate::create( + $db->escapeIdentifier($table), + [ + $db->escapeIdentifier($table . '.OwnerID') => [$schema->sqlColumnForField($ownerClass, 'ID') => []], + $db->escapeIdentifier($table . '.OwnerClass') => [$schema->sqlColumnForField($ownerClass, 'ClassName') => []], + $db->escapeIdentifier($table . '.OwnerRelation') => $hasManyRelation, + ], + $whereClause + ) + ->addInnerJoin($this->oldTableName, $db->escapeIdentifier($this->oldTableName . '.ID') . ' = ' . $db->escapeIdentifier("{$table}.ID")) + ->addInnerJoin($schema->baseDataTable($ownerClass), $schema->sqlColumnForField($ownerClass, 'ID') . ' = ' . $db->escapeIdentifier("{$this->oldTableName}.{$hasOneRelation}ID")); + $update->execute(); + } + } + } + $this->extend('afterMigrateHasManyRelations'); + } + + private function migrateManyManyRelations(): void + { + $this->extend('beforeMigrateManyManyRelations'); + $linksList = static::config()->get('many_many_links_data'); + + // Exit early if there's nothing to migrate + if (empty($linksList)) { + $this->print('No many_many relations to migrate.'); + $this->extend('afterMigrateManyManyRelations'); + return; + } + + $this->print('Migrating many_many relations.'); + $schema = DataObject::getSchema(); + $db = DB::get_conn(); + $baseLinkTable = $schema->baseDataTable(Link::class); + $originalOldLinkTable = str_replace('_obsolete_', '', $this->oldTableName); + foreach ($linksList as $ownerClass => $relations) { + $ownerBaseTable = $schema->baseDataTable($ownerClass); + $ownerTable = $schema->tableName($ownerClass); + foreach ($relations as $manyManyRelation => $spec) { + $throughSpec = $spec['through'] ?? []; + if (!empty($throughSpec)) { + if (!isset($spec['table'])) { + throw new RuntimeException("Must declare the table name for many_many through relation '{$ownerClass}.{$manyManyRelation}'."); + } + $ownerIdField = $throughSpec['from'] . 'ID'; + $linkIdField = $throughSpec['to'] . 'ID'; + } else { + $ownerIdField = "{$ownerTable}ID"; + $linkIdField = "{$originalOldLinkTable}ID"; + } + $extraFields = $spec['extraFields'] ?? []; + $joinTable = $this->getTableOrObsoleteTable($spec['table'] ?? "{$ownerTable}_{$manyManyRelation}"); + + if ($joinTable === null) { + throw new RuntimeException("Couldn't find join table for many_many relation '{$ownerClass}.{$manyManyRelation}'."); + } + + $polymorphicWhereClause = []; + if (!empty($throughSpec)) { + $joinColumns = DB::field_list($joinTable); + if (array_key_exists($throughSpec['from'] . 'Class', $joinColumns)) { + // For polymorphic relations, don't set the owner for records belonging + // to a different class hierarchy. + $validClasses = ClassInfo::subclassesFor($ownerClass, true); + $placeholders = DB::placeholders($validClasses); + $polymorphicClassColumn = $throughSpec['from'] . 'Class'; + $polymorphicWhereClause = [$db->escapeIdentifier("{$joinTable}.{$polymorphicClassColumn}") . " IN ($placeholders)" => $validClasses]; + } + } + + // If the join table for many_many through still has an associated DataObject class, + // something is very weird and we should throw an error. + // Most likely the developer just forgot to delete it or didn't run dev/build before running this task. + if (!empty($throughSpec) && $schema->tableClass($joinTable) !== null) { + throw new RuntimeException("Join table '{$joinTable}' for many_many through relation '{$ownerClass}.{$manyManyRelation}' still has a DataObject class."); + } + + $this->copyDuplicatedLinksInThisRelation($manyManyRelation, $ownerBaseTable, $joinTable, $linkIdField, $ownerIdField, $extraFields, $polymorphicWhereClause); + + $tables = [$baseLinkTable]; + // Include versioned tables if link is versioned + if (Link::has_extension(Versioned::class)) { + $tables[] = "{$baseLinkTable}_Versions"; + $tables[] = "{$baseLinkTable}_Live"; + } + foreach ($tables as $table) { + $ownerIdColumn = $db->escapeIdentifier($table . '.OwnerID'); + $nullCheck = $db->nullCheckClause($ownerIdColumn, true); + + // Set owner fields + $assignments = [ + $ownerIdColumn => [$db->escapeIdentifier("{$ownerBaseTable}.ID") => []], + $db->escapeIdentifier("{$table}.OwnerClass") => [$db->escapeIdentifier("{$ownerBaseTable}.ClassName") => []], + $db->escapeIdentifier("{$table}.OwnerRelation") => $manyManyRelation, + ]; + // Set extra fields + foreach ($extraFields as $fromField => $toField) { + $assignments[$db->escapeIdentifier("{$table}.{$toField}")] = [$db->escapeIdentifier("{$joinTable}.{$fromField}") => []]; + } + + // Make the update, joining on the join table and base owner table + $update = SQLUpdate::create( + $db->escapeIdentifier($table), + $assignments, + [ + // Don't set if there's already an owner for that link + "$ownerIdColumn = 0 OR $nullCheck", + $db->nullCheckClause($db->escapeIdentifier($table . '.OwnerRelation'), true), + ...$polymorphicWhereClause, + ] + )->addInnerJoin($joinTable, $db->escapeIdentifier("{$joinTable}.{$linkIdField}") . ' = ' . $db->escapeIdentifier("{$table}.ID")) + ->addInnerJoin($ownerBaseTable, $db->escapeIdentifier("{$ownerBaseTable}.ID") . ' = ' . $db->escapeIdentifier("{$joinTable}.{$ownerIdField}")); + $update->execute(); + } + // Drop the join table + $this->print("Dropping old many_many join table '{$joinTable}'"); + DB::get_conn()->query("DROP TABLE \"{$joinTable}\""); + } + } + + $this->extend('afterMigrateManyManyRelations'); + } + + /** + * Duplicate any links which appear multiple times in a many_many relation + * and remove the duplicate rows from the join table + */ + private function copyDuplicatedLinksInThisRelation( + string $relationName, + string $ownerBaseTable, + string $joinTable, + string $linkIdField, + string $ownerIdField, + array $extraFields, + array $polymorphicWhereClause + ): void { + $db = DB::get_conn(); + $schema = DataObject::getSchema(); + $baseLinkTable = $schema->baseDataTable(Link::class); + $joinLinkIdColumn = $db->escapeIdentifier("{$joinTable}.{$linkIdField}"); + $joinOwnerIdColumn = $db->escapeIdentifier("{$joinTable}.{$ownerIdField}"); + $subclassLinkJoins = []; + + // Prepare subquery that identifies which rows are for duplicate links + $duplicates = SQLSelect::create( + $joinLinkIdColumn, + $db->escapeIdentifier($joinTable), + $polymorphicWhereClause, + groupby: $joinLinkIdColumn, + having: "COUNT({$joinLinkIdColumn}) > 1" + )->execute(); + + // Exit early if there's no duplicates + if ($duplicates->numRecords() < 1) { + return; + } + + // Get selection fields, aliased so they can be dropped straight into a link record + $selections = [ + 'ID' => $joinLinkIdColumn, + 'OwnerClass' => $db->escapeIdentifier("{$ownerBaseTable}.ClassName"), + 'OwnerID' => $db->escapeIdentifier("{$ownerBaseTable}.ID"), + ]; + // Select additional base columns except where they're mapped as extra fields (e.g. sort may come from manymany) + foreach ($this->getBaseColumnMap() as $baseField) { + if ($baseField !== 'ID' && !in_array($baseField, $extraFields)) { + $selections[$baseField] = $db->escapeIdentifier("{$baseLinkTable}.{$baseField}"); + } + } + // Select extra fields, aliased as appropriate + foreach ($extraFields as $fromField => $toField) { + $selections[$toField] = $db->escapeIdentifier("{$joinTable}.{$fromField}"); + } + // Select columns from subclasses (e.g. Email, Phone, etc) + foreach (static::config()->get('link_type_columns') as $spec) { + foreach ($spec['fields'] as $subclassField) { + $selections[$subclassField] = $schema->sqlColumnForField($spec['class'], $subclassField); + // Make sure we join the subclass table into the query + $subclassTable = $schema->tableForField($spec['class'], $subclassField); + if (!array_key_exists($subclassTable, $subclassLinkJoins)) { + $subclassLinkJoins[$subclassTable] = $db->escapeIdentifier("{$subclassTable}.ID") . ' = ' . $db->escapeIdentifier("{$baseLinkTable}.ID"); + } + } + } + + $toDelete = []; + $originalLinks = []; + $currentChunk = 0; + $chunkSize = static::config()->get('chunk_size'); + $count = $chunkSize; + $duplicateIDs = implode(', ', $duplicates->column()); + + // To ensure this scales well, we'll fetch and duplicate links in chunks. + while ($count >= $chunkSize) { + $select = SQLSelect::create( + $selections, + $db->escapeIdentifier($joinTable), + [ + "{$joinLinkIdColumn} in ({$duplicateIDs})", + ...$polymorphicWhereClause, + ] + ) + ->addInnerJoin($ownerBaseTable, $db->escapeIdentifier("{$ownerBaseTable}.ID") . " = {$joinOwnerIdColumn}") + ->addInnerJoin($baseLinkTable, $db->escapeIdentifier("{$baseLinkTable}.ID") . " = {$joinLinkIdColumn}"); + // Add joins for link subclasses + foreach ($subclassLinkJoins as $subclassTable => $onPredicate) { + if (!$select->isJoinedTo($subclassTable)) { + $select->addLeftJoin($subclassTable, $onPredicate); + } + } + $linkData = $select->setLimit($chunkSize, $chunkSize * $currentChunk)->execute(); + // Prepare for next iteration + $count = $linkData->numRecords(); + $currentChunk++; + + foreach ($linkData as $link) { + $ownerID = $link['OwnerID']; + $linkID = $link['ID']; + unset($link['ID']); + // Skip the first of each duplicate set (i.e. the original link) + if (!array_key_exists($linkID, $originalLinks)) { + $originalLinks[$linkID] = true; + continue; + } + // Mark duplicate join row for deletion + $toDelete[] = "{$joinOwnerIdColumn} = {$ownerID} AND {$joinLinkIdColumn} = {$linkID}"; + // Create the duplicate link - note it already has its correct owner relation and other necessary data + $link['OwnerRelation'] = $relationName; + $newLink = $link['ClassName']::create($link); + $this->extend('updateNewLink', $newLink, $link); + $newLink->write(); + } + + // If $chunkSize was null, we did everything in a single chunk + // but we need to break the loop artificially. + if ($chunkSize === null) { + break; + } + } + + // Delete the duplicate rows from the join table + SQLDelete::create($db->escapeIdentifier($joinTable), $polymorphicWhereClause)->addWhereAny($toDelete)->execute(); + } + + /** + * If the table exists, returns it. If it exists but is obsolete, returned the obsolete + * prefixed name. + * Returns null if the table doesn't exist at all. + */ + private function getTableOrObsoleteTable(string $tableName): ?string + { + $allTables = DB::table_list(); + if (!array_key_exists(strtolower($tableName), $allTables)) { + $tableName = '_obsolete_' . $tableName; + if (!array_key_exists(strtolower($tableName), $allTables)) { + return null; + } + } + return $tableName; + } + + private function getBaseColumnMap(): array + { + $baseColumnMap = static::config()->get('base_link_columns'); + foreach (array_keys(DataObject::config()->uninherited('fixed_fields')) as $fixedField) { + $baseColumnMap[$fixedField] = $fixedField; + } + return $baseColumnMap; + } +} \ No newline at end of file diff --git a/tests/php/Tasks/LinkableMigrationTask/CustomLinkableLink.php b/tests/php/Tasks/LinkableMigrationTask/CustomLinkableLink.php new file mode 100644 index 00000000..1702561a --- /dev/null +++ b/tests/php/Tasks/LinkableMigrationTask/CustomLinkableLink.php @@ -0,0 +1,15 @@ + HasManyLinkableLinkOwner::class, + ]; +} diff --git a/tests/php/Tasks/LinkableMigrationTask/HasManyLinkableLinkOwner.php b/tests/php/Tasks/LinkableMigrationTask/HasManyLinkableLinkOwner.php new file mode 100644 index 00000000..7de95922 --- /dev/null +++ b/tests/php/Tasks/LinkableMigrationTask/HasManyLinkableLinkOwner.php @@ -0,0 +1,15 @@ + CustomLinkableLink::class, + ]; +} diff --git a/tests/php/Tasks/LinkableMigrationTask/HasOneLinkableLinkOwner.php b/tests/php/Tasks/LinkableMigrationTask/HasOneLinkableLinkOwner.php new file mode 100644 index 00000000..2bae70fe --- /dev/null +++ b/tests/php/Tasks/LinkableMigrationTask/HasOneLinkableLinkOwner.php @@ -0,0 +1,15 @@ + CustomLinkableLink::class, + ]; +} diff --git a/tests/php/Tasks/LinkableMigrationTaskTest.php b/tests/php/Tasks/LinkableMigrationTaskTest.php new file mode 100644 index 00000000..1fe1ae5d --- /dev/null +++ b/tests/php/Tasks/LinkableMigrationTaskTest.php @@ -0,0 +1,203 @@ + ExternalLink::class, + 'Email' => EmailLink::class, + 'Phone' => PhoneLink::class, + 'File' => FileLink::class, + 'SiteTree' => SiteTreeLink::class, + 'Custom' => CustomLink::class, + ]; + + protected static $fixture_file = 'LinkableMigrationTaskTest.yml'; + + protected static $extra_dataobjects = [ + CustomLinkableLink::class, + HasManyLinkableLinkOwner::class, + HasOneLinkableLinkOwner::class, + ]; + + protected function setUp(): void + { + parent::setUp(); + $this->baseTable = DataObject::getSchema()->baseDataTable(Link::class); + $this->oldTable = DataObject::getSchema()->baseDataTable(CustomLinkableLink::class); + LinkableMigrationTask::config()->set('old_link_table', $this->oldTable); + + LinkableMigrationTask::config()->merge('link_type_columns', [ + 'Custom' => [ + 'class' => CustomLinkableLink::class, + 'fields' => [ + 'HasManyLinks' => 'ForHasMany', + ], + ], + ]); + LinkableMigrationTask::config()->merge('base_link_columns', [ + 'MySort' => 'Sort', + ]); + } + + public function onBeforeLoadFixtures(): void + { + LinkableMigrationTask::config()->set('old_link_table', self::OLD_LINK_TABLE); + // Set up migration tables + DB::get_schema()->schemaUpdate(function () { + // Old link table + $linkDbColumns = [ + ...DataObject::config()->uninherited('fixed_fields'), + // Fields directly from the Link class + 'Title' => 'Varchar', + 'Type' => 'Varchar(50)', + 'URL' => 'Text', + 'Email' => 'Varchar', + 'Phone' => 'Varchar(30)', + 'OpenInNewWindow' => 'Boolean', + 'SelectedStyle' => 'Varchar', + 'FileID' => 'ForeignKey', + // Fields from the LinkSiteTree extension + 'Anchor' => 'Varchar(255)', + 'SiteTreeID' => 'ForeignKey', + // Field for a custom link type + 'CustomField' => 'Varchar', + // Field for custom sort + 'MySort' => 'Int', + ]; + DB::require_table(self::OLD_LINK_TABLE, $linkDbColumns, options: DataObject::config()->get('create_table_options')); + // many_many tables + $schema = DataObject::getSchema(); + $ownerTable = $schema->tableName(WasManyManyOwner::class); + $normalJoinColumns = [ + "{$ownerTable}ID" => 'ForeignKey', + self::OLD_LINK_TABLE . 'ID' => 'ForeignKey', + 'CustomSort' => 'Int', + ]; + DB::require_table("{$ownerTable}_NormalManyMany", $normalJoinColumns, options: DataObject::config()->get('create_table_options')); + $throughJoinColumns = [ + 'OldOwnerID' => 'ForeignKey', + 'OldLinkID' => 'ForeignKey', + 'CustomSort' => 'Int', + ]; + DB::require_table('GorriecoeMigrationTaskTest_manymany_through', $throughJoinColumns, options: DataObject::config()->get('create_table_options')); + $throughPolymorphicJoinColumns = [ + ...$throughJoinColumns, + // technically it would be a DBClassName enum but this is easier and the actual type doesn't matter + 'OldOwnerClass' => 'Varchar', + ]; + DB::require_table('GorriecoeMigrationTaskTest_manymany_throughpoly', $throughPolymorphicJoinColumns, options: DataObject::config()->get('create_table_options')); + }); + parent::onBeforeLoadFixtures(); + } + + protected function tearDown(): void + { + parent::tearDown(); + } + + public function testGetNeedsMigration() + { + $result = $this->callPrivateMethod('getNeedsMigration'); + $this->assertTrue($result); + } + + public function testInsertBaseRows() + { + $this->callPrivateMethod('insertBaseRows'); + + $db = DB::get_conn(); + + $baseRecords = new SQLSelect('*', $db->escapeIdentifier($this->baseTable)); + $result = $baseRecords->execute(); + + foreach ($result as $link) { + $oldRecord = new SQLSelect('*', $db->escapeIdentifier($this->oldTable), ['ID' => $link['ID']]); + $oldRecord = $oldRecord->execute()->record(); + + $this->assertSame($oldRecord['ID'], $link['ID']); + $this->assertSame($oldRecord['Title'], $link['LinkText']); + $this->assertSame($oldRecord['OpenInNewWindow'], $link['OpenInNew']); + } + + $this->assertFalse(empty($result)); + } + + public function testInsertTypeSpecificRows() + { + $this->callPrivateMethod('insertTypeSpecificRows'); + + $db = DB::get_conn(); + + $baseRecords = new SQLSelect('*', $db->escapeIdentifier($this->baseTable)); + $result = $baseRecords->execute(); + foreach ($result as $link) { + $oldRecord = new SQLSelect('*', $db->escapeIdentifier($this->oldTable), ['ID' => $link['ID']]); + $oldRecord = $oldRecord->execute()->record(); + + $siteTreeLinkRecord = new SQLSelect('*', $db->escapeIdentifier(SiteTreeLink::class), ['ID' => $link['ID']]); + + $this->assertSame($oldRecord['ID'], $link['ID']); + $this->assertSame($oldRecord['Title'], $link['LinkText']); + $this->assertSame($oldRecord['OpenInNewWindow'], $link['OpenInNew']); + } + + $this->assertFalse(empty($result)); + } + + public function testMigrateHasManyRelations() + { + $this->callPrivateMethod('migrateHasManyRelations'); + + $db = DB::get_conn(); + + $baseRecords = new SQLSelect('*', $db->escapeIdentifier($this->baseTable)); + $result = $baseRecords->execute(); + foreach ($result as $link) { + $oldRecord = new SQLSelect('*', $db->escapeIdentifier($this->oldTable), ['ID' => $link['ID']]); + $oldRecord = $oldRecord->execute()->record(); + + $siteTreeLinkRecord = new SQLSelect('*', $db->escapeIdentifier(SiteTreeLink::class), ['ID' => $link['ID']]); + + $this->assertSame($oldRecord['ID'], $link['ID']); + $this->assertSame($oldRecord['Title'], $link['LinkText']); + $this->assertSame($oldRecord['OpenInNewWindow'], $link['OpenInNew']); + } + + $this->assertFalse(empty($result)); + } + + private function callPrivateMethod(string $methodName, array $args = []): mixed + { + $task = Deprecation::withNoReplacement(fn() => new LinkableMigrationTask()); + $reflectionMethod = new ReflectionMethod($task, $methodName); + $reflectionMethod->setAccessible(true); + return $reflectionMethod->invoke($task, ...$args); + } +} diff --git a/tests/php/Tasks/LinkableMigrationTaskTest.yml b/tests/php/Tasks/LinkableMigrationTaskTest.yml new file mode 100644 index 00000000..0615c861 --- /dev/null +++ b/tests/php/Tasks/LinkableMigrationTaskTest.yml @@ -0,0 +1,92 @@ +SilverStripe\LinkField\Tests\Tasks\LinkableMigrationTaskTest\HasManyLinkableLinkOwner: + has-many-owner-1: + Title: 'HasMany Link Owner 1' + has-many-owner-2: + Title: 'HasMany Link Owner 2' + has-many-owner-3: + Title: 'HasMany Link Owner 3' + has-many-owner-4: + Title: 'HasMany Link Owner 4' + +SilverStripe\LinkField\Tests\Tasks\LinkableMigrationTaskTest\CustomLinkableLink: + custom-link-1: + Title: 'HasMany Link 1' + Type: 'Email' + Email: 'test1@test.com' + OpenInNewWindow: true + ForHasManyID: =>SilverStripe\LinkField\Tests\Tasks\LinkableMigrationTaskTest\HasManyLinkableLinkOwner.has-many-owner-1 + custom-link-2: + Title: 'HasMany Link 2' + Type: 'Email' + Email: 'test1@test.com' + OpenInNewWindow: true + ForHasManyID: =>SilverStripe\LinkField\Tests\Tasks\LinkableMigrationTaskTest\HasManyLinkableLinkOwner.has-many-owner-1 + custom-link-3: + Title: 'HasMany Link 3' + Type: 'Email' + Email: 'test2@test.com' + OpenInNewWindow: true + ForHasManyID: =>SilverStripe\LinkField\Tests\Tasks\LinkableMigrationTaskTest\HasManyLinkableLinkOwner.has-many-owner-1 + custom-link-4: + Title: 'HasMany Link 4' + Type: 'Email' + Email: 'test3@test.com' + OpenInNewWindow: true + ForHasManyID: =>SilverStripe\LinkField\Tests\Tasks\LinkableMigrationTaskTest\HasManyLinkableLinkOwner.has-many-owner-2 + custom-link-5: + Title: 'HasMany Link 5' + Type: 'Email' + Email: 'test4@test.com' + OpenInNewWindow: true + ForHasManyID: =>SilverStripe\LinkField\Tests\Tasks\LinkableMigrationTaskTest\HasManyLinkableLinkOwner.has-many-owner-2 + custom-link-6: + Title: 'HasMany Link 6' + Type: 'URL' + URL: 'http://www.silverstripe.org' + OpenInNewWindow: true + ForHasManyID: =>SilverStripe\LinkField\Tests\Tasks\LinkableMigrationTaskTest\HasManyLinkableLinkOwner.has-many-owner-2 + custom-link-7: + Title: 'HasMany Link 7' + Type: 'URL' + URL: 'http://www.silverstripe.org' + OpenInNewWindow: true + ForHasManyID: =>SilverStripe\LinkField\Tests\Tasks\LinkableMigrationTaskTest\HasManyLinkableLinkOwner.has-many-owner-3 + custom-link-8: + Title: 'HasMany Link 8' + Type: 'URL' + URL: 'http://www.silverstripe.org' + OpenInNewWindow: true + ForHasManyID: =>SilverStripe\LinkField\Tests\Tasks\LinkableMigrationTaskTest\HasManyLinkableLinkOwner.has-many-owner-4 + custom-link-9: + Title: 'HasMany Link 9' + Type: 'URL' + URL: 'http://www.silverstripe.org' + OpenInNewWindow: true + ForHasManyID: =>SilverStripe\LinkField\Tests\Tasks\LinkableMigrationTaskTest\HasManyLinkableLinkOwner.has-many-owner-4 + custom-link-10: + Title: 'HasOne Link 10' + Type: 'URL' + URL: 'http://www.silverstripe.org' + OpenInNewWindow: true + custom-link-11: + Title: 'HasOne Link 11' + Type: 'URL' + URL: 'http://www.silverstripe.org' + OpenInNewWindow: true + custom-link-12: + Title: 'HasOne Link 12' + Type: 'URL' + URL: 'http://www.silverstripe.org' + OpenInNewWindow: true + +SilverStripe\LinkField\Tests\Tasks\LinkableMigrationTaskTest\HasOneLinkableLinkOwner: + owner-1: + Title: 'HasOne Link Owner 1' + Link: =>SilverStripe\LinkField\Tests\Tasks\LinkableMigrationTaskTest\CustomLinkableLink.custom-link-10 + owner-2: + Title: 'HasOne Link Owner 2' + Link: =>SilverStripe\LinkField\Tests\Tasks\LinkableMigrationTaskTest\CustomLinkableLink.custom-link-11 + owner-3: + Title: 'HasOne Link Owner 3' + Link: =>SilverStripe\LinkField\Tests\Tasks\LinkableMigrationTaskTest\CustomLinkableLink.custom-link-12 + \ No newline at end of file