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..d21bd278 --- /dev/null +++ b/docs/en/09_migrating/02_gorriecoe-migration.md @@ -0,0 +1,188 @@ +# 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 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..1a9e0933 --- /dev/null +++ b/src/Tasks/GorriecoeMigrationTask.php @@ -0,0 +1,211 @@ + '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', + ], + ], + ]; + + /** + * The table name for the base gorriecoe link model. + */ + private string $oldTableName; + + 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 + { + // If we don't need to migrate, exit early. + if (!$this->getNeedsMigration()) { + $this->print('Cannot perform migration.'); + return; + } + + $db = DB::get_conn(); + 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']); + + $this->print('Done.'); + } + + /** + * Check if we actually need to migrate anything, and if not give clear output as to why not. + */ + public 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; + } + + /** + * 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() + { + // 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; + } + + /* @TODO + - Check if the `Link` table (or `_obsolete_Link` table) exists + - If not, we can bail here. Nothing to migrate. + + - SQLInsert for each link using base column manpping + - For each link, depending on the type: + - Set correct ClassName in base table + - SQLInsert into table for link subclass + - throw error if class doesn't exist + - throw error if class exists but table doesn't (unless mapping is empty) + + - Set owners for has_one relations + - Update HasOneLinkClass as appropriate for polymorphic has_one + - Update HasOneLinkRelation as appropriate for multi-relational has_one + - If the relation the old value points at still exists, skip unless extension says do not skip + - Set owners for has_many relations + - Must handle polymorphic has_one from the link side + - Set owners for old-many_many relations + - Including sort column mapping + - Handle regular many_many as well as many_many through! + + - When fetching data from the old table (if you can't do everything in SQL), + make sure to fetch in chunks of 2000 or so (make that number configurable) + so that if there are a billion links we don't hit memory issues. + */ + } + + /** + * 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; + } +}