@tertium/prisma-codegen 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +236 -0
- package/client/client.test.ts +275 -0
- package/client/client.ts +328 -0
- package/client/client.types.ts +33 -0
- package/dmmf/dmmf.types.ts +53 -0
- package/dmmf/dmmf.utils.ts +125 -0
- package/package.json +35 -0
- package/scripts/generate-client.ts +131 -0
- package/scripts/generate-server.ts +110 -0
- package/server/server.test.ts +352 -0
- package/server/server.ts +766 -0
- package/server/server.types.ts +72 -0
package/server/server.ts
ADDED
|
@@ -0,0 +1,766 @@
|
|
|
1
|
+
import type { DMMFField, DMMFModel } from '../dmmf/dmmf.types';
|
|
2
|
+
import type {
|
|
3
|
+
EntityMetadata,
|
|
4
|
+
Field,
|
|
5
|
+
ForeignKeyField,
|
|
6
|
+
GraphQLResolverConfig,
|
|
7
|
+
LocalizationConfig,
|
|
8
|
+
MetadataInferrerOptions,
|
|
9
|
+
Model,
|
|
10
|
+
RestHandlerConfig,
|
|
11
|
+
RestRouterConfig,
|
|
12
|
+
TypesGeneratorOptions,
|
|
13
|
+
} from './server.types';
|
|
14
|
+
|
|
15
|
+
// ── Schema parser ─────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
export function parsePrismaModels(dmmfModels: readonly DMMFModel[]): Model[] {
|
|
18
|
+
return dmmfModels.map((m) => ({
|
|
19
|
+
name: m.name,
|
|
20
|
+
dbName: m.dbName ?? m.name,
|
|
21
|
+
fields: m.fields.map(_mapDMMFField),
|
|
22
|
+
}));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function parseForeignKeys(dmmfModel: DMMFModel): ForeignKeyField[] {
|
|
26
|
+
const fkFields: ForeignKeyField[] = [];
|
|
27
|
+
for (const field of dmmfModel.fields) {
|
|
28
|
+
if (field.kind !== 'object') continue;
|
|
29
|
+
if (!field.relationFromFields?.length) continue;
|
|
30
|
+
const fkFieldName = field.relationFromFields[0];
|
|
31
|
+
const fkField = dmmfModel.fields.find((f) => f.name === fkFieldName);
|
|
32
|
+
fkFields.push({
|
|
33
|
+
fieldName: fkFieldName,
|
|
34
|
+
relationName: field.name,
|
|
35
|
+
isRequired: fkField ? fkField.isRequired : true,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
return fkFields;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function toKebabCase(str: string): string {
|
|
42
|
+
return str.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function toCamelCase(str: string): string {
|
|
46
|
+
return str.charAt(0).toLowerCase() + str.slice(1);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function prismaToTsType(prismaType: string): string {
|
|
50
|
+
const map: Record<string, string> = {
|
|
51
|
+
String: 'string',
|
|
52
|
+
Int: 'number',
|
|
53
|
+
Float: 'number',
|
|
54
|
+
Boolean: 'boolean',
|
|
55
|
+
DateTime: 'Date',
|
|
56
|
+
BigInt: 'number',
|
|
57
|
+
Decimal: 'number',
|
|
58
|
+
Json: 'any',
|
|
59
|
+
Bytes: 'string',
|
|
60
|
+
};
|
|
61
|
+
return map[prismaType] ?? 'string';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function _mapDMMFField(f: DMMFField): Field {
|
|
65
|
+
return {
|
|
66
|
+
name: f.name,
|
|
67
|
+
type: f.type,
|
|
68
|
+
required: f.isRequired,
|
|
69
|
+
isId: f.isId,
|
|
70
|
+
isRelation: f.kind === 'object',
|
|
71
|
+
isArray: f.isList,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── Metadata inferrer ─────────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
export function inferEntityMetadata(
|
|
78
|
+
dmmfModels: readonly DMMFModel[],
|
|
79
|
+
options: MetadataInferrerOptions = {},
|
|
80
|
+
): Record<string, EntityMetadata> {
|
|
81
|
+
const { skipFilterableFields = [], searchableFieldPatterns = [], enumLikeIntPatterns = [] } = options;
|
|
82
|
+
const skipSet = new Set(skipFilterableFields);
|
|
83
|
+
const metadata: Record<string, EntityMetadata> = {};
|
|
84
|
+
|
|
85
|
+
for (const model of dmmfModels) {
|
|
86
|
+
const filterable: Record<string, 'contains' | 'equals'> = {};
|
|
87
|
+
const searchableFields: string[] = [];
|
|
88
|
+
const includeRelations: string[] = [];
|
|
89
|
+
|
|
90
|
+
for (const field of model.fields) {
|
|
91
|
+
if (field.kind === 'object') {
|
|
92
|
+
if (!field.isList && field.relationFromFields?.length) continue;
|
|
93
|
+
includeRelations.push(field.name);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (field.kind !== 'scalar') continue;
|
|
97
|
+
|
|
98
|
+
const { name, type } = field;
|
|
99
|
+
if (type === 'String' && !name.endsWith('Id')) {
|
|
100
|
+
if (!skipSet.has(name)) filterable[name] = 'contains';
|
|
101
|
+
if (searchableFieldPatterns.some((p) => p.test(name))) searchableFields.push(name);
|
|
102
|
+
} else if (name.endsWith('Id') && !skipSet.has(name)) {
|
|
103
|
+
filterable[name] = 'equals';
|
|
104
|
+
} else if (type === 'Boolean') {
|
|
105
|
+
filterable[name] = 'equals';
|
|
106
|
+
} else if (type === 'Int' && enumLikeIntPatterns.some((p) => p.test(name))) {
|
|
107
|
+
filterable[name] = 'equals';
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const fieldNames = model.fields.map((f) => f.name);
|
|
112
|
+
let orderBy = 'createdAt';
|
|
113
|
+
if (fieldNames.includes('name')) orderBy = 'name';
|
|
114
|
+
else if (fieldNames.includes('title')) orderBy = 'title';
|
|
115
|
+
|
|
116
|
+
if (Object.keys(filterable).length > 0 || searchableFields.length > 0 || includeRelations.length > 0) {
|
|
117
|
+
metadata[model.name] = {
|
|
118
|
+
...(Object.keys(filterable).length > 0 && { filterable }),
|
|
119
|
+
...(searchableFields.length > 0 && { searchableFields }),
|
|
120
|
+
...(includeRelations.length > 0 && { includeRelations }),
|
|
121
|
+
orderBy,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return metadata;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── Types generator ───────────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
const DEFAULT_SKIP_INPUT_FIELDS = new Set(['id', 'createdAt', 'updatedAt']);
|
|
132
|
+
|
|
133
|
+
export function generateEntityTypesContent(model: Model, options: TypesGeneratorOptions = {}): string {
|
|
134
|
+
const skipInputFields = options.skipInputFields ? new Set(options.skipInputFields) : DEFAULT_SKIP_INPUT_FIELDS;
|
|
135
|
+
const scalarFields = model.fields.filter((f) => !f.isRelation);
|
|
136
|
+
const relationFields = model.fields.filter((f) => f.isRelation);
|
|
137
|
+
|
|
138
|
+
const mainFields = [
|
|
139
|
+
...scalarFields.map((f) => ` ${f.name}${f.required ? '' : '?'}: ${prismaToTsType(f.type)};`),
|
|
140
|
+
...relationFields.map((f) =>
|
|
141
|
+
f.isArray ? ` ${f.name}${f.required ? '' : '?'}: ${f.type}[];` : ` ${f.name}${f.required ? '' : '?'}: ${f.type} | null;`,
|
|
142
|
+
),
|
|
143
|
+
].join('\n');
|
|
144
|
+
|
|
145
|
+
const inputFields = scalarFields
|
|
146
|
+
.filter((f) => !skipInputFields.has(f.name))
|
|
147
|
+
.map((f) => ` ${f.name}${f.required ? '' : '?'}: ${prismaToTsType(f.type)};`)
|
|
148
|
+
.join('\n');
|
|
149
|
+
|
|
150
|
+
return `/**
|
|
151
|
+
* ${model.name} Types
|
|
152
|
+
* Auto-generated from Prisma schema - DO NOT EDIT
|
|
153
|
+
*/
|
|
154
|
+
|
|
155
|
+
export interface ${model.name} {
|
|
156
|
+
${mainFields}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export interface ${model.name}Input {
|
|
160
|
+
${inputFields}
|
|
161
|
+
}
|
|
162
|
+
`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ── GraphQL metadata generator ────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
export function generateGraphQLMetadataFileContent(metadata: Record<string, EntityMetadata>): string {
|
|
168
|
+
return `/**
|
|
169
|
+
* GraphQL Entity Metadata - Auto-generated
|
|
170
|
+
* DO NOT EDIT - regenerate with your codegen script
|
|
171
|
+
*/
|
|
172
|
+
|
|
173
|
+
export type EntityMetadata = {
|
|
174
|
+
filterable?: Record<string, 'contains' | 'equals'>;
|
|
175
|
+
searchableFields?: string[];
|
|
176
|
+
includeRelations?: string[];
|
|
177
|
+
orderBy?: string;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
export const GRAPHQL_ENTITY_METADATA: Record<string, EntityMetadata> = ${JSON.stringify(metadata, null, 2)};
|
|
181
|
+
`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function generateGraphQLContextTypesContent(extraFields?: Record<string, string>): string {
|
|
185
|
+
const extra = extraFields
|
|
186
|
+
? Object.entries(extraFields)
|
|
187
|
+
.map(([k, v]) => ` ${k}?: ${v};`)
|
|
188
|
+
.join('\n')
|
|
189
|
+
: '';
|
|
190
|
+
|
|
191
|
+
return `/**
|
|
192
|
+
* GraphQL Context Types - Auto-generated
|
|
193
|
+
* DO NOT EDIT - regenerate with your codegen script
|
|
194
|
+
*/
|
|
195
|
+
|
|
196
|
+
export interface GraphQLResolverContext {
|
|
197
|
+
userId?: string;
|
|
198
|
+
isAdmin?: boolean;
|
|
199
|
+
userRoles?: string[];
|
|
200
|
+
${extra ? extra + '\n' : ''}}\n`;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ── GraphQL resolvers generator ───────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
export function generateGraphQLResolversContent(
|
|
206
|
+
metadata: Record<string, EntityMetadata>,
|
|
207
|
+
dmmfModels: readonly DMMFModel[],
|
|
208
|
+
config: GraphQLResolverConfig,
|
|
209
|
+
): string {
|
|
210
|
+
const {
|
|
211
|
+
prismaClientPath,
|
|
212
|
+
prismaClientExport = 'PrismaClient',
|
|
213
|
+
contextTypePath,
|
|
214
|
+
contextTypeExport = 'GraphQLResolverContext',
|
|
215
|
+
localization,
|
|
216
|
+
} = config;
|
|
217
|
+
|
|
218
|
+
const dmmfModelMap = new Map(dmmfModels.map((m) => [m.name, m]));
|
|
219
|
+
const modelForeignKeys = new Map<string, ForeignKeyField[]>();
|
|
220
|
+
for (const modelName of Object.keys(metadata)) {
|
|
221
|
+
const dmmfModel = dmmfModelMap.get(modelName);
|
|
222
|
+
if (dmmfModel) {
|
|
223
|
+
const fks = parseForeignKeys(dmmfModel);
|
|
224
|
+
if (fks.length > 0) modelForeignKeys.set(modelName, fks);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const transformFunctions = Array.from(modelForeignKeys.entries())
|
|
229
|
+
.map(([name, fks]) => _buildTransformFunction(name, fks))
|
|
230
|
+
.filter(Boolean)
|
|
231
|
+
.join('\n\n');
|
|
232
|
+
|
|
233
|
+
const queryResolvers =
|
|
234
|
+
Object.entries(metadata)
|
|
235
|
+
.map(([name, meta]) => _buildSingleResolver(name, meta, localization))
|
|
236
|
+
.join('\n') +
|
|
237
|
+
'\n' +
|
|
238
|
+
Object.entries(metadata)
|
|
239
|
+
.map(([name, meta]) => _buildListResolver(name, meta, localization))
|
|
240
|
+
.join('\n');
|
|
241
|
+
|
|
242
|
+
const mutationResolvers =
|
|
243
|
+
Object.entries(metadata)
|
|
244
|
+
.map(([name, meta]) => _buildCreateResolver(name, meta, modelForeignKeys.get(name)))
|
|
245
|
+
.join('\n') +
|
|
246
|
+
'\n' +
|
|
247
|
+
Object.entries(metadata)
|
|
248
|
+
.map(([name, meta]) => _buildUpdateResolver(name, meta, modelForeignKeys.get(name)))
|
|
249
|
+
.join('\n') +
|
|
250
|
+
'\n' +
|
|
251
|
+
Object.keys(metadata)
|
|
252
|
+
.map(_buildDeleteResolver)
|
|
253
|
+
.join('\n');
|
|
254
|
+
|
|
255
|
+
const localizeExport = localization?.localizeExport ?? 'localizeEntity';
|
|
256
|
+
const localizationImport = localization
|
|
257
|
+
? `\nimport { ${localizeExport} } from '${localization.localizeImport}';`
|
|
258
|
+
: '';
|
|
259
|
+
|
|
260
|
+
return `/**
|
|
261
|
+
* GraphQL Resolvers - Auto-generated
|
|
262
|
+
* DO NOT EDIT - regenerate with your codegen script
|
|
263
|
+
*/
|
|
264
|
+
|
|
265
|
+
import { ${prismaClientExport} } from '${prismaClientPath}';
|
|
266
|
+
import type { ${contextTypeExport} } from '${contextTypePath}';${localizationImport}
|
|
267
|
+
|
|
268
|
+
function isValidUUID(id: string): boolean {
|
|
269
|
+
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function validateInputIDs(input: any): string | null {
|
|
273
|
+
if (!input || typeof input !== 'object') return null;
|
|
274
|
+
for (const [key, value] of Object.entries(input)) {
|
|
275
|
+
if (key.endsWith('Id') && value !== null && value !== undefined) {
|
|
276
|
+
if (typeof value !== 'string' || !isValidUUID(value as string)) {
|
|
277
|
+
return \`Invalid ID format for field '\${key}' - must be a valid UUID\`;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
${transformFunctions ? transformFunctions + '\n' : ''}
|
|
285
|
+
export interface ResolverContext extends ${contextTypeExport} {
|
|
286
|
+
prisma: ${prismaClientExport};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export const resolvers = {
|
|
290
|
+
Query: {
|
|
291
|
+
${queryResolvers}
|
|
292
|
+
},
|
|
293
|
+
Mutation: {
|
|
294
|
+
${mutationResolvers}
|
|
295
|
+
},
|
|
296
|
+
};
|
|
297
|
+
`;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function _buildTransformFunction(modelName: string, fkFields: ForeignKeyField[]): string {
|
|
301
|
+
if (fkFields.length === 0) return '';
|
|
302
|
+
const transforms = fkFields
|
|
303
|
+
.map((field) => {
|
|
304
|
+
if (field.isRequired) {
|
|
305
|
+
return ` if ('${field.fieldName}' in result) {
|
|
306
|
+
const value = result.${field.fieldName};
|
|
307
|
+
delete result.${field.fieldName};
|
|
308
|
+
if (value !== undefined && value !== null) {
|
|
309
|
+
result.${field.relationName} = { connect: { id: value } };
|
|
310
|
+
}
|
|
311
|
+
}`;
|
|
312
|
+
}
|
|
313
|
+
return ` if ('${field.fieldName}' in result) {
|
|
314
|
+
const value = result.${field.fieldName};
|
|
315
|
+
delete result.${field.fieldName};
|
|
316
|
+
if (value !== undefined && value !== null) {
|
|
317
|
+
result.${field.relationName} = { connect: { id: value } };
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if ('${field.relationName}' in result && result.${field.relationName} === null) {
|
|
322
|
+
delete result.${field.relationName};
|
|
323
|
+
}`;
|
|
324
|
+
})
|
|
325
|
+
.join('\n\n');
|
|
326
|
+
|
|
327
|
+
return `function transform${modelName}InputToPrisma(input: any): any {
|
|
328
|
+
if (!input) return input;
|
|
329
|
+
const result = { ...input };
|
|
330
|
+
|
|
331
|
+
${transforms}
|
|
332
|
+
|
|
333
|
+
return result;
|
|
334
|
+
}`;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function _buildFilterLogicGQL(metadata: EntityMetadata): string {
|
|
338
|
+
if (!metadata.filterable && !metadata.searchableFields?.length) return 'const where: any = {};';
|
|
339
|
+
|
|
340
|
+
let code = 'const where: any = {};\n\n if (filter) {';
|
|
341
|
+
for (const [field, mode] of Object.entries(metadata.filterable ?? {})) {
|
|
342
|
+
if (mode === 'contains') {
|
|
343
|
+
code += `\n if (filter.${field}) where.${field} = typeof filter.${field} === 'string' ? { contains: filter.${field}, mode: 'insensitive' } : filter.${field};`;
|
|
344
|
+
} else {
|
|
345
|
+
code += `\n if (filter.${field}) where.${field} = filter.${field};`;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
if (metadata.searchableFields?.length) {
|
|
349
|
+
code += `\n if (filter.search) {\n where.OR = [`;
|
|
350
|
+
for (const field of metadata.searchableFields) {
|
|
351
|
+
code += `\n { ${field}: { contains: filter.search, mode: 'insensitive' } },`;
|
|
352
|
+
}
|
|
353
|
+
code += `\n ];\n }`;
|
|
354
|
+
}
|
|
355
|
+
code += '\n }';
|
|
356
|
+
return code;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function _buildInclude(metadata: EntityMetadata): string {
|
|
360
|
+
if (!metadata.includeRelations?.length) return '';
|
|
361
|
+
return `include: {\n ${metadata.includeRelations.map((r) => `${r}: true`).join(',\n ')},\n },`;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function _buildSingleResolver(modelName: string, metadata: EntityMetadata, localization?: LocalizationConfig): string {
|
|
365
|
+
const camelCase = toCamelCase(modelName);
|
|
366
|
+
const includeLogic = _buildInclude(metadata);
|
|
367
|
+
const localizeExport = localization?.localizeExport ?? 'localizeEntity';
|
|
368
|
+
const args = localization ? `{ id, lang }: { id: string; lang?: string }` : `{ id }: { id: string }`;
|
|
369
|
+
const returnLogic = localization
|
|
370
|
+
? `\n if (data && lang) {\n return await ${localizeExport}(data, '${modelName}', lang);\n }\n return data;`
|
|
371
|
+
: `\n return data;`;
|
|
372
|
+
|
|
373
|
+
return `
|
|
374
|
+
${camelCase}: async (
|
|
375
|
+
_: any,
|
|
376
|
+
${args},
|
|
377
|
+
{ prisma }: ResolverContext,
|
|
378
|
+
) => {
|
|
379
|
+
if (!isValidUUID(id)) throw new Error('Invalid ID format - must be a valid UUID');
|
|
380
|
+
try {
|
|
381
|
+
const data = await (prisma as any).${camelCase}.findUnique({
|
|
382
|
+
where: { id },
|
|
383
|
+
${includeLogic}
|
|
384
|
+
});
|
|
385
|
+
${returnLogic}
|
|
386
|
+
} catch (error) {
|
|
387
|
+
console.error('GraphQL error in ${camelCase} query:', error);
|
|
388
|
+
throw error;
|
|
389
|
+
}
|
|
390
|
+
},`;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function _buildListResolver(modelName: string, metadata: EntityMetadata, localization?: LocalizationConfig): string {
|
|
394
|
+
const camelCase = toCamelCase(modelName);
|
|
395
|
+
const filterLogic = _buildFilterLogicGQL(metadata);
|
|
396
|
+
const includeLogic = _buildInclude(metadata);
|
|
397
|
+
const orderBy = metadata.orderBy || 'createdAt';
|
|
398
|
+
const localizeExport = localization?.localizeExport ?? 'localizeEntity';
|
|
399
|
+
const args = localization
|
|
400
|
+
? `{ filter, pagination, lang }: { filter?: any; pagination?: any; lang?: string }`
|
|
401
|
+
: `{ filter, pagination }: { filter?: any; pagination?: any }`;
|
|
402
|
+
const returnLogic = localization
|
|
403
|
+
? `\n let localizedData = data;\n if (lang) {\n localizedData = await Promise.all(\n data.map((item: any) => ${localizeExport}(item, '${modelName}', lang)),\n );\n }\n return { data: localizedData, total };`
|
|
404
|
+
: `\n return { data, total };`;
|
|
405
|
+
|
|
406
|
+
return `
|
|
407
|
+
${camelCase}List: async (
|
|
408
|
+
_: any,
|
|
409
|
+
${args},
|
|
410
|
+
{ prisma }: ResolverContext,
|
|
411
|
+
) => {
|
|
412
|
+
try {
|
|
413
|
+
${filterLogic}
|
|
414
|
+
|
|
415
|
+
if (pagination?.limit !== undefined && typeof pagination.limit !== 'number') {
|
|
416
|
+
throw new Error('Invalid pagination parameter: limit must be a positive integer');
|
|
417
|
+
}
|
|
418
|
+
if (pagination?.offset !== undefined && typeof pagination.offset !== 'number') {
|
|
419
|
+
throw new Error('Invalid pagination parameter: offset must be a non-negative integer');
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const limit = Math.min(Math.max(pagination?.limit || 50, 1), 1000);
|
|
423
|
+
const offset = Math.max(pagination?.offset || 0, 0);
|
|
424
|
+
|
|
425
|
+
const [data, total] = await Promise.all([
|
|
426
|
+
(prisma as any).${camelCase}.findMany({
|
|
427
|
+
where,
|
|
428
|
+
${includeLogic}
|
|
429
|
+
take: limit,
|
|
430
|
+
skip: offset,
|
|
431
|
+
orderBy: { ${orderBy}: 'asc' },
|
|
432
|
+
}),
|
|
433
|
+
(prisma as any).${camelCase}.count({ where }),
|
|
434
|
+
]);
|
|
435
|
+
${returnLogic}
|
|
436
|
+
} catch (error) {
|
|
437
|
+
console.error('GraphQL error in ${camelCase}List query:', error);
|
|
438
|
+
throw error;
|
|
439
|
+
}
|
|
440
|
+
},`;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function _buildCreateResolver(modelName: string, metadata: EntityMetadata, fkFields?: ForeignKeyField[]): string {
|
|
444
|
+
const camelCase = toCamelCase(modelName);
|
|
445
|
+
const includeLogic = _buildInclude(metadata);
|
|
446
|
+
const data = fkFields?.length ? `transform${modelName}InputToPrisma(input)` : 'input';
|
|
447
|
+
|
|
448
|
+
return `
|
|
449
|
+
create${modelName}: async (
|
|
450
|
+
_: any,
|
|
451
|
+
{ input }: { input: any },
|
|
452
|
+
{ prisma }: ResolverContext,
|
|
453
|
+
) => {
|
|
454
|
+
const idError = validateInputIDs(input);
|
|
455
|
+
if (idError) throw new Error(idError);
|
|
456
|
+
try {
|
|
457
|
+
return await (prisma as any).${camelCase}.create({
|
|
458
|
+
data: ${data},
|
|
459
|
+
${includeLogic}
|
|
460
|
+
});
|
|
461
|
+
} catch (error) {
|
|
462
|
+
console.error('GraphQL error in create${modelName} mutation:', error);
|
|
463
|
+
throw error;
|
|
464
|
+
}
|
|
465
|
+
},`;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function _buildUpdateResolver(modelName: string, metadata: EntityMetadata, fkFields?: ForeignKeyField[]): string {
|
|
469
|
+
const camelCase = toCamelCase(modelName);
|
|
470
|
+
const includeLogic = _buildInclude(metadata);
|
|
471
|
+
const data = fkFields?.length ? `transform${modelName}InputToPrisma(input)` : 'input';
|
|
472
|
+
|
|
473
|
+
return `
|
|
474
|
+
update${modelName}: async (
|
|
475
|
+
_: any,
|
|
476
|
+
{ id, input }: { id: string; input: any },
|
|
477
|
+
{ prisma }: ResolverContext,
|
|
478
|
+
) => {
|
|
479
|
+
if (!isValidUUID(id)) throw new Error('Invalid ID format - must be a valid UUID');
|
|
480
|
+
const idError = validateInputIDs(input);
|
|
481
|
+
if (idError) throw new Error(idError);
|
|
482
|
+
try {
|
|
483
|
+
return await (prisma as any).${camelCase}.update({
|
|
484
|
+
where: { id },
|
|
485
|
+
data: ${data},
|
|
486
|
+
${includeLogic}
|
|
487
|
+
});
|
|
488
|
+
} catch (error) {
|
|
489
|
+
console.error('GraphQL error in update${modelName} mutation:', error);
|
|
490
|
+
throw error;
|
|
491
|
+
}
|
|
492
|
+
},`;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function _buildDeleteResolver(modelName: string): string {
|
|
496
|
+
const camelCase = toCamelCase(modelName);
|
|
497
|
+
return `
|
|
498
|
+
delete${modelName}: async (
|
|
499
|
+
_: any,
|
|
500
|
+
{ id }: { id: string },
|
|
501
|
+
{ prisma }: ResolverContext,
|
|
502
|
+
) => {
|
|
503
|
+
if (!isValidUUID(id)) throw new Error('Invalid ID format - must be a valid UUID');
|
|
504
|
+
try {
|
|
505
|
+
await (prisma as any).${camelCase}.delete({ where: { id } });
|
|
506
|
+
return true;
|
|
507
|
+
} catch (error) {
|
|
508
|
+
console.error('GraphQL error in delete${modelName} mutation:', error);
|
|
509
|
+
throw error;
|
|
510
|
+
}
|
|
511
|
+
},`;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// ── REST handler generator ────────────────────────────────────────────────────
|
|
515
|
+
|
|
516
|
+
export function generateRestHandlerContent(
|
|
517
|
+
modelName: string,
|
|
518
|
+
metadata: EntityMetadata,
|
|
519
|
+
config: RestHandlerConfig,
|
|
520
|
+
): string {
|
|
521
|
+
const camelCase = toCamelCase(modelName);
|
|
522
|
+
const filterLogic = _buildFilterLogicREST(metadata);
|
|
523
|
+
const orderBy = metadata.orderBy || 'createdAt';
|
|
524
|
+
const { localization } = config;
|
|
525
|
+
const localizeExport = localization?.localizeExport ?? 'localizeEntity';
|
|
526
|
+
|
|
527
|
+
const localizationImport = localization ? `import { ${localizeExport} } from '${localization.localizeImport}';\n` : '';
|
|
528
|
+
const listSignature = localization ? `list${modelName}s(req: Request, lang?: string)` : `list${modelName}s(req: Request)`;
|
|
529
|
+
const localizeList = localization
|
|
530
|
+
? `\n const localizedData = lang\n ? await Promise.all(data.map((item: any) => ${localizeExport}(item, '${modelName}', lang)))\n : data;`
|
|
531
|
+
: '';
|
|
532
|
+
const listData = localization ? 'localizedData' : 'data';
|
|
533
|
+
const getSignature = localization ? `get${modelName}(id: string, lang?: string)` : `get${modelName}(id: string)`;
|
|
534
|
+
const localizeGet = localization
|
|
535
|
+
? `\n const localizedData = lang ? await ${localizeExport}(data, '${modelName}', lang) : data;`
|
|
536
|
+
: '';
|
|
537
|
+
const getData = localization ? 'localizedData' : 'data';
|
|
538
|
+
|
|
539
|
+
return `/**
|
|
540
|
+
* ${modelName} REST API Handlers
|
|
541
|
+
* Auto-generated - DO NOT EDIT
|
|
542
|
+
*/
|
|
543
|
+
|
|
544
|
+
import prisma from '${config.prismaClientPath}';
|
|
545
|
+
${localizationImport}
|
|
546
|
+
function isValidUUID(id: string): boolean {
|
|
547
|
+
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function validateInputIDs(input: any): string | null {
|
|
551
|
+
if (!input || typeof input !== 'object') return null;
|
|
552
|
+
for (const [key, value] of Object.entries(input)) {
|
|
553
|
+
if (key.endsWith('Id') && value !== null && value !== undefined) {
|
|
554
|
+
if (typeof value !== 'string' || !isValidUUID(value as string)) {
|
|
555
|
+
return \`Invalid ID format for field '\${key}' - must be a valid UUID\`;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
return null;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
export async function ${listSignature}: Promise<Response> {
|
|
563
|
+
try {
|
|
564
|
+
const url = new URL(req.url);
|
|
565
|
+
|
|
566
|
+
const limitParam = url.searchParams.get('limit');
|
|
567
|
+
if (limitParam && isNaN(parseInt(limitParam))) {
|
|
568
|
+
return jsonError(400, 'Invalid pagination parameter', { parameter: 'limit', value: limitParam, reason: 'Must be a positive integer' });
|
|
569
|
+
}
|
|
570
|
+
const limit = Math.min(Math.max(parseInt(limitParam || '50'), 1), 1000);
|
|
571
|
+
|
|
572
|
+
const offsetParam = url.searchParams.get('offset');
|
|
573
|
+
let offset = 0;
|
|
574
|
+
if (offsetParam) {
|
|
575
|
+
const parsedOffset = parseInt(offsetParam);
|
|
576
|
+
if (isNaN(parsedOffset)) {
|
|
577
|
+
return jsonError(400, 'Invalid pagination parameter', { parameter: 'offset', value: offsetParam, reason: 'Must be a non-negative integer' });
|
|
578
|
+
}
|
|
579
|
+
offset = Math.max(parsedOffset, 0);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
${filterLogic}
|
|
583
|
+
|
|
584
|
+
const [data, total] = await Promise.all([
|
|
585
|
+
(prisma as any).${camelCase}.findMany({
|
|
586
|
+
where,
|
|
587
|
+
take: limit,
|
|
588
|
+
skip: offset,
|
|
589
|
+
orderBy: { ${orderBy}: 'asc' },
|
|
590
|
+
}),
|
|
591
|
+
(prisma as any).${camelCase}.count({ where }),
|
|
592
|
+
]);
|
|
593
|
+
${localizeList}
|
|
594
|
+
return new Response(
|
|
595
|
+
JSON.stringify({ data: ${listData}, pagination: { limit, offset, total, hasMore: offset + limit < total } }),
|
|
596
|
+
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
|
597
|
+
);
|
|
598
|
+
} catch (error) {
|
|
599
|
+
return jsonError(500, (error as Error).message);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
export async function ${getSignature}: Promise<Response> {
|
|
604
|
+
try {
|
|
605
|
+
if (!isValidUUID(id)) return jsonError(400, 'Invalid ID format - must be a valid UUID');
|
|
606
|
+
const data = await (prisma as any).${camelCase}.findUnique({ where: { id } });
|
|
607
|
+
if (!data) return jsonError(404, '${modelName} not found');
|
|
608
|
+
${localizeGet}
|
|
609
|
+
return new Response(JSON.stringify(${getData}), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
610
|
+
} catch (error) {
|
|
611
|
+
return jsonError(500, (error as Error).message);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
export async function create${modelName}(req: Request): Promise<Response> {
|
|
616
|
+
try {
|
|
617
|
+
const input = await req.json();
|
|
618
|
+
const idError = validateInputIDs(input);
|
|
619
|
+
if (idError) return jsonError(400, idError);
|
|
620
|
+
const data = await (prisma as any).${camelCase}.create({ data: input });
|
|
621
|
+
return new Response(JSON.stringify(data), { status: 201, headers: { 'Content-Type': 'application/json' } });
|
|
622
|
+
} catch (error) {
|
|
623
|
+
return jsonError(400, (error as Error).message);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
export async function update${modelName}(id: string, req: Request): Promise<Response> {
|
|
628
|
+
try {
|
|
629
|
+
if (!isValidUUID(id)) return jsonError(400, 'Invalid ID format - must be a valid UUID');
|
|
630
|
+
const input = await req.json();
|
|
631
|
+
const idError = validateInputIDs(input);
|
|
632
|
+
if (idError) return jsonError(400, idError);
|
|
633
|
+
const data = await (prisma as any).${camelCase}.update({ where: { id }, data: input });
|
|
634
|
+
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
635
|
+
} catch (error) {
|
|
636
|
+
return jsonError(400, (error as Error).message);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
export async function delete${modelName}(id: string): Promise<Response> {
|
|
641
|
+
try {
|
|
642
|
+
if (!isValidUUID(id)) return jsonError(400, 'Invalid ID format - must be a valid UUID');
|
|
643
|
+
const data = await (prisma as any).${camelCase}.delete({ where: { id } });
|
|
644
|
+
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
645
|
+
} catch (error) {
|
|
646
|
+
return jsonError(500, (error as Error).message);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function jsonError(status: number, error: string, details?: unknown): Response {
|
|
651
|
+
return new Response(
|
|
652
|
+
JSON.stringify(details ? { error, details } : { error }),
|
|
653
|
+
{ status, headers: { 'Content-Type': 'application/json' } },
|
|
654
|
+
);
|
|
655
|
+
}
|
|
656
|
+
`;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function _buildFilterLogicREST(metadata: EntityMetadata): string {
|
|
660
|
+
if (!metadata.filterable && !metadata.searchableFields?.length) return 'const where: any = {};';
|
|
661
|
+
|
|
662
|
+
let code =
|
|
663
|
+
"const where: any = {};\n\n const filterPrefix = 'filter.';\n url.searchParams.forEach((value, key) => {\n if (!key.startsWith(filterPrefix)) return;\n const field = key.slice(filterPrefix.length);\n";
|
|
664
|
+
|
|
665
|
+
for (const [field, mode] of Object.entries(metadata.filterable ?? {})) {
|
|
666
|
+
if (mode === 'contains') {
|
|
667
|
+
code += ` if (field === '${field}') where['${field}'] = { contains: value, mode: 'insensitive' };\n`;
|
|
668
|
+
} else {
|
|
669
|
+
code += ` if (field === '${field}') where['${field}'] = value;\n`;
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
code += ' });\n';
|
|
673
|
+
|
|
674
|
+
if (metadata.searchableFields?.length) {
|
|
675
|
+
code += `\n const search = url.searchParams.get('search');\n if (search) {\n where.OR = [\n`;
|
|
676
|
+
for (const field of metadata.searchableFields) {
|
|
677
|
+
code += ` { ${field}: { contains: search, mode: 'insensitive' } },\n`;
|
|
678
|
+
}
|
|
679
|
+
code += ' ];\n }';
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
return code;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// ── REST router generator ─────────────────────────────────────────────────────
|
|
686
|
+
|
|
687
|
+
export function generateRestRouterContent(models: Model[], config: RestRouterConfig): string {
|
|
688
|
+
const { entityImportBase, extraImports = '', extraRoutes = '', extraHelpers = '', localization } = config;
|
|
689
|
+
const getLangExport = localization?.getLangExport ?? 'getLanguageFromRequest';
|
|
690
|
+
const localizationImport = localization ? `import { ${getLangExport} } from '${localization.getLangImport}';\n` : '';
|
|
691
|
+
|
|
692
|
+
const entityImports = models
|
|
693
|
+
.map((m) => {
|
|
694
|
+
const kebab = toKebabCase(m.name);
|
|
695
|
+
const camel = toCamelCase(m.name);
|
|
696
|
+
return `import * as ${camel}Rest from '${entityImportBase}/${kebab}/${kebab}.rest.auto';`;
|
|
697
|
+
})
|
|
698
|
+
.join('\n');
|
|
699
|
+
|
|
700
|
+
const langArg = localization ? ', lang' : '';
|
|
701
|
+
|
|
702
|
+
const routes = models
|
|
703
|
+
.map((m) => {
|
|
704
|
+
const kebab = toKebabCase(m.name);
|
|
705
|
+
const camel = toCamelCase(m.name);
|
|
706
|
+
const plural = kebab.endsWith('s') ? kebab : `${kebab}s`;
|
|
707
|
+
return ` if (entity === '${plural}') {
|
|
708
|
+
if (method === 'GET' && !id) return await ${camel}Rest.list${m.name}s(req${langArg});
|
|
709
|
+
if (method === 'GET' && id) return await ${camel}Rest.get${m.name}(id${langArg});
|
|
710
|
+
if (method === 'POST') return await ${camel}Rest.create${m.name}(req);
|
|
711
|
+
if (method === 'PUT' && id) return await ${camel}Rest.update${m.name}(id, req);
|
|
712
|
+
if (method === 'DELETE' && id) return await ${camel}Rest.delete${m.name}(id);
|
|
713
|
+
}`;
|
|
714
|
+
})
|
|
715
|
+
.join('\n\n');
|
|
716
|
+
|
|
717
|
+
const langDeclaration = localization ? `\n const lang = ${getLangExport}(req);` : '';
|
|
718
|
+
|
|
719
|
+
return `/**
|
|
720
|
+
* REST API Router - Auto-generated
|
|
721
|
+
* DO NOT EDIT - regenerate with your codegen script
|
|
722
|
+
*/
|
|
723
|
+
|
|
724
|
+
${entityImports}
|
|
725
|
+
${localizationImport}${extraImports ? `\n${extraImports}\n` : ''}
|
|
726
|
+
export async function handleRestRequest(req: Request): Promise<Response> {
|
|
727
|
+
const url = new URL(req.url);
|
|
728
|
+
const pathname = url.pathname;
|
|
729
|
+
const method = req.method;
|
|
730
|
+
|
|
731
|
+
if (method === 'OPTIONS') {
|
|
732
|
+
return new Response(null, {
|
|
733
|
+
headers: {
|
|
734
|
+
'Access-Control-Allow-Origin': '*',
|
|
735
|
+
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
|
736
|
+
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
|
737
|
+
},
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
${extraRoutes ? `\n${extraRoutes}\n` : ''}
|
|
741
|
+
const pathMatch = pathname.match(/^\\/api\\/([^\\/]+)(?:\\/([^\\/]+))?$/);
|
|
742
|
+
if (!pathMatch) {
|
|
743
|
+
return new Response(JSON.stringify({ error: 'Invalid API endpoint' }), {
|
|
744
|
+
status: 404,
|
|
745
|
+
headers: { 'Content-Type': 'application/json' },
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
const [, entity, id] = pathMatch;
|
|
750
|
+
${langDeclaration}
|
|
751
|
+
try {
|
|
752
|
+
${routes}
|
|
753
|
+
|
|
754
|
+
return new Response(JSON.stringify({ error: 'Not found' }), {
|
|
755
|
+
status: 404,
|
|
756
|
+
headers: { 'Content-Type': 'application/json' },
|
|
757
|
+
});
|
|
758
|
+
} catch (error) {
|
|
759
|
+
return new Response(
|
|
760
|
+
JSON.stringify({ error: (error as Error).message }),
|
|
761
|
+
{ status: 500, headers: { 'Content-Type': 'application/json' } },
|
|
762
|
+
);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
${extraHelpers ? `\n${extraHelpers}` : ''}`;
|
|
766
|
+
}
|