Files
KantSites/migrations/Version20260912000400.php
T
kantarciandClaude Opus 5 a6ffb9bdfd Seiten im Browser anlegen und löschen, Adresshierarchie und Beitragstyp
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>
2026-09-12 10:31:46 +02:00

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.');
}
}