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>
This commit is contained in:
1 parent
82b07820bc
commit
a6ffb9bdfd
16 files changed
+555
-23
No files matched your search
@@ -0,0 +1,80 @@
|
||||
<?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.');
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user