[ERP-67] Documenter toutes les colonnes BDD via COMMENT ON COLUMN + garde-fou (#24)
Auto Tag Develop / tag (push) Successful in 7s
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>
This commit was merged in pull request #24.
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Core\Infrastructure\Console;
|
||||
|
||||
use App\Shared\Infrastructure\Database\ColumnCommentsCatalog;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'app:apply-column-comments',
|
||||
description: 'Reapplique les COMMENT ON TABLE/COLUMN du catalogue (workaround schema:update).',
|
||||
)]
|
||||
final class ApplyColumnCommentsCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Connection $connection,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$statements = ColumnCommentsCatalog::toSqlStatements();
|
||||
|
||||
foreach ($statements as $sql) {
|
||||
$this->connection->executeStatement($sql);
|
||||
}
|
||||
|
||||
$io->success(sprintf('%d COMMENT ON statements appliques.', count($statements)));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
<?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).'"';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user