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.
Files changed (33) hide show
  1. package/dist/cli/doctor-output.js +45 -0
  2. package/dist/cli/output.js +65 -0
  3. package/dist/engine/file-transaction.js +166 -0
  4. package/dist/engine/generated-code.js +26 -0
  5. package/dist/engine/generation-manifest.js +118 -0
  6. package/dist/engine/migration-plan.js +78 -0
  7. package/dist/engine/module-generator.js +80 -65
  8. package/dist/engine/project-config.js +5 -2
  9. package/dist/engine/project-path.js +25 -0
  10. package/dist/engine/project-preflight.js +96 -0
  11. package/dist/engine/resource-generator.js +1104 -68
  12. package/dist/engine/resource-spec.js +254 -11
  13. package/examples/store-api/README.md +76 -0
  14. package/examples/store-api/definitions/customers.resource.json +14 -0
  15. package/examples/store-api/definitions/order-items.resource.json +26 -0
  16. package/examples/store-api/definitions/orders.resource.json +22 -0
  17. package/examples/store-api/definitions/products.resource.json +19 -0
  18. package/examples/store-api/nestgen.example.json +7 -0
  19. package/nestgen.js +488 -84
  20. package/nestjs-generator/features/configure_bootstrap.mjs +5 -2
  21. package/nestjs-generator/features/docker.sh +6 -4
  22. package/nestjs-generator/features/git.sh +1 -1
  23. package/nestjs-generator/features/inject_module_to_app.sh +8 -6
  24. package/nestjs-generator/features/logger.sh +8 -4
  25. package/nestjs-generator/features/operational_foundation.mjs +210 -0
  26. package/nestjs-generator/features/operations.sh +18 -0
  27. package/nestjs-generator/features/preflight.mjs +2 -44
  28. package/nestjs-generator/features/prisma.sh +3 -3
  29. package/nestjs-generator/features/typeorm.sh +3 -3
  30. package/nestjs-generator/features/utils.sh +5 -3
  31. package/nestjs-generator/generate_project.sh +15 -9
  32. package/package.json +11 -4
  33. package/readme.md +234 -13
@@ -1,117 +1,1153 @@
1
1
  import fs from 'node:fs';
