Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: @@validate should ignore fields that are not present #1104

Merged
merged 4 commits into from
Mar 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
isEnum,
isReferenceExpr,
} from '@zenstackhq/language/ast';
import { isFutureExpr, isRelationshipField, resolved } from '@zenstackhq/sdk';
import { isDataModelFieldReference, isFutureExpr, isRelationshipField, resolved } from '@zenstackhq/sdk';
import { ValidationAcceptor, streamAst } from 'langium';
import pluralize from 'pluralize';
import { AstValidator } from '../types';
Expand Down Expand Up @@ -151,6 +151,19 @@ export default class AttributeApplicationValidator implements AstValidator<Attri
}
}

@check('@@validate')
private _checkValidate(attr: AttributeApplication, accept: ValidationAcceptor) {
const condition = attr.args[0]?.value;
if (
condition &&
streamAst(condition).some(
(node) => isDataModelFieldReference(node) && isDataModel(node.$resolvedType?.decl)
)
) {
accept('error', `\`@@validate\` condition cannot use relation fields`, { node: condition });
}
}

private validatePolicyKinds(
kind: string,
candidates: string[],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,17 @@ import {
Expression,
ExpressionType,
isDataModel,
isDataModelAttribute,
isDataModelField,
isEnum,
isLiteralExpr,
isMemberAccessExpr,
isNullExpr,
isThisExpr,
isDataModelField,
isLiteralExpr,
} from '@zenstackhq/language/ast';
import { isDataModelFieldReference, isEnumFieldReference } from '@zenstackhq/sdk';
import { ValidationAcceptor } from 'langium';
import { getContainingDataModel, isAuthInvocation, isCollectionPredicate } from '../../utils/ast-utils';
import { AstNode, ValidationAcceptor } from 'langium';
import { findUpAst, getContainingDataModel, isAuthInvocation, isCollectionPredicate } from '../../utils/ast-utils';
import { AstValidator } from '../types';
import { typeAssignable } from './utils';

Expand Down Expand Up @@ -123,6 +124,17 @@ export default class ExpressionValidator implements AstValidator<Expression> {

case '==':
case '!=': {
if (this.isInValidationContext(expr)) {
// in validation context, all fields are optional, so we should allow
// comparing any field against null
if (
(isDataModelFieldReference(expr.left) && isNullExpr(expr.right)) ||
(isDataModelFieldReference(expr.right) && isNullExpr(expr.left))
) {
return;
}
}

if (!!expr.left.$resolvedType?.array !== !!expr.right.$resolvedType?.array) {
accept('error', 'incompatible operand types', { node: expr });
break;
Expand All @@ -132,18 +144,24 @@ export default class ExpressionValidator implements AstValidator<Expression> {
// - foo.user.id == userId
// except:
// - future().userId == userId
if(isMemberAccessExpr(expr.left) && isDataModelField(expr.left.member.ref) && expr.left.member.ref.$container != getContainingDataModel(expr)
|| isMemberAccessExpr(expr.right) && isDataModelField(expr.right.member.ref) && expr.right.member.ref.$container != getContainingDataModel(expr))
{
if (
(isMemberAccessExpr(expr.left) &&
isDataModelField(expr.left.member.ref) &&
expr.left.member.ref.$container != getContainingDataModel(expr)) ||
(isMemberAccessExpr(expr.right) &&
isDataModelField(expr.right.member.ref) &&
expr.right.member.ref.$container != getContainingDataModel(expr))
) {
// foo.user.id == auth().id
// foo.user.id == "123"
// foo.user.id == null
// foo.user.id == EnumValue
if(!(this.isNotModelFieldExpr(expr.left) || this.isNotModelFieldExpr(expr.right)))
{
accept('error', 'comparison between fields of different models are not supported', { node: expr });
break;
}
if (!(this.isNotModelFieldExpr(expr.left) || this.isNotModelFieldExpr(expr.right))) {
accept('error', 'comparison between fields of different models are not supported', {
node: expr,
});
break;
}
}

if (
Expand Down Expand Up @@ -205,14 +223,17 @@ export default class ExpressionValidator implements AstValidator<Expression> {
}
}

private isInValidationContext(node: AstNode) {
return findUpAst(node, (n) => isDataModelAttribute(n) && n.decl.$refText === '@@validate');
}

private isNotModelFieldExpr(expr: Expression) {
return isLiteralExpr(expr) || isEnumFieldReference(expr) || isNullExpr(expr) || this.isAuthOrAuthMemberAccess(expr)
return (
isLiteralExpr(expr) || isEnumFieldReference(expr) || isNullExpr(expr) || this.isAuthOrAuthMemberAccess(expr)
);
}

private isAuthOrAuthMemberAccess(expr: Expression) {
return isAuthInvocation(expr) || (isMemberAccessExpr(expr) && isAuthInvocation(expr.operand));
}

}

17 changes: 15 additions & 2 deletions packages/schema/src/utils/ast-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,6 @@ export function isCollectionPredicate(node: AstNode): node is BinaryExpr {
return isBinaryExpr(node) && ['?', '!', '^'].includes(node.operator);
}


export function getContainingDataModel(node: Expression): DataModel | undefined {
let curr: AstNode | undefined = node.$container;
while (curr) {
Expand All @@ -167,4 +166,18 @@ export function getContainingDataModel(node: Expression): DataModel | undefined
curr = curr.$container;
}
return undefined;
}
}

/**
* Walk upward from the current AST node to find the first node that satisfies the predicate.
*/
export function findUpAst(node: AstNode, predicate: (node: AstNode) => boolean): AstNode | undefined {
let curr: AstNode | undefined = node;
while (curr) {
if (predicate(curr)) {
return curr;
}
curr = curr.$container;
}
return undefined;
}
131 changes: 100 additions & 31 deletions packages/schema/src/utils/typescript-expression-transformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,6 @@ import {
DataModel,
Expression,
InvocationExpr,
isDataModel,
isEnumField,
isThisExpr,
LiteralExpr,
MemberAccessExpr,
NullExpr,
Expand All @@ -16,9 +13,15 @@ import {
StringLiteral,
ThisExpr,
UnaryExpr,
isArrayExpr,
isDataModel,
isEnumField,
isLiteralExpr,
isNullExpr,
isThisExpr,
} from '@zenstackhq/language/ast';
import { ExpressionContext, getLiteral, isDataModelFieldReference, isFromStdlib, isFutureExpr } from '@zenstackhq/sdk';
import { match, P } from 'ts-pattern';
import { P, match } from 'ts-pattern';
import { getIdFields } from './ast-utils';

export class TypeScriptExpressionTransformerError extends Error {
Expand Down Expand Up @@ -168,13 +171,17 @@ export class TypeScriptExpressionTransformer {
const max = getLiteral<number>(args[2]);
let result: string;
if (min === undefined) {
result = `(${field}?.length > 0)`;
result = this.ensureBooleanTernary(args[0], field, `${field}?.length > 0`);
} else if (max === undefined) {
result = `(${field}?.length >= ${min})`;
result = this.ensureBooleanTernary(args[0], field, `${field}?.length >= ${min}`);
} else {
result = `(${field}?.length >= ${min} && ${field}?.length <= ${max})`;
result = this.ensureBooleanTernary(
args[0],
field,
`${field}?.length >= ${min} && ${field}?.length <= ${max}`
);
}
return this.ensureBoolean(result);
return result;
}

@func('contains')
Expand Down Expand Up @@ -208,25 +215,29 @@ export class TypeScriptExpressionTransformer {
private _regex(args: Expression[]) {
const field = this.transform(args[0], false);
const pattern = getLiteral<string>(args[1]);
return `new RegExp(${JSON.stringify(pattern)}).test(${field})`;
return this.ensureBooleanTernary(args[0], field, `new RegExp(${JSON.stringify(pattern)}).test(${field})`);
}

@func('email')
private _email(args: Expression[]) {
const field = this.transform(args[0], false);
return `z.string().email().safeParse(${field}).success`;
return this.ensureBooleanTernary(args[0], field, `z.string().email().safeParse(${field}).success`);
}

@func('datetime')
private _datetime(args: Expression[]) {
const field = this.transform(args[0], false);
return `z.string().datetime({ offset: true }).safeParse(${field}).success`;
return this.ensureBooleanTernary(
args[0],
field,
`z.string().datetime({ offset: true }).safeParse(${field}).success`
);
}

@func('url')
private _url(args: Expression[]) {
const field = this.transform(args[0], false);
return `z.string().url().safeParse(${field}).success`;
return this.ensureBooleanTernary(args[0], field, `z.string().url().safeParse(${field}).success`);
}

@func('has')
Expand All @@ -239,22 +250,27 @@ export class TypeScriptExpressionTransformer {
@func('hasEvery')
private _hasEvery(args: Expression[], normalizeUndefined: boolean) {
const field = this.transform(args[0], false);
const result = `${this.transform(args[1], normalizeUndefined)}?.every((item) => ${field}?.includes(item))`;
return this.ensureBoolean(result);
return this.ensureBooleanTernary(
args[0],
field,
`${this.transform(args[1], normalizeUndefined)}?.every((item) => ${field}?.includes(item))`
);
}

@func('hasSome')
private _hasSome(args: Expression[], normalizeUndefined: boolean) {
const field = this.transform(args[0], false);
const result = `${this.transform(args[1], normalizeUndefined)}?.some((item) => ${field}?.includes(item))`;
return this.ensureBoolean(result);
return this.ensureBooleanTernary(
args[0],
field,
`${this.transform(args[1], normalizeUndefined)}?.some((item) => ${field}?.includes(item))`
);
}

@func('isEmpty')
private _isEmpty(args: Expression[]) {
const field = this.transform(args[0], false);
const result = `(!${field} || ${field}?.length === 0)`;
return this.ensureBoolean(result);
return `(!${field} || ${field}?.length === 0)`;
}

private ensureBoolean(expr: string) {
Expand All @@ -263,7 +279,22 @@ export class TypeScriptExpressionTransformer {
// as boolean true
return `(${expr} ?? true)`;
} else {
return `(${expr} ?? false)`;
return `((${expr}) ?? false)`;
}
}

private ensureBooleanTernary(predicate: Expression, transformedPredicate: string, value: string) {
if (isLiteralExpr(predicate) || isArrayExpr(predicate)) {
// these are never undefined
return value;
}

if (this.options.context === ExpressionContext.ValidationRule) {
// all fields are optional in a validation context, so we treat undefined
// as boolean true
return `((${transformedPredicate}) !== undefined ? (${value}): true)`;
} else {
return `((${transformedPredicate}) !== undefined ? (${value}): false)`;
}
}

Expand Down Expand Up @@ -315,7 +346,7 @@ export class TypeScriptExpressionTransformer {
isDataModelFieldReference(expr.operand)
) {
// in a validation context, we treat unary involving undefined as boolean true
result = `(${operand} !== undefined ? (${result}): true)`;
result = this.ensureBooleanTernary(expr.operand, operand, result);
}
return result;
}
Expand All @@ -336,21 +367,45 @@ export class TypeScriptExpressionTransformer {
let _default = `(${left} ${expr.operator} ${right})`;

if (this.options.context === ExpressionContext.ValidationRule) {
// in a validation context, we treat binary involving undefined as boolean true
if (isDataModelFieldReference(expr.left)) {
_default = `(${left} !== undefined ? (${_default}): true)`;
}
if (isDataModelFieldReference(expr.right)) {
_default = `(${right} !== undefined ? (${_default}): true)`;
const nullComparison = this.extractNullComparison(expr);
if (nullComparison) {
// null comparison covers both null and undefined
const { fieldRef } = nullComparison;
const field = this.transform(fieldRef, normalizeUndefined);
if (expr.operator === '==') {
_default = `(${field} === null || ${field} === undefined)`;
} else if (expr.operator === '!=') {
_default = `(${field} !== null && ${field} !== undefined)`;
}
} else {
// for other comparisons, in a validation context,
// we treat binary involving undefined as boolean true
if (isDataModelFieldReference(expr.left)) {
_default = this.ensureBooleanTernary(expr.left, left, _default);
}
if (isDataModelFieldReference(expr.right)) {
_default = this.ensureBooleanTernary(expr.right, right, _default);
}
}
}

return match(expr.operator)
.with('in', () =>
this.ensureBoolean(
`${this.transform(expr.right, false)}?.includes(${this.transform(expr.left, normalizeUndefined)})`
)
)
.with('in', () => {
const left = `${this.transform(expr.left, normalizeUndefined)}`;
const right = `${this.transform(expr.right, false)}`;
let result = `${right}?.includes(${left})`;
if (this.options.context === ExpressionContext.ValidationRule) {
// in a validation context, we treat binary involving undefined as boolean true
result = this.ensureBooleanTernary(
expr.left,
left,
this.ensureBooleanTernary(expr.right, right, result)
);
} else {
result = this.ensureBoolean(result);
}
return result;
})
.with(P.union('==', '!='), () => {
if (isThisExpr(expr.left) || isThisExpr(expr.right)) {
// map equality comparison with `this` to id comparison
Expand All @@ -376,6 +431,20 @@ export class TypeScriptExpressionTransformer {
.otherwise(() => _default);
}

private extractNullComparison(expr: BinaryExpr) {
if (expr.operator !== '==' && expr.operator !== '!=') {
return undefined;
}

if (isDataModelFieldReference(expr.left) && isNullExpr(expr.right)) {
return { fieldRef: expr.left, nullExpr: expr.right };
} else if (isDataModelFieldReference(expr.right) && isNullExpr(expr.left)) {
return { fieldRef: expr.right, nullExpr: expr.left };
} else {
return undefined;
}
}

private collectionPredicate(expr: BinaryExpr, operator: '?' | '!' | '^', normalizeUndefined: boolean) {
const operand = this.transform(expr.left, normalizeUndefined);
const innerTransformer = new TypeScriptExpressionTransformer({
Expand Down
Loading
Loading