Skip to content

Commit aa1b5bf

Browse files
committed
fix: add a few missing zmodel validation checks
1 parent 51b3656 commit aa1b5bf

File tree

7 files changed

+1142
-67
lines changed

7 files changed

+1142
-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+
- [ ] 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: 92 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -259,11 +259,20 @@ 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) => f !== field && f.type.reference?.ref?.name === contextModel.name,
267276
);
268277
oppositeFields = oppositeFields.filter((f) => {
269278
const fieldRel = this.parseRelation(f);
@@ -322,27 +331,41 @@ export default class DataModelValidator implements AstValidator<DataModel> {
322331

323332
let relationOwner: DataModelField;
324333

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;
334+
if (field.type.array && oppositeField.type.array) {
335+
// if both the field is array, then it's an implicit many-to-many relation,
336+
// neither side should have fields/references
337+
for (const r of [thisRelation, oppositeRelation]) {
338+
if (r.fields?.length || r.references?.length) {
339+
accept(
340+
'error',
341+
'Implicit many-to-many relation cannot have "fields" or "references" in @relation attribute',
342+
{
343+
node: r === thisRelation ? field : oppositeField,
344+
},
345+
);
346+
}
342347
}
343348
} 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)) {
349+
if (thisRelation?.references?.length && thisRelation.fields?.length) {
350+
if (oppositeRelation?.references || oppositeRelation?.fields) {
351+
accept('error', '"fields" and "references" must be provided only on one side of relation field', {
352+
node: oppositeField,
353+
});
354+
return;
355+
} else {
356+
relationOwner = oppositeField;
357+
}
358+
} else if (oppositeRelation?.references?.length && oppositeRelation.fields?.length) {
359+
if (thisRelation?.references || thisRelation?.fields) {
360+
accept('error', '"fields" and "references" must be provided only on one side of relation field', {
361+
node: field,
362+
});
363+
return;
364+
} else {
365+
relationOwner = field;
366+
}
367+
} else {
368+
// for non-M2M relations, one side must have fields/references
346369
[field, oppositeField].forEach((f) => {
347370
if (!this.isSelfRelation(f)) {
348371
accept(
@@ -352,56 +375,60 @@ export default class DataModelValidator implements AstValidator<DataModel> {
352375
);
353376
}
354377
});
378+
return;
355379
}
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-
}
365380

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));
381+
if (!relationOwner.type.array && !relationOwner.type.optional) {
382+
accept('error', 'Relation field needs to be list or optional', {
383+
node: relationOwner,
384+
});
385+
return;
387386
}
388387

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

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)