2
- import path from 'node:path';
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 entityColumn(field) {
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 === 'boolean'
16
- ? "'boolean'"
17
- : field.type === 'date'
18
- ? "'timestamptz'"
19
- : field.type === 'uuid'
20
- ? "'uuid'"
21
- : "'varchar'";
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 = optional ? ['@IsOptional()'] : [];
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
- ? '@IsBoolean()'
33
- : field.type === 'date'
34
- ? '@IsDateString()'
35
- : field.type === 'uuid'
36
- ? '@IsUUID()'
37
- : '@IsString()');
38
- return decorators.map((decorator) => ` ${decorator}`).join('\n');
39
- }
40
- function dtoFields(fields, optional) {
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 `${validationDecorators(field, isOptional)}\n ${field.name}${isOptional ? '?' : '!'}: ${type}${field.nullable ? ' | null' : ''};`;
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
- return `${field.name}: ${field.type === 'date' ? `${value} ? new Date(${value}) : ${value}` : value}`;
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 featureFiles(name, className, fields, route, table, profile) {
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 { IsBoolean, IsDateString, IsNumber, IsOptional, IsString, IsUUID } from 'class-validator';\n\nexport class Create${className}Dto {\n${dtoFields(fields, false)}\n}\n`);
67
- files.set('dto/update-' + name + '.dto.ts', `import { IsBoolean, IsDateString, IsNumber, IsOptional, IsString, IsUUID } from 'class-validator';\n\nexport class Update${className}Dto {\n${dtoFields(fields, true)}\n}\n`);
68
- files.set('dto/list-' + name + '.query.ts', `import { Type } from 'class-transformer';\nimport { IsInt, IsOptional, Max, Min } 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;\n}\n`);
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
- files.set('persistence/' + name + '.repository.ts', `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${advanced ? `import { ${className}RepositoryPort } from '../domain/${name}.repository.port.js';\n` : ''}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: Create${className}Dto): 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 new NotFoundException('${className} introuvable');\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: Update${className}Dto): Promise<${className}> {\n const existing = await this.repository.preload({ id, ${fieldAssignments} });\n if (!existing) throw new NotFoundException('${className} introuvable');\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 const result = await this.repository.delete(id);\n if (!result.affected) throw new NotFoundException('${className} introuvable');\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 new ConflictException('Une ressource avec cette valeur unique existe déjà.');\n throw error;\n }\n}\n`);
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
- files.set('domain/' + name + '.repository.port.ts', `import { Create${className}Dto } from '../dto/create-${name}.dto.js';\nimport { Update${className}Dto } from '../dto/update-${name}.dto.js';\nimport { ${className} } from './${name}.js';\n\nexport const ${className}RepositoryToken = Symbol('${className}RepositoryPort');\nexport interface ${className}RepositoryPort { create(input: Create${className}Dto): Promise<${className}>; findOne(id: string): Promise<${className}>; findMany(skip: number, take: number): Promise<${className}[]>; update(id: string, input: Update${className}Dto): Promise<${className}>; remove(id: string): Promise<void>; }\n`);
73
- files.set('application/' + name + '.service.ts', `import { Inject, Injectable } from '@nestjs/common';\nimport { Create${className}Dto } from '../dto/create-${name}.dto.js';\nimport { Update${className}Dto } from '../dto/update-${name}.dto.js';\nimport { ${className}RepositoryPort, ${className}RepositoryToken } from '../domain/${name}.repository.port.js';\n\n@Injectable() export class ${className}Service { constructor(@Inject(${className}RepositoryToken) private readonly repository: ${className}RepositoryPort) {} create(input: Create${className}Dto) { return this.repository.create(input); } findOne(id: string) { return this.repository.findOne(id); } findMany(skip: number, take: number) { return this.repository.findMany(skip, take); } update(id: string, input: Update${className}Dto) { return this.repository.update(id, input); } remove(id: string) { return this.repository.remove(id); } }\n`);
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
- files.set(name + '.controller.ts', `import { Body, Controller, Delete, Get, HttpCode, Param, Patch, Post, Query } from '@nestjs/common';\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}${advanced ? 'Service' : 'Repository'} } from './${advanced ? 'application/' + name + '.service' : 'persistence/' + name + '.repository'}.js';\n\n@Controller('${route}')\nexport class ${className}Controller {\n constructor(private readonly repository: ${className}${advanced ? 'Service' : 'Repository'}) {}\n\n @Post()\n create(@Body() dto: Create${className}Dto) { return this.repository.create(dto); }\n\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 this.repository.findMany(skip, query.limit) };\n }\n\n @Get(':id')\n get(@Param('id') id: string) { return this.repository.findOne(id); }\n\n @Patch(':id')\n update(@Param('id') id: string, @Body() dto: Update${className}Dto) { return this.repository.update(id, dto); }\n\n @Delete(':id')\n @HttpCode(204)\n async remove(@Param('id') id: string): Promise<void> { await this.repository.remove(id); }\n}\n`);
76
- 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 './domain/${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 }, ${className}Service` : ''}],\n})\nexport class ${className}Module {}\n`);
77
- files.set('persistence/' + name + '.prisma', `model ${className} {\n id String @id @default(uuid())\n${fields.map((field) => ` ${field.name} ${prismaType(field)}`).join('\n')}\n\n @@map("${table}")\n}\n`);
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 generateResource(projectRoot, options) {
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('La commande resource prend actuellement en charge TypeORM uniquement.');
85
- const name = options.name.toLowerCase();
86
- const className = pascal(name);
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
- if (fs.existsSync(directory))
92
- throw new Error(`La ressource ${name} existe déjà.`);
93
- if (!fs.existsSync(appModulePath))
94
- throw new Error('src/app.module.ts introuvable.');
95
- const files = featureFiles(name, className, options.fields, options.route, options.table, profile);
96
- const appModule = registerModuleInAppModule(fs.readFileSync(appModulePath, 'utf8'), `${className}Module`, `./app/${name}/${name}.module.js`, `${className}Module`);
97
- const stage = path.join(projectRoot, `.nestgen-resource-${name}-${process.pid}`);
98
- try {
99
- for (const [relative, content] of files) {
100
- const target = path.join(stage, relative);
101
- fs.mkdirSync(path.dirname(target), { recursive: true });
102
- fs.writeFileSync(target, content);
103
- }
104
- fs.writeFileSync(path.join(stage, 'resource.json'), `${JSON.stringify({ name, route: options.route, table: options.table, orm, profile, fields: options.fields }, null, 2)}\n`);
105
- fs.mkdirSync(path.dirname(directory), { recursive: true });
106
- fs.renameSync(stage, directory);
107
- fs.writeFileSync(appModulePath, appModule);
108
- }
109
- catch (error) {
110
- if (fs.existsSync(directory))
111
- fs.rmSync(directory, { recursive: true, force: true });
112
- throw error;
113
- }
114
- finally {
115
- fs.rmSync(stage, { recursive: true, force: true });
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
  }