Skip to content

Commit

Permalink
NEW Add migration task from gorriecoe/silverstripe-link
Browse files Browse the repository at this point in the history
  • Loading branch information
GuySartorelli committed Mar 14, 2024
1 parent 967c457 commit b992618
Show file tree
Hide file tree
Showing 2 changed files with 375 additions and 0 deletions.
164 changes: 164 additions & 0 deletions docs/en/09_migrating/02_gorriecoe-migration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
# 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.

```diff
Example here.
```

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`.
211 changes: 211 additions & 0 deletions src/Tasks/GorriecoeMigrationTask.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
<?php

namespace SilverStripe\LinkField\Tasks;

use SilverStripe\Control\Director;
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\DataObject;
use SilverStripe\ORM\DB;
use SilverStripe\ORM\Queries\SQLInsert;
use SilverStripe\ORM\Queries\SQLSelect;

/**
* @deprecated 4.0.0 Will be removed without equivalent functionality.
*/
class GorriecoeMigrationTask extends BuildTask
{
private static $segment = 'gorriecoe-to-linkfield-migration-task';

protected $title = 'Gorriecoe to Linkfield Migration Task';

protected $description = 'Migrate from gorriecoe/silverstripe-link to silverstripe/linkfield';

/**
* Enable via YAML configuration if you need to run this task
*/
private static ?bool $is_enabled = false;

/**
* The name of the table for the gorriecoe\Link\Models\Link model.
*
* Configurable since it's such a generic name, there's a chance people configured
* it to something different to avoid collisions.
*/
private static string $old_link_table = 'Link';

/**
* Classes which should be skipped when finding owners of links.
* These classes and all of their subclasses will be skipped.
*/
private static array $skip_hierarchies_for_owner = [
// Skip models that are used for internal tracking purposes and cannot own links
ChangeSet::class,
ChangeSetItem::class,
WYSIWYGFileLink::class,
WYSIWYGSiteTreeLink::class,
];

/**
* Mapping for columns in the base link table.
* Doesn't include
*/
private static array $base_link_columns = [
'OpenInNewWindow' => '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" : '<br>';
echo $message . $eol;
}
}

0 comments on commit b992618

Please sign in to comment.