Skip to content

Instantly share code, notes, and snippets.

@wilr
Created May 19, 2026 21:05
Show Gist options
  • Select an option

  • Save wilr/0078ac7fff2a5bb86de2d8d64d0a536e to your computer and use it in GitHub Desktop.

Select an option

Save wilr/0078ac7fff2a5bb86de2d8d64d0a536e to your computer and use it in GitHub Desktop.
LinkableMigrationTask.php
<?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