CategoryType * de ManyToOne a ManyToMany. * * Ordre critique : * 1. Creation de la table de jonction `category_category_type` (FK category ON * DELETE CASCADE, FK category_type ON DELETE RESTRICT — conserve le garde-fou * « on ne supprime pas un type encore reference »). * 2. Backfill : chaque categorie existante recoit une ligne de jonction vers son * ancien `category_type_id` (avant de dropper la colonne). * 3. Drop de l'index unique (LOWER(name), category_type_id), de l'index FK et de * la colonne `category.category_type_id` (Postgres drope la FK dependante). * 4. Nouvel index unique GLOBAL sur le nom : LOWER(name) WHERE deleted_at IS NULL * (l'unicite n'est plus liee au type — RG-1.07 reformulee). * * Sur base fraiche, les categories seedees CLIENT (Distributeur/Courtier/Secteur/ * Autre) et FOURNISSEUR (Negociant/Cooperative/...) n'ont aucun nom en collision * -> l'index unique global passe sans conflit. * * Migration placee au namespace racine `DoctrineMigrations` (regle ABSOLUE n°11) : * Doctrine Migrations 3.x trie par FQCN puis version ; le namespace racine garantit * l'ordre par timestamp apres les migrations d'init des tables. */ final class Version20260608120000 extends AbstractMigration { public function getDescription(): string { return 'Catalog : Category <-> CategoryType en ManyToMany (jonction category_category_type), unicite du nom globalisee.'; } public function up(Schema $schema): void { // 1. Table de jonction. $this->addSql(<<<'SQL' CREATE TABLE category_category_type ( category_id INT NOT NULL, category_type_id INT NOT NULL, PRIMARY KEY (category_id, category_type_id), CONSTRAINT fk_category_category_type_category FOREIGN KEY (category_id) REFERENCES category (id) ON DELETE CASCADE, CONSTRAINT fk_category_category_type_type FOREIGN KEY (category_type_id) REFERENCES category_type (id) ON DELETE RESTRICT ) SQL); $this->addSql('CREATE INDEX idx_cat_cat_type_type ON category_category_type (category_type_id)'); $this->comment('category_category_type', '_table', 'Jointure M2M category <-> category_type (Catalog) — types portes par la categorie, au moins un obligatoire (RG-1.05).'); $this->comment('category_category_type', 'category_id', 'FK -> category.id, ON DELETE CASCADE — categorie portant le type.'); $this->comment('category_category_type', 'category_type_id', 'FK -> category_type.id, ON DELETE RESTRICT — type rattache (un type ne peut etre supprime tant qu il reste reference).'); // 2. Backfill depuis l'ancienne colonne ManyToOne (chaque categorie -> 1 type). $this->addSql(<<<'SQL' INSERT INTO category_category_type (category_id, category_type_id) SELECT id, category_type_id FROM category SQL); // 3. Suppression de l'ancien modele : index unique par type, index FK, colonne. $this->addSql('DROP INDEX uq_category_name_type_active'); $this->addSql('DROP INDEX idx_category_type_id'); // DROP COLUMN drope automatiquement la FK fk_category_type qui en depend. $this->addSql('ALTER TABLE category DROP COLUMN category_type_id'); // 4. Unicite du nom desormais GLOBALE parmi les actifs (RG-1.07 reformulee). $this->addSql(<<<'SQL' CREATE UNIQUE INDEX uq_category_name_active ON category (LOWER(name)) WHERE deleted_at IS NULL SQL); // Realignement de la doc SQL de `category` (le type n'est plus une colonne). $this->comment('category', '_table', 'Categories — referentiel multi-types via la jonction category_category_type, soft-delete via deleted_at, unicite LOWER(name) GLOBALE parmi les actifs (uq_category_name_active).'); $this->comment('category', 'name', 'Libelle de la categorie (≤ 120 caracteres) — unique GLOBALEMENT parmi les actifs (RG-1.07, uq_category_name_active).'); } public function down(Schema $schema): void { // Restauration best-effort de l'ancien modele ManyToOne (1 type par categorie). $this->addSql('DROP INDEX IF EXISTS uq_category_name_active'); $this->addSql('ALTER TABLE category ADD COLUMN category_type_id INT DEFAULT NULL'); // Reprend le premier type de chaque categorie (l'ordre des types perdus // au-dela du premier est best-effort : le modele cible n'en gardait qu'un). $this->addSql(<<<'SQL' UPDATE category c SET category_type_id = ( SELECT cct.category_type_id FROM category_category_type cct WHERE cct.category_id = c.id ORDER BY cct.category_type_id ASC LIMIT 1 ) SQL); // Categories sans aucun type (theorique) : on les rattache a defaut au // premier type existant pour pouvoir reposer le NOT NULL. $this->addSql(<<<'SQL' UPDATE category SET category_type_id = (SELECT id FROM category_type ORDER BY id ASC LIMIT 1) WHERE category_type_id IS NULL SQL); $this->addSql('ALTER TABLE category ALTER COLUMN category_type_id SET NOT NULL'); $this->addSql(<<<'SQL' ALTER TABLE category ADD CONSTRAINT fk_category_type FOREIGN KEY (category_type_id) REFERENCES category_type (id) ON DELETE RESTRICT SQL); $this->addSql('CREATE INDEX idx_category_type_id ON category (category_type_id)'); $this->addSql(<<<'SQL' CREATE UNIQUE INDEX uq_category_name_type_active ON category (LOWER(name), category_type_id) WHERE deleted_at IS NULL SQL); $this->addSql('DROP TABLE category_category_type'); } /** * Emet un `COMMENT ON TABLE` (colonne speciale `_table`) ou `COMMENT ON COLUMN` * en dollar-quoting Postgres ($_$...$_$) pour eviter tout echappement. */ private function comment(string $table, string $column, string $description): void { $quotedTable = '"'.str_replace('"', '""', $table).'"'; if ('_table' === $column) { $this->addSql(sprintf('COMMENT ON TABLE %s IS $_$%s$_$', $quotedTable, $description)); return; } $this->addSql(sprintf( 'COMMENT ON COLUMN %s.%s IS $_$%s$_$', $quotedTable, '"'.str_replace('"', '""', $column).'"', $description, )); } }