@amsom-habitat/nest-generateur
v2.0.0
Published
Générateur de ressources pour les apis NestJS AMSOM : lecture sur vue, actions sur fonctions stockées et CRUD sur table, lus dans Oracle et enregistrés dans le module de l'api.
Maintainers
Readme
@amsom-habitat/nest-generateur
Générateur de ressources des apis NestJS AMSOM : une route en lecture sur une vue
Oracle, des routes d'action sur des fonctions stockées, ou un CRUD sur une table,
écrites dans src/ de l'api, formatées par son Prettier et enregistrées dans son
module.
La première partie est le guide d'utilisation : de quoi générer une route en quelques minutes. La suite explique comment l'outil fonctionne, comment l'étendre, et ce qui casse quand ça casse. Les règles de rangement qu'il applique sont dans
docs/organisation-src.md.
Installation dans une api
npm i -D @amsom-habitat/nest-generateur{
"scripts": {
"generate": "amsom-generer"
}
}oracledb et prettier sont des peerDependencies : ce sont ceux de l'api
qui servent (Prettier lit le .prettierrc de l'api).
Guide d'utilisation
Lancer
Le générateur tourne dans le conteneur de dev de l'api : c'est lui qui a la connexion Oracle et l'Instant Client. Depuis la racine de l'api :
docker compose up -d
docker compose exec --user $(id -u):$(id -g) api-<nom> npm run generateLe --user évite que les fichiers générés appartiennent à root sur l'hôte.
Le bin travaille sur le dossier courant, qui doit être la racine de l'api
(celle qui contient src/app.module.ts) : il y écrit src/, y lit .env,
.env.custom, .env.local et .prettierrc. Rien n'est lu ni écrit dans
node_modules.
amsom-generer [lecture|action|crud] [--schema S] [--prefixes "…"] [--colonnes "…"] [--dates "…"] [-h|--help]| Option | Variable équivalente | Défaut | Rôle |
|---|---|---|---|
| --schema | AMSOM_GENERER_SCHEMA | ORACLE_USER | schéma Oracle où chercher vues, tables et fonctions (utile quand l'utilisateur de connexion n'est pas le propriétaire, ex. SYNEO) |
| --prefixes | AMSOM_GENERER_PREFIXES | AHVWS_,AHVW_,AHV_,AHWS_,AH_ | préfixes Oracle retirés pour proposer un nom de ressource, de route ou de clé FN (le plus long qui convient) |
| --colonnes | AMSOM_GENERER_COLONNES | — | mode hors base (cf. Sans base) |
| --dates | AMSOM_GENERER_DATES | — | colonnes DATE du mode hors base |
Sans argument, il demande d'abord quoi générer :
| Choix | Ce qu'il produit | Il faut avoir sous la main |
|---|---|---|
| lecture | GET /<ressource> (liste) + GET /<ressource>/:id | une vue AHVWS_* |
| action | des routes POST/PATCH/DELETE dans un même contrôleur | les fonctions stockées AH_* à appeler |
| crud | un CRUD sur une table (/admin, le plus souvent), verbes au choix : liste, :id, POST, PATCH, DELETE | une table |
À la fin, Générer une autre ressource ? [O/n] : Entrée enchaîne, n arrête.
Un échec (fichier déjà existant…) ne fait pas sortir de la boucle ; Ctrl+C, si.
Les touches
| Type de question | Ce qu'on fait | |---|---| | recherche (dossier, vue, table, fonction, préfixe) | taper quelques lettres, la liste se filtre ; ↑↓ puis Entrée | | liste | ↑↓ puis Entrée | | cases à cocher | Espace coche, Entrée valide (Entrée seule = rien de coché) | | texte | la valeur entre parenthèses est le défaut : Entrée l'accepte |
La recherche prend plusieurs mots, dans n'importe quel ordre : cally ged
trouve AHVWS_CALLY_CONTRAT_GED. Ce qui contient les mots dans l'ordre tapé
remonte en tête. Les vues, tables et fonctions sont lues dans Oracle au premier
appel.
Une saisie absente de la liste reste possible là où elle a un sens : un nouveau dossier, un nouveau préfixe, ou un nom d'objet Oracle « tel quel ».
Ctrl+C abandonne sans rien écrire.
Exemple : une route en lecture
? Que générer ? lecture
✔ Vue Oracle contrat ged → AHVWS_CALLY_CONTRAT_GED
✔ Dossier parent, dans src/ contrat/
? Nom de la ressource (contrat-ged) ← déduit de la vue et d'API_NOM
✔ Préfixe d'URL cally2/ 33 contrôleur(s)
? Nom dans l'URL (contrat_geds) ← déduit de la ressource
? Colonne identifiant ID
? Route de détail …/:id ? Yes
? Filtres de la liste ◉ idContrat
? Vue lourde ? Exiger un filtre (Entrée si non)
? Cache serveur aucun
? Route publique (sans token) ? No
? Typage proposé (d'après le type Oracle, Entrée pour valider)
Filtres, comparaison par défaut :
?idContrat=… (exact)
? Filtres à comparer autrement (Entrée si aucun)Résultat (dans api-cally) : src/contrat/contrat-ged/contrat-ged.{controller,service}.ts,
enregistrés dans ContratModule (ou AppModule sans module de thème), formatés
par Prettier.
Le typage et les opérateurs de filtre ne posent de question que pour les colonnes qu'on coche : la proposition vient du type Oracle déclaré (cf. Le typage des colonnes, Les opérateurs de filtre).
Exemple : des routes d'action
? Que générer ? action
? Ajouter les routes à un contrôleur d'actions existant ?
❯ non : nouveau contrôleur
sollicitation/sollicitation-action/ 1 route(s), préfixe solly
✔ Fonction stockée sollicitation desc → AHWS_CALLY_SOLLICITATION_DESCRIPTION_MODIFIER
? Nom de la route (snake_case) modifier_sollicitation
? Méthode HTTP POST
? Code de retour 201 Created
? Paramètre Oracle P_IDLIGNE (NUMBER) — d'où vient sa valeur ? un champ du body JSON
? nom du champ dans le body JSON idLigne
? « idLigne » arrive sous quelle forme ? une valeur simple
? si « idLigne » est absent du body erreur : champ obligatoire
… (une question par paramètre de la fonction)
? Appeler une autre fonction après celle-ci ? (rare) No
? Autre route dans ce contrôleur ? NoContrôleur existant : le générateur liste les contrôleurs d'actions de l'api
(service avec const FN = { … } qui appelle des fonctions stockées). En en
choisissant un, les routes y sont ajoutées : méthodes du contrôleur et du
service, entrées de FN (une fonction déjà déclarée garde sa clé), interfaces du
DTO, imports manquants. Un nom de route déjà présent est refusé. Sinon, « nouveau
contrôleur » demande dossier, nom et préfixe.
Avant d'écrire, le générateur affiche le mapping paramètre Oracle ← valeur : le relire, c'est là que se voit une inversion d'arguments. Un paramètre qui a un défaut Oracle est proposé avec « passer NULL » si absent.
Le code produit a la forme des contrôleurs d'actions écrits à la main : corps brut
(@Body() corps: RequestBody), lu champ par champ avec requiredField (clé absente
→ 400) / optionalField (absent ou null → défaut), PEC exigé (exigerPec(pec),
403 s'il manque), @Param('id') id: string. Pas de DTO class-validator : le DTO est
une interface, les règles métier sont contrôlées par Oracle.
@Patch('modifier_composant/:id')
@HttpCode(HttpStatus.OK)
modifierComposant(
@Pec() pec: PecAgent | null,
@Param('id') id: string,
@Body() corps: RequestBody,
) {
return this.service.modifierComposant(exigerPec(pec), id, {
libelle: requiredField(corps, 'libelle'),
urgent: optionalField(corps, 'urgent', false),
});
}Sans base
Oracle injoignable, ou table pas encore créée : on décrit les colonnes.
docker compose exec --user $(id -u):$(id -g) api-<nom> \
npm run generate -- --colonnes "ID,LIBELLE,DATE_MAJ" --dates "DATE_MAJ"Les variables AMSOM_GENERER_COLONNES et AMSOM_GENERER_DATES font la même chose
(docker compose exec -e AMSOM_GENERER_COLONNES=… …). Les noms d'objets Oracle se
saisissent alors en entier. Les colonnes décrivent un seul objet : elles ne
valent que pour la première ressource, la suivante (« Générer une autre
ressource ? ») est lue dans Oracle. Le générateur action lit toujours la
signature de la fonction dans Oracle : il lui faut la base.
Après
Le générateur termine par sa checklist. L'essentiel :
- confronter filtres, identifiant, cache et typage à l'entité v1 ;
- ajouter les requêtes à
tests/collection.json(à la main) ; GET /coveragedoit rester à 200.
Pourquoi il existe
La très grande majorité des routes de ces apis se ressemblent : un GET de liste
et un GET /:id sur une vue AHVWS_*, ou un CRUD complet sur une table. D'un
domaine à l'autre, seuls changent la vue, les filtres et le chemin — le reste est
de la plomberie recopiée, avec un piège : l'enregistrement dans le module,
qu'on oublie — et la route ne répond pas, sans que rien ne le signale.
Le générateur l'écrit correctement, à partir des colonnes réelles lues dans Oracle.
Fonctionnement
Ce qu'il fait
- lit dans le dictionnaire de données Oracle les colonnes et leurs types, la
clé primaire (proposée comme identifiant) et les séquences rattachées à la
table — colonne identité, trigger, ou
<TABLE>_SEQsi elle existe (proposée, ou au choix s'il y en a plusieurs) ; - vérifie qu'aucun des fichiers à créer n'existe (sinon rien n'est écrit), puis
les écrit dans
src/<domaine>/<ressource>/; - passe Prettier dessus (la coupure de ligne dépend de la longueur des identifiants : le même template rend du code conforme pour une ressource et non conforme pour une autre) ;
- enregistre la ressource dans son module — imports,
controllers,providers: le module du thème choisi s'il y en a un, sinonAppModule(cf. Dans quel module la ressource s'enregistre) ; - écrit le typage des colonnes dans le service (
view('…', { schema }), cf. Le typage des colonnes) ; - rappelle en fin d'exécution ce qui reste à faire à la main.
Le code produit importe le socle par ses paquets, en 2.0.0 :
@amsom-habitat/nest-oracle (view, formattedDate, unixTimestamp,
unaccentSql, OracleService, toJsonParam), nest-rest (listPaginated,
orNotFound, CrudService, filtres apply…, RequestBody, requiredField,
optionalField), nest-auth et nest-cache (CachedGet, InvalidateCache).
Le code généré n'a aucune trace de la v1 : il convient aussi à une api neuve. L'api doit
avoir ces paquets en dépendances.
Ce qu'il ne fait pas, volontairement
tests/collection.json— c'est la référence des tests d'intégration ;- la vérification de la parité v1 (filtres, identifiant, TTL, typage) : il
n'écrit aucun commentaire ni
TODOdans le code produit ; ce qui reste à vérifier est rappelé dans la checklist affichée en fin de génération.
Comment le bin est lancé
amsom-generer lance plop par son API (Plop.prepare / Plop.execute) avec le
plopfile.js du paquet et, comme destination et dossier de travail, le
dossier courant. Chaque ressource est générée dans un processus enfant
(lib/executer.mjs) : un échec ou un process.exit d'une génération ne coupe
pas la boucle. Les options du bin passent à l'enfant par les variables
AMSOM_GENERER_*. Tous les chemins de lib/ et toute la configuration passent
par lib/config.js (process.cwd(), .env de l'api), et oracledb /
prettier sont chargés depuis l'api.
Oracle n'est lu que pendant les questions, par une seule connexion (ouverte
au premier besoin, fermée à la fin des questions) ; les lectures sont mémoïsées.
Une erreur prévue (objet introuvable, fonction surchargée…) s'affiche en une
ligne [ERROR] …, sans trace.
Le paquet exporte aussi son plopfile (require('@amsom-habitat/nest-generateur'))
pour qui voudrait le charger avec node-plop.
Les prompts, et ce qu'ils décident
Les questions sont formulées pour être comprises sans connaître le projet. Deux méritent une explication.
Les opérateurs de filtre (générateur lecture)
Après le choix des colonnes filtrables, le générateur affiche la comparaison
par défaut de chacune (exact, ou boolean sur une colonne 0/1), puis demande
lesquelles comparer autrement. Seules celles-là ouvrent la question détaillée :
? Comparaison pour « nom » (NOM)
── comparaison de ?nom= (un seul) ──
❯◉ est exactement la valeur (exact)
◯ est exactement, majuscules ignorées (iexact)
◯ contient (partial) ⚠ balaie la vue
◯ contient, majuscules et accents ignorés (ipartial) ⚠ balaie la vue
◯ commence par (start) ⚠ balaie la vue
◯ finit par (end) ⚠ balaie la vue
◯ est dans la liste (a,b,c) (in)
◯ n'est PAS dans la liste (a,b,c) (not in)
◯ est vrai / faux (true, false, 1, 0) (boolean)
── bornes, cumulables (?nom[gte]=…) ──
◯ plus grand que (gt) ◯ plus grand ou égal à (gte)
◯ plus petit que (lt) ◯ plus petit ou égal à (lte)Le nom entre parenthèses est celui d'API Platform : c'est lui qu'on retrouve
dans les #[ApiFilter] de la v1 quand on vérifie une parité.
| Opérateur | Query param | SQL produit |
|---|---|---|
| exact | ?nom=DUPONT | where('NOM', v) |
| iexact | ?nom=dupont | UPPER(NOM) = UPPER(?) |
| partial | ?nom=UPO | NOM LIKE '%UPO%' |
| ipartial | ?nom=upo | UPPER(unaccent(NOM)) LIKE '%UPO%' |
| start / end | ?nom=DUP | LIKE 'DUP%' / LIKE '%DUP' |
| in / not in | ?nom=a,b,c | whereIn / whereNotIn |
| boolean | ?actif=true | where('ACTIF', 1) |
| gt gte lt lte | ?exercice[gte]=2020 | where('EXERCICE', '>=', v) |
Deux familles, deux comportements. Les opérateurs de valeur comparent le
paramètre nu (?nom=…) : ils se disputent le même query param, donc un seul par
champ — la question le refuse sinon (includes_any, qui lit aussi ?nom=,
compte parmi eux). Les bornes ont chacune leur clé
(?exercice[gte]=, ?exercice[lte]=), elles se cumulent : les deux ensemble
donnent un intervalle.
Le cas booléen
boolean rejoue le BooleanFilter d'API Platform, et ce n'est pas un exact
déguisé :
| Requête | boolean | exact sur la même colonne |
|---|---|---|
| ?actif=1 / ?actif=0 | vrais / faux | vrais / faux (Oracle convertit '0') |
| ?actif=true / ?actif=false | vrais / faux | ORA-01722 → 400 |
| ?actif=oui, ?actif=2 | collection entière | ORA-01722 → 400 |
Le piège est qu'exact a l'air de marcher : la moitié numérique passe par
conversion implicite, et c'est ?actif=true — la valeur qu'un client écrit
spontanément, et que la v1 accepte — qui casse en erreur Oracle.
Deux conséquences dans le code généré : la valeur est comparée à 1/0 (la
forme du booléen dans ces vues), et la garde n'est plus « la valeur est
fournie » mais « la valeur est interprétable » — une valeur inconnue laisse la
collection entière, sans erreur, comme chez API Platform :
const actifBooleen = parseBooleanFilter(actif);
if (actifBooleen !== undefined) query.where('ACTIF', actifBooleen ? 1 : 0);Une colonne typée booléenne (cf. Le typage des colonnes) propose boolean
pré-coché à la place d'exact : le typage est demandé juste avant, et c'est
la réponse donnée qui compte.
⚠️ Les quatre opérateurs marqués « balaie la vue » interdisent l'usage d'un index (mesuré : ~1,5 s par requête sur une vue de recherche d'individus). À réserver aux champs qu'un humain saisit.
Le piège que tout ceci évite : Oracle compare octet par octet. Sur une colonne
stockée en majuscules, ?nom=tahi ne rend rien là où ?nom=TAHI rend la ligne.
include / ninclude (colonne contenant une liste à virgules) encadrent la liste
normalisée de virgules ; la négation passe par un NVL pour remonter aussi les
colonnes vides. in accepte le CSV comme le JSON (souvent encodé %5B…%5D).
Pour ajouter un opérateur au catalogue, une entrée dans lib/operateurs.js
suffit : les questions, le rendu et l'interface générée le reprennent tout seuls.
Le typage des colonnes
Oracle est faiblement typé, et ce que le driver rend ne suffit pas à retrouver le JSON de la v1 :
| Colonne Oracle | Sans déclaration | Ce que rend la v1 |
|---|---|---|
| NUMBER(5,2) | 20.6 ✅ | 20.6 |
| NUMBER sans échelle | 1234.56 ✅ | 1234.56 |
| NUMBER(1) booléen | 1 ❌ | true |
| VARCHAR2 « 1 234,56 » | "1 234,56" ❌ | 1234.56 |
Les décimaux numériques passent tout seuls : node-oracledb rend un
NUMBER en nombre JS, avec le point décimal, quelle que soit la langue de la
session.
Le générateur propose un type par colonne, d'après son seul type déclaré
dans Oracle (ALL_TAB_COLUMNS), et affiche la liste ; on coche celles à
changer, Entrée valide.
| Type Oracle | Proposé |
|---|---|
| NUMBER(1,0) | booléen (convention de la base) |
| tout le reste | tel quel : nombre pour un NUMBER, chaîne sinon, DD/MM/RR pour une DATE |
Aucune lecture de données, aucune déduction sur le nom : la proposition est la
même à chaque exécution, et l'entité v1 tranche. Ce qu'Oracle ne dit pas se
choisit à la main — un booléen calculé (CASE WHEN … THEN 1 ELSE 0, décrit
NUMBER sans précision), un montant stocké en VARCHAR2 (« 1 234,56 »), un
JSON en VARCHAR2 ou CLOB, un ?int de la v1 sur un VARCHAR2.
? Typage proposé — cocher les colonnes à changer, Entrée pour valider
❯◯ id NUMBER → tel quel (nombre)
◯ travaux VARCHAR2(100) → tel quel (chaîne)
◉ pge NUMBER(1) → booléen
◯ creele DATE → tel quel (date DD/MM/RR)
…
? pge NUMBER(1) (PGE)
tel quel (nombre)
❯ booléen 0/1 → true/false ← proposé
entier "28330" → 28330, (int) PHP
…Chaque choix correspond à une clé de ViewSchema (booleans, integers,
decimals, stringOrNumbers, strings, json) ; « tel quel » n'écrit rien.
Le résultat est écrit dans le service généré, dans la déclaration de la vue :
const VIEW = view('AHVWS_GED_EDL', {
schema: {
strings: ['ID'],
integers: ['ID_CONTRAT', 'EXERCICE_EDL'],
},
});
…
return listPaginated(VIEW, query, filters);Sans aucune colonne à convertir, la déclaration se réduit à view('AHVWS_X').
Sans base (--colonnes), il n'y a pas de type déclaré : tout est proposé
« tel quel », à changer dans la même liste.
⚠️ Sur un CRUD, le typage joue dans les deux sens. La même vue est passée
au CrudTableDefinition (table: TABLE), dont le CrudService de @amsom-habitat/nest-rest
relit le typage en écriture : sans déclaration, un POST {"pge": true} écrirait
le booléen JS tel quel au lieu de 1.
Le CRUD du socle lit, écrit et relit par la colonne ID : le générateur crud
refuse une table sans colonne ID, et prévient si sa clé primaire est une autre
colonne.
Les dates
Une colonne Oracle DATE porte toujours jour et heure : rien dans le
dictionnaire ne dit si l'heure compte. On choisit donc la forme dans la liste
de typage :
| Choix | Exemple | Heure |
|---|---|---|
| DD/MM/RR (défaut, comme la v1) | 21/02/23 | perdue : un PATCH la remet à 00:00:00 |
| DD/MM/YYYY, YYYY-MM-DD | 21/02/2023 | perdue |
| DD/MM/YYYY HH24:MI:SS, YYYY-MM-DD HH24:MI:SS | 21/02/2023 14:30:00 | conservée |
| timestamp Unix | 1676986200 | conservée |
| autre format Oracle… | DD/MM/YYYY HH24:MI | selon le format |
Le format libre est vérifié par Oracle à la saisie
(TO_DATE(TO_CHAR(SYSDATE, format), format)) : un format refusé l'est avant
la génération, avec le message d'Oracle.
Un format s'applique en lecture (TO_CHAR) comme en écriture (TO_DATE). Le
timestamp passe par AH_TIMESTAMP / AH_UNTIMESTAMP (Europe/Paris, changement
d'heure compris), comme les fonctions stockées ; une valeur non numérique est
refusée en 400. Dans le service : dates (liste en DD/MM/RR, ou objet
colonne → format) et timestamps du CrudTableDefinition, formattedDate(db, colonne,
format?) / unixTimestamp(db, colonne) dans columns().
Les clés JSON (générateur crud)
Chaque colonne est exposée sous sa clé par défaut, en camelCase (DATE_FIN →
dateFin). La liste les affiche toutes ; on coche celles à renommer et on saisit
le nouveau nom (proposé : la clé par défaut). La clé renommée vaut partout : JSON
rendu, corps du POST/PATCH et nom du query param de filtre. Deux colonnes ne
peuvent pas porter la même clé.
Le cache
Ne cacher qu'une donnée identique pour tous les utilisateurs. La clé de cache est l'URL seule, sans segmentation par appelant : une réponse nominative mise en cache serait servie à quelqu'un d'autre.
Le contrôleur caché porte @CachedGet(ttlMs). Un CRUD caché qui expose aussi une
écriture porte en plus @InvalidateCache('<route>'), sans quoi une lecture
resterait périmée jusqu'à la fin du TTL ; ce tag se déclare dans
src/cache-tags.ts (rappelé en fin de génération).
Le chemin de la route : rien à déclarer
src/ est l'api entière : pas de question « où », le préfixe d'URL et le nom de
la ressource sont découverts à chaque exécution (lib/projet.js,
lib/questions.js).
- préfixes = le premier segment des
@Controller(...)desrc/, comptés et triés par fréquence ; - nom de la ressource = le nom de la vue, de la table ou de la fonction sans
son préfixe Oracle (le plus long de
--prefixes, par défautAHVWS_,AHVW_,AHV_,AHWS_,AH_) ni le nom de l'api :API_NOM, lu dans.env<.env.custom<.env.local(le dernier gagne), suffixe numérique toléré. AvecAPI_NOM=cally,AHVWS_CALLY_CONTRAT_GEDetAHVWS_CALLY2_CONTRAT_GEDdonnentcontrat-ged. Le même retrait propose le nom d'une route d'action (AHWS_CALLY_PLIP_MODIFIER→plip_modifier).
Conséquence : le premier contrôleur d'une api neuve n'a aucun préfixe à proposer. Taper le préfixe et choisir « nouveau préfixe », une fois — les suivants le retrouveront dans la liste.
Le générateur action — routes sur fonctions stockées
Le problème qu'il résout
Les deux autres générateurs couvrent les GET sur vue et le CRUD sur table.
Reste une troisième famille, ni l'une ni l'autre : les routes qui appellent
une fonction stockée.
POST /patri/creer_composant → ah_patri_composant_creer
DELETE /patri/delier_logement_composant → ah_composant_module_delierElles ne sont pas générables en CRUD, et pour trois raisons cumulées :
- les ressources concernées sont exposées par des vues (
AHVWS_*), et on n'écrit pas dans une vue ; - la v1 ne les expose pas en
#[ApiResource]mais dans des contrôleurs Symfony, avec des chemins d'action en snake_case — un CRUD REST serait un autre contrat ; - surtout, la logique est dans la fonction stockée :
ah_patri_composant_creerenchaîne surah_composant_maj_caracteristiques. UnINSERTknex écrirait la ligne sans jouer cette logique, et la donnée serait incomplète sans erreur visible.
Ce qu'il apporte
L'ordre des arguments d'un callFunction est positionnel. Deux arguments
intervertis compilent, passent, et écrivent la mauvaise valeur dans la bonne
colonne — sans que rien ne le signale. C'est ce que le générateur supprime.
Il lit la signature dans Oracle (ALL_ARGUMENTS : nom, position, type, valeurs
par défaut) et demande, pour chaque paramètre, d'où vient sa valeur : un champ du
corps JSON, le :id de l'URL, ou le PEC de l'agent connecté.
Une seule source, Oracle. Rien à monter, rien à installer, le même parcours
pour tout le monde — y compris pour qui n'a pas l'API v1 sur son poste. Ce
qu'Oracle ne sait pas dire (quel champ du corps remplit WCCCP) est demandé.
Trouver la fonction sans connaître son nom
Le générateur liste les fonctions du schéma (ALL_OBJECTS, filtré sur
--schema, par défaut ORACLE_USER, lu une fois par exécution) et les filtre à
la frappe. Les procédures n'y sont pas : callFunction n'appelle que des
fonctions. Une fonction de package, surchargée, ou qui a un paramètre OUT /
IN OUT est refusée avec un message :
? Fonction stockée (tapez un fragment, ex. patri composant) patri compos cre
❯ AH_PATRI_COMPOSANT_CREERLes mots se cherchent dans n'importe quel ordre ; ceux qui suivent l'ordre tapé remontent en tête. La liste affiche au plus 100 résultats : taper un mot de plus si la fonction n'y est pas.
Plusieurs routes en une passe
Un contrôleur d'action en regroupe plusieurs. Le générateur boucle donc : après chaque route, il propose d'en ajouter une autre dans le même fichier. Les générer une par une buterait sur « File already exists » dès la deuxième (ou passerait par « contrôleur existant »).
Le mapping est montré avant écriture
mapping × signature Oracle :
POST /creer_incident → ah_patri_incident_creer
1 WIDRESIDENCE ← corps.residenceId
2 WDESCRIPTION ← corps.incident (optionnel)
3 WURGENT ← corps.urgent
4 WPEC ← exigerPec(@Pec()) (agent du token)
DELETE /supprimer_incident → ah_patri_incident_supprimer
1 WID ← paramètre d'URL {id}
2 WPEC ← exigerPec(@Pec()) (agent du token)C'est le point où l'on rattrape une inversion avant qu'elle n'atteigne la base.
Les paramètres à défaut Oracle
Certaines fonctions déclarent des paramètres DEFAULTED='Y', qu'on peut omettre :
ah_composant_supprimer en déclare quatre et la v1 n'en passe qu'un. Le
générateur propose « ne pas le passer » uniquement quand tous les paramètres
restants ont un défaut — un appel est positionnel, on ne saute pas un trou au
milieu. Ce qui est omis est rappelé dans le mapping.
Les appels enchaînés
Une route d'écriture de la v1 appelle parfois une seconde fonction stockée, sous condition :
if (isset($json->data)) {
if (array_key_exists('caracteristiques', $data)) { … maj_caracteristiques … }Les deux gardes sont demandées, pas devinées : « quand l'appeler ? » (à la
présence d'un champ du corps, ou toujours) et, pour chaque paramètre du second
appel, la même question d'origine que pour le premier — avec un choix de plus,
l'identifiant rendu par l'appel précédent (le $json->data de la v1).
const res = await this.oracle.callFunction<OracleParam>(FN.CREER_COMPOSANT, [ … ]);
const idCree = res.data;
if (idCree !== undefined && dto.caracteristiques !== undefined) {
await this.oracle.callFunction(FN.COMPOSANT_MAJ_CARACTERISTIQUES, [
idCree,
toJsonParam(dto.caracteristiques),
]);
}Le champ déclencheur entre dans le même DTO, lu par optionalField — c'est son
absence qui coupe l'appel. Les fonctions enchaînées rejoignent la table FN,
dédoublonnées (la même sert la création et la mise à jour). Une clé déjà portée
par une autre fonction est suffixée (CREER_2) plutôt qu'écrasée.
La forme des valeurs du corps
Oracle donne le type de chaque paramètre, pas la forme de ce que le front envoie. Deux cas où un bind nu écrirait n'importe quoi, donc demandés :
| Type Oracle | Choix proposé | Code produit |
|---|---|---|
| NUMBER | un booléen | dto.urgent ? 1 : 0 |
| VARCHAR2 | un tableau ou un objet | toJsonParam(dto.caracteristiques) |
| CLOB | un tableau ou un objet | bind { type: 'clob', … } |
toJsonParam (@amsom-habitat/nest-oracle) sérialise en JSON ; absent, [] et
{} deviennent NULL. La valeur simple est le premier choix : une Entrée
suffit dans le cas courant.
« Si absent du body » dépend de la forme : un booléen absent vaut false ou
true (jamais NULL : il passerait en 0), un JSON absent vaut NULL, une
valeur simple vaut NULL, une chaîne vide ou la valeur saisie. Le défaut choisi
est le troisième argument d'optionalField(corps, 'champ', défaut).
Ce qu'il ne fait pas
Le post-traitement de la réponse. La v1 relit l'objet créé et le renvoie
sérialisé ($composantRepository->find($json->data)), c'est-à-dire sous la même
forme que la route GET correspondante. Le générateur rend le retour brut de la
fonction stockée ; la checklist de fin le rappelle.
⚠️ Autre point à confronter à la v1 : l'ordre des erreurs. exigerPec(pec)
est le premier argument, donc le 403 passe avant le 500 d'un champ requis absent ;
si la v1 lit le corps d'abord, déplacer l'appel. Le corps n'est pas validé
(parité v1) : Oracle convertit la valeur ou répond l'erreur.
Dans quel module la ressource s'enregistre
Le module de l'api est src/app.module.ts (AppModule), qui importe aussi le
socle (SocleModule.forRoot(…), CoverageModule). Un thème
peut avoir son module : contrat/contrat.module.ts, sollicitation/sollicitation.module.ts…
importés par AppModule. Le générateur (lib/projet.js) remonte donc du dossier
de la ressource jusqu'à src/, et prend le premier *.module.ts rencontré :
| Dossier saisi | Module modifié |
|---|---|
| (vide) | app.module.ts |
| contrat | contrat/contrat.module.ts |
| sollicitation/sollicitation-action | sollicitation/sollicitation.module.ts |
| nouveau-dossier (sans module) | app.module.ts |
Un dossier qui contient plusieurs modules (ex. referentiel/) est retenu
s'il en a un qui porte son nom, sinon on remonte d'un cran. Le module retenu est
affiché pendant la génération (module cible : …).
Module de thème. Quand un dossier de premier niveau de src/ atteint deux
contrôleurs sans avoir de module (ex. plip/ après plip-plop/ puis plip),
le générateur crée plip/plip.module.ts (PlipModule), y déplace les
enregistrements faits dans AppModule, exporte les services utilisés ailleurs
dans l'api, et ajoute le nouveau module en fin des imports d'AppModule.
MailModule et DocumentModule sont globaux (forRoot({ isGlobal: true }) dans
AppModule) : un module de thème ne les importe pas. Les ressources suivantes du thème s'y enregistrent
directement. La convention complète est dans
docs/organisation-src.md.
Si la nouvelle ressource injecte le service d'un autre module de thème, il faut
encore l'ajouter aux exports de ce dernier, à la main : sinon Nest refuse de
démarrer (Nest can't resolve dependencies…).
Comment le module est modifié
Le générateur ne cherche pas de motif dans le texte : il ouvre le module avec
ts-morph (lib/ast.js), trouve le @Module({ … }) et ajoute les imports et
les entrées de controllers / providers (en tête de tableau, imports juste
après celui de @nestjs/common). La mise en forme du module est donc libre :
tableau sur une ligne ou plusieurs, commentaires, propriété absente (elle est
créée). Une ressource déjà présente n'est pas réinsérée si l'on relance le
générateur. Prettier reformate ensuite le module.
Le même mécanisme sert à l'ajout de routes à un contrôleur existant (imports
complétés, méthodes ajoutées en fin de classe, entrées de FN).
Ajouter un filtre à une route existante
Le générateur ne passe qu'une fois : les filtres qui arrivent ensuite s'ajoutent à la main. Il y a trois endroits à toucher, et en oublier un ne se voit pas toujours à la compilation.
1. Le contrôleur — exposer le query param
@Query('idService') idService?: string, // ← le nom EXPOSÉ, pas la colonne Oracle
// …
return this.service.findAll({ idService, /* … */ });Le nom du query param est celui du champ JSON (idService), pas celui de la
colonne (ID_SERVICE) : c'est ce que rend VIEW.rows, donc ce que le client voit.
2. Le service — déclarer le filtre
export interface CollaborateurFilters extends Pagination {
/** SearchFilter `exact` — code service ('CLIA', 'EXCO'…). */
idService?: string;
}Noter en commentaire l'ApiFilter v1 correspondant : c'est ce qui permet, six
mois plus tard, de vérifier une parité sans rouvrir la v1.
3. Le service — appliquer la condition
Le SQL de chaque opérateur est décrit dans lib/operateurs.js — le générateur
s'en sert, et un ajout à la main gagne à reprendre la même forme. Les trois plus
courantes :
| Cas | Code |
|---|---|
| code, identifiant (défaut) | query.where('ID_SERVICE', idService) |
| valeur saisie, casse ignorée | query.whereRaw('UPPER(ID_SERVICE) = UPPER(?)', [idService]) |
| recherche « humaine » | voir ci-dessous |
const motif = `%${removeAccents(nom).toUpperCase()}%`;
query.whereRaw(`UPPER(${unaccentSql('NOM')}) LIKE ?`, [motif]);removeAccents et unaccentSql viennent de @amsom-habitat/nest-oracle.
⚠️ Cette forme interdit l'usage d'un index : la vue est balayée entièrement.
Deux détails qui comptent :
Une chaîne vide vaut « pas de filtre » — la v1 ignore un query param vide plutôt
que de chercher les lignes dont la colonne vaut '' :
if (idService !== undefined && idService !== '') query.where(…);Un booléen se compare à 1 / 0 : les vues le stockent en NUMBER(1).
Filtre à valeurs multiples
Quand un filtre doit accepter une liste (?idModule=A,B,C) :
if (idModule !== undefined && idModule !== '')
applyIn(query, 'ID_MODULE', idModule);applyIn (@amsom-habitat/nest-rest) accepte le CSV comme le JSON, comme la v1.
Si la route a un garde-fou de disponibilité
Sur les routes qui exigent un filtre sélectif (liste sur une vue lourde), il faut décider si le nouveau filtre ouvre la route ou non. La réponse se mesure, elle ne se devine pas :
curl -s -m 30 -H "Authorization: Bearer $T" \
"http://localhost:<port>/<route>?monFiltre=X&itemsPerPage=1" \
-o /dev/null -w "%{http_code} en %{time_total}s\n"Sous ~1 s → l'ajouter aux filtres sélectifs. Au-delà, surtout borné à une ligne, c'est qu'il balaie la vue : le laisser comme filtre d'appoint.
Et la parité
Un filtre qui n'existe pas côté v1 est un écart, pas une amélioration : la
règle est la parité stricte. Vérifier les #[ApiFilter] de l'entité v1 avant
d'ajouter — et si le filtre est volontairement en plus, l'écrire en commentaire
(Parité v1 : …, une ligne).
Organisation du paquet
| Fichier | Rôle |
|---|---|
| bin/amsom-generer.js | options, vérification de la racine de l'api, boucle « Générer une autre ressource ? » |
| lib/executer.mjs | une génération : plop lancé par son API, plopfile du paquet, destination = dossier courant |
| plopfile.js | enregistre les trois générateurs |
| lib/lecture.js / crud.js / action.js | prompts + actions d'un générateur |
| lib/config.js | racine de l'api, .env de l'api, options AMSOM_GENERER_*, chargement d'oracledb / prettier depuis l'api |
| lib/erreurs.js | ErreurUtilisateur (message sans trace), Ctrl+C, curseur |
| lib/casse.js / noms.js | conversions de casse ; noms proposés d'après les objets Oracle |
| lib/fichiers.js / imports.js | parcours de src/ ; imports d'un fichier généré (texte ou ts-morph) |
| lib/ast.js | éditions ts-morph (imports, @Module, membres de classe, FN) |
| lib/questions-action.js | questions propres au générateur action |
| lib/ajout.js | contrôleurs d'actions existants de l'api, ajout de routes dans leurs fichiers |
| lib/questions.js | questions communes (dossier, objet Oracle, préfixe, cache), nom de ressource déduit, recherche à la frappe |
| lib/oracle.js | dictionnaire de données Oracle (colonnes, types, clé primaire, séquences, fonctions), lecture mémoïsée, mode hors base |
| lib/projet.js | ce qui touche src/ : API_NOM, préfixes existants, enregistrement dans le module, module de thème, Prettier |
| lib/typage.js | type proposé d'après Oracle, retypage des colonnes cochées, déclaration view(…) du service |
| lib/operateurs.js | catalogue des opérateurs de filtre et question associée |
| lib/rendu-lecture.js / rendu-crud.js / rendu-action.js (+ rendu-commun.js) | traduction des réponses en fragments de code |
| templates/ | les fichiers .hbs rendus |
| test/ | tests node:test : fonctions pures, génération de bout en bout hors base |
Le partage des rôles est constant : les templates ne font que de la mise en
page, tout ce qui demande une décision (quelles colonnes, quel TO_CHAR, quel
import) est résolu dans les fichiers de rendu, où c'est lisible et testable.
Étendre le générateur
Modifier le code produit → les .hbs de templates/, et le fichier de
rendu correspondant si la décision est calculée. Aucun commentaire de gabarit
dans le code produit.
Ajouter un prompt → dans le générateur concerné. S'il est commun à
plusieurs, dans lib/questions.js. Une liste longue passe par chercher()
(@inquirer/search), qui filtre à la frappe et peut proposer la saisie libre.
Ajouter un générateur → un fichier dans lib/ exportant
() => ({ description, prompts, actions }), plus une ligne dans
plopfile.js (et dans GENERATEURS du bin).
Tout chemin et toute option passent par lib/config.js : jamais de
__dirname pour atteindre src/ ou un .env (le paquet est installé dans
node_modules), jamais de process.env lu ailleurs. Une nouvelle option du bin
devient une variable AMSOM_GENERER_* lue par config().
Signaler une erreur → throw new ErreurUtilisateur(…lignes) : plop l'affiche
sans trace. Dans un validate d'inquirer, rendre le message (chaîne) plutôt que
lever.
Tester → npm test (node:test). Une fonction pure se teste directement
(test/unitaires.test.js) ; un rendu se teste de bout en bout hors base
(test/generation.test.js : AMSOM_GENERER_COLONNES, node-plop, runActions
sur des réponses scriptées dans une api jetable, contrôle de syntaxe TypeScript
du code produit et absence de commentaire).
Poser une question dont la liste n'est pas connue d'avance
Un tableau de prompts est figé avant l'exécution. Or deux parcours en
dépendent : les paramètres d'une fonction stockée (générateur action) et une
question par colonne filtrable (générateur lecture).
La réponse n'est pas de poser la question depuis une action. prompts accepte
aussi une fonction (cf. node-plop/src/generator-runner.js) : elle reçoit
inquirer, enchaîne autant de questions qu'elle veut, et rend l'objet de réponses.
prompts: async (inquirer) => {
const reponses = await inquirer.prompt([ /* le tableau habituel */ ]);
// … questions supplémentaires, calculées d'après ce qui précède
return reponses;
},⚠️ Aucune question ne doit être posée depuis une action. Plop ferme l'UI
d'inquirer à la fin de la phase prompts et laisse process.stdin en pause : un
prompt posé ensuite s'affiche mais ne reçoit jamais rien. Pas d'erreur, pas de
timeout — le générateur a juste l'air figé sur sa question, et la frappe ne
s'écho pas. Ni process.stdin.resume() ni un inquirer.createPromptModule()
neuf ne le débloquent (vérifié sous Node 24 / inquirer 9.3.8).
Pour tester sans lancer de génération, charger le plopfile avec node-plop
(destBasePath = une copie d'api, dossier courant compris) et appeler
generateur.runActions(reponses) (cf. test/generation.test.js). Les réponses du
générateur action portent déjà le croisement avec Oracle (routesCroisees) :
toute lecture d'Oracle a lieu pendant les questions.
Dépannage
| Symptôme | Cause | Correctif |
|---|---|---|
| ✖ pas d'api ici | lancé ailleurs qu'à la racine de l'api | cd à la racine (celle de src/app.module.ts) ; dans le conteneur, /app |
| amsom-generer: not found | paquet non installé dans l'image | npm i -D @amsom-habitat/nest-generateur, puis reconstruire l'image (node_modules est celui de l'image) |
| Le nom de ressource proposé garde le nom de l'api (cally-contrat-ged) | API_NOM ne correspond pas au préfixe Oracle des vues | corriger API_NOM dans .env, ou saisir le nom |
| [ERROR] Table ou vue X introuvable | faute de frappe, objet absent du schéma, ou schéma différent de l'utilisateur de connexion | vérifier le nom, passer --schema ; s'il n'existe pas encore : --colonnes "ID,NOM" --dates "DATE_MAJ" |
| [ERROR] Lecture d’Oracle impossible | base injoignable (VPN ?) | même échappatoire --colonnes / --dates |
| client Oracle Thick indisponible … : mode Thin | Instant Client absent de ORACLE_CLIENT_LIB_DIR | le mode Thin suffit souvent ; sinon corriger le chemin |
| [ERROR] X est une procédure / surchargée / paramètres OUT | la fonction n'est pas appelable par callFunction tel quel | écrire la route à la main |
| [ERROR] … n’a pas de colonne ID | le CRUD du socle exige ID | générer une lecture et des actions |
| déjà présent(s) : … — rien n’a été écrit | la ressource existe déjà | choisir un autre nom, ou supprimer le dossier (et ses lignes dans le module) |
| La route générée répond 404 Cannot GET | ressource non enregistrée, ou compilation en échec | vérifier le module affiché en module cible : … et les logs du conteneur |
| Cannot find module './xxx/xxx.service' au démarrage | une ressource a été supprimée sans nettoyer le module | retirer les 4 lignes correspondantes ; npx tsc --noEmit les liste |
| Cannot find module '@amsom-habitat/nest-…' | le code généré importe un paquet du socle absent de l'api | l'ajouter aux dépendances de l'api |
| Le spinner efface l'écran en boucle | terminal sans largeur (pty sans taille, ex. expect) | donner une taille au terminal (stty columns 120 rows 40) |
| Une question s'affiche et le parcours reste figé dessus, sans erreur | la question est posée depuis une action | déplacer la question dans prompts, forme fonction (cf. Poser une question dont la liste n'est pas connue d'avance) |
⚠️ Une erreur de compilation ne se voit pas côté client. Le watch recompile,
échoue, et laisse le serveur à terre : Postman affiche socket hang up, ce qui
ressemble à un problème réseau. Le réflexe : npx tsc --noEmit dans le
conteneur, ses logs (« Found N errors. Watching for file changes. »), puis
/health (000 = mort, pas lent).
Après la génération
Le générateur le rappelle lui-même, mais pour mémoire :
- confronter filtres, identifiant et TTL à l'entité v1 ;
- relire le typage de la
viewécrite dans le service généré : proposé d'après le type Oracle, il n'a été revu que pour les colonnes cochées — cf. Le typage des colonnes ; - si la route est cachée et qu'une écriture doit la purger, déclarer le tag dans
src/cache-tags.ts(un CRUD caché porte déjà@InvalidateCachesur son propre chemin : le tag est à déclarer aussi) ; - ajouter les requêtes à
tests/collection.json(édition manuelle) ; GET /coveragedoit rester à 200.
La v1 fait foi y compris quand elle a tort : une bizarrerie reproduite
volontairement porte un commentaire Parité v1 : … d'une ligne, au plus près du
code, pour qu'elle ne soit pas « corrigée » de bonne foi.
⚠️ Une liste sur une vue lourde doit porter un filtre obligatoire. Sans lui,
une seule requête peut saturer le heap et tuer le process — donc couper l'api pour
tout le monde. Le générateur lecture le propose (« Vue lourde ? Exiger un de ces
filtres ») : un 400 si aucun n'est fourni. itemsPerPage n'est pas une
protection : la borne s'applique après le balayage de la vue.
