getTable('page'); // Nullable on purpose: the unique path index is created while existing rows are still // empty, and repeated NULLs do not collide in either SQLite or MySQL. The repository // never writes a page without a path. $page->addColumn('path', 'string', ['length' => 400, 'notnull' => false]); // No foreign key: adding one would force the same table rebuild described above. // PageRepository resolves and checks the parent inside the same transaction as every write. $page->addColumn('parent_id', 'string', ['length' => 64, 'notnull' => false]); $page->addColumn('type', 'string', ['length' => 16, 'notnull' => true, 'default' => 'page']); $page->addColumn('created_at', 'datetime_immutable', ['notnull' => false]); } public function postUp(Schema $schema): void { // Every existing page is a root page, so its public path already equals its slug. $this->connection->executeStatement('UPDATE page SET path = slug WHERE path IS NULL'); $this->connection->executeStatement('UPDATE page SET created_at = ? WHERE created_at IS NULL', [gmdate('Y-m-d H:i:s')]); $open = (int) $this->connection->fetchOne('SELECT COUNT(*) FROM page WHERE path IS NULL'); $this->abortIf(0 !== $open, 'Backfill left pages without a public path. Restore the backup taken before this migration.'); self::applyIndexes($this->connection); } /** * A slug only has to be unique among its siblings, so the site-wide unique index moves from * the slug to the full path. Shared with the test bootstraps so they exercise the same * constraints as a migrated database. */ public static function applyIndexes(Connection $connection): void { $platform = $connection->getDatabasePlatform(); $manager = $connection->createSchemaManager(); $existing = $manager->listTableIndexes('page'); foreach ($existing as $name => $index) { if ($index->isUnique() && ['website_id', 'locale', 'slug'] === $index->getUnquotedColumns()) { $connection->executeStatement($platform->getDropIndexSQL($name, 'page')); } } foreach ([ new Index('uniq_page_path', ['website_id', 'locale', 'path'], true), new Index('idx_page_parent', ['parent_id']), ] as $index) { if (!isset($existing[$index->getName()])) { $connection->executeStatement($platform->getCreateIndexSQL($index, 'page')); } } } public function down(Schema $schema): void { $this->throwIrreversibleMigrationException('Removing the hierarchy would drop parent references of customer pages. Restore a verified database backup with matching code.'); } }