Created
May 19, 2026 21:05
-
-
Save wilr/0078ac7fff2a5bb86de2d8d64d0a536e to your computer and use it in GitHub Desktop.
LinkableMigrationTask.php
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| declare(strict_types=1); | |
| use DNADesign\Elemental\Models\BaseElement; | |
| use SilverStripe\Control\Director; | |
| use SilverStripe\Core\ClassInfo; | |
| use SilverStripe\Core\Config\Config; | |
| use SilverStripe\Core\Injector\Injector; | |
| use SilverStripe\Dev\BuildTask; | |
| use SilverStripe\LinkField\Models\Link; | |
| use SilverStripe\LinkField\Models\EmailLink; | |
| use SilverStripe\LinkField\Models\ExternalLink; | |
| use SilverStripe\LinkField\Models\FileLink; | |
| use SilverStripe\LinkField\Models\PhoneLink; | |
| use SilverStripe\LinkField\Models\SiteTreeLink; | |
| use SilverStripe\LinkField\Tasks\MigrationTaskTrait; | |
| use SilverStripe\LinkField\Tasks\ModuleMigrationTaskTrait; | |
| use SilverStripe\ORM\DataObject; | |
| use SilverStripe\ORM\DB; | |
| use SilverStripe\ORM\Queries\SQLSelect; | |
| use SilverStripe\PolyExecution\PolyOutput; | |
| use Symfony\Component\Console\Input\ArrayInput; | |
| class LinkableMigrationTask extends BuildTask | |
| { | |
| use MigrationTaskTrait; | |
| use ModuleMigrationTaskTrait; | |
| private static $segment = 'linkable-to-linkfield-migration-task'; | |
| protected string $title = 'Linkable to Linkfield Migration Task'; | |
| protected static string $description = 'Migrate from sheadawson/silverstripe-linkable to silverstripe/linkfield'; | |
| /** | |
| * The number of links to process at once, for operations that operate on each link individually. | |
| * Processing links in chunks reduces the chance of hitting memory limits. | |
| * Set to null to process all links in a single chunk. | |
| */ | |
| private static ?int $chunk_size = 1000; | |
| /** | |
| * The name of the table for theSilverStripe\LinkField\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 = 'LinkableLink'; | |
| /** | |
| * Mapping for columns in the base link table. | |
| * Doesn't include subclass columns - see link_type_columns | |
| */ | |
| 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', | |
| ], | |
| ], | |
| ]; | |
| /** | |
| * Maps legacy link IDs to remapped IDs used for migration. | |
| * | |
| * @var array<int, int> | |
| */ | |
| private array $oldToNewLinkIdMap = []; | |
| /** | |
| * Run migration from requireDefaultRecords() if links are missing and legacy data exists. | |
| */ | |
| public static function runAutomaticMigrationIfNeeded(): bool | |
| { | |
| if (!static::shouldAutoRunMigration()) { | |
| return false; | |
| } | |
| /** @var static $task */ | |
| $task = Injector::inst()->create(static::class); | |
| $input = new ArrayInput([]); | |
| $output = PolyOutput::create( | |
| Director::is_cli() ? PolyOutput::FORMAT_ANSI : PolyOutput::FORMAT_HTML | |
| ); | |
| return $task->run($input, $output) === 0; | |
| } | |
| public static function shouldAutoRunMigration(): bool | |
| { | |
| if (Link::get()->exists()) { | |
| return false; | |
| } | |
| $legacyTable = static::getLegacyLinkTableName(); | |
| if ($legacyTable === null) { | |
| return false; | |
| } | |
| $quotedLegacyTable = DB::get_conn()->escapeIdentifier($legacyTable); | |
| $legacyCount = (int) DB::query("SELECT COUNT(*) FROM {$quotedLegacyTable}")->value(); | |
| return $legacyCount > 0; | |
| } | |
| /** | |
| * Perform the actual data migration and publish links as appropriate | |
| */ | |
| public function performMigration(): void | |
| { | |
| $this->extend('beforePerformMigration'); | |
| $this->includeElementSubclassLinkRelations(); | |
| $this->prepareExistingLinkMigration(); | |
| $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'); | |
| } | |
| public function print(string $message): void | |
| { | |
| echo $message . PHP_EOL; | |
| } | |
| private function includeElementSubclassLinkRelations(): void | |
| { | |
| $existingHasMany = static::config()->get('has_many_links_data') ?? []; | |
| $existingManyMany = static::config()->get('many_many_links_data') ?? []; | |
| $generatedHasMany = []; | |
| $generatedManyMany = []; | |
| foreach (ClassInfo::subclassesFor(BaseElement::class, false) as $elementClass) { | |
| $hasMany = Config::forClass($elementClass)->get('has_many') ?? []; | |
| foreach ($hasMany as $relationName => $relationSpec) { | |
| [$relatedClass, $reciprocalRelation] = $this->extractRelationClassAndReciprocal($relationSpec); | |
| if ($relatedClass === null || !is_a($relatedClass, Link::class, true)) { | |
| continue; | |
| } | |
| $generatedHasMany[$elementClass][$relationName] = $reciprocalRelation ?? $relationName; | |
| } | |
| $manyMany = Config::forClass($elementClass)->get('many_many') ?? []; | |
| foreach ($manyMany as $relationName => $relationSpec) { | |
| [$relatedClass] = $this->extractRelationClassAndReciprocal($relationSpec); | |
| if ($relatedClass === null || !is_a($relatedClass, Link::class, true)) { | |
| continue; | |
| } | |
| $generatedManyMany[$elementClass][$relationName] = $this->extractManyManyMigrationSpec($relationSpec); | |
| } | |
| } | |
| Config::modify()->set( | |
| static::class, | |
| 'has_many_links_data', | |
| array_replace_recursive($generatedHasMany, $existingHasMany) | |
| ); | |
| Config::modify()->set( | |
| static::class, | |
| 'many_many_links_data', | |
| array_replace_recursive($generatedManyMany, $existingManyMany) | |
| ); | |
| } | |
| private function prepareExistingLinkMigration(): void | |
| { | |
| if (!Link::get()->exists()) { | |
| return; | |
| } | |
| $legacyRows = SQLSelect::create(['ID'], DB::get_conn()->escapeIdentifier($this->oldTableName)) | |
| ->setOrderBy('"ID" ASC') | |
| ->execute(); | |
| if ($legacyRows->numRecords() < 1) { | |
| return; | |
| } | |
| $oldIds = []; | |
| foreach ($legacyRows as $legacyRow) { | |
| $oldIds[] = (int) $legacyRow['ID']; | |
| } | |
| $legacyMaxId = max($oldIds); | |
| $currentMaxLinkId = (int) (Link::get()->max('ID') ?? 0); | |
| $nextNewId = max($legacyMaxId, $currentMaxLinkId) + 1; | |
| foreach ($oldIds as $oldId) { | |
| $this->oldToNewLinkIdMap[$oldId] = $nextNewId; | |
| $nextNewId++; | |
| } | |
| $this->print(sprintf( | |
| 'Existing Link records found. Remapping %d legacy IDs to avoid collisions.', | |
| count($this->oldToNewLinkIdMap) | |
| )); | |
| $this->printLegacyIdMapping(); | |
| $this->remapColumnIds($this->oldTableName, 'ID', $this->oldToNewLinkIdMap); | |
| $this->remapHasOneLinkColumns($this->oldToNewLinkIdMap); | |
| $this->remapManyManyJoinLinkColumns($this->oldToNewLinkIdMap); | |
| } | |
| /** | |
| * @param array<int, int> $idMap | |
| */ | |
| private function remapHasOneLinkColumns(array $idMap): void | |
| { | |
| if (empty($idMap)) { | |
| return; | |
| } | |
| $schema = DataObject::getSchema(); | |
| foreach (ClassInfo::subclassesFor(DataObject::class, false) as $dataClass) { | |
| $tableName = $schema->tableName($dataClass); | |
| if (!ClassInfo::hasTable($tableName)) { | |
| continue; | |
| } | |
| $hasOnes = Config::forClass($dataClass)->get('has_one') ?? []; | |
| foreach ($hasOnes as $relationName => $relationSpec) { | |
| [$relatedClass] = $this->extractRelationClassAndReciprocal($relationSpec); | |
| if ($relatedClass === null || !is_a($relatedClass, Link::class, true)) { | |
| continue; | |
| } | |
| $relationIdColumn = "{$relationName}ID"; | |
| $tablesToUpdate = array_unique([ | |
| $schema->baseDataTable($dataClass), | |
| $schema->tableName($dataClass), | |
| $schema->baseDataTable($dataClass) . '_Live', | |
| $schema->baseDataTable($dataClass) . '_Versions', | |
| ]); | |
| foreach ($tablesToUpdate as $table) { | |
| if (!$this->tableExists($table)) { | |
| continue; | |
| } | |
| $tableColumns = DB::field_list($table); | |
| if (!array_key_exists($relationIdColumn, $tableColumns)) { | |
| continue; | |
| } | |
| $this->remapColumnIds($table, $relationIdColumn, $idMap); | |
| } | |
| } | |
| } | |
| } | |
| /** | |
| * @param array<int, int> $idMap | |
| */ | |
| private function remapManyManyJoinLinkColumns(array $idMap): void | |
| { | |
| if (empty($idMap)) { | |
| return; | |
| } | |
| $schema = DataObject::getSchema(); | |
| $linksList = static::config()->get('many_many_links_data') ?? []; | |
| $originalOldLinkTable = str_replace('_obsolete_', '', $this->oldTableName); | |
| foreach ($linksList as $ownerClass => $relations) { | |
| $ownerTable = $schema->tableName($ownerClass); | |
| foreach ($relations as $manyManyRelation => $spec) { | |
| if (!is_array($spec)) { | |
| $spec = []; | |
| } | |
| $throughSpec = $spec['through'] ?? []; | |
| if (!empty($throughSpec)) { | |
| $joinTable = $this->getTableOrObsoleteTable($spec['table'] ?? ''); | |
| $linkIdColumn = ($throughSpec['to'] ?? '') . 'ID'; | |
| } else { | |
| $joinTable = $this->getTableOrObsoleteTable($spec['table'] ?? "{$ownerTable}_{$manyManyRelation}"); | |
| $linkIdColumn = "{$originalOldLinkTable}ID"; | |
| } | |
| if (!$joinTable || $linkIdColumn === 'ID') { | |
| continue; | |
| } | |
| if (!$this->tableExists($joinTable)) { | |
| continue; | |
| } | |
| $joinTableColumns = DB::field_list($joinTable); | |
| if (!array_key_exists($linkIdColumn, $joinTableColumns)) { | |
| continue; | |
| } | |
| $this->remapColumnIds($joinTable, $linkIdColumn, $idMap); | |
| } | |
| } | |
| } | |
| /** | |
| * @param array<int, int> $idMap | |
| */ | |
| private function remapColumnIds(string $table, string $column, array $idMap): void | |
| { | |
| if (empty($idMap)) { | |
| return; | |
| } | |
| $db = DB::get_conn(); | |
| $quotedTable = $db->escapeIdentifier($table); | |
| $quotedColumn = $db->escapeIdentifier($column); | |
| foreach (array_chunk($idMap, 500, true) as $chunk) { | |
| $cases = []; | |
| $ids = []; | |
| foreach ($chunk as $oldId => $newId) { | |
| $oldId = (int) $oldId; | |
| $newId = (int) $newId; | |
| $cases[] = "WHEN {$oldId} THEN {$newId}"; | |
| $ids[] = $oldId; | |
| } | |
| $idList = implode(', ', $ids); | |
| $caseExpr = implode(' ', $cases); | |
| DB::query( | |
| "UPDATE {$quotedTable} " . | |
| "SET {$quotedColumn} = CASE {$quotedColumn} {$caseExpr} ELSE {$quotedColumn} END " . | |
| "WHERE {$quotedColumn} IN ({$idList})" | |
| ); | |
| } | |
| } | |
| /** | |
| * @return array{0: ?string, 1: ?string} | |
| */ | |
| private function extractRelationClassAndReciprocal(mixed $relationSpec): array | |
| { | |
| $relation = null; | |
| if (is_array($relationSpec)) { | |
| $relation = $relationSpec['class'] ?? null; | |
| } elseif (is_string($relationSpec)) { | |
| $relation = $relationSpec; | |
| } | |
| if (!is_string($relation) || $relation === '') { | |
| return [null, null]; | |
| } | |
| if (!str_contains($relation, '.')) { | |
| return [$relation, null]; | |
| } | |
| [$class, $reciprocal] = explode('.', $relation, 2); | |
| return [$class ?: null, $reciprocal ?: null]; | |
| } | |
| private function extractManyManyMigrationSpec(mixed $relationSpec): ?array | |
| { | |
| if (!is_array($relationSpec)) { | |
| return null; | |
| } | |
| $spec = []; | |
| foreach (['table', 'through', 'extraFields'] as $key) { | |
| if (array_key_exists($key, $relationSpec)) { | |
| $spec[$key] = $relationSpec[$key]; | |
| } | |
| } | |
| return $spec ?: null; | |
| } | |
| private function tableExists(string $table): bool | |
| { | |
| if ($table === '') { | |
| return false; | |
| } | |
| return array_key_exists(strtolower($table), DB::table_list()); | |
| } | |
| protected static function getLegacyLinkTableName(): ?string | |
| { | |
| $tableName = static::config()->get('old_link_table'); | |
| if (!is_string($tableName) || $tableName === '') { | |
| return null; | |
| } | |
| $tables = DB::table_list(); | |
| if (array_key_exists(strtolower($tableName), $tables)) { | |
| return $tableName; | |
| } | |
| $obsolete = '_obsolete_' . $tableName; | |
| if (array_key_exists(strtolower($obsolete), $tables)) { | |
| return $obsolete; | |
| } | |
| return null; | |
| } | |
| private function printLegacyIdMapping(): void | |
| { | |
| if (empty($this->oldToNewLinkIdMap)) { | |
| return; | |
| } | |
| $this->print('Legacy link ID mapping (old => new):'); | |
| foreach ($this->oldToNewLinkIdMap as $oldId => $newId) { | |
| $this->print(sprintf('%d => %d', $oldId, $newId)); | |
| } | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment