feat(db) : documenter toutes les colonnes BDD via COMMENT ON COLUMN + garde-fou (ERP-67)
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m13s
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Failing after 1m35s

- Migration retrofit Version20260528120000 : pose COMMENT ON TABLE/COLUMN sur
  les 11 tables metier existantes (53 colonnes) via le catalogue partage
  ColumnCommentsCatalog (Shared/Infrastructure/Database).
- Commande app:apply-column-comments : 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 workflow CI Gitea.
- Test architecture tests/Architecture/ColumnsHaveSqlCommentTest : echoue si
  une colonne public n'a pas de col_description (hors doctrine_migration_versions
  et fake_site_aware_entity). Whitelist metier vide.
- Convention documentee dans CLAUDE.md (regle ABSOLUE n°12) et
  .claude/rules/backend.md (section Migrations Doctrine) avec exemples et
  helper standardise pour les colonnes Timestampable/Blamable.
This commit is contained in:
Matthieu
2026-05-28 16:44:18 +02:00
parent 9613857650
commit 2ecfe226af
8 changed files with 469 additions and 0 deletions
+50
View File
@@ -74,3 +74,53 @@ Exemple : pour qu'`User.profile` soit embarque au lieu d'un lien IRI sous le gro
## PostgreSQL
- Noms de colonnes toujours en **minuscules** dans le SQL brut (commun a tous les projets MALIO)
## Migrations Doctrine
### Documentation SQL obligatoire (`COMMENT ON COLUMN`)
**Toute migration qui cree ou modifie une colonne d'une table metier doit poser un `COMMENT ON COLUMN` decrivant le champ.** La description est stockee dans `pg_description` et visible dans tous les outils d'admin BDD (DBeaver, DataGrip, pgAdmin), sans avoir a lire les annotations PHP.
**Format de la description** :
- En francais
- ≤ 200 caracteres
- Semantique du champ — contraintes / lien RG si pertinent
- Pour les colonnes d'identifiant ou FK, mentionner la cible
Exemples :
```php
// Migration : creation d'une colonne avec son commentaire dans la meme migration
$this->addSql("ALTER TABLE client ADD COLUMN siren VARCHAR(9) DEFAULT NULL");
$this->addSql("COMMENT ON COLUMN client.siren IS 'SIREN (9 chiffres) — identifiant legal entreprise. Unique parmi non-archives (RG-1.15).'");
// Cas FK : preciser la cible
$this->addSql("COMMENT ON COLUMN client.legal_form_id IS 'Reference forme juridique (SARL, SAS, SA...) — FK -> legal_form.id, ON DELETE RESTRICT.'");
// Cas booleen : preciser le sens et la valeur par defaut
$this->addSql("COMMENT ON COLUMN user.is_admin IS 'Drapeau super-administrateur — bypass complet RBAC. Faux par defaut.'");
// Bonus : decrire la table elle-meme
$this->addSql("COMMENT ON TABLE client IS 'Repertoire clients (M1 Commercial) — entites archivables.'");
```
### Helper Timestampable/Blamable
Les 4 colonnes `created_at`, `updated_at`, `created_by`, `updated_by` ajoutees par `TimestampableBlamableTrait` recoivent une description **standardisee** via le helper centralise pour eviter la duplication. Helper a creer ou appeler :
```php
// Dans la migration, apres avoir ajoute les 4 colonnes :
$this->addStandardTimestampableBlamableComments($schema, 'client');
```
L'implementation du helper applique :
- `created_at` : « Horodatage de creation de la ligne (UTC, rempli automatiquement par TimestampableBlamableSubscriber). »
- `updated_at` : « Horodatage de derniere modification de la ligne (UTC, rempli automatiquement par TimestampableBlamableSubscriber). »
- `created_by` : « ID de l'utilisateur ayant cree la ligne — null pour les creations 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 pour les modifications hors HTTP. FK -> user.id, ON DELETE SET NULL. »
### Garde-fou architecture
`tests/Architecture/ColumnsHaveSqlCommentTest` parcourt `information_schema.columns` filtre sur le schema `public` et echoue si **une seule colonne** n'a pas de `col_description`. Seules les tables system (`doctrine_migration_versions`) et la whitelist `EXCLUDED_TABLES` explicite (commentaire de justification + ticket Lesstime ouvert pour le retrofit) sont tolerees.
Conclusion : si tu crees une colonne sans poser son `COMMENT ON COLUMN`, `make test` casse en CI.
+3
View File
@@ -77,6 +77,9 @@ jobs:
php bin/console doctrine:database:create --env=test --if-not-exists --no-interaction
php bin/console doctrine:migrations:migrate --env=test --no-interaction
php bin/console doctrine:schema:update --env=test --force --no-interaction
# Rejoue le catalogue COMMENT ON apres schema:update (cf. ERP-67) :
# schema:update drop les commentaires des tables managees par l'ORM.
php bin/console app:apply-column-comments --env=test --no-interaction
php bin/console doctrine:fixtures:load --env=test --no-interaction
php bin/console app:sync-permissions --env=test --no-interaction
+1
View File
@@ -24,6 +24,7 @@ Doc humaine : @README.md — Spec audit : @doc/audit-log.md
9. **Jamais commit sans demande explicite** de l'utilisateur ; jamais force push sans confirmation.
10. **Jamais mentionner Claude, Anthropic ou une IA** dans un commit (message, titre, body, footer, trailer) ou une PR (titre, description). Pas de `Co-Authored-By: Claude`, pas de `Generated with Claude Code`, pas de `🤖`, pas d'emoji robot, rien. Les commits sont signes par l'utilisateur uniquement.
11. **Migrations d'initialisation au namespace racine** `DoctrineMigrations` dans `migrations/` (setup user, RBAC, seed de base). Les migrations modulaires (`src/Module/*/Infrastructure/Doctrine/Migrations/`) sont reservees aux evolutions post-schema (ajout de colonnes, index) — cf. @.claude/rules/architecture.md pour la raison.
12. **Toujours documenter chaque colonne BDD via `COMMENT ON COLUMN`** dans la migration qui la cree ou la modifie. Description en francais, courte (≤ 200 caracteres), explique la semantique metier + contraintes implicites (unicite partielle, FK importante, lien RG). Garde-fou : `tests/Architecture/ColumnsHaveSqlCommentTest` echoue si une colonne `public` n'a pas de description (`col_description IS NULL`). Details et exemples : @.claude/rules/backend.md § Migrations Doctrine.
## Conventions
@.claude/rules/architecture.md
+6
View File
@@ -207,10 +207,16 @@ migration-migrate:
# recree par dbal:run-sql pour que les tests RG-1.07 (unicite
# case-insensitive) voient bien la contrainte SQL. Sans ce restore, les
# POST doublons remontent 201 au lieu de 409.
# 5. app:apply-column-comments : meme cause, schema:update drop les COMMENT
# ON COLUMN/TABLE des tables managees par l'ORM (le mapping PHP ne porte
# pas d'attribut options['comment']). On rejoue le catalogue partage
# `ColumnCommentsCatalog` pour conserver la documentation SQL exigee par
# le test architecture ColumnsHaveSqlCommentTest (ERP-67).
test-db-setup:
$(SYMFONY_CONSOLE) doctrine:database:create --env=test --if-not-exists
$(SYMFONY_CONSOLE) doctrine:migrations:migrate --env=test --no-interaction
$(SYMFONY_CONSOLE) doctrine:schema:update --env=test --force
$(SYMFONY_CONSOLE) --env=test --no-interaction app:apply-column-comments
$(SYMFONY_CONSOLE) --env=test --no-interaction doctrine:fixtures:load
$(SYMFONY_CONSOLE) --env=test --no-interaction app:sync-permissions
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_category_name_type_active ON category (LOWER(name), category_type_id) WHERE deleted_at IS NULL"
+66
View File
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use App\Shared\Infrastructure\Database\ColumnCommentsCatalog;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* ERP-67 — Retrofit `COMMENT ON COLUMN` / `COMMENT ON TABLE` sur toutes les
* tables metier existantes.
*
* Postgres stocke la description dans `pg_description`. Les outils d'admin
* (DBeaver, DataGrip, pgAdmin) l'affichent automatiquement, ce qui evite de
* remonter au code Doctrine pour comprendre la semantique d'une colonne.
*
* Source unique : `ColumnCommentsCatalog::comments()`. Le meme catalogue est
* rejoue par `app:apply-column-comments` apres `doctrine:schema:update --force`
* en environnement de test (Doctrine ORM ne conservant pas les commentaires
* absents du mapping PHP).
*
* Convention :
* - Description en francais, ≤ 200 caracteres.
* - Semantique du champ + contraintes / lien RG si pertinent.
*
* Migration placee au namespace racine `DoctrineMigrations` (regle ABSOLUE
* Starseed n°11) car elle touche plusieurs modules. Les futures migrations
* applicatives devront poser leur propre `COMMENT ON COLUMN` au moment de
* creer leurs colonnes (cf. regle ABSOLUE n°12 + .claude/rules/backend.md).
*/
final class Version20260528120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'ERP-67 : retrofit COMMENT ON COLUMN/TABLE sur toutes les tables metier existantes.';
}
public function up(Schema $schema): void
{
foreach (ColumnCommentsCatalog::toSqlStatements() as $sql) {
$this->addSql($sql);
}
}
public function down(Schema $schema): void
{
foreach (ColumnCommentsCatalog::comments() as $table => $entries) {
$quotedTable = '"'.str_replace('"', '""', $table).'"';
foreach ($entries as $column => $_) {
if ('_table' === $column) {
$this->addSql(sprintf('COMMENT ON TABLE %s IS NULL', $quotedTable));
continue;
}
$this->addSql(sprintf(
'COMMENT ON COLUMN %s.%s IS NULL',
$quotedTable,
'"'.str_replace('"', '""', $column).'"',
));
}
}
}
}
@@ -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).'"';
}
}
@@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
namespace App\Tests\Architecture;
use Doctrine\DBAL\ArrayParameterType;
use Doctrine\DBAL\Connection;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* Garde-fou architecture : toute colonne d'une table metier doit porter une
* description SQL (`COMMENT ON COLUMN`).
*
* Postgres stocke la description dans `pg_description`, recuperable via
* `col_description(table_oid, column_position)`. Une colonne sans description
* remonte `NULL`. Le test parcourt `information_schema.columns` filtre sur le
* schema `public` et echoue si une seule colonne metier n'a pas de description.
*
* Tables ignorees :
* - `doctrine_migration_versions` : table system Doctrine, schema fige par la
* librairie.
* - Whitelist `EXCLUDED_TABLES` : doit rester vide ou justifiee — toute entree
* doit avoir un ticket Lesstime ouvert pour le retrofit.
*
* @internal
*/
final class ColumnsHaveSqlCommentTest extends KernelTestCase
{
/**
* Tables system, gerees par Doctrine — leur schema n'est pas notre.
*/
private const EXCLUDED_BUILTINS = [
'doctrine_migration_versions',
];
/**
* Entites mappees uniquement en `when@test` (fixtures techniques pour les
* tests d'integration, jamais en prod). Pas de migration, donc pas de
* lieu naturel pour poser un COMMENT ON COLUMN.
*
* @var list<string>
*/
private const EXCLUDED_TEST_FIXTURES = [
// tests/Fixtures/SiteAware/FakeSiteAwareEntity.php — fixture du module
// Sites pour couvrir le SiteScopedQueryExtension. Cree via schema:update
// sur la DB de test uniquement.
'fake_site_aware_entity',
];
/**
* Whitelist metier — DOIT rester vide ou justifiee.
*
* Chaque entree doit comporter (1) un commentaire expliquant pourquoi la
* table n'est pas encore documentee et (2) la reference d'un ticket
* Lesstime ouvert pour le retrofit.
*
* @var list<string>
*/
private const EXCLUDED_TABLES = [];
public function testAllPublicColumnsHaveASqlComment(): void
{
/** @var Connection $conn */
$conn = self::getContainer()->get('doctrine.dbal.default_connection');
$excluded = [...self::EXCLUDED_BUILTINS, ...self::EXCLUDED_TEST_FIXTURES, ...self::EXCLUDED_TABLES];
$rows = $conn->fetchAllAssociative(
<<<'SQL'
SELECT c.table_name, c.column_name
FROM information_schema.columns c
WHERE c.table_schema = 'public'
AND c.table_name NOT IN (:excluded)
AND col_description(
(c.table_schema || '.' || c.table_name)::regclass,
c.ordinal_position
) IS NULL
ORDER BY c.table_name, c.ordinal_position
SQL,
['excluded' => $excluded],
['excluded' => ArrayParameterType::STRING],
);
if ([] !== $rows) {
$missing = array_map(
static fn (array $row): string => sprintf('%s.%s', $row['table_name'], $row['column_name']),
$rows,
);
self::fail(sprintf(
"%d colonne(s) sans COMMENT ON COLUMN — ajouter une description SQL dans la migration qui les cree (cf. .claude/rules/backend.md § Migrations Doctrine) :\n - %s",
count($missing),
implode("\n - ", $missing),
));
}
// Garde : si la requete ne renvoie rien et qu'aucune table publique
// n'existe (sauf doctrine_migration_versions), le test deviendrait un
// faux positif vert. On verifie qu'il y a bien des tables a auditer.
$tableCount = (int) $conn->fetchOne(
<<<'SQL'
SELECT COUNT(*)
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name NOT IN (:excluded)
SQL,
['excluded' => $excluded],
['excluded' => ArrayParameterType::STRING],
);
self::assertGreaterThan(0, $tableCount, 'Aucune table publique a auditer : schema vide ou whitelist trop large.');
}
}