Skip to content

Commit a61eb0c

Browse files
authored
fix: add a few missing zmodel validation checks (#101)
* chore: add CLAUDE.md * fix: add a few missing zmodel validation checks * update * update * update
1 parent a27a132 commit a61eb0c

File tree

7 files changed

+1145
-67
lines changed

7 files changed

+1145
-67
lines changed

TODO.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77
- [x] migrate
88
- [x] info
99
- [x] init
10+
- [x] validate
11+
- [ ] format
12+
- [ ] db seed
1013
- [ ] ORM
1114
- [x] Create
1215
- [x] Input validation

packages/language/res/stdlib.zmodel

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,11 @@ attribute @@@prisma()
224224
*/
225225
attribute @@@completionHint(_ values: String[])
226226

227+
/**
228+
* Indicates that the attribute can only be applied once to a declaration.
229+
*/
230+
attribute @@@once()
231+
227232
/**
228233
* Defines a single-field ID on the model.
229234
*
@@ -232,7 +237,7 @@ attribute @@@completionHint(_ values: String[])
232237
* @param sort: Allows you to specify in what order the entries of the ID are stored in the database. The available options are Asc and Desc.
233238
* @param clustered: Defines whether the ID is clustered or non-clustered. Defaults to true.
234239
*/
235-
attribute @id(map: String?, length: Int?, sort: SortOrder?, clustered: Boolean?) @@@prisma @@@supportTypeDef
240+
attribute @id(map: String?, length: Int?, sort: SortOrder?, clustered: Boolean?) @@@prisma @@@supportTypeDef @@@once
236241

237242
/**
238243
* Defines a default value for a field.
@@ -247,7 +252,7 @@ attribute @default(_ value: ContextType, map: String?) @@@prisma @@@supportTypeD
247252
* @param sort: Allows you to specify in what order the entries of the constraint are stored in the database. The available options are Asc and Desc.
248253
* @param clustered: Boolean Defines whether the constraint is clustered or non-clustered. Defaults to false.
249254
*/
250-
attribute @unique(map: String?, length: Int?, sort: SortOrder?, clustered: Boolean?) @@@prisma
255+
attribute @unique(map: String?, length: Int?, sort: SortOrder?, clustered: Boolean?) @@@prisma @@@once
251256

252257
/**
253258
* Defines a multi-field ID (composite ID) on the model.

packages/language/src/validators/attribute-application-validator.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,8 @@ export default class AttributeApplicationValidator implements AstValidator<Attri
7575
accept('error', `attribute "${decl.name}" cannot be used on type declarations`, { node: attr });
7676
}
7777

78+
this.checkDuplicatedAttributes(attr, accept);
79+
7880
const filledParams = new Set<AttributeParam>();
7981

8082
for (const arg of attr.args) {
@@ -131,6 +133,18 @@ export default class AttributeApplicationValidator implements AstValidator<Attri
131133
}
132134
}
133135

136+
private checkDuplicatedAttributes(attr: AttributeApplication, accept: ValidationAcceptor) {
137+
const attrDecl = attr.decl.ref;
138+
if (!attrDecl?.attributes.some((a) => a.decl.ref?.name === '@@@once')) {
139+
return;
140+
}
141+
142+
const duplicates = attr.$container.attributes.filter((a) => a.decl.ref === attrDecl && a !== attr);
143+
if (duplicates.length > 0) {
144+
accept('error', `Attribute "${attrDecl.name}" can only be applied once`, { node: attr });
145+
}
146+
}
147+
134148
@check('@@allow')
135149
@check('@@deny')
136150
// @ts-expect-error
@@ -197,13 +211,21 @@ export default class AttributeApplicationValidator implements AstValidator<Attri
197211
}
198212

199213
@check('@@unique')
214+
@check('@@id')
200215
// @ts-expect-error
201216
private _checkUnique(attr: AttributeApplication, accept: ValidationAcceptor) {
202217
const fields = attr.args[0]?.value;
203218
if (!fields) {
219+
accept('error', `expects an array of field references`, {
220+
node: attr.args[0]!,
221+
});
204222
return;
205223
}
206224
if (isArrayExpr(fields)) {
225+
if (fields.items.length === 0) {
226+
accept('error', `\`@@unique\` expects at least one field reference`, { node: fields });
227+
return;
228+
}
207229
fields.items.forEach((item) => {
208230
if (!isReferenceExpr(item)) {
209231
accept('error', `Expecting a field reference`, {

packages/language/src/validators/datamodel-validator.ts

Lines changed: 94 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -259,11 +259,22 @@ export default class DataModelValidator implements AstValidator<DataModel> {
259259
return;
260260
}
261261

262+
if (this.isSelfRelation(field)) {
263+
if (!thisRelation.name) {
264+
accept('error', 'Self-relation field must have a name in @relation attribute', {
265+
node: field,
266+
});
267+
return;
268+
}
269+
}
270+
262271
const oppositeModel = field.type.reference!.ref! as DataModel;
263272

264273
// Use name because the current document might be updated
265274
let oppositeFields = getModelFieldsWithBases(oppositeModel, false).filter(
266-
(f) => f.type.reference?.ref?.name === contextModel.name,
275+
(f) =>
276+
f !== field && // exclude self in case of self relation
277+
f.type.reference?.ref?.name === contextModel.name,
267278
);
268279
oppositeFields = oppositeFields.filter((f) => {
269280
const fieldRel = this.parseRelation(f);
@@ -322,27 +333,41 @@ export default class DataModelValidator implements AstValidator<DataModel> {
322333

323334
let relationOwner: DataModelField;
324335

325-
if (thisRelation?.references?.length && thisRelation.fields?.length) {
326-
if (oppositeRelation?.references || oppositeRelation?.fields) {
327-
accept('error', '"fields" and "references" must be provided only on one side of relation field', {
328-
node: oppositeField,
329-
});
330-
return;
331-
} else {
332-
relationOwner = oppositeField;
333-
}
334-
} else if (oppositeRelation?.references?.length && oppositeRelation.fields?.length) {
335-
if (thisRelation?.references || thisRelation?.fields) {
336-
accept('error', '"fields" and "references" must be provided only on one side of relation field', {
337-
node: field,
338-
});
339-
return;
340-
} else {
341-
relationOwner = field;
336+
if (field.type.array && oppositeField.type.array) {
337+
// if both the field is array, then it's an implicit many-to-many relation,
338+
// neither side should have fields/references
339+
for (const r of [thisRelation, oppositeRelation]) {
340+
if (r.fields?.length || r.references?.length) {
341+
accept(
342+
'error',
343+
'Implicit many-to-many relation cannot have "fields" or "references" in @relation attribute',
344+
{
345+
node: r === thisRelation ? field : oppositeField,
346+
},
347+
);
348+
}
342349
}
343350
} else {
344-
// if both the field is array, then it's an implicit many-to-many relation
345-
if (!(field.type.array && oppositeField.type.array)) {
351+
if (thisRelation?.references?.length && thisRelation.fields?.length) {
352+
if (oppositeRelation?.references || oppositeRelation?.fields) {
353+
accept('error', '"fields" and "references" must be provided only on one side of relation field', {
354+
node: oppositeField,
355+
});
356+
return;
357+
} else {
358+
relationOwner = oppositeField;
359+
}
360+
} else if (oppositeRelation?.references?.length && oppositeRelation.fields?.length) {
361+
if (thisRelation?.references || thisRelation?.fields) {
362+
accept('error', '"fields" and "references" must be provided only on one side of relation field', {
363+
node: field,
364+
});
365+
return;
366+
} else {
367+
relationOwner = field;
368+
}
369+
} else {
370+
// for non-M2M relations, one side must have fields/references
346371
[field, oppositeField].forEach((f) => {
347372
if (!this.isSelfRelation(f)) {
348373
accept(
@@ -352,56 +377,60 @@ export default class DataModelValidator implements AstValidator<DataModel> {
352377
);
353378
}
354379
});
380+
return;
355381
}
356-
return;
357-
}
358-
359-
if (!relationOwner.type.array && !relationOwner.type.optional) {
360-
accept('error', 'Relation field needs to be list or optional', {
361-
node: relationOwner,
362-
});
363-
return;
364-
}
365382

366-
if (relationOwner !== field && !relationOwner.type.array) {
367-
// one-to-one relation requires defining side's reference field to be @unique
368-
// e.g.:
369-
// model User {
370-
// id String @id @default(cuid())
371-
// data UserData?
372-
// }
373-
// model UserData {
374-
// id String @id @default(cuid())
375-
// user User @relation(fields: [userId], references: [id])
376-
// userId String
377-
// }
378-
//
379-
// UserData.userId field needs to be @unique
380-
381-
const containingModel = field.$container as DataModel;
382-
const uniqueFieldList = getUniqueFields(containingModel);
383-
384-
// field is defined in the abstract base model
385-
if (containingModel !== contextModel) {
386-
uniqueFieldList.push(...getUniqueFields(contextModel));
383+
if (!relationOwner.type.array && !relationOwner.type.optional) {
384+
accept('error', 'Relation field needs to be list or optional', {
385+
node: relationOwner,
386+
});
387+
return;
387388
}
388389

389-
thisRelation.fields?.forEach((ref) => {
390-
const refField = ref.target.ref as DataModelField;
391-
if (refField) {
392-
if (refField.attributes.find((a) => a.decl.ref?.name === '@id' || a.decl.ref?.name === '@unique')) {
393-
return;
394-
}
395-
if (uniqueFieldList.some((list) => list.includes(refField))) {
396-
return;
397-
}
398-
accept(
399-
'error',
400-
`Field "${refField.name}" on model "${containingModel.name}" is part of a one-to-one relation and must be marked as @unique or be part of a model-level @@unique attribute`,
401-
{ node: refField },
402-
);
390+
if (relationOwner !== field && !relationOwner.type.array) {
391+
// one-to-one relation requires defining side's reference field to be @unique
392+
// e.g.:
393+
// model User {
394+
// id String @id @default(cuid())
395+
// data UserData?
396+
// }
397+
// model UserData {
398+
// id String @id @default(cuid())
399+
// user User @relation(fields: [userId], references: [id])
400+
// userId String
401+
// }
402+
//
403+
// UserData.userId field needs to be @unique
404+
405+
const containingModel = field.$container as DataModel;
406+
const uniqueFieldList = getUniqueFields(containingModel);
407+
408+
// field is defined in the abstract base model
409+
if (containingModel !== contextModel) {
410+
uniqueFieldList.push(...getUniqueFields(contextModel));
403411
}
404-
});
412+
413+
thisRelation.fields?.forEach((ref) => {
414+
const refField = ref.target.ref as DataModelField;
415+
if (refField) {
416+
if (
417+
refField.attributes.find(
418+
(a) => a.decl.ref?.name === '@id' || a.decl.ref?.name === '@unique',
419+
)
420+
) {
421+
return;
422+
}
423+
if (uniqueFieldList.some((list) => list.includes(refField))) {
424+
return;
425+
}
426+
accept(
427+
'error',
428+
`Field "${refField.name}" on model "${containingModel.name}" is part of a one-to-one relation and must be marked as @unique or be part of a model-level @@unique attribute`,
429+
{ node: refField },
430+
);
431+
}
432+
});
433+
}
405434
}
406435
}
407436

pnpm-lock.yaml

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tests/e2e/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,8 @@
77
},
88
"dependencies": {
99
"@zenstackhq/testtools": "workspace:*"
10+
},
11+
"devDependencies": {
12+
"@zenstackhq/cli": "workspace:*"
1013
}
1114
}

0 commit comments

Comments
 (0)