diff --git a/migrations/Version20260602100000.php b/migrations/Version20260602100000.php index 60ca994..03d9521 100644 --- a/migrations/Version20260602100000.php +++ b/migrations/Version20260602100000.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace DoctrineMigrations; +use App\Shared\Infrastructure\Database\CategoryCodeSql; use Doctrine\DBAL\Schema\Schema; use Doctrine\Migrations\AbstractMigration; @@ -75,18 +76,19 @@ final class Version20260602100000 extends AbstractMigration // 3. Re-pointer toute categorie pre-existante (rattachee a un ancien type) // vers le type CLIENT, en lui donnant un code derive du nom si absent. // En prod la table est vide -> no-op ; defensif pour les envs qui - // auraient deja seede des categories sous les anciens types. - $this->addSql(<<<'SQL' - UPDATE category c - SET category_type_id = (SELECT id FROM category_type WHERE code = 'CLIENT'), - code = COALESCE( - c.code, - LEFT(UPPER(REGEXP_REPLACE(c.name, '[^A-Za-z0-9]+', '_', 'g')), 50) - ) - WHERE c.category_type_id IN ( - SELECT id FROM category_type WHERE code IN (:legacyCodes) - ) - SQL, ['legacyCodes' => self::LEGACY_TYPE_CODES], ['legacyCodes' => \Doctrine\DBAL\ArrayParameterType::STRING]); + // auraient deja seede des categories sous les anciens types. Le slug + // SQL est le miroir EXACT de CategoryCodeGenerator::slugify (cf. + // CategoryCodeSql + CategoryCodeSqlSlugTest) : un nom accentue produit + // le meme code que la generation applicative (« Independant » -> + // INDEPENDANT, et non IND_PENDANT). + $this->addSql( + 'UPDATE category c ' + ."SET category_type_id = (SELECT id FROM category_type WHERE code = 'CLIENT'), " + .'code = COALESCE(c.code, '.CategoryCodeSql::slugExpression('c.name').') ' + .'WHERE c.category_type_id IN (SELECT id FROM category_type WHERE code IN (:legacyCodes))', + ['legacyCodes' => self::LEGACY_TYPE_CODES], + ['legacyCodes' => \Doctrine\DBAL\ArrayParameterType::STRING], + ); // 4. Creer les 4 categories systeme sous CLIENT (si leur code est libre // parmi les actifs). created_at/updated_at NOT NULL -> now() ; le blame @@ -104,12 +106,11 @@ final class Version20260602100000 extends AbstractMigration } // 5. Backfill defensif : toute categorie encore sans code recoit un slug - // de son nom (garantit que le SET NOT NULL passe). - $this->addSql(<<<'SQL' - UPDATE category - SET code = LEFT(UPPER(REGEXP_REPLACE(name, '[^A-Za-z0-9]+', '_', 'g')), 50) - WHERE code IS NULL - SQL); + // de son nom (garantit que le SET NOT NULL passe). Meme expression de + // slug fidele au generateur applicatif (CategoryCodeSql). + $this->addSql( + 'UPDATE category SET code = '.CategoryCodeSql::slugExpression('name').' WHERE code IS NULL', + ); // 6. Index unique partiel sur le code parmi les actifs (non exprimable en // ORM : recree aussi dans `test-db-setup` apres schema:update). diff --git a/src/Shared/Infrastructure/Database/CategoryCodeSql.php b/src/Shared/Infrastructure/Database/CategoryCodeSql.php new file mode 100644 index 0000000..41c303e --- /dev/null +++ b/src/Shared/Infrastructure/Database/CategoryCodeSql.php @@ -0,0 +1,60 @@ +`OE`, `ß`->`SS`) ne sont PAS gerees + * par `translate()` (mapping 1->1 uniquement) ; elles n'apparaissent pas dans + * les noms de categories CLIENT et le backfill ne s'execute de toute facon que + * sur des bases dev deja peuplees (en prod la table `category` est vide). + */ +final class CategoryCodeSql +{ + /** Longueur maximale de la colonne `category.code` (cf. CategoryCodeGenerator). */ + private const int MAX_LENGTH = 50; + + /** + * Accents Latin-1 (minuscules puis majuscules) translitteres vers leur + * equivalent ASCII minuscule — `UPPER()` repasse tout en majuscule ensuite. + * `translate()` mappe caractere a caractere : `ACCENT_FROM` et `ACCENT_TO` + * doivent avoir EXACTEMENT le meme nombre de caracteres. + */ + private const string ACCENT_FROM = 'àâäáãåçéèêëíìîïñóòôöõúùûüýÿÀÂÄÁÃÅÇÉÈÊËÍÌÎÏÑÓÒÔÖÕÚÙÛÜÝŸ'; + private const string ACCENT_TO = 'aaaaaaceeeeiiiinooooouuuuyyaaaaaaceeeeiiiinooooouuuuyy'; + + /** + * Expression SQL produisant le slug du `$column` donne (ex: `name`, `c.name`). + * Reproduit fidelement `CategoryCodeGenerator::slugify` : translitteration des + * accents, separateurs non alphanumeriques reduits a `_`, MAJUSCULE, borne a + * 50, `_` de bord retires, fallback `CATEGORY` si vide. + */ + public static function slugExpression(string $column): string + { + return sprintf( + "COALESCE(NULLIF(TRIM(BOTH '_' FROM " + ."LEFT(UPPER(REGEXP_REPLACE(translate(%s, '%s', '%s'), '[^A-Za-z0-9]+', '_', 'g')), %d)" + ."), ''), 'CATEGORY')", + $column, + self::ACCENT_FROM, + self::ACCENT_TO, + self::MAX_LENGTH, + ); + } +} diff --git a/tests/Module/Catalog/Api/CategoryCodeSqlSlugTest.php b/tests/Module/Catalog/Api/CategoryCodeSqlSlugTest.php new file mode 100644 index 0000000..12dca15 --- /dev/null +++ b/tests/Module/Catalog/Api/CategoryCodeSqlSlugTest.php @@ -0,0 +1,73 @@ + IND_PENDANT cote SQL + * faute de translitteration des accents, vs INDEPENDANT cote PHP). On ne couvre + * volontairement PAS les ligatures (`Œ`, `ß`) : `translate()` est 1->1 et ne + * peut produire `OE`/`SS` ; elles sont hors du domaine des categories CLIENT. + * + * @internal + */ +final class CategoryCodeSqlSlugTest extends KernelTestCase +{ + /** + * Noms representatifs du domaine reel : accents, cedille, apostrophe, + * separateurs varies, parentheses, majuscules accentuees. + * + * @return iterable + */ + public static function nameProvider(): iterable + { + yield 'sans accent' => ['Distributeur']; + yield 'tiret' => ['Agro-alimentaire']; + yield 'slash' => ['Transport/Logistique']; + yield 'accent aigu' => ['Indépendant']; + yield 'apostrophe + accent' => ["L'Oréal"]; + yield 'esperluette' => ['Forêt & Bûcheron']; + yield 'cedille majuscule' => ['Ça va']; + yield 'accents multiples' => ['Naïve façade']; + yield 'circonflexe' => ["Côte d'Azur"]; + yield 'parentheses' => ['Zone (Sud)']; + } + + #[DataProvider('nameProvider')] + public function testSqlSlugMatchesPhpSlug(string $name): void + { + self::bootKernel(); + $container = self::getContainer(); + + /** @var Connection $conn */ + $conn = $container->get('doctrine')->getConnection(); + /** @var CategoryCodeGenerator $generator */ + $generator = $container->get(CategoryCodeGenerator::class); + + // Evaluation pure de l'expression (aucune table requise) : le nom est + // passe en parametre lie a la place de la colonne. + $sqlSlug = $conn->fetchOne( + 'SELECT '.CategoryCodeSql::slugExpression(':name'), + ['name' => $name], + ); + + self::assertSame( + $generator->slugify($name), + $sqlSlug, + sprintf('SQL et PHP doivent produire le meme slug pour "%s".', $name), + ); + } +}