nestgen-cli 0.1.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/doctor-output.js +45 -0
- package/dist/cli/output.js +65 -0
- package/dist/engine/file-transaction.js +166 -0
- package/dist/engine/generated-code.js +26 -0
- package/dist/engine/generation-manifest.js +118 -0
- package/dist/engine/migration-plan.js +78 -0
- package/dist/engine/module-generator.js +80 -65
- package/dist/engine/project-config.js +5 -2
- package/dist/engine/project-path.js +25 -0
- package/dist/engine/project-preflight.js +96 -0
- package/dist/engine/resource-generator.js +1104 -68
- package/dist/engine/resource-spec.js +254 -11
- package/examples/store-api/README.md +76 -0
- package/examples/store-api/definitions/customers.resource.json +14 -0
- package/examples/store-api/definitions/order-items.resource.json +26 -0
- package/examples/store-api/definitions/orders.resource.json +22 -0
- package/examples/store-api/definitions/products.resource.json +19 -0
- package/examples/store-api/nestgen.example.json +7 -0
- package/nestgen.js +488 -84
- package/nestjs-generator/features/configure_bootstrap.mjs +5 -2
- package/nestjs-generator/features/docker.sh +6 -4
- package/nestjs-generator/features/git.sh +1 -1
- package/nestjs-generator/features/inject_module_to_app.sh +8 -6
- package/nestjs-generator/features/logger.sh +8 -4
- package/nestjs-generator/features/operational_foundation.mjs +210 -0
- package/nestjs-generator/features/operations.sh +18 -0
- package/nestjs-generator/features/preflight.mjs +2 -44
- package/nestjs-generator/features/prisma.sh +3 -3
- package/nestjs-generator/features/typeorm.sh +3 -3
- package/nestjs-generator/features/utils.sh +5 -3
- package/nestjs-generator/generate_project.sh +15 -9
- package/package.json +11 -4
- package/readme.md +234 -13
|
@@ -1,117 +1,1153 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
-
import
|
|
2
|
+
import { formatGeneratedCode } from './generated-code.js';
|
|
3
|
+
import { inspectProject } from './project-preflight.js';
|
|
4
|
+
import { projectPath } from './project-path.js';
|
|
5
|
+
import { loadGenerationManifest, modifiedGeneratedFiles, resourceGenerationDefinition, resourceGenerationKey, withGenerationManifest, } from './generation-manifest.js';
|
|
6
|
+
import { applyFileChanges, previewFileChanges } from './file-transaction.js';
|
|
3
7
|
import { parseArchitectureProfile } from './architecture-profile.js';
|
|
4
8
|
import { registerModuleInAppModule } from './module-generator.js';
|
|
5
|
-
import { prismaType, typescriptType } from './resource-spec.js';
|
|
9
|
+
import { prismaType, typescriptType, } from './resource-spec.js';
|
|
6
10
|
function pascal(value) {
|
|
7
11
|
return value
|
|
8
12
|
.split('-')
|
|
9
13
|
.map((part) => `${part[0].toUpperCase()}${part.slice(1)}`)
|
|
10
14
|
.join('');
|
|
11
15
|
}
|
|
12
|
-
function
|
|
16
|
+
function camel(value) {
|
|
17
|
+
const parts = value.split('-');
|
|
18
|
+
return (parts[0] +
|
|
19
|
+
parts
|
|
20
|
+
.slice(1)
|
|
21
|
+
.map((part) => `${part[0].toUpperCase()}${part.slice(1)}`)
|
|
22
|
+
.join(''));
|
|
23
|
+
}
|
|
24
|
+
function plural(value) {
|
|
25
|
+
if (/[^aeiou]y$/i.test(value))
|
|
26
|
+
return `${value.slice(0, -1)}ies`;
|
|
27
|
+
if (/(s|x|z|ch|sh)$/i.test(value))
|
|
28
|
+
return `${value}es`;
|
|
29
|
+
return `${value}s`;
|
|
30
|
+
}
|
|
31
|
+
function relationField(relation) {
|
|
32
|
+
return relation.field ?? (relation.type === 'manyToMany' ? plural(camel(relation.target)) : camel(relation.target));
|
|
33
|
+
}
|
|
34
|
+
function relationIdField(relation) {
|
|
35
|
+
return `${relationField(relation)}Id`;
|
|
36
|
+
}
|
|
37
|
+
function relationName(relation, className) {
|
|
38
|
+
return `${relation.targetClassName}${className}${pascal(relation.field)}`;
|
|
39
|
+
}
|
|
40
|
+
function joinTableName(table, relation) {
|
|
41
|
+
const value = `join_${table}_${relation.targetTable}_${relation.field}`;
|
|
42
|
+
return value.length <= 63 ? value : `${value.slice(0, 54)}_${stableSuffix(value)}`;
|
|
43
|
+
}
|
|
44
|
+
function resolveRelations(projectRoot, sourceRoot, name, orm, fields, relations) {
|
|
45
|
+
const knownFields = new Set(fields.map((field) => field.name));
|
|
46
|
+
const resolved = (relations ?? []).map((relation) => {
|
|
47
|
+
if (relation.target === name)
|
|
48
|
+
throw new Error('Une ressource ne peut pas se référencer elle-même avec une relation générée.');
|
|
49
|
+
const field = relationField(relation);
|
|
50
|
+
if (relation.type === 'belongsTo') {
|
|
51
|
+
const idField = relationIdField(relation);
|
|
52
|
+
if (knownFields.has(idField))
|
|
53
|
+
throw new Error(`Le champ ${idField} est réservé à la relation vers ${relation.target}.`);
|
|
54
|
+
knownFields.add(idField);
|
|
55
|
+
}
|
|
56
|
+
const resourcePath = projectPath(projectRoot, `${sourceRoot}/app/${relation.target}/resource.json`);
|
|
57
|
+
if (!fs.existsSync(resourcePath))
|
|
58
|
+
throw new Error(`La ressource cible ${relation.target} est introuvable. Générez-la avant la relation.`);
|
|
59
|
+
let target;
|
|
60
|
+
try {
|
|
61
|
+
target = JSON.parse(fs.readFileSync(resourcePath, 'utf8'));
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
throw new Error(`Le manifeste de la ressource cible ${relation.target} est invalide.`);
|
|
65
|
+
}
|
|
66
|
+
if (!target || typeof target !== 'object' || target.orm !== orm)
|
|
67
|
+
throw new Error(`La ressource cible ${relation.target} doit utiliser l'ORM ${orm}.`);
|
|
68
|
+
const manifest = target;
|
|
69
|
+
if (!Array.isArray(manifest.fields) || typeof manifest.route !== 'string' || typeof manifest.table !== 'string')
|
|
70
|
+
throw new Error(`Le manifeste de la ressource cible ${relation.target} est incomplet.`);
|
|
71
|
+
return {
|
|
72
|
+
...relation,
|
|
73
|
+
field,
|
|
74
|
+
inverse: relation.inverse ?? plural(camel(name)),
|
|
75
|
+
nullable: relation.nullable,
|
|
76
|
+
onDelete: relation.onDelete,
|
|
77
|
+
targetClassName: pascal(relation.target),
|
|
78
|
+
targetFields: manifest.fields,
|
|
79
|
+
targetRoute: manifest.route,
|
|
80
|
+
targetTable: manifest.table,
|
|
81
|
+
};
|
|
82
|
+
});
|
|
83
|
+
if (new Set(resolved.map((relation) => relationField(relation))).size !== resolved.length)
|
|
84
|
+
throw new Error('Chaque relation doit utiliser un champ distinct.');
|
|
85
|
+
if (new Set(resolved.map((relation) => `${relation.target}:${relation.inverse}`)).size !== resolved.length)
|
|
86
|
+
throw new Error('Chaque relation vers une même ressource cible doit utiliser une propriété inverse distincte.');
|
|
87
|
+
return resolved;
|
|
88
|
+
}
|
|
89
|
+
function relationFields(relations) {
|
|
90
|
+
return relations
|
|
91
|
+
.filter((relation) => relation.type === 'belongsTo')
|
|
92
|
+
.map((relation) => ({
|
|
93
|
+
name: relationIdField(relation),
|
|
94
|
+
type: 'uuid',
|
|
95
|
+
nullable: relation.nullable,
|
|
96
|
+
unique: false,
|
|
97
|
+
indexed: true,
|
|
98
|
+
}));
|
|
99
|
+
}
|
|
100
|
+
function relationManifest(relations) {
|
|
101
|
+
return relations.map(({ type, target, field, inverse, nullable, onDelete }) => ({
|
|
102
|
+
type,
|
|
103
|
+
target,
|
|
104
|
+
field,
|
|
105
|
+
inverse,
|
|
106
|
+
nullable,
|
|
107
|
+
onDelete,
|
|
108
|
+
}));
|
|
109
|
+
}
|
|
110
|
+
function updateResourceChanges(projectRoot, sourceRoot, name, changes) {
|
|
111
|
+
const resourceRoot = `${sourceRoot}/app/${name}/`;
|
|
112
|
+
return changes.flatMap((change) => {
|
|
113
|
+
const exists = fs.existsSync(projectPath(projectRoot, change.path));
|
|
114
|
+
if (!exists || change.operation === 'replace')
|
|
115
|
+
return [change];
|
|
116
|
+
if (!change.path.startsWith(resourceRoot))
|
|
117
|
+
return [];
|
|
118
|
+
return [{ ...change, operation: 'replace' }];
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
function resourceUpdateConflicts(projectRoot, sourceRoot, name) {
|
|
122
|
+
const manifest = loadGenerationManifest(projectRoot);
|
|
123
|
+
const entry = manifest?.generations[resourceGenerationKey(sourceRoot, name)];
|
|
124
|
+
if (!entry)
|
|
125
|
+
throw new Error(`Aucun manifeste de génération pour la ressource ${name}.`);
|
|
126
|
+
if (entry.definition.kind !== 'resource')
|
|
127
|
+
throw new Error(`Le manifeste de la ressource ${name} est invalide.`);
|
|
128
|
+
const resourceRoot = `${sourceRoot}/app/${name}/`;
|
|
129
|
+
return new Set(modifiedGeneratedFiles(projectRoot, entry).filter((file) => file.startsWith(resourceRoot)));
|
|
130
|
+
}
|
|
131
|
+
function enumName(className, field) {
|
|
132
|
+
return `${className}${pascal(field.name)}`;
|
|
133
|
+
}
|
|
134
|
+
function enumValues(field) {
|
|
135
|
+
return field.enumValues.map((value) => `'${value}'`).join(', ');
|
|
136
|
+
}
|
|
137
|
+
function stableSuffix(value) {
|
|
138
|
+
let hash = 2166136261;
|
|
139
|
+
for (const character of value)
|
|
140
|
+
hash = Math.imul(hash ^ character.charCodeAt(0), 16777619);
|
|
141
|
+
return (hash >>> 0).toString(36);
|
|
142
|
+
}
|
|
143
|
+
function indexName(table, fields) {
|
|
144
|
+
const value = `idx_${table}_${fields.join('_')}`;
|
|
145
|
+
return value.length <= 63 ? value : `${value.slice(0, 54)}_${stableSuffix(value)}`;
|
|
146
|
+
}
|
|
147
|
+
function uniqueIndexName(table, fields) {
|
|
148
|
+
const value = `uq_${table}_${fields.join('_')}`;
|
|
149
|
+
return value.length <= 63 ? value : `${value.slice(0, 54)}_${stableSuffix(value)}`;
|
|
150
|
+
}
|
|
151
|
+
function resolvedIndexes(fields, indexes) {
|
|
152
|
+
const resolved = [
|
|
153
|
+
...fields.filter((field) => field.indexed).map((field) => ({ fields: [field.name] })),
|
|
154
|
+
...(indexes ?? []),
|
|
155
|
+
];
|
|
156
|
+
const seen = new Set();
|
|
157
|
+
return resolved.filter((index) => {
|
|
158
|
+
const key = index.fields.join('+');
|
|
159
|
+
if (seen.has(key))
|
|
160
|
+
return false;
|
|
161
|
+
seen.add(key);
|
|
162
|
+
return true;
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
function defaultLiteral(field) {
|
|
166
|
+
if (typeof field.defaultValue === 'string')
|
|
167
|
+
return `'${field.defaultValue.replaceAll("'", "''")}'`;
|
|
168
|
+
return String(field.defaultValue);
|
|
169
|
+
}
|
|
170
|
+
function prismaDefault(field) {
|
|
171
|
+
if (field.defaultValue === undefined)
|
|
172
|
+
return '';
|
|
173
|
+
if (field.type === 'string')
|
|
174
|
+
return ` @default(${JSON.stringify(field.defaultValue)})`;
|
|
175
|
+
if (field.type === 'enum')
|
|
176
|
+
return ` @default(${field.defaultValue})`;
|
|
177
|
+
return ` @default(${field.defaultValue})`;
|
|
178
|
+
}
|
|
179
|
+
function entityColumn(field, className) {
|
|
13
180
|
const type = field.type === 'number'
|
|
14
181
|
? "'double precision'"
|
|
15
|
-
: field.type === '
|
|
16
|
-
? "'
|
|
17
|
-
: field.type === '
|
|
18
|
-
? "'
|
|
19
|
-
: field.type === '
|
|
20
|
-
? "'
|
|
21
|
-
:
|
|
182
|
+
: field.type === 'integer'
|
|
183
|
+
? "'integer'"
|
|
184
|
+
: field.type === 'decimal'
|
|
185
|
+
? "'numeric'"
|
|
186
|
+
: field.type === 'enum'
|
|
187
|
+
? "'enum'"
|
|
188
|
+
: field.type === 'boolean'
|
|
189
|
+
? "'boolean'"
|
|
190
|
+
: field.type === 'date'
|
|
191
|
+
? "'timestamptz'"
|
|
192
|
+
: field.type === 'uuid'
|
|
193
|
+
? "'uuid'"
|
|
194
|
+
: "'varchar'";
|
|
22
195
|
const options = [`type: ${type}`, `nullable: ${field.nullable}`];
|
|
196
|
+
if (field.type === 'decimal')
|
|
197
|
+
options.push(`precision: ${field.precision}`, `scale: ${field.scale}`);
|
|
198
|
+
if (field.type === 'enum')
|
|
199
|
+
options.push(`enum: [${enumValues(field)}]`, `enumName: '${enumName(className, field)}'`);
|
|
200
|
+
if (field.length !== undefined)
|
|
201
|
+
options.push(`length: ${field.length}`);
|
|
202
|
+
if (field.defaultValue !== undefined)
|
|
203
|
+
options.push(`default: ${defaultLiteral(field)}`);
|
|
23
204
|
if (field.unique)
|
|
24
205
|
options.push('unique: true');
|
|
25
206
|
return ` @Column({ ${options.join(', ')} })\n ${field.name}${field.nullable ? '?' : '!'}: ${typescriptType(field)}${field.nullable ? ' | null' : ''};`;
|
|
26
207
|
}
|
|
27
208
|
function validationDecorators(field, optional) {
|
|
28
|
-
const decorators =
|
|
209
|
+
const decorators = field.nullable
|
|
210
|
+
? ['@IsOptional()']
|
|
211
|
+
: optional
|
|
212
|
+
? ['@ValidateIf((_object: unknown, value: unknown) => value !== undefined)']
|
|
213
|
+
: ['@IsDefined()'];
|
|
29
214
|
decorators.push(field.type === 'number'
|
|
30
215
|
? '@IsNumber()'
|
|
216
|
+
: field.type === 'integer'
|
|
217
|
+
? '@IsInt()'
|
|
218
|
+
: field.type === 'decimal'
|
|
219
|
+
? `@IsDecimal({ decimal_digits: '1,${field.scale}', force_decimal: false })`
|
|
220
|
+
: field.type === 'enum'
|
|
221
|
+
? `@IsIn([${enumValues(field)}])`
|
|
222
|
+
: field.type === 'boolean'
|
|
223
|
+
? '@IsBoolean()'
|
|
224
|
+
: field.type === 'date'
|
|
225
|
+
? '@IsDateString()'
|
|
226
|
+
: field.type === 'uuid'
|
|
227
|
+
? '@IsUUID()'
|
|
228
|
+
: '@IsString()');
|
|
229
|
+
if (field.length !== undefined)
|
|
230
|
+
decorators.push(`@MaxLength(${field.length})`);
|
|
231
|
+
if (field.min !== undefined)
|
|
232
|
+
decorators.push(`@Min(${field.min})`);
|
|
233
|
+
if (field.max !== undefined)
|
|
234
|
+
decorators.push(`@Max(${field.max})`);
|
|
235
|
+
return decorators;
|
|
236
|
+
}
|
|
237
|
+
function swaggerType(field) {
|
|
238
|
+
return field.type === 'number' || field.type === 'integer'
|
|
239
|
+
? 'Number'
|
|
31
240
|
: field.type === 'boolean'
|
|
32
|
-
? '
|
|
33
|
-
:
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
241
|
+
? 'Boolean'
|
|
242
|
+
: 'String';
|
|
243
|
+
}
|
|
244
|
+
function swaggerProperty(field, optional) {
|
|
245
|
+
const required = !optional && !field.nullable;
|
|
246
|
+
const options = [`type: ${swaggerType(field)}`, `required: ${required}`, `example: ${sampleValue(field, true)}`];
|
|
247
|
+
if (field.nullable)
|
|
248
|
+
options.push('nullable: true');
|
|
249
|
+
if (field.type === 'date')
|
|
250
|
+
options.push("format: 'date-time'");
|
|
251
|
+
if (field.type === 'uuid')
|
|
252
|
+
options.push("format: 'uuid'");
|
|
253
|
+
if (field.type === 'integer')
|
|
254
|
+
options.push("format: 'int32'");
|
|
255
|
+
if (field.type === 'decimal')
|
|
256
|
+
options.push("format: 'decimal'", "description: 'Nombre décimal transmis sous forme de chaîne pour préserver sa précision.'");
|
|
257
|
+
if (field.type === 'enum')
|
|
258
|
+
options.push(`enum: [${enumValues(field)}]`);
|
|
259
|
+
if (field.length !== undefined)
|
|
260
|
+
options.push(`maxLength: ${field.length}`);
|
|
261
|
+
if (field.min !== undefined)
|
|
262
|
+
options.push(`minimum: ${field.min}`);
|
|
263
|
+
if (field.max !== undefined)
|
|
264
|
+
options.push(`maximum: ${field.max}`);
|
|
265
|
+
if (field.defaultValue !== undefined)
|
|
266
|
+
options.push(`default: ${defaultLiteral(field)}`);
|
|
267
|
+
if (field.unique)
|
|
268
|
+
options.push("description: 'Valeur unique.'");
|
|
269
|
+
return `@${required ? 'ApiProperty' : 'ApiPropertyOptional'}({ ${options.join(', ')} })`;
|
|
270
|
+
}
|
|
271
|
+
function swaggerImports(fields, optional) {
|
|
272
|
+
const decorators = new Set(fields.map((field) => (!optional && !field.nullable ? 'ApiProperty' : 'ApiPropertyOptional')));
|
|
273
|
+
return [...decorators].sort().join(', ');
|
|
274
|
+
}
|
|
275
|
+
function dtoFields(fields, optional, swagger = false) {
|
|
41
276
|
return fields
|
|
42
277
|
.map((field) => {
|
|
43
|
-
const isOptional = optional || field.nullable;
|
|
278
|
+
const isOptional = optional || field.nullable || field.defaultValue !== undefined;
|
|
44
279
|
const type = field.type === 'date' ? 'string' : typescriptType(field);
|
|
45
|
-
return `${
|
|
280
|
+
return `${swagger ? ` ${swaggerProperty(field, isOptional)}\n` : ''}${validationDecorators(field, isOptional)
|
|
281
|
+
.map((decorator) => ` ${decorator}`)
|
|
282
|
+
.join('\n')}\n ${field.name}${isOptional ? '?' : '!'}: ${type}${field.nullable ? ' | null' : ''};`;
|
|
46
283
|
})
|
|
47
284
|
.join('\n\n');
|
|
48
285
|
}
|
|
286
|
+
function validationImports(fields, optional) {
|
|
287
|
+
const names = fields.flatMap((field) => validationDecorators(field, optional || field.defaultValue !== undefined).map((decorator) => decorator.slice(1, decorator.indexOf('('))));
|
|
288
|
+
return [...new Set(names)].sort().join(', ');
|
|
289
|
+
}
|
|
49
290
|
function propertyMap(fields) {
|
|
50
291
|
return fields
|
|
51
292
|
.map((field) => {
|
|
52
293
|
const value = `input.${field.name}`;
|
|
53
|
-
|
|
294
|
+
const converted = field.type === 'date' ? `${value} === null ? null : new Date(${value})` : value;
|
|
295
|
+
return `...(${value} === undefined ? {} : { ${field.name}: ${converted} })`;
|
|
54
296
|
})
|
|
55
297
|
.join(', ');
|
|
56
298
|
}
|
|
57
|
-
function
|
|
299
|
+
function applicationInputMap(fields, optional) {
|
|
300
|
+
return fields
|
|
301
|
+
.map((field) => {
|
|
302
|
+
const value = `dto.${field.name}`;
|
|
303
|
+
const converted = field.type === 'date' ? `${value} === null ? null : new Date(${value})` : value;
|
|
304
|
+
return optional || field.nullable
|
|
305
|
+
? `...(${value} === undefined ? {} : { ${field.name}: ${converted} })`
|
|
306
|
+
: `${field.name}: ${converted}`;
|
|
307
|
+
})
|
|
308
|
+
.join(', ');
|
|
309
|
+
}
|
|
310
|
+
function applicationContractFields(fields, optional) {
|
|
311
|
+
return fields
|
|
312
|
+
.map((field) => ` ${field.name}${optional || field.nullable || field.defaultValue !== undefined ? '?' : ''}: ${typescriptType(field)}${field.nullable ? ' | null' : ''};`)
|
|
313
|
+
.join('\n');
|
|
314
|
+
}
|
|
315
|
+
function applicationContract(name, className, fields) {
|
|
316
|
+
const create = applicationContractFields(fields, false);
|
|
317
|
+
const update = applicationContractFields(fields, true);
|
|
318
|
+
const output = applicationContractFields(fields, false);
|
|
319
|
+
return {
|
|
320
|
+
path: `application/${name}.contract.ts`,
|
|
321
|
+
content: `export interface Create${className}Input {\n${create}\n}\n\n` +
|
|
322
|
+
`export interface Update${className}Input {\n${update}\n}\n\n` +
|
|
323
|
+
`export interface ${className}Output {\n id: string;\n${output}\n}\n`,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
function sampleValue(field, transport) {
|
|
327
|
+
if (field.type === 'number')
|
|
328
|
+
return '42';
|
|
329
|
+
if (field.type === 'integer')
|
|
330
|
+
return '42';
|
|
331
|
+
if (field.type === 'decimal')
|
|
332
|
+
return `'123${field.scale ? `.${'4'.repeat(field.scale)}` : ''}'`;
|
|
333
|
+
if (field.type === 'enum')
|
|
334
|
+
return `'${field.enumValues[0]}'`;
|
|
335
|
+
if (field.type === 'boolean')
|
|
336
|
+
return 'true';
|
|
337
|
+
if (field.type === 'date')
|
|
338
|
+
return transport ? "'2026-01-02T03:04:05.000Z'" : "new Date('2026-01-02T03:04:05.000Z')";
|
|
339
|
+
if (field.type === 'uuid')
|
|
340
|
+
return "'00000000-0000-4000-8000-000000000123'";
|
|
341
|
+
return `'${field.name}-value'`;
|
|
342
|
+
}
|
|
343
|
+
function testInput(fields, transport) {
|
|
344
|
+
return fields.map((field) => `${field.name}: ${sampleValue(field, transport)}`).join(', ');
|
|
345
|
+
}
|
|
346
|
+
function generatedUnitTest(name, className, fields, advanced) {
|
|
347
|
+
const transportInput = testInput(fields, true);
|
|
348
|
+
const applicationInput = testInput(fields, false);
|
|
349
|
+
if (advanced) {
|
|
350
|
+
return {
|
|
351
|
+
path: `${name}.service.spec.ts`,
|
|
352
|
+
content: `import { ConflictException, NotFoundException } from '@nestjs/common';\nimport { ${className}ConflictError, ${className}NotFoundError } from './application/${name}.errors.js';\nimport { ${className}Service } from './application/${name}.service.js';\nimport { ${className}Controller } from './${name}.controller.js';\nimport { ${className} } from './domain/${name}.js';\n\ndescribe('${className} advanced resource', () => {\n const entity = Object.assign(new ${className}(), { id: '00000000-0000-4000-8000-000000000001', ${applicationInput} });\n const repository = {\n create: jest.fn(),\n findOne: jest.fn(),\n findMany: jest.fn(),\n update: jest.fn(),\n remove: jest.fn(),\n };\n const service = new ${className}Service(repository);\n const controller = new ${className}Controller(service);\n\n beforeEach(() => jest.resetAllMocks());\n\n it('returns an application output from a persisted domain entity', async () => {\n repository.create.mockResolvedValue(entity);\n\n await expect(service.create({ ${applicationInput} })).resolves.toEqual({ id: entity.id, ${fields.map((field) => `${field.name}: entity.${field.name}`).join(', ')} });\n expect(repository.create).toHaveBeenCalledWith({ ${applicationInput} });\n });\n\n it('maps an application not-found error to HTTP 404', async () => {\n repository.findOne.mockRejectedValue(new ${className}NotFoundError());\n\n await expect(controller.get(entity.id)).rejects.toBeInstanceOf(NotFoundException);\n });\n\n it('maps an application conflict error to HTTP 409', async () => {\n repository.create.mockRejectedValue(new ${className}ConflictError());\n\n await expect(controller.create({ ${transportInput} })).rejects.toBeInstanceOf(ConflictException);\n });\n});\n`,
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
return {
|
|
356
|
+
path: `persistence/${name}.repository.spec.ts`,
|
|
357
|
+
content: `import { ConflictException, NotFoundException } from '@nestjs/common';\nimport { QueryFailedError } from 'typeorm';\nimport { ${className}Repository } from './${name}.repository.js';\n\ndescribe('${className} repository', () => {\n const input = { ${transportInput} };\n const entity = { id: '00000000-0000-4000-8000-000000000001', ${applicationInput} };\n const persistence = {\n create: jest.fn((value) => value),\n save: jest.fn(),\n findOneBy: jest.fn(),\n find: jest.fn(),\n preload: jest.fn(),\n delete: jest.fn(),\n };\n const repository = new ${className}Repository(persistence as never);\n\n beforeEach(() => jest.resetAllMocks());\n\n it('persists valid input and returns the domain value', async () => {\n persistence.save.mockResolvedValue(entity);\n\n await expect(repository.create(input)).resolves.toMatchObject(entity);\n expect(persistence.create).toHaveBeenCalledWith(expect.objectContaining({ ${applicationInput} }));\n });\n\n it('reports an absent resource', async () => {\n persistence.findOneBy.mockResolvedValue(null);\n\n await expect(repository.findOne(entity.id)).rejects.toBeInstanceOf(NotFoundException);\n });\n\n it('reports a uniqueness conflict from persistence', async () => {\n const driverError = Object.assign(new Error('duplicate'), { code: '23505' });\n persistence.save.mockRejectedValue(new QueryFailedError('INSERT', [], driverError));\n\n await expect(repository.create(input)).rejects.toBeInstanceOf(ConflictException);\n });\n});\n`,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
function updatedTestValue(field) {
|
|
361
|
+
if (field.type === 'number')
|
|
362
|
+
return '84';
|
|
363
|
+
if (field.type === 'integer')
|
|
364
|
+
return '84';
|
|
365
|
+
if (field.type === 'decimal')
|
|
366
|
+
return `'456${field.scale ? `.${'5'.repeat(field.scale)}` : ''}'`;
|
|
367
|
+
if (field.type === 'enum')
|
|
368
|
+
return `'${field.enumValues[1] ?? field.enumValues[0]}'`;
|
|
369
|
+
if (field.type === 'boolean')
|
|
370
|
+
return 'false';
|
|
371
|
+
if (field.type === 'date')
|
|
372
|
+
return "'2026-02-03T04:05:06.000Z'";
|
|
373
|
+
if (field.type === 'uuid')
|
|
374
|
+
return "'00000000-0000-4000-8000-000000000456'";
|
|
375
|
+
return `'${field.name}-updated'`;
|
|
376
|
+
}
|
|
377
|
+
function invalidTestValue(field) {
|
|
378
|
+
if (field.type === 'number')
|
|
379
|
+
return "'invalid-number'";
|
|
380
|
+
if (field.type === 'integer')
|
|
381
|
+
return "'invalid-integer'";
|
|
382
|
+
if (field.type === 'decimal')
|
|
383
|
+
return "'invalid-decimal'";
|
|
384
|
+
if (field.type === 'enum')
|
|
385
|
+
return "'INVALID_ENUM_VALUE'";
|
|
386
|
+
if (field.type === 'boolean')
|
|
387
|
+
return "'invalid-boolean'";
|
|
388
|
+
if (field.type === 'date')
|
|
389
|
+
return "'invalid-date'";
|
|
390
|
+
if (field.type === 'uuid')
|
|
391
|
+
return "'invalid-uuid'";
|
|
392
|
+
return '42';
|
|
393
|
+
}
|
|
394
|
+
function generatedRestTest(name, className, fields, route, relations, sourceRoot) {
|
|
395
|
+
const input = testInput(fields, true);
|
|
396
|
+
const firstField = fields[0];
|
|
397
|
+
const uniqueField = fields.find((field) => field.unique);
|
|
398
|
+
const update = `${firstField.name}: ${updatedTestValue(firstField)}`;
|
|
399
|
+
const invalid = `${firstField.name}: ${invalidTestValue(firstField)}`;
|
|
400
|
+
const relationSetup = [...new Map(relations.map((relation) => [relation.target, relation])).values()]
|
|
401
|
+
.map((relation) => ` const ${relation.field}Target = await api.post('/${relation.targetRoute}').send({ ${testInput(relation.targetFields, true)} }).expect(201);\n${relations
|
|
402
|
+
.filter((candidate) => candidate.target === relation.target)
|
|
403
|
+
.map((candidate) => ` input.${relationIdField(candidate)} = ${relation.field}Target.body.id;`)
|
|
404
|
+
.join('\n')}`)
|
|
405
|
+
.join('\n');
|
|
406
|
+
const duplicateTest = uniqueField
|
|
407
|
+
? `\n it('returns 409 when a unique value already exists', async () => {\n${relationSetup ? `${relationSetup}\n` : ''} await api.post('/${route}').send(input).expect(201);\n await api.post('/${route}').send(input).expect(409);\n });\n`
|
|
408
|
+
: '';
|
|
409
|
+
return {
|
|
410
|
+
path: `test/${name}.e2e-spec.ts`,
|
|
411
|
+
content: `import 'reflect-metadata';
|
|
412
|
+
import { ValidationPipe, type INestApplication } from '@nestjs/common';
|
|
413
|
+
import { Test } from '@nestjs/testing';
|
|
414
|
+
import { beforeAll, beforeEach, afterAll, describe, expect, it } from '@jest/globals';
|
|
415
|
+
import { config } from 'dotenv';
|
|
416
|
+
import { resolve } from 'node:path';
|
|
417
|
+
import request from 'supertest';
|
|
418
|
+
import { DataSource } from 'typeorm';
|
|
419
|
+
|
|
420
|
+
config({ path: resolve(process.cwd(), 'test/.env.e2e') });
|
|
421
|
+
|
|
422
|
+
const testDatabase = process.env.DATABASE_TEST_NAME;
|
|
423
|
+
if (!testDatabase) throw new Error('DATABASE_TEST_NAME est requis pour les tests E2E.');
|
|
424
|
+
if (testDatabase === process.env.DATABASE_NAME)
|
|
425
|
+
throw new Error('DATABASE_TEST_NAME doit être différent de DATABASE_NAME.');
|
|
426
|
+
process.env.DATABASE_NAME = testDatabase;
|
|
427
|
+
|
|
428
|
+
describe('${className} REST', () => {
|
|
429
|
+
let app: INestApplication;
|
|
430
|
+
let database: DataSource;
|
|
431
|
+
let api: ReturnType<typeof request>;
|
|
432
|
+
const input: Record<string, unknown> = { ${input} };
|
|
433
|
+
|
|
434
|
+
beforeAll(async () => {
|
|
435
|
+
const { AppModule } = await import('../${sourceRoot}/app.module.js');
|
|
436
|
+
const module = await Test.createTestingModule({ imports: [AppModule] }).compile();
|
|
437
|
+
app = module.createNestApplication();
|
|
438
|
+
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }));
|
|
439
|
+
await app.init();
|
|
440
|
+
database = app.get(DataSource);
|
|
441
|
+
api = request(app.getHttpServer());
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
beforeEach(async () => {
|
|
445
|
+
await database.synchronize(true);
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
afterAll(async () => {
|
|
449
|
+
try {
|
|
450
|
+
await database?.dropDatabase();
|
|
451
|
+
} finally {
|
|
452
|
+
await app?.close();
|
|
453
|
+
}
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it('creates, reads, partially updates, paginates and deletes a resource', async () => {
|
|
457
|
+
${relationSetup ? `${relationSetup}\n` : ''} const created = await api.post('/${route}').send(input).expect(201);
|
|
458
|
+
expect(created.body).toEqual(expect.objectContaining(input));
|
|
459
|
+
|
|
460
|
+
const found = await api.get('/${route}/' + created.body.id).expect(200);
|
|
461
|
+
expect(found.body).toEqual(expect.objectContaining(input));
|
|
462
|
+
await api.patch('/${route}/' + created.body.id).send({ ${update} }).expect(200);
|
|
463
|
+
const updated = await api.get('/${route}/' + created.body.id).expect(200);
|
|
464
|
+
expect(updated.body).toEqual(expect.objectContaining({ ...input, ${update} }));
|
|
465
|
+
const listed = await api.get('/${route}?page=1&limit=1').expect(200);
|
|
466
|
+
expect(listed.body).toMatchObject({ page: 1, limit: 1 });
|
|
467
|
+
expect(listed.body.data).toHaveLength(1);
|
|
468
|
+
await api.delete('/${route}/' + created.body.id).expect(204);
|
|
469
|
+
await api.get('/${route}/' + created.body.id).expect(404);
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
it('rejects invalid input, malformed UUIDs and missing resources', async () => {
|
|
473
|
+
await api.post('/${route}').send({ ...input, ${invalid} }).expect(400);
|
|
474
|
+
await api.get('/${route}/not-a-uuid').expect(400);
|
|
475
|
+
await api.get('/${route}/00000000-0000-4000-8000-000000000001').expect(404);
|
|
476
|
+
});${duplicateTest}});
|
|
477
|
+
`,
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
function e2eSupportFiles(projectRoot) {
|
|
481
|
+
const files = new Map();
|
|
482
|
+
const support = new Map([
|
|
483
|
+
[
|
|
484
|
+
'test/.env.e2e',
|
|
485
|
+
'DATABASE_HOST=127.0.0.1\nDATABASE_PORT=5433\nDATABASE_USER=nestgen\nDATABASE_PASSWORD=nestgen\nDATABASE_TEST_NAME=nestgen_e2e\n',
|
|
486
|
+
],
|
|
487
|
+
[
|
|
488
|
+
'test/compose.e2e.yaml',
|
|
489
|
+
`services:\n postgres-e2e:\n image: postgres:16-alpine\n environment:\n POSTGRES_DB: nestgen_e2e\n POSTGRES_USER: nestgen\n POSTGRES_PASSWORD: nestgen\n ports:\n - '127.0.0.1:5433:5432'\n healthcheck:\n test: ['CMD-SHELL', 'pg_isready -U nestgen -d nestgen_e2e']\n interval: 2s\n timeout: 3s\n retries: 30\n`,
|
|
490
|
+
],
|
|
491
|
+
]);
|
|
492
|
+
for (const [file, content] of support) {
|
|
493
|
+
if (!fs.existsSync(projectPath(projectRoot, file)))
|
|
494
|
+
files.set(file, content);
|
|
495
|
+
}
|
|
496
|
+
return files;
|
|
497
|
+
}
|
|
498
|
+
function prismaModelFields(className, fields) {
|
|
499
|
+
return fields
|
|
500
|
+
.map((field) => {
|
|
501
|
+
if (field.type === 'enum')
|
|
502
|
+
return ` ${field.name} ${enumName(className, field)}${field.nullable ? '?' : ''}${field.unique ? ' @unique' : ''}${prismaDefault(field)}`;
|
|
503
|
+
if (field.type === 'decimal')
|
|
504
|
+
return ` ${field.name} ${prismaType({ ...field, nullable: false, unique: false })}${field.nullable ? '?' : ''} @db.Decimal(${field.precision}, ${field.scale})${field.unique ? ' @unique' : ''}${prismaDefault(field)}`;
|
|
505
|
+
return ` ${field.name} ${prismaType(field)}${prismaDefault(field)}`;
|
|
506
|
+
})
|
|
507
|
+
.join('\n');
|
|
508
|
+
}
|
|
509
|
+
function updatePrismaModel(source, className, property, propertyName) {
|
|
510
|
+
const model = new RegExp(`(model\\s+${className}\\s*\\{)([\\s\\S]*?)(\\n\\})`);
|
|
511
|
+
const match = model.exec(source);
|
|
512
|
+
if (!match)
|
|
513
|
+
throw new Error(`Le modèle Prisma cible ${className} est introuvable.`);
|
|
514
|
+
if (new RegExp(`\\b${propertyName}\\b`).test(match[2]))
|
|
515
|
+
throw new Error(`La propriété Prisma ${propertyName} existe déjà sur ${className}.`);
|
|
516
|
+
const insertion = match[2].includes('\n @@')
|
|
517
|
+
? match[2].replace(/\n( @@)/, `\n ${property}\n\n$1`)
|
|
518
|
+
: `${match[2]}\n ${property}`;
|
|
519
|
+
return `${source.slice(0, match.index)}${match[1]}${insertion}${match[3]}${source.slice(match.index + match[0].length)}`;
|
|
520
|
+
}
|
|
521
|
+
function addPrismaInverseRelations(source, className, relations) {
|
|
522
|
+
return relations.reduce((current, relation) => updatePrismaModel(current, relation.targetClassName, `${relation.inverse} ${className}[] @relation("${relationName(relation, className)}")`, relation.inverse), source);
|
|
523
|
+
}
|
|
524
|
+
function prismaSchema(source, className, table, fields, indexes, relations) {
|
|
525
|
+
if (new RegExp(`\\bmodel\\s+${className}\\b`).test(source))
|
|
526
|
+
throw new Error(`Le modèle Prisma ${className} existe déjà.`);
|
|
527
|
+
const definitions = fields
|
|
528
|
+
.filter((field) => field.type === 'enum')
|
|
529
|
+
.map((field) => {
|
|
530
|
+
const name = enumName(className, field);
|
|
531
|
+
if (new RegExp(`\\benum\\s+${name}\\b`).test(source))
|
|
532
|
+
throw new Error(`L'enum Prisma ${name} existe déjà.`);
|
|
533
|
+
return `enum ${name} {\n${field.enumValues.map((value) => ` ${value}`).join('\n')}\n}`;
|
|
534
|
+
});
|
|
535
|
+
const indexDefinitions = indexes
|
|
536
|
+
.map((index) => index.unique
|
|
537
|
+
? ` @@unique([${index.fields.join(', ')}], map: "${uniqueIndexName(table, index.fields)}")`
|
|
538
|
+
: ` @@index([${index.fields.join(', ')}], map: "${indexName(table, index.fields)}")`)
|
|
539
|
+
.join('\n');
|
|
540
|
+
const withInverses = addPrismaInverseRelations(source, className, relations);
|
|
541
|
+
const relationProperties = relations
|
|
542
|
+
.map((relation) => relation.type === 'manyToMany'
|
|
543
|
+
? ` ${relation.field} ${relation.targetClassName}[] @relation("${relationName(relation, className)}")`
|
|
544
|
+
: ` ${relation.field} ${relation.targetClassName}${relation.nullable ? '?' : ''} @relation("${relationName(relation, className)}", fields: [${relationIdField(relation)}], references: [id], onDelete: ${relation.onDelete === 'SET NULL' ? 'SetNull' : 'Restrict'})`)
|
|
545
|
+
.join('\n');
|
|
546
|
+
return `${withInverses.trimEnd()}${definitions.length ? `\n\n${definitions.join('\n\n')}` : ''}\n\nmodel ${className} {\n id String @id @default(uuid())\n${prismaModelFields(className, fields)}${relationProperties ? `\n${relationProperties}` : ''}${indexDefinitions ? `\n\n${indexDefinitions}` : ''}\n\n @@map("${table}")\n}\n`;
|
|
547
|
+
}
|
|
548
|
+
function prismaRuntime(projectRoot, sourceRoot) {
|
|
549
|
+
const service = projectPath(projectRoot, `${sourceRoot}/prisma/prisma.service.ts`);
|
|
550
|
+
const module = projectPath(projectRoot, `${sourceRoot}/prisma/prisma.module.ts`);
|
|
551
|
+
if (fs.existsSync(service) && fs.existsSync(module))
|
|
552
|
+
return [];
|
|
553
|
+
if (fs.existsSync(service) || fs.existsSync(module))
|
|
554
|
+
throw new Error('Runtime Prisma incomplet.');
|
|
555
|
+
return [
|
|
556
|
+
{
|
|
557
|
+
path: `${sourceRoot}/prisma/prisma.service.ts`,
|
|
558
|
+
operation: 'create',
|
|
559
|
+
content: "import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';\nimport { PrismaClient } from '@prisma/client';\n\n@Injectable()\nexport class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {\n async onModuleInit(): Promise<void> { await this.$connect(); }\n async onModuleDestroy(): Promise<void> { await this.$disconnect(); }\n}\n",
|
|
560
|
+
},
|
|
561
|
+
{
|
|
562
|
+
path: `${sourceRoot}/prisma/prisma.module.ts`,
|
|
563
|
+
operation: 'create',
|
|
564
|
+
content: "import { Global, Module } from '@nestjs/common';\nimport { PrismaService } from './prisma.service.js';\n\n@Global()\n@Module({ providers: [PrismaService], exports: [PrismaService] })\nexport class PrismaModule {}\n",
|
|
565
|
+
},
|
|
566
|
+
];
|
|
567
|
+
}
|
|
568
|
+
function queryProperty(field, operator) {
|
|
569
|
+
return operator === 'eq' ? field : `${field}${pascal(operator)}`;
|
|
570
|
+
}
|
|
571
|
+
function listFilterDecorators(field) {
|
|
572
|
+
if (field.type === 'number')
|
|
573
|
+
return ' @Type(() => Number)\n @IsNumber()';
|
|
574
|
+
if (field.type === 'integer')
|
|
575
|
+
return ' @Type(() => Number)\n @IsInt()';
|
|
576
|
+
if (field.type === 'boolean')
|
|
577
|
+
return " @Transform(({ value }) => (value === 'true' ? true : value === 'false' ? false : value))\n @IsBoolean()";
|
|
578
|
+
if (field.type === 'date')
|
|
579
|
+
return ' @Type(() => Date)\n @IsDate()';
|
|
580
|
+
return ' @IsString()';
|
|
581
|
+
}
|
|
582
|
+
function listQueryDto(className, list, fields) {
|
|
583
|
+
const fieldsByName = new Map(fields.map((field) => [field.name, field]));
|
|
584
|
+
const filterFields = Object.keys(list.filters).map((name) => fieldsByName.get(name));
|
|
585
|
+
const filters = Object.entries(list.filters)
|
|
586
|
+
.flatMap(([name, operators]) => operators.map((operator) => ({ field: fieldsByName.get(name), property: queryProperty(name, operator) })))
|
|
587
|
+
.map(({ field, property }) => ` @IsOptional()\n${listFilterDecorators(field)}\n ${property}?: ${typescriptType(field)};`)
|
|
588
|
+
.join('\n\n');
|
|
589
|
+
const validators = new Set(['IsInt', 'IsOptional', 'Max', 'Min']);
|
|
590
|
+
if (filterFields.some((field) => !['number', 'integer', 'boolean', 'date'].includes(field.type)) ||
|
|
591
|
+
list.search.length ||
|
|
592
|
+
list.sort.length)
|
|
593
|
+
validators.add('IsString');
|
|
594
|
+
if (filterFields.some((field) => field.type === 'number'))
|
|
595
|
+
validators.add('IsNumber');
|
|
596
|
+
if (filterFields.some((field) => field.type === 'boolean'))
|
|
597
|
+
validators.add('IsBoolean');
|
|
598
|
+
if (filterFields.some((field) => field.type === 'date'))
|
|
599
|
+
validators.add('IsDate');
|
|
600
|
+
const usesTransform = filterFields.some((field) => field.type === 'boolean');
|
|
601
|
+
return `import { ${usesTransform ? 'Transform, ' : ''}Type } from 'class-transformer';\nimport { ${[...validators].sort().join(', ')} } from 'class-validator';\n\nexport const MAX_PAGE_SIZE = 100;\n\nexport class List${className}Query {\n @IsOptional()\n @Type(() => Number)\n @IsInt()\n @Min(1)\n page = 1;\n\n @IsOptional()\n @Type(() => Number)\n @IsInt()\n @Min(1)\n @Max(MAX_PAGE_SIZE)\n limit = 20;${filters ? `\n\n${filters}` : ''}${list.search.length ? `\n\n @IsOptional()\n @IsString()\n q?: string;` : ''}${list.sort.length ? `\n\n @IsOptional()\n @IsString()\n sort?: string;` : ''}${list.cursor ? `\n\n @IsOptional()\n @IsString()\n after?: string;` : ''}\n}\n`;
|
|
602
|
+
}
|
|
603
|
+
function cursorHelpers() {
|
|
604
|
+
return ` private decodeCursor(value: string): string {\n try {\n const cursor = JSON.parse(Buffer.from(value, 'base64url').toString('utf8')) as { id?: unknown };\n if (typeof cursor.id !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(cursor.id)) throw new Error();\n return cursor.id;\n } catch {\n throw new BadRequestException('Curseur invalide.');\n }\n }\n\n private encodeCursor(id: string): string {\n return Buffer.from(JSON.stringify({ id }), 'utf8').toString('base64url');\n }`;
|
|
605
|
+
}
|
|
606
|
+
function typeOrmListMethod(className, name, list) {
|
|
607
|
+
const filters = Object.entries(list.filters)
|
|
608
|
+
.flatMap(([field, operators]) => operators.map((operator) => {
|
|
609
|
+
const property = queryProperty(field, operator);
|
|
610
|
+
const comparison = operator === 'eq'
|
|
611
|
+
? '='
|
|
612
|
+
: operator === 'neq'
|
|
613
|
+
? '!='
|
|
614
|
+
: operator === 'gt'
|
|
615
|
+
? '>'
|
|
616
|
+
: operator === 'gte'
|
|
617
|
+
? '>='
|
|
618
|
+
: operator === 'lt'
|
|
619
|
+
? '<'
|
|
620
|
+
: '<=';
|
|
621
|
+
if (operator === 'contains')
|
|
622
|
+
return ` if (query.${property} !== undefined) builder.andWhere('${name}.${field} ILIKE :${property}', { ${property}: \`%\${query.${property}}%\` });`;
|
|
623
|
+
return ` if (query.${property} !== undefined) builder.andWhere('${name}.${field} ${comparison} :${property}', { ${property}: query.${property} });`;
|
|
624
|
+
}))
|
|
625
|
+
.join('\n');
|
|
626
|
+
const search = list.search.length
|
|
627
|
+
? ` if (query.q !== undefined) builder.andWhere('(${list.search.map((field) => `${name}.${field} ILIKE :q`).join(' OR ')})', { q: \`%\${query.q}%\` });\n`
|
|
628
|
+
: '';
|
|
629
|
+
const sort = list.sort.length
|
|
630
|
+
? ` const sortParts = (query.sort ?? 'id:asc').split(':');\n const [field, direction = 'asc'] = sortParts;\n if (sortParts.length > 2 || !${JSON.stringify(['id', ...list.sort])}.includes(field) || !['asc', 'desc'].includes(direction.toLowerCase())) throw new BadRequestException('Tri invalide.');\n builder.orderBy('${name}.' + field, direction.toUpperCase() as 'ASC' | 'DESC');\n`
|
|
631
|
+
: ` builder.orderBy('${name}.id', 'ASC');\n`;
|
|
632
|
+
const body = `${filters ? `${filters}\n` : ''}${search}${sort}`.replaceAll('query.', 'listQuery.');
|
|
633
|
+
if (list.cursor)
|
|
634
|
+
return ` async findMany(skip: number, take: number, query: object): Promise<{ data: ${className}[]; nextCursor?: string }> {\n const listQuery = query as List${className}Query;\n if (listQuery.after !== undefined && listQuery.sort !== undefined && listQuery.sort !== 'id:asc') throw new BadRequestException('Le curseur requiert le tri id:asc.');\n const afterId = listQuery.after === undefined ? undefined : this.decodeCursor(listQuery.after);\n const builder = this.repository.createQueryBuilder('${name}');\n${body} if (afterId !== undefined) builder.andWhere('${name}.id > :afterId', { afterId });\n const values = await builder.addOrderBy('${name}.id', 'ASC').skip(afterId === undefined ? skip : 0).take(take + 1).getMany();\n const data = values.slice(0, take).map((value) => this.toDomain(value));\n const last = data.at(-1);\n return { data, nextCursor: values.length > take && last && (listQuery.sort ?? 'id:asc') === 'id:asc' ? this.encodeCursor(last.id) : undefined };\n }`;
|
|
635
|
+
return ` async findMany(skip: number, take: number, query: object): Promise<${className}[]> {\n const listQuery = query as List${className}Query;\n const builder = this.repository.createQueryBuilder('${name}');\n${body} builder.addOrderBy('${name}.id', 'ASC').skip(skip).take(take);\n return (await builder.getMany()).map((value) => this.toDomain(value));\n }`;
|
|
636
|
+
}
|
|
637
|
+
function prismaListMethod(className, list) {
|
|
638
|
+
const filters = Object.entries(list.filters)
|
|
639
|
+
.flatMap(([field, operators]) => operators.map((operator) => {
|
|
640
|
+
const property = queryProperty(field, operator);
|
|
641
|
+
const condition = operator === 'eq'
|
|
642
|
+
? `{ equals: query.${property} }`
|
|
643
|
+
: operator === 'neq'
|
|
644
|
+
? `{ not: query.${property} }`
|
|
645
|
+
: operator === 'contains'
|
|
646
|
+
? `{ contains: query.${property}, mode: 'insensitive' }`
|
|
647
|
+
: `{ ${operator}: query.${property} }`;
|
|
648
|
+
return ` if (query.${property} !== undefined) Object.assign(where, { ${field}: ${condition} });`;
|
|
649
|
+
}))
|
|
650
|
+
.join('\n');
|
|
651
|
+
const search = list.search.length
|
|
652
|
+
? ` if (query.q !== undefined) where.OR = [${list.search.map((field) => `{ ${field}: { contains: query.q, mode: 'insensitive' } }`).join(', ')}];\n`
|
|
653
|
+
: '';
|
|
654
|
+
const sort = list.sort.length
|
|
655
|
+
? ` const sortParts = (query.sort ?? 'id:asc').split(':');\n const [field, direction = 'asc'] = sortParts;\n if (sortParts.length > 2 || !${JSON.stringify(['id', ...list.sort])}.includes(field) || !['asc', 'desc'].includes(direction.toLowerCase())) throw new BadRequestException('Tri invalide.');\n const orderBy = [{ [field]: direction.toLowerCase() }, { id: 'asc' }];\n`
|
|
656
|
+
: ` const orderBy = [{ id: 'asc' }];\n`;
|
|
657
|
+
if (list.cursor)
|
|
658
|
+
return ` async findMany(skip: number, take: number, query: List${className}Query): Promise<{ data: ${className}[]; nextCursor?: string }> {\n if (query.after !== undefined && query.sort !== undefined && query.sort !== 'id:asc') throw new BadRequestException('Le curseur requiert le tri id:asc.');\n const afterId = query.after === undefined ? undefined : this.decodeCursor(query.after);\n const where: Record<string, unknown> = {};\n${filters ? `${filters}\n` : ''}${search}${sort} const values = await this.model.findMany({ skip: afterId === undefined ? skip : 1, take: take + 1, cursor: afterId === undefined ? undefined : { id: afterId }, where, orderBy } as never);\n const data = values.slice(0, take).map((value) => this.toDomain(value));\n const last = data.at(-1);\n return { data, nextCursor: values.length > take && last && (query.sort ?? 'id:asc') === 'id:asc' ? this.encodeCursor(last.id) : undefined };\n }`;
|
|
659
|
+
return ` async findMany(skip: number, take: number, query: List${className}Query): Promise<${className}[]> {\n const where: Record<string, unknown> = {};\n${filters ? `${filters}\n` : ''}${search}${sort} return (await this.model.findMany({ skip, take, where, orderBy } as never)).map((value) => this.toDomain(value));\n }`;
|
|
660
|
+
}
|
|
661
|
+
function manyToManyRelations(relations) {
|
|
662
|
+
return relations.filter((relation) => relation.type === 'manyToMany');
|
|
663
|
+
}
|
|
664
|
+
function relationMethodName(prefix, relation) {
|
|
665
|
+
return `${prefix}${pascal(relation.field)}`;
|
|
666
|
+
}
|
|
667
|
+
function authorizationContract(name) {
|
|
668
|
+
return `import { SetMetadata } from '@nestjs/common';
|
|
669
|
+
|
|
670
|
+
export const ResourcePolicyMetadata = 'nestgen:resource-policy';
|
|
671
|
+
export const resourceActions = ['create', 'list', 'read', 'update', 'delete'] as const;
|
|
672
|
+
export type ResourceActionName = (typeof resourceActions)[number];
|
|
673
|
+
export type ResourcePolicyRequirement = { resource: '${name}'; action: ResourceActionName };
|
|
674
|
+
export type OwnershipSubject = { ownerId?: string | null };
|
|
675
|
+
|
|
676
|
+
/** A small policy primitive for guards that implement owner-only access. */
|
|
677
|
+
export function isResourceOwner(actorId: string | undefined, subject: OwnershipSubject): boolean {
|
|
678
|
+
return typeof actorId === 'string' && actorId.length > 0 && actorId === subject.ownerId;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* Metadata for an application-provided guard. NestGen does not authenticate a
|
|
683
|
+
* request or register a guard: connect this contract to the existing identity
|
|
684
|
+
* and policy layer of the generated application.
|
|
685
|
+
*/
|
|
686
|
+
export const ResourceAction = (action: ResourceActionName) =>
|
|
687
|
+
SetMetadata(ResourcePolicyMetadata, { resource: '${name}', action } satisfies ResourcePolicyRequirement);
|
|
688
|
+
`;
|
|
689
|
+
}
|
|
690
|
+
function relationControllerMethods(relations, target) {
|
|
691
|
+
return manyToManyRelations(relations)
|
|
692
|
+
.map((relation) => `
|
|
693
|
+
|
|
694
|
+
@ResourceAction('update')
|
|
695
|
+
@Post(':id/${relation.field}/:targetId')
|
|
696
|
+
@HttpCode(204)
|
|
697
|
+
async ${relationMethodName('attach', relation)}(
|
|
698
|
+
@Param('id', new ParseUUIDPipe()) id: string,
|
|
699
|
+
@Param('targetId', new ParseUUIDPipe()) targetId: string,
|
|
700
|
+
): Promise<void> {
|
|
701
|
+
await this.${target}.${relationMethodName('attach', relation)}(id, targetId);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
@ResourceAction('update')
|
|
705
|
+
@Delete(':id/${relation.field}/:targetId')
|
|
706
|
+
@HttpCode(204)
|
|
707
|
+
async ${relationMethodName('detach', relation)}(
|
|
708
|
+
@Param('id', new ParseUUIDPipe()) id: string,
|
|
709
|
+
@Param('targetId', new ParseUUIDPipe()) targetId: string,
|
|
710
|
+
): Promise<void> {
|
|
711
|
+
await this.${target}.${relationMethodName('detach', relation)}(id, targetId);
|
|
712
|
+
}`)
|
|
713
|
+
.join('');
|
|
714
|
+
}
|
|
715
|
+
function typeOrmRelationMethods(className, relations) {
|
|
716
|
+
return manyToManyRelations(relations)
|
|
717
|
+
.map((relation) => `
|
|
718
|
+
|
|
719
|
+
async ${relationMethodName('attach', relation)}(id: string, targetId: string): Promise<void> {
|
|
720
|
+
await this.findOne(id);
|
|
721
|
+
try {
|
|
722
|
+
await this.repository
|
|
723
|
+
.createQueryBuilder()
|
|
724
|
+
.relation(${className}Entity, '${relation.field}')
|
|
725
|
+
.of(id)
|
|
726
|
+
.add(targetId);
|
|
727
|
+
} catch (error) {
|
|
728
|
+
this.rethrowPersistenceError(error);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
async ${relationMethodName('detach', relation)}(id: string, targetId: string): Promise<void> {
|
|
733
|
+
await this.findOne(id);
|
|
734
|
+
try {
|
|
735
|
+
await this.repository
|
|
736
|
+
.createQueryBuilder()
|
|
737
|
+
.relation(${className}Entity, '${relation.field}')
|
|
738
|
+
.of(id)
|
|
739
|
+
.remove(targetId);
|
|
740
|
+
} catch (error) {
|
|
741
|
+
this.rethrowPersistenceError(error);
|
|
742
|
+
}
|
|
743
|
+
}`)
|
|
744
|
+
.join('');
|
|
745
|
+
}
|
|
746
|
+
function prismaRelationMethods(relations) {
|
|
747
|
+
return manyToManyRelations(relations)
|
|
748
|
+
.map((relation) => `
|
|
749
|
+
|
|
750
|
+
async ${relationMethodName('attach', relation)}(id: string, targetId: string): Promise<void> {
|
|
751
|
+
try {
|
|
752
|
+
await this.model.update({ where: { id }, data: { ${relation.field}: { connect: { id: targetId } } } } as never);
|
|
753
|
+
} catch (error) {
|
|
754
|
+
this.handle(error);
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
async ${relationMethodName('detach', relation)}(id: string, targetId: string): Promise<void> {
|
|
759
|
+
try {
|
|
760
|
+
await this.model.update({ where: { id }, data: { ${relation.field}: { disconnect: { id: targetId } } } } as never);
|
|
761
|
+
} catch (error) {
|
|
762
|
+
this.handle(error);
|
|
763
|
+
}
|
|
764
|
+
}`)
|
|
765
|
+
.join('');
|
|
766
|
+
}
|
|
767
|
+
function addTypeOrmInverseRelations(projectRoot, sourceRoot, name, className, relations) {
|
|
768
|
+
const byTarget = new Map();
|
|
769
|
+
for (const relation of relations) {
|
|
770
|
+
const group = byTarget.get(relation.target) ?? [];
|
|
771
|
+
group.push(relation);
|
|
772
|
+
byTarget.set(relation.target, group);
|
|
773
|
+
}
|
|
774
|
+
return [...byTarget.entries()].map(([target, targetRelations]) => {
|
|
775
|
+
const relative = `${sourceRoot}/app/${target}/persistence/${target}.entity.ts`;
|
|
776
|
+
const source = fs.readFileSync(projectPath(projectRoot, relative), 'utf8');
|
|
777
|
+
const decoratorImport = /import\s*\{\s*([\s\S]*?)\s*\}\s*from 'typeorm';/;
|
|
778
|
+
const importMatch = decoratorImport.exec(source);
|
|
779
|
+
if (!importMatch)
|
|
780
|
+
throw new Error(`L'entité cible ${targetRelations[0].targetClassName}Entity utilise un import TypeORM non supporté.`);
|
|
781
|
+
const decorators = new Set(importMatch[1]
|
|
782
|
+
.split(',')
|
|
783
|
+
.map((item) => item.trim())
|
|
784
|
+
.filter(Boolean));
|
|
785
|
+
if (targetRelations.some((relation) => relation.type === 'belongsTo'))
|
|
786
|
+
decorators.add('OneToMany');
|
|
787
|
+
if (targetRelations.some((relation) => relation.type === 'manyToMany')) {
|
|
788
|
+
decorators.add('ManyToMany');
|
|
789
|
+
decorators.add('type Relation');
|
|
790
|
+
}
|
|
791
|
+
const decoratorNames = [...decorators].sort();
|
|
792
|
+
const decoratorStatement = `import { ${decoratorNames.join(', ')} } from 'typeorm';`;
|
|
793
|
+
const decoratorReplacement = decoratorStatement.length <= 80
|
|
794
|
+
? decoratorStatement
|
|
795
|
+
: `import {\n${decoratorNames.map((decorator) => ` ${decorator},`).join('\n')}\n} from 'typeorm';`;
|
|
796
|
+
const withDecorator = source.replace(decoratorImport, decoratorReplacement);
|
|
797
|
+
const childImport = `import { ${className}Entity } from '../../${name}/persistence/${name}.entity.js';\n`;
|
|
798
|
+
if (withDecorator.includes(childImport))
|
|
799
|
+
throw new Error(`L'entité cible ${targetRelations[0].targetClassName}Entity référence déjà ${className}Entity.`);
|
|
800
|
+
const withImport = withDecorator.replace(/\n\n/, `\n${childImport}\n`);
|
|
801
|
+
const indentation = /\n(\s+)@PrimaryGeneratedColumn/.exec(withImport)?.[1] ?? ' ';
|
|
802
|
+
const property = `\n\n${targetRelations
|
|
803
|
+
.map((relation) => {
|
|
804
|
+
if (new RegExp(`\\b${relation.inverse}\\b`).test(source))
|
|
805
|
+
throw new Error(`La propriété ${relation.inverse} existe déjà sur ${relation.targetClassName}Entity.`);
|
|
806
|
+
return relation.type === 'manyToMany'
|
|
807
|
+
? `${indentation}@ManyToMany(() => ${className}Entity, (${camel(name)}) => ${camel(name)}.${relation.field})\n${indentation}${relation.inverse}!: Relation<${className}Entity>[];`
|
|
808
|
+
: `${indentation}@OneToMany(() => ${className}Entity, (${camel(name)}) => ${camel(name)}.${relation.field})\n${indentation}${relation.inverse}!: ${className}Entity[];`;
|
|
809
|
+
})
|
|
810
|
+
.join('\n\n')}`;
|
|
811
|
+
const end = withImport.lastIndexOf('\n}');
|
|
812
|
+
if (end === -1)
|
|
813
|
+
throw new Error(`L'entité cible ${targetRelations[0].targetClassName}Entity est invalide.`);
|
|
814
|
+
return {
|
|
815
|
+
path: relative,
|
|
816
|
+
content: `${withImport.slice(0, end)}${property}${withImport.slice(end)}`,
|
|
817
|
+
operation: 'replace',
|
|
818
|
+
};
|
|
819
|
+
});
|
|
820
|
+
}
|
|
821
|
+
function prismaResourceFiles(name, className, fields, route, relations, list) {
|
|
822
|
+
const files = new Map();
|
|
823
|
+
const properties = fields
|
|
824
|
+
.map((field) => ` ${field.name}${field.nullable ? '?' : '!'}: ${typescriptType(field)}${field.nullable ? ' | null' : ''};`)
|
|
825
|
+
.join('\n');
|
|
826
|
+
const input = fields
|
|
827
|
+
.map((field) => `...((input as Record<string, unknown>).${field.name} === undefined ? {} : { ${field.name}${field.type === 'date' ? `: (input as Record<string, unknown>).${field.name} === null ? null : new Date((input as Record<string, unknown>).${field.name} as string)` : `: (input as Record<string, unknown>).${field.name}`} })`)
|
|
828
|
+
.join(', ');
|
|
829
|
+
const decimalMapping = fields
|
|
830
|
+
.filter((field) => field.type === 'decimal')
|
|
831
|
+
.map((field) => `${field.name}: value.${field.name} === null ? null : String(value.${field.name})`)
|
|
832
|
+
.join(', ');
|
|
833
|
+
files.set(`domain/${name}.ts`, `export class ${className} {\n id!: string;\n${properties}\n}\n`);
|
|
834
|
+
files.set(`dto/create-${name}.dto.ts`, `import { ${validationImports(fields, false)} } from 'class-validator';\n\nexport class Create${className}Dto {\n${dtoFields(fields, false)}\n}\n`);
|
|
835
|
+
files.set(`dto/update-${name}.dto.ts`, `import { ${validationImports(fields, true)} } from 'class-validator';\n\nexport class Update${className}Dto {\n${dtoFields(fields, true)}\n}\n`);
|
|
836
|
+
files.set(`dto/list-${name}.query.ts`, listQueryDto(className, list, fields));
|
|
837
|
+
files.set(`persistence/${name}.repository.ts`, `import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';\nimport { ${className} as ${className}Record, Prisma } from '@prisma/client';\nimport { PrismaService } from '../../../prisma/prisma.service.js';\nimport { ${className} } from '../domain/${name}.js';\n\n@Injectable()\nexport class ${className}Repository {\n constructor(private readonly prisma: PrismaService) {}\n private readonly model = this.prisma.${className[0].toLowerCase() + className.slice(1)};\n async create(input: Record<string, unknown>): Promise<${className}> { try { return this.toDomain(await this.model.create({ data: { ${input} } } as never)); } catch (error) { this.handle(error); } }\n async findOne(id: string): Promise<${className}> { const value = await this.model.findUnique({ where: { id } }); if (!value) throw new NotFoundException('${className} introuvable'); return this.toDomain(value); }\n async findMany(skip: number, take: number): Promise<${className}[]> { return (await this.model.findMany({ skip, take, orderBy: { id: 'asc' } })).map((value) => this.toDomain(value)); }\n async update(id: string, input: Record<string, unknown>): Promise<${className}> { try { return this.toDomain(await this.model.update({ where: { id }, data: { ${input} } } as never)); } catch (error) { this.handle(error); } }\n async remove(id: string): Promise<void> { try { await this.model.delete({ where: { id } }); } catch (error) { this.handle(error); } }${prismaRelationMethods(relations)}\n private toDomain(value: ${className}Record): ${className} { return Object.assign(new ${className}(), value${decimalMapping ? `, { ${decimalMapping} }` : ''}); }\n private handle(error: unknown): never { if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') throw new ConflictException('Une ressource avec cette valeur unique existe déjà.'); if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2003') throw new ConflictException('Cette ressource est référencée par une autre ressource.'); if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2025') throw new NotFoundException('${className} introuvable'); throw error; }\n}\n`);
|
|
838
|
+
files.set(`${name}.controller.ts`, `import { Body, ConflictException, Controller, Delete, Get, HttpCode, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';\nimport { ResourceAction } from './authorization/resource-policy.js';\nimport { Create${className}Dto } from './dto/create-${name}.dto.js';\nimport { List${className}Query } from './dto/list-${name}.query.js';\nimport { Update${className}Dto } from './dto/update-${name}.dto.js';\nimport { ${className}Repository } from './persistence/${name}.repository.js';\n\n@Controller('${route}')\nexport class ${className}Controller {\n constructor(private readonly repository: ${className}Repository) {}\n @ResourceAction('create')\n @Post() create(@Body() dto: Create${className}Dto) { return this.repository.create(dto); }\n @ResourceAction('list')\n @Get() async list(@Query() query: List${className}Query) { return { page: query.page, limit: query.limit, data: await this.repository.findMany((query.page - 1) * query.limit, query.limit) }; }\n @ResourceAction('read')\n @Get(':id') get(@Param('id', new ParseUUIDPipe()) id: string) { return this.repository.findOne(id); }\n @ResourceAction('update')\n @Patch(':id') update(@Param('id', new ParseUUIDPipe()) id: string, @Body() dto: Update${className}Dto) { return this.repository.update(id, dto); }\n @ResourceAction('delete')\n @Delete(':id') @HttpCode(204) remove(@Param('id', new ParseUUIDPipe()) id: string) { return this.repository.remove(id); }${relationControllerMethods(relations, 'repository')}\n}\n`);
|
|
839
|
+
files.set('authorization/resource-policy.ts', authorizationContract(name));
|
|
840
|
+
files.set(`${name}.module.ts`, `import { Module } from '@nestjs/common';\nimport { PrismaModule } from '../../prisma/prisma.module.js';\nimport { ${className}Controller } from './${name}.controller.js';\nimport { ${className}Repository } from './persistence/${name}.repository.js';\n\n@Module({ imports: [PrismaModule], controllers: [${className}Controller], providers: [${className}Repository] })\nexport class ${className}Module {}\n`);
|
|
841
|
+
const repositoryPath = `persistence/${name}.repository.ts`;
|
|
842
|
+
files.set(repositoryPath, files
|
|
843
|
+
.get(repositoryPath)
|
|
844
|
+
.replace(`private readonly model = this.prisma.${className[0].toLowerCase() + className.slice(1)};`, `private get model() { return this.prisma.${className[0].toLowerCase() + className.slice(1)}; }`)
|
|
845
|
+
.replaceAll('input: Record<string, unknown>', 'input: object'));
|
|
846
|
+
const hasListFeatures = list.cursor || Object.keys(list.filters).length > 0 || list.search.length > 0 || list.sort.length > 0;
|
|
847
|
+
if (hasListFeatures) {
|
|
848
|
+
files.set(repositoryPath, files
|
|
849
|
+
.get(repositoryPath)
|
|
850
|
+
.replace(`import { ${className} } from '../domain/${name}.js';`, `${list.cursor ? "import { Buffer } from 'node:buffer';\n" : ''}import { ${className} } from '../domain/${name}.js';\nimport { List${className}Query } from '../dto/list-${name}.query.js';`)
|
|
851
|
+
.replace(`import { ${className}Entity } from './${name}.entity.js';`, `import { BadRequestException } from '@nestjs/common';\nimport { ${className}Entity } from './${name}.entity.js';`)
|
|
852
|
+
.replace(`import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';`, `import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';`)
|
|
853
|
+
.replace(/ async findMany[\s\S]*?\n async update/, `${prismaListMethod(className, list)}\n\n async update`)
|
|
854
|
+
.replace(' private toDomain', list.cursor ? `${cursorHelpers()}\n\n private toDomain` : ' private toDomain'));
|
|
855
|
+
}
|
|
856
|
+
const controllerPath = `${name}.controller.ts`;
|
|
857
|
+
files.set(controllerPath, files
|
|
858
|
+
.get(controllerPath)
|
|
859
|
+
.replace('ConflictException, ', '')
|
|
860
|
+
.replace('this.repository.create(dto)', 'this.repository.create(dto as object)')
|
|
861
|
+
.replace('this.repository.update(id, dto)', 'this.repository.update(id, dto as object)'));
|
|
862
|
+
if (hasListFeatures) {
|
|
863
|
+
files.set(controllerPath, files
|
|
864
|
+
.get(controllerPath)
|
|
865
|
+
.replace('this.repository.findMany((query.page - 1) * query.limit, query.limit)', 'this.repository.findMany((query.page - 1) * query.limit, query.limit, query)'));
|
|
866
|
+
}
|
|
867
|
+
if (list.cursor) {
|
|
868
|
+
files.set(controllerPath, files
|
|
869
|
+
.get(controllerPath)
|
|
870
|
+
.replace('return { page: query.page, limit: query.limit, data: await this.repository.findMany((query.page - 1) * query.limit, query.limit, query) };', 'const result = await this.repository.findMany((query.page - 1) * query.limit, query.limit, query);\n return { page: query.page, limit: query.limit, data: result.data, nextCursor: result.nextCursor };'));
|
|
871
|
+
}
|
|
872
|
+
return files;
|
|
873
|
+
}
|
|
874
|
+
function featureFiles(name, className, fields, route, table, profile, swagger, indexes, relations, list) {
|
|
58
875
|
const advanced = profile === 'advanced';
|
|
59
|
-
const entityProperties = fields.map(entityColumn).join('\n\n');
|
|
876
|
+
const entityProperties = fields.map((field) => entityColumn(field, className)).join('\n\n');
|
|
877
|
+
const belongsToRelations = relations.filter((relation) => relation.type === 'belongsTo');
|
|
878
|
+
const manyRelations = manyToManyRelations(relations);
|
|
879
|
+
const relationImports = relations
|
|
880
|
+
.map((relation) => `import { ${relation.targetClassName}Entity } from '../../${relation.target}/persistence/${relation.target}.entity.js';`)
|
|
881
|
+
.join('\n');
|
|
882
|
+
const relationProperties = relations
|
|
883
|
+
.map((relation) => relation.type === 'manyToMany'
|
|
884
|
+
? ` @ManyToMany(() => ${relation.targetClassName}Entity, (${camel(relation.target)}) => ${camel(relation.target)}.${relation.inverse})\n @JoinTable({ name: '${joinTableName(table, relation)}', joinColumn: { name: '${camel(name)}Id', referencedColumnName: 'id' }, inverseJoinColumn: { name: '${camel(relation.target)}Id', referencedColumnName: 'id' } })\n ${relation.field}!: Relation<${relation.targetClassName}Entity>[];`
|
|
885
|
+
: ` @ManyToOne(() => ${relation.targetClassName}Entity, (${relation.field}) => ${relation.field}.${relation.inverse}, { nullable: ${relation.nullable}, onDelete: '${relation.onDelete}' })\n @JoinColumn({ name: '${relationIdField(relation)}' })\n ${relation.field}${relation.nullable ? '?' : '!'}: Relation<${relation.targetClassName}Entity>${relation.nullable ? ' | null' : ''};`)
|
|
886
|
+
.join('\n\n');
|
|
887
|
+
const entityIndexes = indexes
|
|
888
|
+
.map((index) => `@Index('${index.unique ? uniqueIndexName(table, index.fields) : indexName(table, index.fields)}', [${index.fields.map((field) => `'${field}'`).join(', ')}]${index.unique ? ', { unique: true }' : ''})`)
|
|
889
|
+
.join('\n');
|
|
60
890
|
const fieldAssignments = propertyMap(fields);
|
|
61
891
|
const domainProperties = fields
|
|
62
892
|
.map((field) => ` ${field.name}!: ${typescriptType(field)}${field.nullable ? ' | null' : ''};`)
|
|
63
893
|
.join('\n');
|
|
64
894
|
const files = new Map();
|
|
65
895
|
files.set('domain/' + name + '.ts', `export class ${className} {\n id!: string;\n${domainProperties}\n}\n`);
|
|
66
|
-
files.set('dto/create-' + name + '.dto.ts', `import {
|
|
67
|
-
files.set('dto/update-' + name + '.dto.ts', `import {
|
|
68
|
-
files.set('dto/list-' + name + '.query.ts',
|
|
69
|
-
files.set('persistence/' + name + '.entity.ts', `import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';\n\n@Entity({ name: '${table}' })\nexport class ${className}Entity {\n @PrimaryGeneratedColumn('uuid')\n id!: string;\n\n${entityProperties}\n}\n`);
|
|
70
|
-
|
|
896
|
+
files.set('dto/create-' + name + '.dto.ts', `import { ${validationImports(fields, false)} } from 'class-validator';\n${swagger ? `import { ${swaggerImports(fields, false)} } from '@nestjs/swagger';\n` : ''}\nexport class Create${className}Dto {\n${dtoFields(fields, false, swagger)}\n}\n`);
|
|
897
|
+
files.set('dto/update-' + name + '.dto.ts', `import { ${validationImports(fields, true)} } from 'class-validator';\n${swagger ? `import { ${swaggerImports(fields, true)} } from '@nestjs/swagger';\n` : ''}\nexport class Update${className}Dto {\n${dtoFields(fields, true, swagger)}\n}\n`);
|
|
898
|
+
files.set('dto/list-' + name + '.query.ts', listQueryDto(className, list, fields));
|
|
899
|
+
files.set('persistence/' + name + '.entity.ts', `import { Column, Entity${indexes.length ? ', Index' : ''}${belongsToRelations.length ? ', JoinColumn, ManyToOne' : ''}${manyRelations.length ? ', JoinTable, ManyToMany' : ''}${relations.length ? ', type Relation' : ''}, PrimaryGeneratedColumn } from 'typeorm';\n${relationImports ? `${relationImports}\n` : ''}\n${entityIndexes ? `${entityIndexes}\n` : ''}@Entity({ name: '${table}' })\nexport class ${className}Entity {\n @PrimaryGeneratedColumn('uuid')\n id!: string;\n\n${entityProperties}${relationProperties ? `\n\n${relationProperties}` : ''}\n}\n`);
|
|
900
|
+
if (swagger) {
|
|
901
|
+
const queryPath = 'dto/list-' + name + '.query.ts';
|
|
902
|
+
const query = files.get(queryPath);
|
|
903
|
+
files.set(queryPath, `import { ApiPropertyOptional } from '@nestjs/swagger';\n` +
|
|
904
|
+
query
|
|
905
|
+
.replace(' @IsOptional()\n @Type(() => Number)\n @IsInt()\n @Min(1)\n page = 1;', ' @ApiPropertyOptional({ minimum: 1, default: 1, example: 1 })\n @IsOptional()\n @Type(() => Number)\n @IsInt()\n @Min(1)\n page = 1;')
|
|
906
|
+
.replace(' @IsOptional()\n @Type(() => Number)\n @IsInt()\n @Min(1)\n @Max(MAX_PAGE_SIZE)\n limit = 20;', ' @ApiPropertyOptional({ minimum: 1, maximum: MAX_PAGE_SIZE, default: 20, example: 20 })\n @IsOptional()\n @Type(() => Number)\n @IsInt()\n @Min(1)\n @Max(MAX_PAGE_SIZE)\n limit = 20;'));
|
|
907
|
+
}
|
|
908
|
+
const persistenceImports = advanced
|
|
909
|
+
? `import { Injectable } from '@nestjs/common';\nimport { InjectRepository } from '@nestjs/typeorm';\nimport { QueryFailedError, Repository } from 'typeorm';\nimport { Create${className}Input, Update${className}Input } from '../application/${name}.contract.js';\nimport { ${className}ConflictError, ${className}NotFoundError } from '../application/${name}.errors.js';\nimport { ${className}RepositoryPort } from '../application/ports/${name}.repository.port.js';\nimport { ${className} } from '../domain/${name}.js';\n`
|
|
910
|
+
: `import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';\nimport { InjectRepository } from '@nestjs/typeorm';\nimport { QueryFailedError, Repository } from 'typeorm';\nimport { Create${className}Dto } from '../dto/create-${name}.dto.js';\nimport { Update${className}Dto } from '../dto/update-${name}.dto.js';\nimport { ${className} } from '../domain/${name}.js';\n`;
|
|
911
|
+
const persistenceInputTypes = advanced ? `Create${className}Input` : `Create${className}Dto`;
|
|
912
|
+
const persistenceUpdateTypes = advanced ? `Update${className}Input` : `Update${className}Dto`;
|
|
913
|
+
const missingError = advanced
|
|
914
|
+
? `new ${className}NotFoundError('${className} introuvable')`
|
|
915
|
+
: `new NotFoundException('${className} introuvable')`;
|
|
916
|
+
const conflictError = advanced
|
|
917
|
+
? `new ${className}ConflictError('Une ressource avec cette valeur unique existe déjà.')`
|
|
918
|
+
: `new ConflictException('Une ressource avec cette valeur unique existe déjà.')`;
|
|
919
|
+
const foreignKeyConflictError = advanced
|
|
920
|
+
? `new ${className}ConflictError('Cette ressource est référencée par une autre ressource.')`
|
|
921
|
+
: `new ConflictException('Cette ressource est référencée par une autre ressource.')`;
|
|
922
|
+
files.set('persistence/' + name + '.repository.ts', `${persistenceImports}import { ${className}Entity } from './${name}.entity.js';\n\n@Injectable()\nexport class ${className}Repository${advanced ? ` implements ${className}RepositoryPort` : ''} {\n constructor(@InjectRepository(${className}Entity) private readonly repository: Repository<${className}Entity>) {}\n\n async create(input: ${persistenceInputTypes}): Promise<${className}> {\n try {\n return this.toDomain(await this.repository.save(this.repository.create({ ${fieldAssignments} })));\n } catch (error) {\n this.rethrowPersistenceError(error);\n }\n }\n\n async findOne(id: string): Promise<${className}> {\n const value = await this.repository.findOneBy({ id });\n if (!value) throw ${missingError};\n return this.toDomain(value);\n }\n\n async findMany(skip: number, take: number): Promise<${className}[]> {\n return (await this.repository.find({ skip, take, order: { id: 'ASC' } })).map((value) => this.toDomain(value));\n }\n\n async update(id: string, input: ${persistenceUpdateTypes}): Promise<${className}> {\n const existing = await this.repository.preload({ id, ${fieldAssignments} });\n if (!existing) throw ${missingError};\n try {\n return this.toDomain(await this.repository.save(existing));\n } catch (error) {\n this.rethrowPersistenceError(error);\n }\n }\n\n async remove(id: string): Promise<void> {\n try {\n const result = await this.repository.delete(id);\n if (!result.affected) throw ${missingError};\n } catch (error) {\n this.rethrowPersistenceError(error);\n }\n }\n\n private toDomain(value: ${className}Entity): ${className} {\n return Object.assign(new ${className}(), value);\n }\n\n private rethrowPersistenceError(error: unknown): never {\n if (error instanceof QueryFailedError && (error.driverError as { code?: string }).code === '23505')\n throw ${conflictError};\n if (error instanceof QueryFailedError && (error.driverError as { code?: string }).code === '23503')\n throw ${foreignKeyConflictError};\n throw error;\n }\n}\n`);
|
|
923
|
+
const hasListFeatures = list.cursor || Object.keys(list.filters).length > 0 || list.search.length > 0 || list.sort.length > 0;
|
|
924
|
+
if (hasListFeatures) {
|
|
925
|
+
const repositoryPath = 'persistence/' + name + '.repository.ts';
|
|
926
|
+
files.set(repositoryPath, files
|
|
927
|
+
.get(repositoryPath)
|
|
928
|
+
.replace(`import { ${className} } from '../domain/${name}.js';`, `${list.cursor ? "import { Buffer } from 'node:buffer';\n" : ''}import { ${className} } from '../domain/${name}.js';\nimport { List${className}Query } from '../dto/list-${name}.query.js';`)
|
|
929
|
+
.replace("import { Injectable } from '@nestjs/common';", "import { BadRequestException, Injectable } from '@nestjs/common';")
|
|
930
|
+
.replace("import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';", "import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';")
|
|
931
|
+
.replace(/ async findMany[\s\S]*?\n async update/, `${typeOrmListMethod(className, name, list)}\n\n async update`)
|
|
932
|
+
.replace(' private toDomain', list.cursor ? `${cursorHelpers()}\n\n private toDomain` : ' private toDomain'));
|
|
933
|
+
}
|
|
934
|
+
if (manyRelations.length) {
|
|
935
|
+
const persistencePath = 'persistence/' + name + '.repository.ts';
|
|
936
|
+
files.set(persistencePath, files
|
|
937
|
+
.get(persistencePath)
|
|
938
|
+
.replace(' private toDomain', `${typeOrmRelationMethods(className, relations)}\n\n private toDomain`));
|
|
939
|
+
}
|
|
71
940
|
if (advanced) {
|
|
72
|
-
|
|
73
|
-
files.set(
|
|
941
|
+
const contract = applicationContract(name, className, fields);
|
|
942
|
+
files.set(contract.path, contract.content);
|
|
943
|
+
files.set(`application/${name}.errors.ts`, `export class ${className}NotFoundError extends Error {\n constructor(message = '${className} introuvable') {\n super(message);\n this.name = '${className}NotFoundError';\n }\n}\n\nexport class ${className}ConflictError extends Error {\n constructor(message = 'Une ressource avec cette valeur unique existe déjà.') {\n super(message);\n this.name = '${className}ConflictError';\n }\n}\n`);
|
|
944
|
+
files.set(`application/ports/${name}.repository.port.ts`, `import { Create${className}Input, Update${className}Input } from '../${name}.contract.js';\nimport { ${className} } from '../../domain/${name}.js';\n\nexport const ${className}RepositoryToken = Symbol('${className}RepositoryPort');\n\nexport interface ${className}RepositoryPort {\n create(input: Create${className}Input): Promise<${className}>;\n findOne(id: string): Promise<${className}>;\n findMany(skip: number, take: number): Promise<${className}[]>;\n update(id: string, input: Update${className}Input): Promise<${className}>;\n remove(id: string): Promise<void>;\n}\n`);
|
|
945
|
+
files.set(`application/${name}.service.ts`, `import { Create${className}Input, ${className}Output, Update${className}Input } from './${name}.contract.js';\nimport { ${className}RepositoryPort } from './ports/${name}.repository.port.js';\nimport { ${className} } from '../domain/${name}.js';\n\nexport class ${className}Service {\n constructor(private readonly repository: ${className}RepositoryPort) {}\n\n async create(input: Create${className}Input): Promise<${className}Output> {\n return this.toOutput(await this.repository.create(input));\n }\n\n async findOne(id: string): Promise<${className}Output> {\n return this.toOutput(await this.repository.findOne(id));\n }\n\n async findMany(skip: number, take: number): Promise<${className}Output[]> {\n return (await this.repository.findMany(skip, take)).map((value) => this.toOutput(value));\n }\n\n async update(id: string, input: Update${className}Input): Promise<${className}Output> {\n return this.toOutput(await this.repository.update(id, input));\n }\n\n remove(id: string): Promise<void> {\n return this.repository.remove(id);\n }\n\n private toOutput(value: ${className}): ${className}Output {\n return { id: value.id, ${fields.map((field) => `${field.name}: value.${field.name}`).join(', ')} };\n }\n}\n`);
|
|
946
|
+
if (hasListFeatures) {
|
|
947
|
+
const portPath = `application/ports/${name}.repository.port.ts`;
|
|
948
|
+
const servicePath = `application/${name}.service.ts`;
|
|
949
|
+
files.set(portPath, files
|
|
950
|
+
.get(portPath)
|
|
951
|
+
.replace('findMany(skip: number, take: number):', 'findMany(skip: number, take: number, query: object):'));
|
|
952
|
+
files.set(servicePath, files
|
|
953
|
+
.get(servicePath)
|
|
954
|
+
.replace('findMany(skip: number, take: number):', 'findMany(skip: number, take: number, query: object):')
|
|
955
|
+
.replace('this.repository.findMany(skip, take)', 'this.repository.findMany(skip, take, query)'));
|
|
956
|
+
if (list.cursor) {
|
|
957
|
+
files.set(portPath, files
|
|
958
|
+
.get(portPath)
|
|
959
|
+
.replace(`findMany(skip: number, take: number, query: object): Promise<${className}[]>;`, `findMany(skip: number, take: number, query: object): Promise<{ data: ${className}[]; nextCursor?: string }>;`));
|
|
960
|
+
files.set(servicePath, files
|
|
961
|
+
.get(servicePath)
|
|
962
|
+
.replace(`async findMany(skip: number, take: number, query: object): Promise<${className}Output[]> {\n return (await this.repository.findMany(skip, take, query)).map((value) => this.toOutput(value));\n }`, `async findMany(skip: number, take: number, query: object): Promise<{ data: ${className}Output[]; nextCursor?: string }> {\n const result = await this.repository.findMany(skip, take, query);\n return { data: result.data.map((value) => this.toOutput(value)), nextCursor: result.nextCursor };\n }`));
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
if (manyRelations.length) {
|
|
966
|
+
const portPath = `application/ports/${name}.repository.port.ts`;
|
|
967
|
+
const servicePath = `application/${name}.service.ts`;
|
|
968
|
+
const portMethods = manyRelations
|
|
969
|
+
.flatMap((relation) => [
|
|
970
|
+
` ${relationMethodName('attach', relation)}(id: string, targetId: string): Promise<void>;`,
|
|
971
|
+
` ${relationMethodName('detach', relation)}(id: string, targetId: string): Promise<void>;`,
|
|
972
|
+
])
|
|
973
|
+
.join('\n');
|
|
974
|
+
const serviceMethods = manyRelations
|
|
975
|
+
.flatMap((relation) => [
|
|
976
|
+
` ${relationMethodName('attach', relation)}(id: string, targetId: string): Promise<void> { return this.repository.${relationMethodName('attach', relation)}(id, targetId); }`,
|
|
977
|
+
` ${relationMethodName('detach', relation)}(id: string, targetId: string): Promise<void> { return this.repository.${relationMethodName('detach', relation)}(id, targetId); }`,
|
|
978
|
+
])
|
|
979
|
+
.join('\n\n');
|
|
980
|
+
files.set(portPath, files
|
|
981
|
+
.get(portPath)
|
|
982
|
+
.replace(' remove(id: string): Promise<void>;', ` remove(id: string): Promise<void>;\n${portMethods}`));
|
|
983
|
+
files.set(servicePath, files.get(servicePath).replace(' private toOutput', `${serviceMethods}\n\n private toOutput`));
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
let controllerImports = advanced
|
|
987
|
+
? `import { Body, ConflictException, Controller, Delete, Get, HttpCode, NotFoundException, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';\nimport { ${className}ConflictError, ${className}NotFoundError } from './application/${name}.errors.js';\nimport { ${className}Service } from './application/${name}.service.js';\n`
|
|
988
|
+
: `import { Body, Controller, Delete, Get, HttpCode, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';\nimport { ${className}Repository } from './persistence/${name}.repository.js';\n`;
|
|
989
|
+
const controllerTarget = advanced ? 'service' : 'repository';
|
|
990
|
+
const controllerType = advanced ? `${className}Service` : `${className}Repository`;
|
|
991
|
+
const createArgument = advanced ? `{ ${applicationInputMap(fields, false)} }` : 'dto';
|
|
992
|
+
const updateArgument = advanced ? `{ ${applicationInputMap(fields, true)} }` : 'dto';
|
|
993
|
+
const errorBoundary = advanced
|
|
994
|
+
? `\n private async respond<T>(operation: Promise<T>): Promise<T> {\n try {\n return await operation;\n } catch (error) {\n if (error instanceof ${className}NotFoundError) throw new NotFoundException(error.message);\n if (error instanceof ${className}ConflictError) throw new ConflictException(error.message);\n throw error;\n }\n }\n`
|
|
995
|
+
: '';
|
|
996
|
+
files.set(name + '.controller.ts', `${controllerImports}import { ResourceAction } from './authorization/resource-policy.js';\nimport { Create${className}Dto } from './dto/create-${name}.dto.js';\nimport { List${className}Query } from './dto/list-${name}.query.js';\nimport { Update${className}Dto } from './dto/update-${name}.dto.js';\n\n@Controller('${route}')\nexport class ${className}Controller {\n constructor(private readonly ${controllerTarget}: ${controllerType}) {}\n\n @ResourceAction('create')\n @Post()\n create(@Body() dto: Create${className}Dto) { return ${advanced ? 'this.respond(' : ''}this.${controllerTarget}.create(${createArgument})${advanced ? ')' : ''}; }\n\n @ResourceAction('list')\n @Get()\n async list(@Query() query: List${className}Query) {\n const skip = (query.page - 1) * query.limit;\n return { page: query.page, limit: query.limit, data: await ${advanced ? 'this.respond(' : ''}this.${controllerTarget}.findMany(skip, query.limit)${advanced ? ')' : ''} };\n }\n\n @ResourceAction('read')\n @Get(':id')\n get(@Param('id', new ParseUUIDPipe()) id: string) { return ${advanced ? 'this.respond(' : ''}this.${controllerTarget}.findOne(id)${advanced ? ')' : ''}; }\n\n @ResourceAction('update')\n @Patch(':id')\n update(@Param('id', new ParseUUIDPipe()) id: string, @Body() dto: Update${className}Dto) { return ${advanced ? 'this.respond(' : ''}this.${controllerTarget}.update(id, ${updateArgument})${advanced ? ')' : ''}; }\n\n @ResourceAction('delete')\n @Delete(':id')\n @HttpCode(204)\n async remove(@Param('id', new ParseUUIDPipe()) id: string): Promise<void> { await ${advanced ? 'this.respond(' : ''}this.${controllerTarget}.remove(id)${advanced ? ')' : ''}; }${errorBoundary}}\n`);
|
|
997
|
+
files.set('authorization/resource-policy.ts', authorizationContract(name));
|
|
998
|
+
if (hasListFeatures) {
|
|
999
|
+
const controllerPath = name + '.controller.ts';
|
|
1000
|
+
files.set(controllerPath, files
|
|
1001
|
+
.get(controllerPath)
|
|
1002
|
+
.replace(`.${controllerTarget}.findMany(skip, query.limit)`, `.${controllerTarget}.findMany(skip, query.limit, query)`));
|
|
74
1003
|
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
1004
|
+
if (list.cursor) {
|
|
1005
|
+
const controllerPath = name + '.controller.ts';
|
|
1006
|
+
const invocation = advanced
|
|
1007
|
+
? `this.respond(this.${controllerTarget}.findMany(skip, query.limit, query))`
|
|
1008
|
+
: `this.${controllerTarget}.findMany(skip, query.limit, query)`;
|
|
1009
|
+
files.set(controllerPath, files
|
|
1010
|
+
.get(controllerPath)
|
|
1011
|
+
.replace(`return { page: query.page, limit: query.limit, data: await ${invocation} };`, `const result = await ${invocation};\n return { page: query.page, limit: query.limit, data: result.data, nextCursor: result.nextCursor };`));
|
|
1012
|
+
}
|
|
1013
|
+
if (manyRelations.length) {
|
|
1014
|
+
const controllerPath = name + '.controller.ts';
|
|
1015
|
+
const controller = files.get(controllerPath);
|
|
1016
|
+
const insertion = advanced ? controller.indexOf(errorBoundary) : controller.lastIndexOf('}');
|
|
1017
|
+
if (insertion === -1)
|
|
1018
|
+
throw new Error(`Contrôleur généré invalide pour ${className}.`);
|
|
1019
|
+
files.set(controllerPath, `${controller.slice(0, insertion)}${relationControllerMethods(relations, controllerTarget)}${controller.slice(insertion)}`);
|
|
1020
|
+
}
|
|
1021
|
+
if (swagger) {
|
|
1022
|
+
const controllerPath = name + '.controller.ts';
|
|
1023
|
+
const controller = files.get(controllerPath);
|
|
1024
|
+
files.set(controllerPath, `import { ApiBadRequestResponse, ApiConflictResponse, ApiCreatedResponse, ApiNoContentResponse, ApiNotFoundResponse, ApiOkResponse, ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger';\n` +
|
|
1025
|
+
controller
|
|
1026
|
+
.replace(`@Controller('${route}')`, `@ApiTags('${route}')\n@Controller('${route}')`)
|
|
1027
|
+
.replace(' @Post()\n', ` @ApiOperation({ summary: 'Créer ${name}' })\n @ApiCreatedResponse({ description: '${className} créé.' })\n @ApiBadRequestResponse({ description: 'Requête invalide.' })\n @ApiConflictResponse({ description: 'Valeur unique déjà utilisée.' })\n @Post()\n`)
|
|
1028
|
+
.replace(' @Get()\n', ` @ApiOperation({ summary: 'Lister ${name}' })\n @ApiOkResponse({ description: 'Page de résultats.' })\n @Get()\n`)
|
|
1029
|
+
.replaceAll(" @Get(':id')\n", ` @ApiParam({ name: 'id', format: 'uuid' })\n @ApiOkResponse({ description: '${className} trouvé.' })\n @ApiNotFoundResponse({ description: '${className} introuvable.' })\n @Get(':id')\n`)
|
|
1030
|
+
.replaceAll(" @Patch(':id')\n", ` @ApiParam({ name: 'id', format: 'uuid' })\n @ApiOkResponse({ description: '${className} mis à jour.' })\n @ApiBadRequestResponse({ description: 'Requête invalide.' })\n @ApiNotFoundResponse({ description: '${className} introuvable.' })\n @Patch(':id')\n`)
|
|
1031
|
+
.replace(" @Delete(':id')\n", ` @ApiParam({ name: 'id', format: 'uuid' })\n @ApiNoContentResponse({ description: '${className} supprimé.' })\n @ApiNotFoundResponse({ description: '${className} introuvable.' })\n @Delete(':id')\n`));
|
|
1032
|
+
}
|
|
1033
|
+
files.set(name + '.module.ts', `import { Module } from '@nestjs/common';\nimport { TypeOrmModule } from '@nestjs/typeorm';\nimport { ${className}Controller } from './${name}.controller.js';\n${advanced ? `import { ${className}Service } from './application/${name}.service.js';\nimport { ${className}RepositoryToken } from './application/ports/${name}.repository.port.js';\n` : ''}import { ${className}Entity } from './persistence/${name}.entity.js';\nimport { ${className}Repository } from './persistence/${name}.repository.js';\n\n@Module({\n imports: [TypeOrmModule.forFeature([${className}Entity])],\n controllers: [${className}Controller],\n providers: [${className}Repository${advanced ? `, { provide: ${className}RepositoryToken, useExisting: ${className}Repository }, { provide: ${className}Service, useFactory: (repository: ${className}Repository) => new ${className}Service(repository), inject: [${className}RepositoryToken] }` : ''}],\n})\nexport class ${className}Module {}\n`);
|
|
1034
|
+
const unitTest = generatedUnitTest(name, className, fields, advanced);
|
|
1035
|
+
const unitTestContent = unitTest.content
|
|
1036
|
+
.replaceAll('jest.fn(),', 'jest.fn<() => Promise<unknown>>(),')
|
|
1037
|
+
.replaceAll('jest.fn((value) => value)', 'jest.fn((value: unknown) => value)')
|
|
1038
|
+
.replace(`new ${className}Service(repository)`, `new ${className}Service(repository as never)`)
|
|
1039
|
+
.replaceAll('beforeEach(() => jest.resetAllMocks());', 'beforeEach(() => { jest.resetAllMocks(); });');
|
|
1040
|
+
files.set(unitTest.path, "import { beforeEach, describe, expect, it, jest } from '@jest/globals';\n" + unitTestContent);
|
|
78
1041
|
return files;
|
|
79
1042
|
}
|
|
80
|
-
export function
|
|
1043
|
+
export async function planResourceGeneration(projectRoot, options) {
|
|
81
1044
|
const profile = parseArchitectureProfile(options.profile);
|
|
82
1045
|
const orm = options.orm ?? 'typeorm';
|
|
83
|
-
if (orm !== 'typeorm')
|
|
84
|
-
throw new Error('
|
|
85
|
-
const name = options.name.toLowerCase();
|
|
86
|
-
|
|
87
|
-
const directory = path.join(projectRoot, 'src', 'app', name);
|
|
88
|
-
const appModulePath = path.join(projectRoot, 'src', 'app.module.ts');
|
|
89
|
-
if (!/^[a-z][a-z0-9-]*$/.test(name))
|
|
1046
|
+
if (orm !== 'typeorm' && orm !== 'prisma')
|
|
1047
|
+
throw new Error('ORM non supporté.');
|
|
1048
|
+
const name = options.name.trim().toLowerCase().replaceAll('_', '-');
|
|
1049
|
+
if (!/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(name))
|
|
90
1050
|
throw new Error('Nom de ressource invalide.');
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
const
|
|
96
|
-
const
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
fs.
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
1051
|
+
const className = pascal(name);
|
|
1052
|
+
const project = inspectProject(projectRoot, orm, { cqrs: false, application: options.application });
|
|
1053
|
+
projectRoot = project.root;
|
|
1054
|
+
const featureDirectory = projectPath(projectRoot, `${project.sourceRoot}/app/${name}`);
|
|
1055
|
+
const appModulePath = project.appModulePath;
|
|
1056
|
+
const relations = resolveRelations(projectRoot, project.sourceRoot, name, orm, options.fields, options.relations);
|
|
1057
|
+
const fields = [...options.fields, ...relationFields(relations)];
|
|
1058
|
+
const indexes = resolvedIndexes(fields, options.indexes);
|
|
1059
|
+
const list = options.list ?? { cursor: false, filters: {}, sort: [], search: [] };
|
|
1060
|
+
if (orm === 'prisma') {
|
|
1061
|
+
const files = prismaResourceFiles(name, className, fields, options.route, relations, list);
|
|
1062
|
+
const appModule = registerModuleInAppModule(fs.readFileSync(appModulePath, 'utf8'), `${className}Module`, `./app/${name}/${name}.module.js`, `${className}Module`);
|
|
1063
|
+
const schemaPath = projectPath(projectRoot, 'prisma/schema.prisma');
|
|
1064
|
+
const changes = [...files].map(([relative, content]) => ({
|
|
1065
|
+
path: `${project.sourceRoot}/app/${name}/${relative}`,
|
|
1066
|
+
content,
|
|
1067
|
+
operation: 'create',
|
|
1068
|
+
}));
|
|
1069
|
+
changes.push({
|
|
1070
|
+
path: 'prisma/schema.prisma',
|
|
1071
|
+
content: prismaSchema(fs.readFileSync(schemaPath, 'utf8'), className, options.table, fields, indexes, relations),
|
|
1072
|
+
operation: 'replace',
|
|
1073
|
+
});
|
|
1074
|
+
changes.push(...prismaRuntime(projectRoot, project.sourceRoot));
|
|
1075
|
+
changes.push({
|
|
1076
|
+
path: `${project.sourceRoot}/app/${name}/resource.json`,
|
|
1077
|
+
content: `${JSON.stringify({ name, route: options.route, table: options.table, orm, profile, fields: options.fields, indexes, relations: relationManifest(relations), list }, null, 2)}\n`,
|
|
1078
|
+
operation: 'create',
|
|
1079
|
+
});
|
|
1080
|
+
changes.push({ path: `${project.sourceRoot}/app.module.ts`, content: appModule, operation: 'replace' });
|
|
1081
|
+
const formattedChanges = await formatGeneratedCode(projectRoot, changes);
|
|
1082
|
+
const updateConflicts = options.update
|
|
1083
|
+
? resourceUpdateConflicts(projectRoot, project.sourceRoot, name)
|
|
1084
|
+
: new Set();
|
|
1085
|
+
const updateChanges = options.update
|
|
1086
|
+
? updateResourceChanges(projectRoot, project.sourceRoot, name, formattedChanges)
|
|
1087
|
+
: formattedChanges;
|
|
1088
|
+
const changesWithManifest = withGenerationManifest(projectRoot, updateChanges, resourceGenerationDefinition(name, orm, project.sourceRoot, options.route, options.table, profile, options.fields, indexes, relationManifest(relations), list));
|
|
1089
|
+
const preview = previewFileChanges(projectRoot, changesWithManifest).map((change) => updateConflicts.has(change.path)
|
|
1090
|
+
? { ...change, status: 'conflict', reason: `Modification manuelle détectée : ${change.path}` }
|
|
1091
|
+
: change);
|
|
1092
|
+
return {
|
|
1093
|
+
root: projectRoot,
|
|
1094
|
+
changes: changesWithManifest,
|
|
1095
|
+
preview,
|
|
1096
|
+
featureConflict: fs.existsSync(featureDirectory)
|
|
1097
|
+
? options.update
|
|
1098
|
+
? undefined
|
|
1099
|
+
: `Le module ou la ressource ${name} existe déjà.`
|
|
1100
|
+
: undefined,
|
|
1101
|
+
};
|
|
116
1102
|
}
|
|
1103
|
+
const manifest = JSON.parse(fs.readFileSync(projectPath(projectRoot, 'package.json'), 'utf8'));
|
|
1104
|
+
const swagger = Boolean(manifest.dependencies?.['@nestjs/swagger'] ?? manifest.devDependencies?.['@nestjs/swagger']);
|
|
1105
|
+
const files = featureFiles(name, className, fields, options.route, options.table, profile, swagger, indexes, relations, list);
|
|
1106
|
+
const restTest = generatedRestTest(name, className, fields, options.route, relations, project.sourceRoot);
|
|
1107
|
+
const e2eSupport = e2eSupportFiles(projectRoot);
|
|
1108
|
+
const appModule = registerModuleInAppModule(fs.readFileSync(appModulePath, 'utf8'), `${className}Module`, `./app/${name}/${name}.module.js`, `${className}Module`);
|
|
1109
|
+
const changes = [...files].map(([relative, content]) => ({
|
|
1110
|
+
path: `${project.sourceRoot}/app/${name}/${relative}`,
|
|
1111
|
+
content,
|
|
1112
|
+
operation: 'create',
|
|
1113
|
+
}));
|
|
1114
|
+
changes.push({ path: restTest.path, content: restTest.content, operation: 'create' });
|
|
1115
|
+
changes.push(...[...e2eSupport].map(([file, content]) => ({ path: file, content, operation: 'create' })));
|
|
1116
|
+
changes.push(...addTypeOrmInverseRelations(projectRoot, project.sourceRoot, name, className, relations));
|
|
1117
|
+
changes.push({
|
|
1118
|
+
path: `${project.sourceRoot}/app/${name}/resource.json`,
|
|
1119
|
+
content: `${JSON.stringify({ name, route: options.route, table: options.table, orm, profile, fields: options.fields, indexes, relations: relationManifest(relations), list }, null, 2)}\n`,
|
|
1120
|
+
operation: 'create',
|
|
1121
|
+
});
|
|
1122
|
+
changes.push({ path: `${project.sourceRoot}/app.module.ts`, content: appModule, operation: 'replace' });
|
|
1123
|
+
const formattedChanges = await formatGeneratedCode(projectRoot, changes);
|
|
1124
|
+
const updateConflicts = options.update
|
|
1125
|
+
? resourceUpdateConflicts(projectRoot, project.sourceRoot, name)
|
|
1126
|
+
: new Set();
|
|
1127
|
+
const updateChanges = options.update
|
|
1128
|
+
? updateResourceChanges(projectRoot, project.sourceRoot, name, formattedChanges)
|
|
1129
|
+
: formattedChanges;
|
|
1130
|
+
const changesWithManifest = withGenerationManifest(projectRoot, updateChanges, resourceGenerationDefinition(name, orm, project.sourceRoot, options.route, options.table, profile, options.fields, indexes, relationManifest(relations), list));
|
|
1131
|
+
const preview = previewFileChanges(projectRoot, changesWithManifest).map((change) => updateConflicts.has(change.path)
|
|
1132
|
+
? { ...change, status: 'conflict', reason: `Modification manuelle détectée : ${change.path}` }
|
|
1133
|
+
: change);
|
|
1134
|
+
return {
|
|
1135
|
+
root: projectRoot,
|
|
1136
|
+
changes: changesWithManifest,
|
|
1137
|
+
preview,
|
|
1138
|
+
featureConflict: fs.existsSync(featureDirectory)
|
|
1139
|
+
? options.update
|
|
1140
|
+
? undefined
|
|
1141
|
+
: `Le module ou la ressource ${name} existe déjà.`
|
|
1142
|
+
: undefined,
|
|
1143
|
+
};
|
|
1144
|
+
}
|
|
1145
|
+
export async function generateResource(projectRoot, options) {
|
|
1146
|
+
const plan = await planResourceGeneration(projectRoot, options);
|
|
1147
|
+
if (plan.featureConflict)
|
|
1148
|
+
throw new Error(plan.featureConflict);
|
|
1149
|
+
const conflict = plan.preview.find((change) => change.status === 'conflict');
|
|
1150
|
+
if (conflict)
|
|
1151
|
+
throw new Error(conflict.reason);
|
|
1152
|
+
applyFileChanges(plan.root, plan.changes);
|
|
117
1153
|
}
|