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;
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use App\Shared\Infrastructure\Database\CategoryCodeSql;
|
||||||
use Doctrine\DBAL\Schema\Schema;
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
use Doctrine\Migrations\AbstractMigration;
|
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)
|
// 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.
|
// 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
|
// En prod la table est vide -> no-op ; defensif pour les envs qui
|
||||||
// auraient deja seede des categories sous les anciens types.
|
// auraient deja seede des categories sous les anciens types. Le slug
|
||||||
$this->addSql(<<<'SQL'
|
// SQL est le miroir EXACT de CategoryCodeGenerator::slugify (cf.
|
||||||
UPDATE category c
|
// CategoryCodeSql + CategoryCodeSqlSlugTest) : un nom accentue produit
|
||||||
SET category_type_id = (SELECT id FROM category_type WHERE code = 'CLIENT'),
|
// le meme code que la generation applicative (« Independant » ->
|
||||||
code = COALESCE(
|
// INDEPENDANT, et non IND_PENDANT).
|
||||||
c.code,
|
$this->addSql(
|
||||||
LEFT(UPPER(REGEXP_REPLACE(c.name, '[^A-Za-z0-9]+', '_', 'g')), 50)
|
'UPDATE category c '
|
||||||
)
|
."SET category_type_id = (SELECT id FROM category_type WHERE code = 'CLIENT'), "
|
||||||
WHERE c.category_type_id IN (
|
.'code = COALESCE(c.code, '.CategoryCodeSql::slugExpression('c.name').') '
|
||||||
SELECT id FROM category_type WHERE code IN (:legacyCodes)
|
.'WHERE c.category_type_id IN (SELECT id FROM category_type WHERE code IN (:legacyCodes))',
|
||||||
)
|
['legacyCodes' => self::LEGACY_TYPE_CODES],
|
||||||
SQL, ['legacyCodes' => self::LEGACY_TYPE_CODES], ['legacyCodes' => \Doctrine\DBAL\ArrayParameterType::STRING]);
|
['legacyCodes' => \Doctrine\DBAL\ArrayParameterType::STRING],
|
||||||
|
);
|
||||||
|
|
||||||
// 4. Creer les 4 categories systeme sous CLIENT (si leur code est libre
|
// 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
|
// 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
|
// 5. Backfill defensif : toute categorie encore sans code recoit un slug
|
||||||
// de son nom (garantit que le SET NOT NULL passe).
|
// de son nom (garantit que le SET NOT NULL passe). Meme expression de
|
||||||
$this->addSql(<<<'SQL'
|
// slug fidele au generateur applicatif (CategoryCodeSql).
|
||||||
UPDATE category
|
$this->addSql(
|
||||||
SET code = LEFT(UPPER(REGEXP_REPLACE(name, '[^A-Za-z0-9]+', '_', 'g')), 50)
|
'UPDATE category SET code = '.CategoryCodeSql::slugExpression('name').' WHERE code IS NULL',
|
||||||
WHERE code IS NULL
|
);
|
||||||
SQL);
|
|
||||||
|
|
||||||
// 6. Index unique partiel sur le code parmi les actifs (non exprimable en
|
// 6. Index unique partiel sur le code parmi les actifs (non exprimable en
|
||||||
// ORM : recree aussi dans `test-db-setup` apres schema:update).
|
// 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