a948eed9b6
Auto Tag Develop / tag (push) Successful in 7s
Ticket Lesstime : ERP-67 — `[Convention SQL / Backend / L]` ## Objectif Documenter toutes les colonnes BDD via `COMMENT ON COLUMN` (visible dans DBeaver / DataGrip / pgAdmin sans lire le code Doctrine) et verrouiller la convention par un garde-fou de test architecture. ## Changements ### Convention (CLAUDE.md + rules) - `CLAUDE.md` regle ABSOLUE n°12 : toute migration creant ou modifiant une colonne doit poser un `COMMENT ON COLUMN` (FR, ≤ 200 caracteres). - `.claude/rules/backend.md` § Migrations Doctrine : exemples + helper standardise pour les 4 colonnes du `TimestampableBlamableTrait`. ### Garde-fou architecture - `tests/Architecture/ColumnsHaveSqlCommentTest` : echoue si une colonne `public` n'a pas de `col_description` (hors `doctrine_migration_versions` et `fake_site_aware_entity` fixture de test). - Whitelist metier `EXCLUDED_TABLES` volontairement vide. ### Retrofit des tables existantes - Migration `Version20260528120000` : 64 `COMMENT ON TABLE/COLUMN` sur les 11 tables metier (audit_log, category, category_type, permission, role, role_permission, site, user, user_permission, user_role, user_site). - Source unique de verite : `src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php`. - Commande `app:apply-column-comments` (Module/Core/Infrastructure/Console) : rejoue le catalogue apres `doctrine:schema:update --force` (sinon l'ORM drop les commentaires absents du mapping PHP). Branchee dans `makefile test-db-setup` et `.gitea/workflows/pull-request.yml`. ## Validation - `make db-reset` puis `make test` : 312 tests verts, 0 regression. - `make php-cs-fixer-allow-risky` : 0 fix. - Couverture : 53/53 colonnes documentees sur `starseed` et `starseed_test`. ## Test plan - [ ] `make db-reset` passe sans erreur. - [ ] `make test` passe ; `ColumnsHaveSqlCommentTest` vert sur DB de test. - [ ] Verifier dans DBeaver / pgAdmin que les commentaires apparaissent sur les colonnes de `category`, `user`, `audit_log`. - [ ] Verifier que le workflow CI Gitea (`pull-request.yml`) passe. ## A noter pour la suite La convention `options: ['comment' => '...']` sur chaque `#[ORM\Column]` reste recommandee pour les nouvelles entites — Doctrine genere alors automatiquement le `COMMENT ON COLUMN` dans la migration et `schema:update` le preserve sans avoir a rejouer le catalogue. A discuter si on veut en faire une regle forte. --------- Co-authored-by: admin malio <malio@yuno.malio.fr> Co-authored-by: Matthieu <contact@malio.fr> Co-authored-by: Matthieu <mtholot19@gmail.com> Reviewed-on: #24 Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr> Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
189 lines
11 KiB
PHP
189 lines
11 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Shared\Infrastructure\Database;
|
|
|
|
/**
|
|
* Catalogue centralise des descriptions SQL (`COMMENT ON TABLE` /
|
|
* `COMMENT ON COLUMN`) appliquees aux tables metier de Starseed.
|
|
*
|
|
* Source unique de verite, utilisee par :
|
|
* - `migrations/Version20260528120000.php` : retrofit initial des tables
|
|
* pre-existantes (ERP-67).
|
|
* - `App\Module\Core\Infrastructure\Console\ApplyColumnCommentsCommand` :
|
|
* reapplique les commentaires apres `doctrine:schema:update --force` en
|
|
* environnement de test (cf. commentaire de `test-db-setup` dans le
|
|
* `makefile`). Doctrine ORM ne conservant pas les commentaires absents
|
|
* du mapping PHP, on les rejoue depuis ce catalogue.
|
|
*
|
|
* Pour ajouter ou modifier un commentaire :
|
|
* - Mettre a jour `comments()` ci-dessous.
|
|
* - La migration retrofit pose la valeur initiale, la commande la rejoue
|
|
* en boucle. Toute future colonne doit etre documentee dans sa propre
|
|
* migration (cf. CLAUDE.md regle ABSOLUE n°12) — ce catalogue ne sert
|
|
* qu'au retrofit + au workaround schema:update.
|
|
*
|
|
* Convention : description en francais, ≤ 200 caracteres, semantique du
|
|
* champ + contraintes / lien RG si pertinent. La cle speciale `_table` est
|
|
* appliquee a la table elle-meme (`COMMENT ON TABLE`).
|
|
*/
|
|
final class ColumnCommentsCatalog
|
|
{
|
|
/**
|
|
* @return array<string, array<string, string>>
|
|
*/
|
|
public static function comments(): array
|
|
{
|
|
return [
|
|
'audit_log' => [
|
|
'_table' => "Journal d'audit append-only — trace toutes les modifications BDD sur entites annotees #[Auditable]. Lecture seule via API.",
|
|
'id' => "UUID v7 — identifiant de la ligne d'audit (genere en PHP, ordre temporel garanti).",
|
|
'entity_type' => "Type d'entite auditee au format module.Entity (ex: core.User, commercial.Client) — evite les collisions inter-modules.",
|
|
'entity_id' => "Identifiant de l'entite auditee (supporte INT et UUID — stocke en varchar pour rester generique).",
|
|
'action' => "Type d'operation auditee : 'create', 'update' ou 'delete'.",
|
|
'changes' => 'Snapshot complet pour create/delete, diff {champ: {old, new}} pour update. Cles sensibles filtrees (password, token, secret).',
|
|
'performed_by' => "Username de l'auteur de l'action (denormalise, survit a la suppression du user) — vaut 'system' en CLI.",
|
|
'performed_at' => "Horodatage UTC de l'action auditee.",
|
|
'ip_address' => "Adresse IP de l'auteur (IPv4/IPv6) — null hors contexte HTTP.",
|
|
'request_id' => "UUID v4 de la requete HTTP — regroupe les changements d'un meme flush, facilite la correlation logs.",
|
|
],
|
|
|
|
'category' => [
|
|
'_table' => 'Categories M0 — referentiel type par category_type, soft-delete via deleted_at, unicite (LOWER(name), category_type_id) parmi les actifs.',
|
|
'id' => 'Identifiant interne auto-incremente.',
|
|
'name' => 'Libelle de la categorie (≤ 120 caracteres) — unique par type parmi les actifs (RG-1.06).',
|
|
'category_type_id' => 'Reference au type de la categorie — FK -> category_type.id, ON DELETE RESTRICT (un type ne peut etre supprime tant qu il a des categories).',
|
|
'deleted_at' => 'Horodatage UTC du soft-delete (archivage logique) — null si la categorie est active.',
|
|
] + self::timestampableBlamableComments(),
|
|
|
|
'category_type' => [
|
|
'_table' => 'Referentiel statique des types de categories — code technique stable + libelle FR.',
|
|
'id' => 'Identifiant interne auto-incremente.',
|
|
'code' => 'Code technique stable du type (snake_case, ≤ 40 caracteres) — unique, utilise dans le code et les configurations.',
|
|
'label' => 'Libelle affichable du type (FR, ≤ 120 caracteres).',
|
|
],
|
|
|
|
'permission' => [
|
|
'_table' => 'Referentiel des permissions RBAC — codes au format module.resource[.subresource].action, synchronise par app:sync-permissions.',
|
|
'id' => 'Identifiant interne auto-incremente.',
|
|
'code' => 'Code RBAC au format module.resource[.subresource].action — unique, synchronise par app:sync-permissions.',
|
|
'label' => 'Libelle affichable de la permission (FR).',
|
|
'module' => 'Identifiant du module proprietaire de la permission (snake_case, ex: core, commercial).',
|
|
'orphan' => "Drapeau permission orpheline — vrai quand son module declarant a ete supprime, masquee de l'interface RBAC.",
|
|
],
|
|
|
|
'role' => [
|
|
'_table' => 'Referentiel des roles RBAC — agregent un ensemble de permissions, attribues aux utilisateurs.',
|
|
'id' => 'Identifiant interne auto-incremente.',
|
|
'code' => 'Code technique stable du role (snake_case) — utilise dans le code (ex: admin, user). Unique.',
|
|
'label' => 'Libelle affichable du role (FR).',
|
|
'description' => 'Description longue du role (optionnelle).',
|
|
'is_system' => "Drapeau role systeme — bloque la suppression et la modification du code via l'interface.",
|
|
],
|
|
|
|
'role_permission' => [
|
|
'_table' => 'Table de jointure roles <-> permissions (ManyToMany).',
|
|
'role_id' => 'FK -> role.id, ON DELETE CASCADE — role qui porte la permission.',
|
|
'permission_id' => 'FK -> permission.id, ON DELETE CASCADE — permission attribuee au role.',
|
|
],
|
|
|
|
'site' => [
|
|
'_table' => 'Sites geographiques — perimetre de scoping multi-site, attribues aux utilisateurs via user_site.',
|
|
'id' => 'Identifiant interne auto-incremente.',
|
|
'name' => 'Nom du site (≤ 100 caracteres).',
|
|
'city' => 'Ville du site (≤ 100 caracteres).',
|
|
'postal_code' => 'Code postal (chaine ≤ 20 caracteres) — VARCHAR pour gerer les zeros initiaux et les formats internationaux.',
|
|
'color' => "Code couleur hexadecimal (#RRGGBB) — differenciation visuelle dans l'UI.",
|
|
'street' => "Numero et voie de l'adresse (≤ 200 caracteres).",
|
|
'complement' => "Complement d'adresse (etage, batiment...) — optionnel.",
|
|
'created_at' => 'Horodatage UTC de creation de la ligne — rempli par TimestampableBlamableSubscriber au prePersist.',
|
|
'updated_at' => 'Horodatage UTC de derniere modification — rempli par TimestampableBlamableSubscriber au preUpdate.',
|
|
],
|
|
|
|
'user' => [
|
|
'_table' => 'Comptes utilisateurs Starseed — authentification JWT, RBAC via roles et permissions directes.',
|
|
'id' => 'Identifiant interne auto-incremente.',
|
|
'username' => 'Identifiant de connexion (≤ 100 caracteres) — unique.',
|
|
'password' => 'Hash du mot de passe (algorithme courant Symfony) — exclu de l audit via #[AuditIgnore].',
|
|
'created_at' => 'Horodatage UTC de creation du compte — rempli manuellement dans le constructeur (pas via TimestampableBlamableSubscriber).',
|
|
'is_admin' => 'Drapeau super-administrateur — bypass complet RBAC. Faux par defaut.',
|
|
'current_site_id' => "Site actuellement selectionne par l'utilisateur (contexte de session) — FK -> site.id, ON DELETE SET NULL.",
|
|
],
|
|
|
|
'user_permission' => [
|
|
'_table' => 'Table de jointure utilisateurs <-> permissions directes (hors role).',
|
|
'user_id' => 'FK -> user.id, ON DELETE CASCADE — utilisateur destinataire de la permission directe.',
|
|
'permission_id' => 'FK -> permission.id, ON DELETE CASCADE — permission accordee individuellement.',
|
|
],
|
|
|
|
'user_role' => [
|
|
'_table' => 'Table de jointure utilisateurs <-> roles (ManyToMany).',
|
|
'user_id' => 'FK -> user.id, ON DELETE CASCADE — utilisateur portant le role.',
|
|
'role_id' => 'FK -> role.id, ON DELETE CASCADE — role attribue a l utilisateur.',
|
|
],
|
|
|
|
'user_site' => [
|
|
'_table' => 'Table de jointure utilisateurs <-> sites accessibles — gere le scoping multi-site (un user ne voit que les donnees de ses sites).',
|
|
'user_id' => 'FK -> user.id, ON DELETE CASCADE — utilisateur ayant acces au site.',
|
|
'site_id' => 'FK -> site.id, ON DELETE CASCADE — site accessible par l utilisateur.',
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Descriptions standardisees pour les 4 colonnes du pattern
|
|
* Timestampable/Blamable (`TimestampableBlamableTrait`).
|
|
*
|
|
* @return array<string, string>
|
|
*/
|
|
public static function timestampableBlamableComments(): array
|
|
{
|
|
return [
|
|
'created_at' => 'Horodatage UTC de creation de la ligne — rempli par TimestampableBlamableSubscriber au prePersist.',
|
|
'updated_at' => 'Horodatage UTC de derniere modification — rempli par TimestampableBlamableSubscriber au preUpdate.',
|
|
'created_by' => "ID de l'utilisateur ayant cree la ligne — null hors HTTP (CLI, migration, fixture). FK -> \"user\".id, ON DELETE SET NULL.",
|
|
'updated_by' => "ID de l'utilisateur ayant modifie la ligne en dernier — null hors HTTP. FK -> \"user\".id, ON DELETE SET NULL.",
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Construit la liste des requetes SQL `COMMENT ON TABLE/COLUMN` (en
|
|
* dollar-quoting Postgres `$_$`) a partir du catalogue.
|
|
*
|
|
* @return list<string>
|
|
*/
|
|
public static function toSqlStatements(): array
|
|
{
|
|
$statements = [];
|
|
foreach (self::comments() as $table => $entries) {
|
|
$quotedTable = self::quoteIdent($table);
|
|
foreach ($entries as $column => $description) {
|
|
if ('_table' === $column) {
|
|
$statements[] = sprintf('COMMENT ON TABLE %s IS $_$%s$_$', $quotedTable, $description);
|
|
|
|
continue;
|
|
}
|
|
|
|
$statements[] = sprintf(
|
|
'COMMENT ON COLUMN %s.%s IS $_$%s$_$',
|
|
$quotedTable,
|
|
self::quoteIdent($column),
|
|
$description,
|
|
);
|
|
}
|
|
}
|
|
|
|
return $statements;
|
|
}
|
|
|
|
/**
|
|
* Quote un identifiant SQL avec des guillemets doubles. Necessaire pour
|
|
* la table `user` (mot reserve PG) ; applique a tous par coherence.
|
|
*/
|
|
private static function quoteIdent(string $name): string
|
|
{
|
|
return '"'.str_replace('"', '""', $name).'"';
|
|
}
|
|
}
|