@vibeorm/parser 1.0.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 +66 -0
- package/package.json +42 -0
- package/src/index.ts +27 -0
- package/src/parser.ts +688 -0
- package/src/schema-validator.ts +625 -0
- package/src/types.ts +223 -0
|
@@ -0,0 +1,625 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema IR Validator
|
|
3
|
+
*
|
|
4
|
+
* Performs semantic validation on the parsed Schema IR,
|
|
5
|
+
* catching issues that the structural TypeScript types cannot express:
|
|
6
|
+
* missing primary keys, dangling relation references, FK type mismatches, etc.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type {
|
|
10
|
+
Schema,
|
|
11
|
+
Model,
|
|
12
|
+
Field,
|
|
13
|
+
ScalarField,
|
|
14
|
+
EnumField,
|
|
15
|
+
RelationField,
|
|
16
|
+
} from "./types.ts";
|
|
17
|
+
import { PRISMA_TO_TS, PRISMA_TO_PG } from "./types.ts";
|
|
18
|
+
|
|
19
|
+
// ─── Validation Result Types ──────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
export type ValidationSeverity = "error" | "warning";
|
|
22
|
+
|
|
23
|
+
export type SchemaValidationError = {
|
|
24
|
+
severity: ValidationSeverity;
|
|
25
|
+
path: string;
|
|
26
|
+
code: string;
|
|
27
|
+
message: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type ValidationResult =
|
|
31
|
+
| { valid: true; warnings: SchemaValidationError[] }
|
|
32
|
+
| { valid: false; errors: SchemaValidationError[]; warnings: SchemaValidationError[] };
|
|
33
|
+
|
|
34
|
+
const VALID_PRISMA_TYPES = new Set<string>([
|
|
35
|
+
"String", "Boolean", "Int", "BigInt", "Float", "Decimal", "DateTime", "Json", "Bytes",
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
// ─── Public API ───────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
export function validateSchema(params: { schema: Schema }): ValidationResult {
|
|
41
|
+
const { schema } = params;
|
|
42
|
+
const errors: SchemaValidationError[] = [];
|
|
43
|
+
const warnings: SchemaValidationError[] = [];
|
|
44
|
+
|
|
45
|
+
const modelNames = new Set(schema.models.map((m) => m.name));
|
|
46
|
+
const enumNames = new Set(schema.enums.map((e) => e.name));
|
|
47
|
+
|
|
48
|
+
// Build lookup maps
|
|
49
|
+
const modelMap = new Map<string, Model>();
|
|
50
|
+
for (const model of schema.models) {
|
|
51
|
+
modelMap.set(model.name, model);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ── Schema-level checks ──
|
|
55
|
+
validateSchemaLevel({ schema, modelNames, enumNames, errors, warnings });
|
|
56
|
+
|
|
57
|
+
// ── Enum-level checks ──
|
|
58
|
+
for (const enumDef of schema.enums) {
|
|
59
|
+
validateEnum({ enumDef, path: `enums.${enumDef.name}`, errors, warnings });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── Model-level checks ──
|
|
63
|
+
for (const model of schema.models) {
|
|
64
|
+
validateModel({
|
|
65
|
+
model,
|
|
66
|
+
modelNames,
|
|
67
|
+
enumNames,
|
|
68
|
+
modelMap,
|
|
69
|
+
errors,
|
|
70
|
+
warnings,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (errors.length > 0) {
|
|
75
|
+
return { valid: false, errors, warnings };
|
|
76
|
+
}
|
|
77
|
+
return { valid: true, warnings };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ─── Schema-Level Validation ──────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
function validateSchemaLevel(params: {
|
|
83
|
+
schema: Schema;
|
|
84
|
+
modelNames: Set<string>;
|
|
85
|
+
enumNames: Set<string>;
|
|
86
|
+
errors: SchemaValidationError[];
|
|
87
|
+
warnings: SchemaValidationError[];
|
|
88
|
+
}): void {
|
|
89
|
+
const { schema, modelNames, enumNames, errors } = params;
|
|
90
|
+
|
|
91
|
+
// Check for duplicate model names
|
|
92
|
+
const seenModels = new Set<string>();
|
|
93
|
+
for (const model of schema.models) {
|
|
94
|
+
if (seenModels.has(model.name)) {
|
|
95
|
+
errors.push({
|
|
96
|
+
severity: "error",
|
|
97
|
+
path: `models.${model.name}`,
|
|
98
|
+
code: "DUPLICATE_MODEL_NAME",
|
|
99
|
+
message: `Duplicate model name "${model.name}"`,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
seenModels.add(model.name);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Check for duplicate enum names
|
|
106
|
+
const seenEnums = new Set<string>();
|
|
107
|
+
for (const enumDef of schema.enums) {
|
|
108
|
+
if (seenEnums.has(enumDef.name)) {
|
|
109
|
+
errors.push({
|
|
110
|
+
severity: "error",
|
|
111
|
+
path: `enums.${enumDef.name}`,
|
|
112
|
+
code: "DUPLICATE_ENUM_NAME",
|
|
113
|
+
message: `Duplicate enum name "${enumDef.name}"`,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
seenEnums.add(enumDef.name);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Check for model/enum name collisions
|
|
120
|
+
for (const name of modelNames) {
|
|
121
|
+
if (enumNames.has(name)) {
|
|
122
|
+
errors.push({
|
|
123
|
+
severity: "error",
|
|
124
|
+
path: `schema`,
|
|
125
|
+
code: "MODEL_ENUM_NAME_COLLISION",
|
|
126
|
+
message: `Model and enum share the same name "${name}"`,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ─── Enum Validation ──────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
function validateEnum(params: {
|
|
135
|
+
enumDef: Schema["enums"][number];
|
|
136
|
+
path: string;
|
|
137
|
+
errors: SchemaValidationError[];
|
|
138
|
+
warnings: SchemaValidationError[];
|
|
139
|
+
}): void {
|
|
140
|
+
const { enumDef, path, errors } = params;
|
|
141
|
+
|
|
142
|
+
if (enumDef.values.length === 0) {
|
|
143
|
+
errors.push({
|
|
144
|
+
severity: "error",
|
|
145
|
+
path,
|
|
146
|
+
code: "EMPTY_ENUM",
|
|
147
|
+
message: `Enum "${enumDef.name}" has no values`,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Check for duplicate enum value names
|
|
152
|
+
const seenValues = new Set<string>();
|
|
153
|
+
for (const value of enumDef.values) {
|
|
154
|
+
if (seenValues.has(value.name)) {
|
|
155
|
+
errors.push({
|
|
156
|
+
severity: "error",
|
|
157
|
+
path: `${path}.${value.name}`,
|
|
158
|
+
code: "DUPLICATE_ENUM_VALUE",
|
|
159
|
+
message: `Duplicate enum value "${value.name}" in enum "${enumDef.name}"`,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
seenValues.add(value.name);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ─── Model Validation ─────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
function validateModel(params: {
|
|
169
|
+
model: Model;
|
|
170
|
+
modelNames: Set<string>;
|
|
171
|
+
enumNames: Set<string>;
|
|
172
|
+
modelMap: Map<string, Model>;
|
|
173
|
+
errors: SchemaValidationError[];
|
|
174
|
+
warnings: SchemaValidationError[];
|
|
175
|
+
}): void {
|
|
176
|
+
const { model, modelNames, enumNames, modelMap, errors, warnings } = params;
|
|
177
|
+
const modelPath = `models.${model.name}`;
|
|
178
|
+
|
|
179
|
+
// Build field lookup for this model
|
|
180
|
+
const fieldMap = new Map<string, Field>();
|
|
181
|
+
const scalarEnumFieldNames = new Set<string>();
|
|
182
|
+
|
|
183
|
+
// Check for duplicate field names
|
|
184
|
+
const seenFields = new Set<string>();
|
|
185
|
+
for (const field of model.fields) {
|
|
186
|
+
if (seenFields.has(field.name)) {
|
|
187
|
+
errors.push({
|
|
188
|
+
severity: "error",
|
|
189
|
+
path: `${modelPath}.fields.${field.name}`,
|
|
190
|
+
code: "DUPLICATE_FIELD_NAME",
|
|
191
|
+
message: `Duplicate field name "${field.name}" in model "${model.name}"`,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
seenFields.add(field.name);
|
|
195
|
+
fieldMap.set(field.name, field);
|
|
196
|
+
if (field.kind === "scalar" || field.kind === "enum") {
|
|
197
|
+
scalarEnumFieldNames.add(field.name);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Primary key validation
|
|
202
|
+
if (model.primaryKey.fields.length === 0) {
|
|
203
|
+
errors.push({
|
|
204
|
+
severity: "error",
|
|
205
|
+
path: `${modelPath}.primaryKey`,
|
|
206
|
+
code: "MISSING_PRIMARY_KEY",
|
|
207
|
+
message: `Model "${model.name}" has no primary key -- every model must have an @id or @@id`,
|
|
208
|
+
});
|
|
209
|
+
} else {
|
|
210
|
+
// isComposite consistency
|
|
211
|
+
const shouldBeComposite = model.primaryKey.fields.length > 1;
|
|
212
|
+
if (model.primaryKey.isComposite !== shouldBeComposite) {
|
|
213
|
+
warnings.push({
|
|
214
|
+
severity: "warning",
|
|
215
|
+
path: `${modelPath}.primaryKey`,
|
|
216
|
+
code: "INCONSISTENT_COMPOSITE_FLAG",
|
|
217
|
+
message: `Model "${model.name}" has isComposite=${model.primaryKey.isComposite} but ${model.primaryKey.fields.length} PK field(s)`,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// PK fields reference actual scalar/enum fields
|
|
222
|
+
for (const pkField of model.primaryKey.fields) {
|
|
223
|
+
if (!scalarEnumFieldNames.has(pkField)) {
|
|
224
|
+
errors.push({
|
|
225
|
+
severity: "error",
|
|
226
|
+
path: `${modelPath}.primaryKey`,
|
|
227
|
+
code: "PK_FIELD_NOT_FOUND",
|
|
228
|
+
message: `Primary key field "${pkField}" does not exist as a scalar/enum field on model "${model.name}"`,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Unique constraint field references
|
|
235
|
+
for (const uc of model.uniqueConstraints) {
|
|
236
|
+
for (const ucField of uc.fields) {
|
|
237
|
+
if (!scalarEnumFieldNames.has(ucField)) {
|
|
238
|
+
errors.push({
|
|
239
|
+
severity: "error",
|
|
240
|
+
path: `${modelPath}.uniqueConstraints`,
|
|
241
|
+
code: "UNIQUE_FIELD_NOT_FOUND",
|
|
242
|
+
message: `Unique constraint references field "${ucField}" which does not exist on model "${model.name}"`,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Index field references
|
|
249
|
+
for (const idx of model.indexes) {
|
|
250
|
+
for (const idxField of idx.fields) {
|
|
251
|
+
if (!scalarEnumFieldNames.has(idxField)) {
|
|
252
|
+
errors.push({
|
|
253
|
+
severity: "error",
|
|
254
|
+
path: `${modelPath}.indexes`,
|
|
255
|
+
code: "INDEX_FIELD_NOT_FOUND",
|
|
256
|
+
message: `Index references field "${idxField}" which does not exist on model "${model.name}"`,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Per-field validation
|
|
263
|
+
for (const field of model.fields) {
|
|
264
|
+
const fieldPath = `${modelPath}.fields.${field.name}`;
|
|
265
|
+
|
|
266
|
+
switch (field.kind) {
|
|
267
|
+
case "scalar":
|
|
268
|
+
validateScalarField({
|
|
269
|
+
field,
|
|
270
|
+
model,
|
|
271
|
+
fieldPath,
|
|
272
|
+
errors,
|
|
273
|
+
warnings,
|
|
274
|
+
});
|
|
275
|
+
break;
|
|
276
|
+
case "enum":
|
|
277
|
+
validateEnumField({
|
|
278
|
+
field,
|
|
279
|
+
enumNames,
|
|
280
|
+
fieldPath,
|
|
281
|
+
errors,
|
|
282
|
+
});
|
|
283
|
+
break;
|
|
284
|
+
case "relation":
|
|
285
|
+
validateRelationField({
|
|
286
|
+
field,
|
|
287
|
+
model,
|
|
288
|
+
modelNames,
|
|
289
|
+
modelMap,
|
|
290
|
+
fieldPath,
|
|
291
|
+
errors,
|
|
292
|
+
warnings,
|
|
293
|
+
});
|
|
294
|
+
break;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ─── Scalar Field Validation ──────────────────────────────────────
|
|
300
|
+
|
|
301
|
+
function validateScalarField(params: {
|
|
302
|
+
field: ScalarField;
|
|
303
|
+
model: Model;
|
|
304
|
+
fieldPath: string;
|
|
305
|
+
errors: SchemaValidationError[];
|
|
306
|
+
warnings: SchemaValidationError[];
|
|
307
|
+
}): void {
|
|
308
|
+
const { field, model, fieldPath, errors, warnings } = params;
|
|
309
|
+
|
|
310
|
+
// Valid Prisma type
|
|
311
|
+
if (!VALID_PRISMA_TYPES.has(field.prismaType)) {
|
|
312
|
+
errors.push({
|
|
313
|
+
severity: "error",
|
|
314
|
+
path: fieldPath,
|
|
315
|
+
code: "INVALID_PRISMA_TYPE",
|
|
316
|
+
message: `Field "${field.name}" has invalid Prisma type "${field.prismaType}"`,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// tsType consistency (only when no nativeType override)
|
|
321
|
+
if (!field.nativeType) {
|
|
322
|
+
const expectedTs = PRISMA_TO_TS[field.prismaType];
|
|
323
|
+
if (expectedTs && field.tsType !== expectedTs) {
|
|
324
|
+
warnings.push({
|
|
325
|
+
severity: "warning",
|
|
326
|
+
path: fieldPath,
|
|
327
|
+
code: "TS_TYPE_MISMATCH",
|
|
328
|
+
message: `Field "${field.name}" has tsType "${field.tsType}" but expected "${expectedTs}" for Prisma type "${field.prismaType}"`,
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const expectedPg = PRISMA_TO_PG[field.prismaType];
|
|
333
|
+
if (expectedPg && field.pgType !== expectedPg) {
|
|
334
|
+
warnings.push({
|
|
335
|
+
severity: "warning",
|
|
336
|
+
path: fieldPath,
|
|
337
|
+
code: "PG_TYPE_MISMATCH",
|
|
338
|
+
message: `Field "${field.name}" has pgType "${field.pgType}" but expected "${expectedPg}" for Prisma type "${field.prismaType}"`,
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// @default(autoincrement()) only on Int/BigInt
|
|
344
|
+
if (field.default?.kind === "autoincrement") {
|
|
345
|
+
if (field.prismaType !== "Int" && field.prismaType !== "BigInt") {
|
|
346
|
+
errors.push({
|
|
347
|
+
severity: "error",
|
|
348
|
+
path: fieldPath,
|
|
349
|
+
code: "AUTOINCREMENT_WRONG_TYPE",
|
|
350
|
+
message: `Field "${field.name}" uses @default(autoincrement()) but has type "${field.prismaType}" (must be Int or BigInt)`,
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// @updatedAt only on DateTime
|
|
356
|
+
if (field.isUpdatedAt && field.prismaType !== "DateTime") {
|
|
357
|
+
errors.push({
|
|
358
|
+
severity: "error",
|
|
359
|
+
path: fieldPath,
|
|
360
|
+
code: "UPDATED_AT_WRONG_TYPE",
|
|
361
|
+
message: `Field "${field.name}" uses @updatedAt but has type "${field.prismaType}" (must be DateTime)`,
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// isId consistency with primaryKey
|
|
366
|
+
if (field.isId && !model.primaryKey.fields.includes(field.name)) {
|
|
367
|
+
warnings.push({
|
|
368
|
+
severity: "warning",
|
|
369
|
+
path: fieldPath,
|
|
370
|
+
code: "ID_NOT_IN_PK",
|
|
371
|
+
message: `Field "${field.name}" has isId=true but is not listed in model "${model.name}" primaryKey.fields`,
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// dbName must be non-empty
|
|
376
|
+
if (!field.dbName) {
|
|
377
|
+
errors.push({
|
|
378
|
+
severity: "error",
|
|
379
|
+
path: fieldPath,
|
|
380
|
+
code: "EMPTY_DB_NAME",
|
|
381
|
+
message: `Field "${field.name}" has an empty dbName`,
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// ─── Enum Field Validation ────────────────────────────────────────
|
|
387
|
+
|
|
388
|
+
function validateEnumField(params: {
|
|
389
|
+
field: EnumField;
|
|
390
|
+
enumNames: Set<string>;
|
|
391
|
+
fieldPath: string;
|
|
392
|
+
errors: SchemaValidationError[];
|
|
393
|
+
}): void {
|
|
394
|
+
const { field, enumNames, fieldPath, errors } = params;
|
|
395
|
+
|
|
396
|
+
if (!enumNames.has(field.enumName)) {
|
|
397
|
+
errors.push({
|
|
398
|
+
severity: "error",
|
|
399
|
+
path: fieldPath,
|
|
400
|
+
code: "ENUM_NOT_FOUND",
|
|
401
|
+
message: `Field "${field.name}" references enum "${field.enumName}" which does not exist`,
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if (!field.dbName) {
|
|
406
|
+
errors.push({
|
|
407
|
+
severity: "error",
|
|
408
|
+
path: fieldPath,
|
|
409
|
+
code: "EMPTY_DB_NAME",
|
|
410
|
+
message: `Field "${field.name}" has an empty dbName`,
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// ─── Relation Field Validation ────────────────────────────────────
|
|
416
|
+
|
|
417
|
+
function validateRelationField(params: {
|
|
418
|
+
field: RelationField;
|
|
419
|
+
model: Model;
|
|
420
|
+
modelNames: Set<string>;
|
|
421
|
+
modelMap: Map<string, Model>;
|
|
422
|
+
fieldPath: string;
|
|
423
|
+
errors: SchemaValidationError[];
|
|
424
|
+
warnings: SchemaValidationError[];
|
|
425
|
+
}): void {
|
|
426
|
+
const { field, model, modelNames, modelMap, fieldPath, errors, warnings } = params;
|
|
427
|
+
const rel = field.relation;
|
|
428
|
+
|
|
429
|
+
// relatedModel exists
|
|
430
|
+
if (!modelNames.has(field.relatedModel)) {
|
|
431
|
+
errors.push({
|
|
432
|
+
severity: "error",
|
|
433
|
+
path: fieldPath,
|
|
434
|
+
code: "RELATED_MODEL_NOT_FOUND",
|
|
435
|
+
message: `Field "${field.name}" references model "${field.relatedModel}" which does not exist`,
|
|
436
|
+
});
|
|
437
|
+
return; // Can't do further relation checks without the related model
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// relatedModel consistency (two places store the same value)
|
|
441
|
+
if (field.relatedModel !== rel.relatedModel) {
|
|
442
|
+
errors.push({
|
|
443
|
+
severity: "error",
|
|
444
|
+
path: fieldPath,
|
|
445
|
+
code: "RELATED_MODEL_MISMATCH",
|
|
446
|
+
message: `Field "${field.name}" has relatedModel="${field.relatedModel}" but relation.relatedModel="${rel.relatedModel}"`,
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// isList / type consistency
|
|
451
|
+
if ((rel.type === "oneToMany" || rel.type === "manyToMany") && !field.isList) {
|
|
452
|
+
errors.push({
|
|
453
|
+
severity: "error",
|
|
454
|
+
path: fieldPath,
|
|
455
|
+
code: "LIST_TYPE_MISMATCH",
|
|
456
|
+
message: `Field "${field.name}" has relation type "${rel.type}" but isList=false (should be true)`,
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
if ((rel.type === "manyToOne" || rel.type === "oneToOne") && field.isList) {
|
|
460
|
+
errors.push({
|
|
461
|
+
severity: "error",
|
|
462
|
+
path: fieldPath,
|
|
463
|
+
code: "LIST_TYPE_MISMATCH",
|
|
464
|
+
message: `Field "${field.name}" has relation type "${rel.type}" but isList=true (should be false)`,
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// isForeignKey / type consistency
|
|
469
|
+
if (rel.type === "manyToOne" && !rel.isForeignKey) {
|
|
470
|
+
errors.push({
|
|
471
|
+
severity: "error",
|
|
472
|
+
path: fieldPath,
|
|
473
|
+
code: "FK_TYPE_MISMATCH",
|
|
474
|
+
message: `Field "${field.name}" has relation type "manyToOne" but isForeignKey=false (should be true)`,
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
if (rel.type === "oneToMany" && rel.isForeignKey) {
|
|
478
|
+
errors.push({
|
|
479
|
+
severity: "error",
|
|
480
|
+
path: fieldPath,
|
|
481
|
+
code: "FK_TYPE_MISMATCH",
|
|
482
|
+
message: `Field "${field.name}" has relation type "oneToMany" but isForeignKey=true (should be false)`,
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
if (rel.type === "manyToMany" && rel.isForeignKey) {
|
|
486
|
+
errors.push({
|
|
487
|
+
severity: "error",
|
|
488
|
+
path: fieldPath,
|
|
489
|
+
code: "FK_TYPE_MISMATCH",
|
|
490
|
+
message: `Field "${field.name}" has relation type "manyToMany" but isForeignKey=true (should be false)`,
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// FK fields/references validation
|
|
495
|
+
if (rel.isForeignKey) {
|
|
496
|
+
if (rel.fields.length === 0) {
|
|
497
|
+
errors.push({
|
|
498
|
+
severity: "error",
|
|
499
|
+
path: fieldPath,
|
|
500
|
+
code: "FK_FIELDS_EMPTY",
|
|
501
|
+
message: `Field "${field.name}" has isForeignKey=true but relation.fields is empty`,
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
if (rel.references.length === 0) {
|
|
505
|
+
errors.push({
|
|
506
|
+
severity: "error",
|
|
507
|
+
path: fieldPath,
|
|
508
|
+
code: "FK_REFERENCES_EMPTY",
|
|
509
|
+
message: `Field "${field.name}" has isForeignKey=true but relation.references is empty`,
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
if (rel.fields.length !== rel.references.length) {
|
|
513
|
+
errors.push({
|
|
514
|
+
severity: "error",
|
|
515
|
+
path: fieldPath,
|
|
516
|
+
code: "FK_FIELDS_REFERENCES_LENGTH_MISMATCH",
|
|
517
|
+
message: `Field "${field.name}" has ${rel.fields.length} FK field(s) but ${rel.references.length} reference(s)`,
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// FK fields reference actual scalar/enum fields on THIS model
|
|
522
|
+
const thisScalarFields = new Set(
|
|
523
|
+
model.fields
|
|
524
|
+
.filter((f): f is ScalarField | EnumField => f.kind === "scalar" || f.kind === "enum")
|
|
525
|
+
.map((f) => f.name)
|
|
526
|
+
);
|
|
527
|
+
|
|
528
|
+
for (const fkField of rel.fields) {
|
|
529
|
+
if (!thisScalarFields.has(fkField)) {
|
|
530
|
+
errors.push({
|
|
531
|
+
severity: "error",
|
|
532
|
+
path: fieldPath,
|
|
533
|
+
code: "FK_FIELD_NOT_FOUND",
|
|
534
|
+
message: `Relation field "${field.name}" references FK field "${fkField}" which does not exist on model "${model.name}"`,
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// References point to actual fields on the RELATED model
|
|
540
|
+
const relatedModel = modelMap.get(field.relatedModel);
|
|
541
|
+
if (relatedModel) {
|
|
542
|
+
const relatedScalarFields = new Set(
|
|
543
|
+
relatedModel.fields
|
|
544
|
+
.filter((f): f is ScalarField | EnumField => f.kind === "scalar" || f.kind === "enum")
|
|
545
|
+
.map((f) => f.name)
|
|
546
|
+
);
|
|
547
|
+
|
|
548
|
+
for (const refField of rel.references) {
|
|
549
|
+
if (!relatedScalarFields.has(refField)) {
|
|
550
|
+
errors.push({
|
|
551
|
+
severity: "error",
|
|
552
|
+
path: fieldPath,
|
|
553
|
+
code: "FK_REFERENCE_NOT_FOUND",
|
|
554
|
+
message: `Relation field "${field.name}" references field "${refField}" on model "${field.relatedModel}" which does not exist`,
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
} else {
|
|
560
|
+
// Non-FK side should have empty fields/references
|
|
561
|
+
if (rel.fields.length > 0) {
|
|
562
|
+
warnings.push({
|
|
563
|
+
severity: "warning",
|
|
564
|
+
path: fieldPath,
|
|
565
|
+
code: "NON_FK_HAS_FIELDS",
|
|
566
|
+
message: `Field "${field.name}" has isForeignKey=false but relation.fields is non-empty`,
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
if (rel.references.length > 0) {
|
|
570
|
+
warnings.push({
|
|
571
|
+
severity: "warning",
|
|
572
|
+
path: fieldPath,
|
|
573
|
+
code: "NON_FK_HAS_REFERENCES",
|
|
574
|
+
message: `Field "${field.name}" has isForeignKey=false but relation.references is non-empty`,
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// Cross-model: check for inverse relation
|
|
580
|
+
const relatedModel = modelMap.get(field.relatedModel);
|
|
581
|
+
if (relatedModel) {
|
|
582
|
+
const inverseRelations = relatedModel.fields.filter(
|
|
583
|
+
(f): f is RelationField =>
|
|
584
|
+
f.kind === "relation" &&
|
|
585
|
+
f.relatedModel === model.name &&
|
|
586
|
+
(!rel.name || !f.relation.name || f.relation.name === rel.name)
|
|
587
|
+
);
|
|
588
|
+
|
|
589
|
+
if (inverseRelations.length === 0) {
|
|
590
|
+
warnings.push({
|
|
591
|
+
severity: "warning",
|
|
592
|
+
path: fieldPath,
|
|
593
|
+
code: "MISSING_INVERSE_RELATION",
|
|
594
|
+
message: `Field "${field.name}" on "${model.name}" has no inverse relation on "${field.relatedModel}"`,
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// ─── Format Errors for CLI Output ─────────────────────────────────
|
|
601
|
+
|
|
602
|
+
export function formatValidationErrors(params: {
|
|
603
|
+
result: ValidationResult;
|
|
604
|
+
}): string {
|
|
605
|
+
const { result } = params;
|
|
606
|
+
const lines: string[] = [];
|
|
607
|
+
|
|
608
|
+
if (!result.valid) {
|
|
609
|
+
lines.push(`Schema validation failed with ${result.errors.length} error(s):`);
|
|
610
|
+
lines.push("");
|
|
611
|
+
for (const err of result.errors) {
|
|
612
|
+
lines.push(` [ERROR] ${err.path}: ${err.message}`);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
if (result.warnings.length > 0) {
|
|
617
|
+
if (lines.length > 0) lines.push("");
|
|
618
|
+
lines.push(`${result.warnings.length} warning(s):`);
|
|
619
|
+
for (const warn of result.warnings) {
|
|
620
|
+
lines.push(` [WARN] ${warn.path}: ${warn.message}`);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
return lines.join("\n");
|
|
625
|
+
}
|