Skip to content

Commit 9c7527f

Browse files
authored
fix: cross-model field comparison validation issue (#1509)
1 parent 665f9b3 commit 9c7527f

File tree

3 files changed

+99
-65
lines changed

3 files changed

+99
-65
lines changed

packages/schema/src/language-server/validator/expression-validator.ts

Lines changed: 60 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
DataModelAttribute,
55
Expression,
66
ExpressionType,
7+
isArrayExpr,
78
isDataModel,
89
isDataModelAttribute,
910
isDataModelField,
@@ -82,6 +83,8 @@ export default class ExpressionValidator implements AstValidator<Expression> {
8283
node: expr.right,
8384
});
8485
}
86+
87+
this.validateCrossModelFieldComparison(expr, accept);
8588
break;
8689
}
8790

@@ -137,6 +140,7 @@ export default class ExpressionValidator implements AstValidator<Expression> {
137140
accept('error', 'incompatible operand types', { node: expr });
138141
}
139142

143+
this.validateCrossModelFieldComparison(expr, accept);
140144
break;
141145
}
142146

@@ -158,43 +162,8 @@ export default class ExpressionValidator implements AstValidator<Expression> {
158162
break;
159163
}
160164

161-
// not supported:
162-
// - foo.a == bar
163-
// - foo.user.id == userId
164-
// except:
165-
// - future().userId == userId
166-
if (
167-
(isMemberAccessExpr(expr.left) &&
168-
isDataModelField(expr.left.member.ref) &&
169-
expr.left.member.ref.$container != getContainingDataModel(expr)) ||
170-
(isMemberAccessExpr(expr.right) &&
171-
isDataModelField(expr.right.member.ref) &&
172-
expr.right.member.ref.$container != getContainingDataModel(expr))
173-
) {
174-
// foo.user.id == auth().id
175-
// foo.user.id == "123"
176-
// foo.user.id == null
177-
// foo.user.id == EnumValue
178-
if (!(this.isNotModelFieldExpr(expr.left) || this.isNotModelFieldExpr(expr.right))) {
179-
const containingPolicyAttr = findUpAst(
180-
expr,
181-
(node) => isDataModelAttribute(node) && ['@@allow', '@@deny'].includes(node.decl.$refText)
182-
) as DataModelAttribute | undefined;
183-
184-
if (containingPolicyAttr) {
185-
const operation = getAttributeArgLiteral<string>(containingPolicyAttr, 'operation');
186-
if (operation?.split(',').includes('all') || operation?.split(',').includes('read')) {
187-
accept(
188-
'error',
189-
'comparison between fields of different models is not supported in model-level "read" rules',
190-
{
191-
node: expr,
192-
}
193-
);
194-
break;
195-
}
196-
}
197-
}
165+
if (!this.validateCrossModelFieldComparison(expr, accept)) {
166+
break;
198167
}
199168

200169
if (
@@ -262,6 +231,49 @@ export default class ExpressionValidator implements AstValidator<Expression> {
262231
}
263232
}
264233

234+
private validateCrossModelFieldComparison(expr: BinaryExpr, accept: ValidationAcceptor) {
235+
// not supported in "read" rules:
236+
// - foo.a == bar
237+
// - foo.user.id == userId
238+
// except:
239+
// - future().userId == userId
240+
if (
241+
(isMemberAccessExpr(expr.left) &&
242+
isDataModelField(expr.left.member.ref) &&
243+
expr.left.member.ref.$container != getContainingDataModel(expr)) ||
244+
(isMemberAccessExpr(expr.right) &&
245+
isDataModelField(expr.right.member.ref) &&
246+
expr.right.member.ref.$container != getContainingDataModel(expr))
247+
) {
248+
// foo.user.id == auth().id
249+
// foo.user.id == "123"
250+
// foo.user.id == null
251+
// foo.user.id == EnumValue
252+
if (!(this.isNotModelFieldExpr(expr.left) || this.isNotModelFieldExpr(expr.right))) {
253+
const containingPolicyAttr = findUpAst(
254+
expr,
255+
(node) => isDataModelAttribute(node) && ['@@allow', '@@deny'].includes(node.decl.$refText)
256+
) as DataModelAttribute | undefined;
257+
258+
if (containingPolicyAttr) {
259+
const operation = getAttributeArgLiteral<string>(containingPolicyAttr, 'operation');
260+
if (operation?.split(',').includes('all') || operation?.split(',').includes('read')) {
261+
accept(
262+
'error',
263+
'comparison between fields of different models is not supported in model-level "read" rules',
264+
{
265+
node: expr,
266+
}
267+
);
268+
return false;
269+
}
270+
}
271+
}
272+
}
273+
274+
return true;
275+
}
276+
265277
private validateCollectionPredicate(expr: BinaryExpr, accept: ValidationAcceptor) {
266278
if (!expr.$resolvedType) {
267279
accept('error', 'collection predicate can only be used on an array of model type', { node: expr });
@@ -273,9 +285,18 @@ export default class ExpressionValidator implements AstValidator<Expression> {
273285
return findUpAst(node, (n) => isDataModelAttribute(n) && n.decl.$refText === '@@validate');
274286
}
275287

276-
private isNotModelFieldExpr(expr: Expression) {
288+
private isNotModelFieldExpr(expr: Expression): boolean {
277289
return (
278-
isLiteralExpr(expr) || isEnumFieldReference(expr) || isNullExpr(expr) || this.isAuthOrAuthMemberAccess(expr)
290+
// literal
291+
isLiteralExpr(expr) ||
292+
// enum field
293+
isEnumFieldReference(expr) ||
294+
// null
295+
isNullExpr(expr) ||
296+
// `auth()` access
297+
this.isAuthOrAuthMemberAccess(expr) ||
298+
// array
299+
(isArrayExpr(expr) && expr.items.every((item) => this.isNotModelFieldExpr(item)))
279300
);
280301
}
281302

packages/schema/tests/schema/validation/attribute-validation.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -701,6 +701,37 @@ describe('Attribute tests', () => {
701701
`)
702702
).toContain('comparison between fields of different models is not supported in model-level "read" rules');
703703

704+
expect(
705+
await loadModelWithError(`
706+
${prelude}
707+
model User {
708+
id Int @id
709+
lists List[]
710+
todos Todo[]
711+
value Int
712+
}
713+
714+
model List {
715+
id Int @id
716+
user User @relation(fields: [userId], references: [id])
717+
userId Int
718+
todos Todo[]
719+
}
720+
721+
model Todo {
722+
id Int @id
723+
user User @relation(fields: [userId], references: [id])
724+
userId Int
725+
list List @relation(fields: [listId], references: [id])
726+
listId Int
727+
value Int
728+
729+
@@allow('all', list.user.value > value)
730+
}
731+
732+
`)
733+
).toContain('comparison between fields of different models is not supported in model-level "read" rules');
734+
704735
expect(
705736
await loadModel(`
706737
${prelude}
Lines changed: 8 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
import { loadSchema } from '@zenstackhq/testtools';
1+
import { loadModelWithError } from '@zenstackhq/testtools';
22
describe('issue 1506', () => {
33
it('regression', async () => {
4-
const { prisma, enhance } = await loadSchema(
5-
`
4+
await expect(
5+
loadModelWithError(
6+
`
67
model A {
78
id Int @id @default(autoincrement())
89
value Int
@@ -29,29 +30,10 @@ describe('issue 1506', () => {
2930
3031
@@allow('read', true)
3132
}
32-
`,
33-
{ preserveTsFiles: true, logPrismaQuery: true }
33+
`
34+
)
35+
).resolves.toContain(
36+
'comparison between fields of different models is not supported in model-level "read" rules'
3437
);
35-
36-
await prisma.a.create({
37-
data: {
38-
value: 3,
39-
b: {
40-
create: {
41-
value: 2,
42-
c: {
43-
create: {
44-
value: 1,
45-
},
46-
},
47-
},
48-
},
49-
},
50-
});
51-
52-
const db = enhance();
53-
const read = await db.a.findMany({ include: { b: true } });
54-
expect(read).toHaveLength(1);
55-
expect(read[0].b).toBeTruthy();
5638
});
5739
});

0 commit comments

Comments
 (0)