Migration 20260912000400 ergänzt page.path, parent_id, type und
created_at. Die websiteweite Eindeutigkeit wandert vom Slug auf den
vollständigen Pfad, damit gleiche Segmente unter verschiedenen Eltern
möglich sind. Nur ADD COLUMN: Index- oder Fremdschlüsseländerungen
würden SQLite die Tabelle neu aufbauen lassen, was am Fremdschlüssel
revision.page_id scheitert; die Indizes werden in postUp() gesetzt.
POST /admin/pages legt Seiten und Beiträge als privaten Entwurf an
(ROLE_EDITOR), POST /admin/pages/{id}/delete entfernt eine Seite mit
allen Revisionen (ROLE_ADMIN). Beides mit CSRF, ohne JavaScript
bedienbar, mit Schutz für Elternseiten und veraltete Versionen.
Die öffentliche Route bedient verschachtelte Pfade und lässt die
CMS-Routen unberührt.
Testumgebung: Login-Throttling zählt jetzt in einem Array-Cache,
weil die persistierten Zähler die Anmeldung über Testläufe hinweg
aussperrten.
Geprüft: 56 PHP-Tests / 212 Assertions unter PHP 8.2.12 und 8.4.22,
10 JavaScript-Modelltests, Lint/Format/Container, Migration zuerst
gegen eine Datenbankkopie. Keine Browser- oder Lighthouse-Läufe.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
81 lines
3.6 KiB
PHP
81 lines
3.6 KiB
PHP
<?php
|
|
|
|
namespace DoctrineMigrations;
|
|
|
|
use Doctrine\DBAL\Connection;
|
|
use Doctrine\DBAL\Schema\Index;
|
|
use Doctrine\DBAL\Schema\Schema;
|
|
use Doctrine\Migrations\AbstractMigration;
|
|
|
|
final class Version20260912000400 extends AbstractMigration
|
|
{
|
|
public function getDescription(): string
|
|
{
|
|
return 'Page hierarchy, public paths and content types without changing any stored document.';
|
|
}
|
|
|
|
public function isTransactional(): bool
|
|
{
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Columns only. Changing an index here would make Doctrine rebuild the whole page table on
|
|
* SQLite, which the foreign key from revision.page_id rejects. The indexes are swapped in
|
|
* applyIndexes() instead, after the backfill has given every row a unique path.
|
|
*/
|
|
public function up(Schema $schema): void
|
|
{
|
|
$page = $schema->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.');
|
|
}
|
|
}
|