fix(catalog) : align SQL backfill slug with CategoryCodeGenerator (ERP-78)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Has been cancelled
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Has been cancelled

Le backfill de code de la migration Version20260602100000 utilisait un slug
SQL (REGEXP_REPLACE) qui ne translitterait pas les accents : « Independant »
produisait IND_PENDANT la ou le generateur applicatif (AsciiSlugger) produit
INDEPENDANT. Le code categorie, cle censee etre deterministe entre
environnements, divergeait selon le chemin (SQL migration vs PHP runtime).

- CategoryCodeSql : source unique de l'expression SQL de slug, miroir fidele
  de CategoryCodeGenerator::slugify (translate() des accents Latin-1, trim _,
  fallback CATEGORY).
- Migration : etapes 3 et 5 du backfill branchees sur ce helper.
- CategoryCodeSqlSlugTest : garde-fou verrouillant l'egalite SQL = PHP sur le
  domaine accentue, pour empecher toute future derive (cause racine du bug).
This commit is contained in:
Matthieu
2026-06-02 09:50:59 +02:00
parent 5b16fd9928
commit f3c6db28dc
3 changed files with 152 additions and 18 deletions
+19 -18
View File
@@ -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).
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\Database;
/**
* Miroir SQL de `CategoryCodeGenerator::slugify()` (module Catalog, ERP-78).
*
* Le `code` d'une `Category` est un slug MAJUSCULE deterministe du nom. A
* l'execution (POST/PATCH API), il est genere en PHP par `CategoryCodeGenerator`
* via `AsciiSlugger`. Mais la migration corrective `Version20260602100000` doit
* backfiller le `code` des categories pre-existantes en SQL pur (le backfill
* tourne dans le plan `addSql`, sans acces aux services applicatifs).
*
* Deux implementations d'un meme slug = risque de derive : un nom accentue
* comme « Independant » doit produire le MEME code (`INDEPENDANT`) quel que soit
* le chemin. Cette classe est la SOURCE UNIQUE de l'expression SQL ; son egalite
* avec le générateur PHP est verrouillee par `CategoryCodeSqlSlugTest`.
*
* Domaine couvert : noms francais / Latin-1 (tous les accents, minuscule +
* majuscule, translitteres vers l'ASCII comme le fait `AsciiSlugger`). Limite
* connue et assumee : les ligatures (`Œ`->`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,
);
}
}
@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Catalog\Api;
use App\Module\Catalog\Application\Service\CategoryCodeGenerator;
use App\Shared\Infrastructure\Database\CategoryCodeSql;
use Doctrine\DBAL\Connection;
use PHPUnit\Framework\Attributes\DataProvider;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* Garde-fou ERP-78 : l'expression SQL de slug (CategoryCodeSql, utilisee par le
* backfill de la migration Version20260602100000) doit produire EXACTEMENT le
* meme code que le generateur applicatif (CategoryCodeGenerator::slugify), sur
* tout le domaine de noms francais / Latin-1.
*
* Verrouille la cause racine du bug initial : deux implementations d'un meme
* slug qui derivent silencieusement (« Independant » -> 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<string, array{string}>
*/
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),
);
}
}