a9998d4bcd
Entites metier (Client, ClientContact, ClientAddress, ClientRib) avec #[Auditable] + Timestampable/Blamable, et 4 referentiels comptables statiques (TvaMode, PaymentDelay, PaymentType, Bank). 8 repositories interfaces + impl Doctrine. Aucun ApiResource (Provider/Processor = ERP-55). - Client : 2 FK auto-referentes distributor/broker (mutuellement exclusives, CHECK en base), M2M categories, FK referentiels comptables, groupes de serialisation par onglet. Pas de #[ORM\UniqueConstraint] : unicite du nom de societe portee par l'index partiel Postgres (decision Q4). - ClientRib : tous les champs audites, aucun #[AuditIgnore] sur iban/bic (decision 29/05, audit admin-only). - M2M Category via le contrat Shared CategoryInterface + resolve_target_entities (regle n°1, pas d'import inter-modules) ; sites via SiteInterface. - CommercialReferentialFixtures : re-seed idempotent des 4 referentiels (sinon vides apres db-reset car desormais tables mappees, purgees par les fixtures). - Referentiels whitelistes dans EntitiesAreTimestampableBlamableTest::EXCLUDED. - doctrine.yaml : mapping ORM du module Commercial + resolve CategoryInterface. - ColumnCommentsCatalog : ajout des colonnes M1 (chemin schema:update/test) ; migration retrofit Version20260528120000 filtree sur les tables existantes pour ne pas casser sur les tables des modules crees plus tard. - makefile test-db-setup : recreation de l'index partiel uq_client_company_name_active. Refs ERP-54.
331 lines
23 KiB
PHP
331 lines
23 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.',
|
|
],
|
|
|
|
// === M1 Commercial (ERP-53/54) — miroir des COMMENT de la migration
|
|
// Version20260601000000 pour le chemin schema:update (dev/test). ===
|
|
|
|
'tva_mode' => [
|
|
'_table' => 'Referentiel des modes de TVA appliques a un client (France, Export, Intracom).',
|
|
'id' => 'Identifiant interne auto-incremente.',
|
|
'code' => 'Code technique stable (UPPER_SNAKE, ≤ 30 caracteres) — unique, utilise par le code metier.',
|
|
'label' => 'Libelle affichable (FR, ≤ 120 caracteres).',
|
|
'position' => 'Ordre d affichage croissant dans les selecteurs (tri position ASC puis label ASC).',
|
|
],
|
|
|
|
'payment_delay' => [
|
|
'_table' => 'Referentiel des delais de reglement (15 jours, 30 jours, a reception).',
|
|
'id' => 'Identifiant interne auto-incremente.',
|
|
'code' => 'Code technique stable (UPPER_SNAKE, ≤ 30 caracteres) — unique, utilise par le code metier.',
|
|
'label' => 'Libelle affichable (FR, ≤ 120 caracteres).',
|
|
'position' => 'Ordre d affichage croissant dans les selecteurs (tri position ASC puis label ASC).',
|
|
],
|
|
|
|
'payment_type' => [
|
|
'_table' => 'Referentiel des types de reglement (virement, LCR, cheque, non soumise).',
|
|
'id' => 'Identifiant interne auto-incremente.',
|
|
'code' => 'Code technique stable (UPPER_SNAKE, ≤ 30 caracteres) — unique, utilise par le code metier.',
|
|
'label' => 'Libelle affichable (FR, ≤ 120 caracteres).',
|
|
'position' => 'Ordre d affichage croissant dans les selecteurs (tri position ASC puis label ASC).',
|
|
],
|
|
|
|
'bank' => [
|
|
'_table' => 'Referentiel des banques selectionnables pour le reglement par virement.',
|
|
'id' => 'Identifiant interne auto-incremente.',
|
|
'code' => 'Code technique stable (UPPER_SNAKE, ≤ 30 caracteres) — unique, utilise par le code metier.',
|
|
'label' => 'Libelle affichable (FR, ≤ 120 caracteres).',
|
|
'position' => 'Ordre d affichage croissant dans les selecteurs (tri position ASC puis label ASC).',
|
|
],
|
|
|
|
'client' => [
|
|
'_table' => 'Repertoire clients (M1 Commercial) — entites archivables (is_archived) et soft-deletables (deleted_at, HP M2).',
|
|
'id' => 'Identifiant interne auto-incremente.',
|
|
'company_name' => 'Raison sociale (stockee en MAJUSCULES, RG-1.18). Unique case-insensitive parmi les actifs non archives/non supprimes (RG-1.16, uq_client_company_name_active).',
|
|
'first_name' => 'Prenom du contact principal (capitalise serveur, RG-1.19). first_name OU last_name obligatoire (RG-1.01).',
|
|
'last_name' => 'Nom du contact principal (capitalise serveur, RG-1.19). first_name OU last_name obligatoire (RG-1.01).',
|
|
'phone_primary' => 'Telephone principal — stocke en chiffres uniquement (RG-1.20). Obligatoire.',
|
|
'phone_secondary' => 'Telephone secondaire optionnel — chiffres uniquement (RG-1.20).',
|
|
'email' => 'Email principal (lowercase serveur, RG-1.21). NON unique (RG-1.17 supprimee, Q4).',
|
|
'distributor_id' => 'FK auto-referente vers un client porteur de la categorie DISTRIBUTEUR — exclusive avec broker_id (RG-1.03, chk_client_distrib_or_broker). FK -> client.id, ON DELETE SET NULL.',
|
|
'broker_id' => 'FK auto-referente vers un client porteur de la categorie COURTIER — exclusive avec distributor_id (RG-1.03). FK -> client.id, ON DELETE SET NULL.',
|
|
'triage_service' => 'Drapeau service triage active pour le client. Faux par defaut.',
|
|
'description' => 'Onglet Information : description libre. Obligatoire pour le role Commerciale (RG-1.04), optionnel sinon.',
|
|
'competitors' => 'Onglet Information : concurrents identifies (texte libre ≤ 255). Obligatoire role Commerciale (RG-1.04).',
|
|
'founded_at' => 'Onglet Information : date de creation de l entreprise. Obligatoire role Commerciale (RG-1.04).',
|
|
'employees_count' => 'Onglet Information : effectif (entier >= 0). Obligatoire role Commerciale (RG-1.04).',
|
|
'revenue_amount' => 'Onglet Information : chiffre d affaires (NUMERIC 15,2). Obligatoire role Commerciale (RG-1.04).',
|
|
'director_name' => 'Onglet Information : nom du dirigeant. Obligatoire role Commerciale (RG-1.04).',
|
|
'profit_amount' => 'Onglet Information : resultat / benefice (NUMERIC 15,2). Obligatoire role Commerciale (RG-1.04).',
|
|
'siren' => 'Onglet Comptabilite : SIREN (9 chiffres attendus). NON unique — peut etre partage entre etablissements (RG-1.15 supprimee, Q4).',
|
|
'account_number' => 'Onglet Comptabilite : numero de compte comptable du client.',
|
|
'tva_mode_id' => 'Onglet Comptabilite : mode de TVA applique — FK -> tva_mode.id, ON DELETE RESTRICT.',
|
|
'n_tva' => 'Onglet Comptabilite : numero de TVA intracommunautaire.',
|
|
'payment_delay_id' => 'Onglet Comptabilite : delai de reglement — FK -> payment_delay.id, ON DELETE RESTRICT.',
|
|
'payment_type_id' => 'Onglet Comptabilite : type de reglement — FK -> payment_type.id, ON DELETE RESTRICT. Code LCR impose >= 1 RIB (RG-1.13), VIREMENT impose une banque (RG-1.12).',
|
|
'bank_id' => 'Onglet Comptabilite : banque — FK -> bank.id, ON DELETE RESTRICT. Obligatoire si payment_type = VIREMENT (RG-1.12).',
|
|
'is_archived' => 'Drapeau fonctionnel d archivage — masque par defaut dans la liste. Bascule via permission commercial.clients.archive (RG-1.22/23).',
|
|
'archived_at' => 'Horodatage de l archivage — pose quand is_archived passe a vrai, remis a null a la restauration (RG-1.22/23).',
|
|
'deleted_at' => 'Horodatage du soft-delete technique (HP M2) — non expose par l API au M1. Null = ligne active.',
|
|
] + self::timestampableBlamableComments(),
|
|
|
|
'client_category' => [
|
|
'_table' => 'Jointure M2M client <-> category (Catalog) — categories metier du client (au moins une obligatoire).',
|
|
'client_id' => 'FK -> client.id, ON DELETE CASCADE — client porteur de la categorie.',
|
|
'category_id' => 'FK -> category.id, ON DELETE RESTRICT — categorie rattachee au client.',
|
|
],
|
|
|
|
'client_contact' => [
|
|
'_table' => 'Contacts d un client (1:n) — au moins firstName OU lastName par contact (RG-1.05).',
|
|
'id' => 'Identifiant interne auto-incremente.',
|
|
'client_id' => 'FK -> client.id, ON DELETE CASCADE — client proprietaire du contact.',
|
|
'first_name' => 'Prenom du contact (capitalise serveur). first_name OU last_name obligatoire (RG-1.05, chk_client_contact_name).',
|
|
'last_name' => 'Nom du contact (capitalise serveur). first_name OU last_name obligatoire (RG-1.05, chk_client_contact_name).',
|
|
'job_title' => 'Fonction / intitule de poste du contact (≤ 120 caracteres).',
|
|
'phone_primary' => 'Telephone principal du contact — chiffres uniquement (RG-1.20).',
|
|
'phone_secondary' => 'Telephone secondaire du contact — chiffres uniquement (RG-1.20).',
|
|
'email' => 'Email du contact (lowercase serveur, RG-1.21).',
|
|
'position' => 'Ordre d affichage du contact dans la liste du client (croissant).',
|
|
] + self::timestampableBlamableComments(),
|
|
|
|
'client_address' => [
|
|
'_table' => 'Adresses d un client (1:n) — prospect exclusif de livraison/facturation (RG-1.06/07/08), >= 1 site rattache (RG-1.10).',
|
|
'id' => 'Identifiant interne auto-incremente.',
|
|
'client_id' => 'FK -> client.id, ON DELETE CASCADE — client proprietaire de l adresse.',
|
|
'is_prospect' => 'Adresse de prospection — exclusive de is_delivery/is_billing (RG-1.06/07/08, chk_client_address_prospect_exclusive). Faux par defaut.',
|
|
'is_delivery' => 'Adresse de livraison. Exclusive de is_prospect. Faux par defaut.',
|
|
'is_billing' => 'Adresse de facturation. Exclusive de is_prospect. Impose billing_email (RG-1.11). Faux par defaut.',
|
|
'country' => 'Pays de l adresse — defaut France.',
|
|
'postal_code' => 'Code postal (4-5 chiffres attendus, RG-1.09).',
|
|
'city' => 'Ville — preremplie depuis le code postal via API BAN cote front (RG-1.09).',
|
|
'street' => 'Numero et voie de l adresse.',
|
|
'street_complement' => 'Complement d adresse (etage, batiment...) — optionnel.',
|
|
'billing_email' => 'Email de facturation — obligatoire si is_billing, null sinon (RG-1.11, chk_client_address_billing_email).',
|
|
'position' => 'Ordre d affichage de l adresse dans la liste du client (croissant).',
|
|
] + self::timestampableBlamableComments(),
|
|
|
|
'client_address_site' => [
|
|
'_table' => 'Jointure M2M client_address <-> site (Sites) — sites desservis par l adresse (>= 1 obligatoire, RG-1.10).',
|
|
'client_address_id' => 'FK -> client_address.id, ON DELETE CASCADE — adresse concernee.',
|
|
'site_id' => 'FK -> site.id, ON DELETE RESTRICT — site rattache a l adresse.',
|
|
],
|
|
|
|
'client_address_contact' => [
|
|
'_table' => 'Jointure M2M client_address <-> client_contact — contacts associes a une adresse.',
|
|
'client_address_id' => 'FK -> client_address.id, ON DELETE CASCADE — adresse concernee.',
|
|
'client_contact_id' => 'FK -> client_contact.id, ON DELETE CASCADE — contact associe a l adresse.',
|
|
],
|
|
|
|
'client_address_category' => [
|
|
'_table' => 'Jointure M2M client_address <-> category — categories d adresse (types SECTEUR/AUTRE uniquement, RG-1.29).',
|
|
'client_address_id' => 'FK -> client_address.id, ON DELETE CASCADE — adresse concernee.',
|
|
'category_id' => 'FK -> category.id, ON DELETE RESTRICT — categorie d adresse (type SECTEUR ou AUTRE, RG-1.29).',
|
|
],
|
|
|
|
'client_rib' => [
|
|
'_table' => 'Coordonnees bancaires d un client (1:n) — >= 1 RIB obligatoire si payment_type = LCR (RG-1.13). Tous les champs audites (pas d AuditIgnore).',
|
|
'id' => 'Identifiant interne auto-incremente.',
|
|
'client_id' => 'FK -> client.id, ON DELETE CASCADE — client proprietaire du RIB.',
|
|
'label' => 'Libelle du RIB (ex: compte principal).',
|
|
'bic' => 'Code BIC/SWIFT de la banque (8 ou 11 caracteres).',
|
|
'iban' => 'IBAN du compte (≤ 34 caracteres).',
|
|
'position' => 'Ordre d affichage du RIB dans la liste du client (croissant).',
|
|
] + self::timestampableBlamableComments(),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* @param null|list<string> $onlyTables Restreint la generation a ces tables
|
|
* (utile pour la migration retrofit qui
|
|
* ne doit commenter que les tables deja
|
|
* presentes a son instant T — les tables
|
|
* des modules crees plus tard posent
|
|
* leurs propres COMMENT). null = tout.
|
|
*
|
|
* @return list<string>
|
|
*/
|
|
public static function toSqlStatements(?array $onlyTables = null): array
|
|
{
|
|
$allowed = null === $onlyTables ? null : array_fill_keys($onlyTables, true);
|
|
|
|
$statements = [];
|
|
foreach (self::comments() as $table => $entries) {
|
|
if (null !== $allowed && !isset($allowed[$table])) {
|
|
continue;
|
|
}
|
|
|
|
$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).'"';
|
|
}
|
|
}
|