fix(catalog) : align SQL backfill slug with CategoryCodeGenerator (ERP-78)
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:
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user