From ed5fd39d2cdab452aa3fee98872ab72556fb6336 Mon Sep 17 00:00:00 2001 From: Guy Sartorelli Date: Thu, 14 Mar 2024 14:41:05 +1300 Subject: [PATCH] NEW Add migration task from gorriecoe/silverstripe-link --- .../en/09_migrating/02_gorriecoe-migration.md | 194 ++++++++ src/Tasks/GorriecoeMigrationTask.php | 385 +++++++++++++++ src/Tasks/LinkFieldMigrationTask.php | 416 +---------------- src/Tasks/TemporaryNameTrait.php | 437 ++++++++++++++++++ 4 files changed, 1024 insertions(+), 408 deletions(-) create mode 100644 docs/en/09_migrating/02_gorriecoe-migration.md create mode 100644 src/Tasks/GorriecoeMigrationTask.php create mode 100644 src/Tasks/TemporaryNameTrait.php diff --git a/docs/en/09_migrating/02_gorriecoe-migration.md b/docs/en/09_migrating/02_gorriecoe-migration.md new file mode 100644 index 00000000..402fbd03 --- /dev/null +++ b/docs/en/09_migrating/02_gorriecoe-migration.md @@ -0,0 +1,194 @@ +# Title to shut my IDE linter up + +> [!WARNING] +> This guide and the associated migration task assume all of the data for your links are in the base table for `gorriecoe\Link\Models\Link` or in automatically generated tables (e.g. join tables for `many_many` relations). +> If you have subclassed `gorriecoe\Link\Models\Link`, there may be additional steps you need to take to migrate the data for your subclass. + +Remove the gorriecoe modules and add silverstripe/linkfield: + +```bash +composer require silverstripe/linkfield:^4 +composer remove gorriecoe/silverstripe-link gorriecoe/silverstripe-linkfield +``` + +If you added any database columns to the `Link` class for sorting `has_many` relations, or any `has_one` relations for storing them, remove the extension or yaml configuration for that now. + +```diff +- gorriecoe\Link\Models\Link: +- db: +- MySortColumn: Int +- has_one: +- Record: App\Model\MyRecord +- belongs_many_many: +- BelongsRecord : App\Model\MyRecord.LinkListTwo +``` + +Update namespaces and relations. + +```diff + namespace App\Model; + +- use gorriecoe\Link\Models\Link; +- use gorriecoe\LinkField\LinkField; ++ use SilverStripe\LinkField\Models\Link; ++ use SilverStripe\LinkField\Form\LinkField; ++ use SilverStripe\LinkField\Form\MultiLinkField; + use SilverStripe\ORM\DataObject; + + class MyRecord extends DataObject + { + private static array $has_one = [ + 'HasOneLink' => Link::class, + ]; + + private static array $has_many = [ +- 'LinkListOne' => Link::class . '.Record', ++ 'LinkListOne' => Link::class . '.Owner', ++ 'LinkListTwo' => Link::class . '.Owner', + ]; + ++ private static array $owns = [ ++ 'HasOneLink', ++ 'LinkListOne', ++ 'LinkListTwo', ++ ]; ++ +- private static array $many_many = [ +- 'LinkListTwo' => Link::class, +- ]; +- +- private static array $many_many_extraFields = [ +- 'LinkListTwo' => [ +- 'Sort' => 'Int', +- ] +- ]; + + public function getCMSFields() + { + $fields = parent::getCMSFields(); ++ $fields->removeByName(['LinkListOneID', 'LinkListOne', 'LinkListTwo']); + $fields->addFieldsToTab( + 'Root.Main', + [ +- LinkField::create('HasOneLink', 'Has one link', $this), +- LinkField::create('LinkListOne', 'List list one', $this)->setSortColumn('MySortColumn'), +- LinkField::create('LinkListTwo', 'Link list two', $this), ++ LinkField::create('HasOneLink', 'Has one link'), ++ MultiLinkField::create('LinkListOne', 'List list one'), ++ MultiLinkField::create('LinkListTwo', 'Link list two'), + ] + ); + return $fields; + } + } +``` + +If you had `many_many` through, and the `DataObject` class being used as the join table isn't used for any other purpose, delete that class. + +NOTE: If the same link appears in multiple `many_many` relation lists, the link will be duplicated so that a single link exists for each `has_many` relation list. +Unless you were doing something custom to manage links, it's unlikely this will affect you - but if it does, just be aware of this and prepare your content authors for +this change in their authoring workflow. + +If you applied [linkfield configuration](https://github.com/elliot-sawyer/silverstripe-linkfield?tab=readme-ov-file#configuration), update that now also. +See [configuring links and link fields](../02_configuration.md) for more information. + +```diff ++ use SilverStripe\LinkField\Models\ExternalLink; ++ use SilverStripe\LinkField\Models\SiteTreeLink; + +- $linkConfig = [ +- 'types' => [ +- 'SiteTree', +- 'URL', +- ], +- 'title_display' => false, +- ]; +- $linkField->setLinkConfig($linkConfig); ++ $allowedTypes = [ ++ SiteTreeLink::class, ++ ExternalLink::class, ++ ]; ++ $linkField->setAllowedTypes($allowedTypes); ++ $linkField->setExcludeLinkTextField(true); +``` + +Custom link implementations you may be using include: + +- [gorriecoe/silverstripe-securitylinks](https://github.com/gorriecoe/silverstripe-securitylinks) +- [gorriecoe/silverstripe-directionslink](https://github.com/gorriecoe/silverstripe-directionslink) +- [gorriecoe/silverstripe-advancedemaillink](https://github.com/gorriecoe/silverstripe-advancedemaillinks) + +Other customisations you may be using that will require manual migration include: + +- [gorriecoe/silverstripe-linkicon](https://github.com/gorriecoe/silverstripe-linkicon) +- [gorriecoe/silverstripe-ymlpresetlinks](https://github.com/gorriecoe/silverstripe-ymlpresetlinks) + +Things devs need to handle or be aware of: + +- Phone number validation +- External URL link doesn't allow relative URLs +- No `addExtraClass()` or related methods for templates +- `getLinkURL()` is now just `getURL()` +- No `SiteTree` helpers like `isCurrent()`, `isOrphaned()` etc (you can check those methods on the `Page` in `SiteTreeLink` instead) +- Permission checks are based on `Owner` (previously just returned `true` for everything) +- No `link_to_folders` config - uses `UploadField` instead. +- No graphql helper methods - just use regular GraphQL scaffolding if you need to fetch the links via GraphQL. +- No "Settings" tab +- Can't swap link type. +- https://github.com/elliot-sawyer/silverstripe-link/blob/master/src/extensions/DefineableMarkupID.php +- https://github.com/elliot-sawyer/silverstripe-link/blob/master/src/extensions/DBStringLink.php +- May want to update localisations. Link to transifex. + +One config for DB fields that are shared across all links + +```yml +base_link_columns: + ID: 'ID' + OpenInNewWindow: 'OpenInNew' + Title: 'LinkText' + # Can add sort for has_many here too + Sort: 'Sort' +``` + +Sort is ascending in gorriecoe's module + +One config for per-type (i.e. dependent on the "Type" column) to class + +```yml +link_type_columns: + URL: + class: 'SilverStripe\LinkField\Models\ExternalLink' + fields: + URL: 'ExternalUrl' + Email: + class: 'SilverStripe\LinkField\Models\EmailLink' + fields: + Email: 'Email' + Phone: + class: 'SilverStripe\LinkField\Models\PhoneLink' + fields: + Phone: 'Phone' + File: + class: 'SilverStripe\LinkField\Models\FileLink' + fields: + FileID: 'FileID' + SiteTree: + class: 'SilverStripe\LinkField\Models\SiteTreeLink' + fields: + SiteTreeID: 'PageID' +``` + +Note that `SiteTreeLink` also needs to take the `Anchor` column and split it out between `Anchor` and `QueryString`, and remove the `#` and `?`prefixes respectively. +If there's no prefix, make these assumptions: + It's a query string if there's a `=` or `&` anywhere in it + It's an anchor in all other cases. + +Note also that the `Title` may be equal to the default title, which differs based on the type. +We do not want to copy default titles into `LinkText`. + +This no longer does anything, and has no equivalent (the field is always `Sort` which is on Link by default): + +```yml +gorriecoe\LinkField\LinkField: + sort_column: 'SortOrder' +``` diff --git a/src/Tasks/GorriecoeMigrationTask.php b/src/Tasks/GorriecoeMigrationTask.php new file mode 100644 index 00000000..4792f377 --- /dev/null +++ b/src/Tasks/GorriecoeMigrationTask.php @@ -0,0 +1,385 @@ + '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. + * + * Keys are the FQCN for the class where the has_many is declared. + * Values are the name of the old has_one. + * + * Example: + * + * // SiteConfig had two has_many relationships, + * // one to Link.MyHasOne and another to Link.DifferentHasOne. + * SiteConfig::class => [ + * 'LinksListOne' => 'MyHasOne', + * 'LinksListTwo' => 'DifferentHasOne', + * ] + * + */ + private static array $has_many_links_data = []; + + /** + * in-memory cache of Link relational data so we don't keep slamming the filesystem cache + * when checking these relations in CLI + */ + private array $linkRelationData = []; + + /** + * The table name for the base gorriecoe link model. + */ + private string $oldTableName; + + /** + * Check if we actually need to migrate anything, and if not give clear output as to why not. + */ + private function getNeedsMigration(): bool + { + $oldTableName = static::config()->get('old_link_table'); + $allTables = DB::table_list(); + if (!in_array(strtolower($oldTableName), $allTables)) { + $oldTableName = '_obsolete_' . $oldTableName; + if (!in_array(strtolower($oldTableName), $allTables)) { + $this->print('Nothing to migrate - old link table doesn\'t exist.'); + return false; + } + } + $this->oldTableName = $oldTableName; + return true; + } + + /** + * Perform the actual data migration and publish links as appropriate + */ + public function performMigration(): void + { + $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('-----------------'); + } + + /** + * 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 + { + $db = DB::get_conn(); + + // Get a full map of columns to migrate that applies to all link types + $baseTableColumnMap = static::config()->get('base_link_columns'); + foreach (array_keys(DataObject::config()->uninherited('fixed_fields')) as $fixedField) { + // ClassName will need to be handled per link type + if ($fixedField === 'ClassName') { + continue; + } + $baseTableColumnMap[$fixedField] = $fixedField; + } + + // 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 = $spec['class']; + $classNameSelect .= "WHEN {$typeColumn} = '{$type}' THEN '{$toClass}' "; + } + $classNameSelect .= 'ELSE ' . Link::class . ' END AS ClassName'; + + // Insert rows + $baseTable = DataObject::getSchema()->baseDataTable(Link::class); + $quotedBaseTable = $db->escapeIdentifier($baseTable); + $baseColumns = implode(', ', array_keys($baseTableColumnMap)); + $subQuery = SQLSelect::create( + array_values($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}"); + } + + /** + * Insert rows for all link subclasses based on the type of the old link + */ + private function insertTypeSpecificRows(): void + { + $schema = DataObject::getSchema(); + $db = DB::get_conn(); + foreach (static::config()->get('link_type_columns') as $type => $spec) { + $toClass = $spec['class']; + $columnMap = $spec['fields']; + + $table = $schema->tableName($toClass); + $quotedTable = $db->escapeIdentifier($table); + $baseColumns = implode(', ', array_keys($columnMap)); + $subQuery = SQLSelect::create( + array_values($columnMap), + $db->escapeIdentifier($this->oldTableName) + )->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} ({$baseColumns}, ClassName) {$subQuery}"); + } + } + + /** + * Update the Anchor column for SiteTreeLink + */ + private function updateSiteTreeRows(): void + { + // 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; + } + // 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; + } + } + } + + 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(); + foreach ($linksList as $ownerClass => $relations) { + foreach ($relations as $hasManyRelation => $hasOneRelation) { + $oldTableFields = DB::field_list($this->oldTableName); + // 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 = 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]; + } + } + 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")) + ->execute(); + } + } + } + $this->extend('afterMigrateHasManyRelations'); + } + + private function migrateManyManyRelations(): void + { + /* + @TODO + */ + } + + private function classIsOldLink(string $class): bool + { + return $class === 'gorriecoe\Link\Models\Link'; + } +} diff --git a/src/Tasks/LinkFieldMigrationTask.php b/src/Tasks/LinkFieldMigrationTask.php index 558cc14f..ef052df2 100644 --- a/src/Tasks/LinkFieldMigrationTask.php +++ b/src/Tasks/LinkFieldMigrationTask.php @@ -4,27 +4,13 @@ use LogicException; use RuntimeException; -use SilverStripe\Assets\Shortcodes\FileLink as WYSIWYGFileLink; -use SilverStripe\CMS\Model\SiteTreeLink as WYSIWYGSiteTreeLink; -use SilverStripe\Control\Director; use SilverStripe\Core\ClassInfo; -use SilverStripe\Core\Config\Config; use SilverStripe\Dev\BuildTask; -use SilverStripe\Dev\Deprecation; -use SilverStripe\LinkField\Models\EmailLink; -use SilverStripe\LinkField\Models\ExternalLink; -use SilverStripe\LinkField\Models\FileLink; use SilverStripe\LinkField\Models\Link; -use SilverStripe\LinkField\Models\PhoneLink; -use SilverStripe\LinkField\Models\SiteTreeLink; -use SilverStripe\ORM\DataList; use SilverStripe\ORM\DataObject; -use SilverStripe\ORM\DataObjectSchema; use SilverStripe\ORM\DB; use SilverStripe\ORM\Queries\SQLSelect; use SilverStripe\ORM\Queries\SQLUpdate; -use SilverStripe\Versioned\ChangeSet; -use SilverStripe\Versioned\ChangeSetItem; use SilverStripe\Versioned\Versioned; /** @@ -32,6 +18,8 @@ */ class LinkFieldMigrationTask extends BuildTask { + use TemporaryNameTrait; + private static $segment = 'linkfield-tov4-migration-task'; protected $title = 'Linkfield v2/3 to v4 Migration Task'; @@ -43,18 +31,6 @@ class LinkFieldMigrationTask extends BuildTask */ private static ?bool $is_enabled = false; - /** - * Classes which should be skipped when finding owners of links. - * These classes and all of their subclasses will be skipped. - */ - private static array $classes_that_are_not_link_owners = [ - // Skip models that are used for internal tracking purposes and cannot own links - ChangeSet::class, - ChangeSetItem::class, - WYSIWYGFileLink::class, - WYSIWYGSiteTreeLink::class, - ]; - /** * List any has_many relations that should be migrated. * @@ -86,55 +62,10 @@ class LinkFieldMigrationTask extends BuildTask */ private array $linkRelationData = []; - public function __construct() - { - // Use withNoReplacement() because otherwise even viewing the dev/tasks list will trigger this warning. - Deprecation::withNoReplacement( - fn () => Deprecation::notice('4.0.0', 'Will be removed without equivalent functionality.', Deprecation::SCOPE_CLASS) - ); - parent::__construct(); - } - - public function run($request): void - { - $db = DB::get_conn(); - $baseTable = DataObject::getSchema()->baseDataTable(Link::class); - - // If we don't need to migrate, exit early. - if (!$this->getNeedsMigration($baseTable)) { - $this->print('Cannot perform migration.'); - return; - } - - if (!$db->supportsTransactions()) { - $this->print('Database transactions are not supported for this database. Errors may result in a partially-migrated state.'); - } - - $db->withTransaction([$this, 'performMigration'], [$this, 'failedTransaction']); - - if ($request->getVar('skipBrokenLinks')) { - $this->print('Skipping broken link check as requested.'); - } else { - $this->checkForBrokenLinks(); - } - - $this->print('Done.'); - } - - /** - * Used in a callback if there is an error with the migration that causes a rolled back DB transaction - */ - public function failedTransaction() - { - if (DB::get_conn()->supportsTransactions()) { - $this->print('There was an error with the migration. Rolling back.'); - } - } - /** * Perform the actual data migration and publish links as appropriate */ - public function performMigration() + public function performMigration(): void { // Migrate data $this->migrateTitleColumn(); @@ -155,9 +86,10 @@ public function performMigration() /** * Check if we actually need to migrate anything, and if not give clear output as to why not. */ - private function getNeedsMigration(string $baseTable): bool + private function getNeedsMigration(): bool { $needsMigration = false; + $baseTable = DataObject::getSchema()->baseDataTable(Link::class); $needColumns = ['LinkText', 'Title']; $baseDbColumns = array_keys(DB::field_list($baseTable)); $baseNeededColumns = array_intersect($needColumns, $baseDbColumns); @@ -285,7 +217,7 @@ private function migrateHasManyRelations(): void foreach ($relationData as $hasManyRelation => $spec) { $linkClass = $spec['linkClass']; $hasOneRelation = $spec['hasOne']; - // Skip if the has_one relation still exists + // Stop migration if the has_one relation still exists if (array_key_exists($hasOneRelation, $this->getLinkRelationData($linkClass, 'has_one'))) { throw new RuntimeException("has_one relation '{$linkClass}.{$hasOneRelation} still exists. Cannot migrate has_many relation '{$ownerClass}.{$hasManyRelation}'."); }; @@ -351,340 +283,8 @@ private function migrateHasManyRelations(): void $this->extend('afterMigrateHasManyRelations'); } - /** - * Find all `has_one` relations to link and set the corresponding `Owner` relation - */ - private function setOwnerForHasOneLinks(): void - { - $this->extend('beforeSetOwnerForHasOneLinks'); - $this->print('Setting owners for has_one relations.'); - $allDataObjectModels = ClassInfo::subclassesFor(DataObject::class, false); - $allLinkModels = ClassInfo::subclassesFor(Link::class, true); - foreach ($allDataObjectModels as $modelClass) { - if ($this->shouldSkipClassForOwnerCheck($modelClass)) { - continue; - } - $hasOnes = Config::forClass($modelClass)->uninherited('has_one') ?? []; - foreach ($hasOnes as $hasOneName => $spec) { - // Get the class of the has_one - $hasOneClass = $spec['class'] ?? null; - if (!is_array($spec)) { - $hasOneClass = $spec; - $spec = ['class' => $hasOneClass]; - } - - // Skip malformed has_one relations - if ($hasOneClass === null) { - continue; - } - - // Polymorphic has_one needs some extra handling - if ($hasOneClass === DataObject::class) { - if ($this->hasReciprocalRelation($allLinkModels, $hasOneName, $modelClass)) { - continue; - } - $this->updateOwnerForRelation(Link::class, $hasOneName, $modelClass, $spec); - continue; - } - - // Skip if the has_one isn't for Link, or points at a belongs_to or has_many on Link - if (!is_a($hasOneClass, Link::class, true)) { - continue; - } - if ($this->hasReciprocalRelation([$hasOneClass], $hasOneName, $modelClass)) { - continue; - } - - // Update Owner for the relevant links to point at this relation - $this->updateOwnerForRelation($hasOneClass, $hasOneName, $modelClass); - } - } - $this->extend('afterSetOwnerForHasOneLinks'); - } - - private function shouldSkipClassForOwnerCheck(string $modelClass): bool - { - // This is a workaround for tests, since ClassInfo will get info about all TestOnly classes, - // even if they're not in your test class's "extra_dataobjects" list. - // Some classes don't have tables and don't NEED tables - but those classes also - // won't declare has_one relations, so it's okay to skip those too. - if (!ClassInfo::hasTable(DataObject::getSchema()->tableName($modelClass))) { - return true; - } - // Skip class hierarchies that we explicitly said we want to skip - $classHierarchiesToSkip = static::config()->get('classes_that_are_not_link_owners') ?? []; - foreach ($classHierarchiesToSkip as $skipClass) { - if (is_a($modelClass, $skipClass, true)) { - return true; - } - } - return false; - } - - /** - * Store relation data in memory so we're not hitting config over and over again unnecessarily. - * The task is likely run in CLI which relies on filesystem cache for config. - */ - private function getLinkRelationData(string $linkClass, string $configName): array - { - if (!isset($this->linkRelationData[$linkClass][$configName])) { - $config = Config::forClass($linkClass); - $this->linkRelationData[$linkClass][$configName] = $config->uninherited($configName) ?? []; - } - return $this->linkRelationData[$linkClass][$configName]; - } - - private function hasReciprocalRelation(array $linkClasses, string $hasOneName, string $foreignClass): bool - { - foreach ($linkClasses as $linkClass) { - $relationData = array_merge( - $this->getLinkRelationData($linkClass, 'belongs_to'), - $this->getLinkRelationData($linkClass, 'has_many'), - ); - // Check if the given link class has a belongs_to or has_many pointing at the has_one relation - // we're asking about - foreach ($relationData as $relationName => $value) { - $parsedRelation = $this->parseRelationData($value); - - if ($foreignClass !== $parsedRelation['class']) { - continue; - } - - // If we can't tell what relation the belongs_to or has_many points at, - // assume it's for the relation we're asking about - if ($parsedRelation['reciprocalRelation'] === null) { - // Printing so developers can double check after the task is run. - // They can manually set the owner if it turns out our assumption was wrong. - // Not adding an extension point here because developers should use dot notation for the relation instead - // of working around their ambiguous relation declaration. - $this->print("Ambiguous relation '{$linkClass}.{$relationName}' found - assuming it points at '{$foreignClass}.{$hasOneName}'"); - return true; - } - - if ($hasOneName !== $parsedRelation['reciprocalRelation']) { - continue; - } - - // If we get here, then the relation points back at the has_one we're - // checking against. - return true; - } - } - return false; - } - - /** - * Parses a belongs_to or has_many relation class to separate the class from - * the reciprocal relation name. - * - * Modified from RelationValidationService in framework. - */ - private function parseRelationData(string $relationData): array - { - if (mb_strpos($relationData ?? '', '.') === false) { - return [ - 'class' => $relationData, - 'reciprocalRelation' => null, - ]; - } - - $segments = explode('.', $relationData ?? ''); - - // Theoretically this is the same as the mb_strpos check above, - // but both checks are in RelationValidationService so I'm leaving - // this here in case there's some edge case it's covering. - if (count($segments) !== 2) { - return [ - 'class' => $relationData, - 'reciprocalRelation' => null, - ]; - } - - $class = array_shift($segments); - $relation = array_shift($segments); - return [ - 'class' => $class, - 'reciprocalRelation' => $relation, - ]; - } - - /** - * Bulk update the owner for links stored in a has_one relation - */ - private function updateOwnerForRelation(string $linkClass, string $hasOneName, string $foreignClass, array $polymorphicSpec = []): void - { - $db = DB::get_conn(); - $schema = DataObject::getSchema(); - $isPolymorphic = !empty($polymorphicSpec); - - $ownerIdColumn = $schema->sqlColumnForField($linkClass, 'OwnerID'); - $ownerClassColumn = $schema->sqlColumnForField($linkClass, 'OwnerClass'); - $ownerRelationColumn = $schema->sqlColumnForField($linkClass, 'OwnerRelation'); - $linkIdColumn = $schema->sqlColumnForField($linkClass, 'ID'); - $relationIdColumn = $schema->sqlColumnForField($foreignClass, "{$hasOneName}ID"); - - $nullCheck = $db->nullCheckClause($ownerIdColumn, true); - $baseTable = $schema->tableForField($linkClass, 'OwnerID'); - $update = SQLUpdate::create( - $db->escapeIdentifier($baseTable), - [ - $ownerIdColumn => [$schema->sqlColumnForField($foreignClass, 'ID') => []], - $ownerClassColumn => [$schema->sqlColumnForField($foreignClass, 'ClassName') => []], - $ownerRelationColumn => $hasOneName, - ], - [ - $linkIdColumn . ' = ' . $relationIdColumn, - // Only set the owner if it isn't already set - // Don't check class here - see https://github.com/silverstripe/silverstripe-framework/issues/11165 - "$ownerIdColumn = 0 OR $nullCheck", - $db->nullCheckClause($ownerRelationColumn, true), - ] - ); - // Join the table for $foreignClass - $foreignClassTable = $schema->tableName($foreignClass); - if ($foreignClassTable !== $baseTable) { - $update->addInnerJoin($foreignClassTable, $relationIdColumn . ' = ' . $linkIdColumn); - // If the table for $foreignClass is not its base table, we need to join that as well - // so we can get the ID and classname. - $baseForeignTable = $schema->baseDataTable($foreignClass); - if (!$update->isJoinedTo($baseForeignTable)) { - $update->addInnerJoin( - $baseForeignTable, - $db->escapeIdentifier($baseForeignTable . '.ID') . ' = ' . $db->escapeIdentifier($foreignClassTable . '.ID') - ); - } - // Add join and where clauses for polymorphic relations so we don't set the wrong owners - if ($isPolymorphic) { - $relationClassColumn = $schema->sqlColumnForField($foreignClass, "{$hasOneName}Class"); - $linkClassColumn = $schema->sqlColumnForField($linkClass, 'ClassName'); - $update->addFilterToJoin($foreignClassTable, $relationClassColumn . ' = ' . $linkClassColumn); - // Make sure we ignore any multi-relational has_one pointing at something other than Link.Owner - if ($polymorphicSpec[DataObjectSchema::HAS_ONE_MULTI_RELATIONAL] ?? false) { - $update->addWhere([$schema->sqlColumnForField($foreignClass, "{$hasOneName}Relation") => 'Owner']); - } - } - } - $update->execute(); - } - - /** - * Publishes links unless Link isn't versioned or developers opt out. - */ - private function publishLinks(): void - { - if (Link::has_extension(Versioned::class)) { - $shouldPublishLinks = true; - $this->extend('updateShouldPublishLinks', $shouldPublishLinks); - if ($shouldPublishLinks) { - $this->print('Publishing links.'); - /** @var Versioned&Link $link */ - foreach (Link::get()->chunkedFetch() as $link) { - // Allow developers to skip publishing each link - this allows for scenarios - // where links were Versioned in v2/v3 projects. - $shouldPublishLink = true; - $this->extend('updateShouldPublishLink', $link, $shouldPublishLink); - if ($shouldPublishLink) { - $link->copyVersionToStage(Versioned::DRAFT, Versioned::LIVE); - } - $link->destroy(); - } - $this->print('Publishing complete.'); - } else { - $this->print('Skipping publish step.'); - } - } else { - $this->print('Links are not versioned - skipping publish step due to project-level customisation.'); - } - } - - /** - * Check for broken links and output information about them. - * Doesn't actually check if file or page exists for those link types, - * this is just about whether there's data there or not. - */ - private function checkForBrokenLinks(): void - { - $this->print('Checking for broken links.'); - // Using draft stage is safe for unversioned links, and ensures we - // get all relevant data for versioned but unpublished links. - Versioned::withVersionedMode(function () { - Versioned::set_reading_mode('Stage.' . Versioned::DRAFT); - $checkForBrokenLinks = [ - EmailLink::class => [ - 'field' => 'Email', - 'emptyValue' => [null, ''], - ], - ExternalLink::class => [ - 'field' => 'ExternalUrl', - 'emptyValue' => [null, ''], - ], - FileLink::class => [ - 'field' => 'FileID', - 'emptyValue' => [null, 0], - ], - PhoneLink::class => [ - 'field' => 'Phone', - 'emptyValue' => [null, ''], - ], - SiteTreeLink::class => [ - 'field' => 'PageID', - 'emptyValue' => [null, 0], - ], - ]; - $this->extend('updateCheckForBrokenLinks', $checkForBrokenLinks); - $brokenLinks = []; - foreach ($checkForBrokenLinks as $class => $data) { - $field = $data['field']; - $emptyValue = $data['emptyValue']; - $ids = DataObject::get($class)->filter([$field => $emptyValue])->column('ID'); - $numBroken = count($ids); - $this->print("Found $numBroken broken links for the '$class' class."); - if ($numBroken > 0) { - $brokenLinks[$class] = $ids; - } - } - - if (empty($brokenLinks)) { - $this->print('No broken links.'); - return; - } - - // Output table of broken links - $this->print('Broken links:'); - if (Director::is_cli()) { - // Output in a somewhat CLI friendly table. - // Pad by the length of the longest class name so things align nicely. - $longestClassLen = max(array_map('strlen', array_keys($brokenLinks))); - $paddedClassTitle = str_pad('Link class', $longestClassLen); - $classSeparator = str_repeat('-', $longestClassLen); - $output = <<< CLI_TABLE - $paddedClassTitle | IDs of broken links - $classSeparator | ------------------- - CLI_TABLE; - foreach ($brokenLinks as $class => $ids) { - $paddedClass = str_pad($class, $longestClassLen); - $idsString = implode(', ', $ids); - $output .= "\n$paddedClass | $idsString"; - } - } else { - // Output as an HTML table - $output = ''; - foreach ($brokenLinks as $class => $ids) { - $idsString = implode(', ', $ids); - $output .= ""; - } - $output .= '
Link classIDs of broken links
$class$idsString
'; - } - $this->print($output); - }); - } - - /** - * A convenience method for printing a line to the browser or terminal with appropriate line breaks. - */ - private function print(string $message): void + private function classIsOldLink(string $class): bool { - $eol = Director::is_cli() ? "\n" : '
'; - echo $message . $eol; + return is_a($class, Link::class, true); } } diff --git a/src/Tasks/TemporaryNameTrait.php b/src/Tasks/TemporaryNameTrait.php new file mode 100644 index 00000000..9451f9a3 --- /dev/null +++ b/src/Tasks/TemporaryNameTrait.php @@ -0,0 +1,437 @@ + Deprecation::notice('4.0.0', 'Will be removed without equivalent functionality.', Deprecation::SCOPE_CLASS) + ); + parent::__construct(); + } + + public function run($request): void + { + $db = DB::get_conn(); + + // If we don't need to migrate, exit early. + if (!$this->getNeedsMigration()) { + $this->print('Cannot perform migration.'); + return; + } + + if (!$db->supportsTransactions()) { + $this->print('Database transactions are not supported for this database. Errors may result in a partially-migrated state.'); + } + + $db->withTransaction([$this, 'performMigration'], [$this, 'failedTransaction']); + + if ($request->getVar('skipBrokenLinks')) { + $this->print('Skipping broken link check as requested.'); + } else { + $this->checkForBrokenLinks(); + } + + $this->print('Done.'); + } + + /** + * Used in a callback if there is an error with the migration that causes a rolled back DB transaction + */ + public function failedTransaction(): void + { + if (DB::get_conn()->supportsTransactions()) { + $this->print('There was an error with the migration. Rolling back.'); + } + } + + /** + * Find all `has_one` relations to link and set the corresponding `Owner` relation + */ + private function setOwnerForHasOneLinks(): void + { + $this->extend('beforeSetOwnerForHasOneLinks'); + $this->print('Setting owners for has_one relations.'); + $allDataObjectModels = ClassInfo::subclassesFor(DataObject::class, false); + $allLinkModels = ClassInfo::subclassesFor(Link::class, true); + foreach ($allDataObjectModels as $modelClass) { + if ($this->shouldSkipClassForOwnerCheck($modelClass)) { + continue; + } + $hasOnes = Config::forClass($modelClass)->uninherited('has_one') ?? []; + foreach ($hasOnes as $hasOneName => $spec) { + // Get the class of the has_one + $hasOneClass = $spec['class'] ?? null; + if (!is_array($spec)) { + $hasOneClass = $spec; + $spec = ['class' => $hasOneClass]; + } + + // Skip malformed has_one relations + if ($hasOneClass === null) { + continue; + } + + // Polymorphic has_one needs some extra handling + if ($hasOneClass === DataObject::class) { + if ($this->hasReciprocalRelation($allLinkModels, $hasOneName, $modelClass)) { + continue; + } + $this->updateOwnerForRelation(Link::class, $hasOneName, $modelClass, $spec); + continue; + } + + // Skip if the has_one isn't for Link, or points at a belongs_to or has_many on Link + if (!$this->classIsOldLink($hasOneClass)) { + continue; + } + if ($this->hasReciprocalRelation([$hasOneClass], $hasOneName, $modelClass)) { + continue; + } + + // Update Owner for the relevant links to point at this relation + $this->updateOwnerForRelation($hasOneClass, $hasOneName, $modelClass); + } + } + $this->extend('afterSetOwnerForHasOneLinks'); + } + + /** + * Bulk update the owner for links stored in a has_one relation + */ + private function updateOwnerForRelation(string $linkClass, string $hasOneName, string $foreignClass, array $polymorphicSpec = []): void + { + $db = DB::get_conn(); + $schema = DataObject::getSchema(); + $isPolymorphic = !empty($polymorphicSpec); + + $ownerIdColumn = $schema->sqlColumnForField($linkClass, 'OwnerID'); + $ownerClassColumn = $schema->sqlColumnForField($linkClass, 'OwnerClass'); + $ownerRelationColumn = $schema->sqlColumnForField($linkClass, 'OwnerRelation'); + $linkIdColumn = $schema->sqlColumnForField($linkClass, 'ID'); + $relationIdColumn = $schema->sqlColumnForField($foreignClass, "{$hasOneName}ID"); + + $nullCheck = $db->nullCheckClause($ownerIdColumn, true); + $baseTable = $schema->tableForField($linkClass, 'OwnerID'); + $update = SQLUpdate::create( + $db->escapeIdentifier($baseTable), + [ + $ownerIdColumn => [$schema->sqlColumnForField($foreignClass, 'ID') => []], + $ownerClassColumn => [$schema->sqlColumnForField($foreignClass, 'ClassName') => []], + $ownerRelationColumn => $hasOneName, + ], + [ + $linkIdColumn . ' = ' . $relationIdColumn, + // Only set the owner if it isn't already set + // Don't check class here - see https://github.com/silverstripe/silverstripe-framework/issues/11165 + "$ownerIdColumn = 0 OR $nullCheck", + $db->nullCheckClause($ownerRelationColumn, true), + ] + ); + // Join the table for $foreignClass + $foreignClassTable = $schema->tableName($foreignClass); + if ($foreignClassTable !== $baseTable) { + $update->addInnerJoin($foreignClassTable, $relationIdColumn . ' = ' . $linkIdColumn); + // If the table for $foreignClass is not its base table, we need to join that as well + // so we can get the ID and classname. + $baseForeignTable = $schema->baseDataTable($foreignClass); + if (!$update->isJoinedTo($baseForeignTable)) { + $update->addInnerJoin( + $baseForeignTable, + $db->escapeIdentifier($baseForeignTable . '.ID') . ' = ' . $db->escapeIdentifier($foreignClassTable . '.ID') + ); + } + // Add join and where clauses for polymorphic relations so we don't set the wrong owners + if ($isPolymorphic) { + $relationClassColumn = $schema->sqlColumnForField($foreignClass, "{$hasOneName}Class"); + $linkClassColumn = $schema->sqlColumnForField($linkClass, 'ClassName'); + $update->addFilterToJoin($foreignClassTable, $relationClassColumn . ' = ' . $linkClassColumn); + // Make sure we ignore any multi-relational has_one pointing at something other than Link.Owner + if ($polymorphicSpec[DataObjectSchema::HAS_ONE_MULTI_RELATIONAL] ?? false) { + $update->addWhere([$schema->sqlColumnForField($foreignClass, "{$hasOneName}Relation") => 'Owner']); + } + } + } + $update->execute(); + } + + private function shouldSkipClassForOwnerCheck(string $modelClass): bool + { + // This is a workaround for tests, since ClassInfo will get info about all TestOnly classes, + // even if they're not in your test class's "extra_dataobjects" list. + // Some classes don't have tables and don't NEED tables - but those classes also + // won't declare has_one relations, so it's okay to skip those too. + if (!ClassInfo::hasTable(DataObject::getSchema()->tableName($modelClass))) { + return true; + } + // Skip class hierarchies that we explicitly said we want to skip + $classHierarchiesToSkip = static::config()->get('classes_that_are_not_link_owners') ?? []; + foreach ($classHierarchiesToSkip as $skipClass) { + if (is_a($modelClass, $skipClass, true)) { + return true; + } + } + return false; + } + + /** + * Store relation data in memory so we're not hitting config over and over again unnecessarily. + * The task is likely run in CLI which relies on filesystem cache for config. + */ + private function getLinkRelationData(string $linkClass, string $configName): array + { + if (!isset($this->linkRelationData[$linkClass][$configName])) { + $config = Config::forClass($linkClass); + $this->linkRelationData[$linkClass][$configName] = $config->uninherited($configName) ?? []; + } + return $this->linkRelationData[$linkClass][$configName]; + } + + private function hasReciprocalRelation(array $linkClasses, string $hasOneName, string $foreignClass): bool + { + foreach ($linkClasses as $linkClass) { + $relationData = array_merge( + $this->getLinkRelationData($linkClass, 'belongs_to'), + $this->getLinkRelationData($linkClass, 'has_many'), + ); + // Check if the given link class has a belongs_to or has_many pointing at the has_one relation + // we're asking about + foreach ($relationData as $relationName => $value) { + $parsedRelation = $this->parseRelationData($value); + + if ($foreignClass !== $parsedRelation['class']) { + continue; + } + + // If we can't tell what relation the belongs_to or has_many points at, + // assume it's for the relation we're asking about + if ($parsedRelation['reciprocalRelation'] === null) { + // Printing so developers can double check after the task is run. + // They can manually set the owner if it turns out our assumption was wrong. + // Not adding an extension point here because developers should use dot notation for the relation instead + // of working around their ambiguous relation declaration. + $this->print("Ambiguous relation '{$linkClass}.{$relationName}' found - assuming it points at '{$foreignClass}.{$hasOneName}'"); + return true; + } + + if ($hasOneName !== $parsedRelation['reciprocalRelation']) { + continue; + } + + // If we get here, then the relation points back at the has_one we're + // checking against. + return true; + } + } + return false; + } + + /** + * Parses a belongs_to or has_many relation class to separate the class from + * the reciprocal relation name. + * + * Modified from RelationValidationService in framework. + */ + private function parseRelationData(string $relationData): array + { + if (mb_strpos($relationData ?? '', '.') === false) { + return [ + 'class' => $relationData, + 'reciprocalRelation' => null, + ]; + } + + $segments = explode('.', $relationData ?? ''); + + // Theoretically this is the same as the mb_strpos check above, + // but both checks are in RelationValidationService so I'm leaving + // this here in case there's some edge case it's covering. + if (count($segments) !== 2) { + return [ + 'class' => $relationData, + 'reciprocalRelation' => null, + ]; + } + + $class = array_shift($segments); + $relation = array_shift($segments); + return [ + 'class' => $class, + 'reciprocalRelation' => $relation, + ]; + } + + /** + * Publishes links unless Link isn't versioned or developers opt out. + */ + private function publishLinks(): void + { + if (Link::has_extension(Versioned::class)) { + $shouldPublishLinks = true; + $this->extend('updateShouldPublishLinks', $shouldPublishLinks); + if ($shouldPublishLinks) { + $this->print('Publishing links.'); + /** @var Versioned&Link $link */ + foreach (Link::get()->chunkedFetch() as $link) { + // Allow developers to skip publishing each link - this allows for scenarios + // where links were Versioned in v2/v3 projects. + $shouldPublishLink = true; + $this->extend('updateShouldPublishLink', $link, $shouldPublishLink); + if ($shouldPublishLink) { + $link->copyVersionToStage(Versioned::DRAFT, Versioned::LIVE); + } + $link->destroy(); + } + $this->print('Publishing complete.'); + } else { + $this->print('Skipping publish step.'); + } + } else { + $this->print('Links are not versioned - skipping publish step due to project-level customisation.'); + } + } + + /** + * Check for broken links and output information about them. + * Doesn't actually check if file or page exists for those link types, + * this is just about whether there's data there or not. + */ + private function checkForBrokenLinks(): void + { + $this->print('Checking for broken links.'); + // Using draft stage is safe for unversioned links, and ensures we + // get all relevant data for versioned but unpublished links. + Versioned::withVersionedMode(function () { + Versioned::set_reading_mode('Stage.' . Versioned::DRAFT); + $checkForBrokenLinks = [ + EmailLink::class => [ + 'field' => 'Email', + 'emptyValue' => [null, ''], + ], + ExternalLink::class => [ + 'field' => 'ExternalUrl', + 'emptyValue' => [null, ''], + ], + FileLink::class => [ + 'field' => 'FileID', + 'emptyValue' => [null, 0], + ], + PhoneLink::class => [ + 'field' => 'Phone', + 'emptyValue' => [null, ''], + ], + SiteTreeLink::class => [ + 'field' => 'PageID', + 'emptyValue' => [null, 0], + ], + ]; + $this->extend('updateCheckForBrokenLinks', $checkForBrokenLinks); + $brokenLinks = []; + foreach ($checkForBrokenLinks as $class => $data) { + $field = $data['field']; + $emptyValue = $data['emptyValue']; + $ids = DataObject::get($class)->filter([$field => $emptyValue])->column('ID'); + $numBroken = count($ids); + $this->print("Found $numBroken broken links for the '$class' class."); + if ($numBroken > 0) { + $brokenLinks[$class] = $ids; + } + } + + if (empty($brokenLinks)) { + $this->print('No broken links.'); + return; + } + + // Output table of broken links + $this->print('Broken links:'); + if (Director::is_cli()) { + // Output in a somewhat CLI friendly table. + // Pad by the length of the longest class name so things align nicely. + $longestClassLen = max(array_map('strlen', array_keys($brokenLinks))); + $paddedClassTitle = str_pad('Link class', $longestClassLen); + $classSeparator = str_repeat('-', $longestClassLen); + $output = <<< CLI_TABLE + $paddedClassTitle | IDs of broken links + $classSeparator | ------------------- + CLI_TABLE; + foreach ($brokenLinks as $class => $ids) { + $paddedClass = str_pad($class, $longestClassLen); + $idsString = implode(', ', $ids); + $output .= "\n$paddedClass | $idsString"; + } + } else { + // Output as an HTML table + $output = ''; + foreach ($brokenLinks as $class => $ids) { + $idsString = implode(', ', $ids); + $output .= ""; + } + $output .= '
Link classIDs of broken links
$class$idsString
'; + } + $this->print($output); + }); + } + + /** + * A convenience method for printing a line to the browser or terminal with appropriate line breaks. + */ + private function print(string $message): void + { + $eol = Director::is_cli() ? "\n" : '
'; + echo $message . $eol; + } + + /** + * Perform the actual data migration and publish links as appropriate + */ + abstract public function performMigration(): void; + + /** + * Check if we actually need to migrate anything, and if not give clear output as to why not. + */ + abstract private function getNeedsMigration(): bool; + + /** + * Returns true if the class represents an old link to be migrated + */ + abstract private function classIsOldLink(string $class): bool; +}