diff --git a/packages/schema/src/plugins/access-policy/typescript-expression-transformer.ts b/packages/schema/src/plugins/access-policy/typescript-expression-transformer.ts index d5acda4b1..3c70f8562 100644 --- a/packages/schema/src/plugins/access-policy/typescript-expression-transformer.ts +++ b/packages/schema/src/plugins/access-policy/typescript-expression-transformer.ts @@ -17,6 +17,7 @@ import { name } from '.'; import { FILTER_OPERATOR_FUNCTIONS } from '../../language-server/constants'; import { isAuthInvocation } from '../../utils/ast-utils'; import { isFutureExpr } from './utils'; +import { isFromStdlib } from '../../language-server/utils'; /** * Transforms ZModel expression to plain TypeScript expression. @@ -103,12 +104,17 @@ export default class TypeScriptExpressionTransformer { if (isAuthInvocation(expr)) { return 'user'; - } else if (FILTER_OPERATOR_FUNCTIONS.includes(expr.function.ref.name)) { + } + + const funcName = expr.function.ref.name; + const isStdFunc = isFromStdlib(expr.function.ref); + + if (isStdFunc && FILTER_OPERATOR_FUNCTIONS.includes(funcName)) { // arguments are already type-checked const arg0 = this.transform(expr.args[0].value, false); let result: string; - switch (expr.function.ref.name) { + switch (funcName) { case 'contains': { const caseInsensitive = getLiteral(expr.args[2]?.value) === true; if (caseInsensitive) { @@ -121,40 +127,52 @@ export default class TypeScriptExpressionTransformer { } break; } + case 'search': throw new PluginError(name, '"search" function must be used against a field'); + case 'startsWith': result = `${arg0}?.startsWith(${this.transform(expr.args[1].value, normalizeUndefined)})`; break; + case 'endsWith': result = `${arg0}?.endsWith(${this.transform(expr.args[1].value, normalizeUndefined)})`; break; + case 'has': result = `${arg0}?.includes(${this.transform(expr.args[1].value, normalizeUndefined)})`; break; + case 'hasEvery': result = `${this.transform( expr.args[1].value, normalizeUndefined )}?.every((item) => ${arg0}?.includes(item))`; break; + case 'hasSome': result = `${this.transform( expr.args[1].value, normalizeUndefined )}?.some((item) => ${arg0}?.includes(item))`; break; + case 'isEmpty': result = `${arg0}?.length === 0`; break; + default: throw new PluginError(name, `Function invocation is not supported: ${expr.function.ref?.name}`); } return `(${result} ?? false)`; - } else { - throw new PluginError(name, `Function invocation is not supported: ${expr.function.ref?.name}`); } + + if (isStdFunc && funcName === 'now') { + return `(new Date())`; + } + + throw new PluginError(name, `Function invocation is not supported: ${expr.function.ref?.name}`); } private reference(expr: ReferenceExpr) { diff --git a/packages/schema/tests/generator/expression-writer.test.ts b/packages/schema/tests/generator/expression-writer.test.ts index 178abe78e..0acfe4002 100644 --- a/packages/schema/tests/generator/expression-writer.test.ts +++ b/packages/schema/tests/generator/expression-writer.test.ts @@ -1,3 +1,5 @@ +/// + import { DataModel, Enum, Expression, isDataModel, isEnum } from '@zenstackhq/language/ast'; import { GUARD_FIELD_NAME } from '@zenstackhq/sdk'; import * as tmp from 'tmp'; @@ -1132,153 +1134,167 @@ describe('Expression Writer Tests', () => { ` ); }); -}); -it('filter operators non-field access', async () => { - const userInit = `{ id: 'user1', email: 'test@zenstack.dev', roles: [Role.ADMIN] }`; - const prelude = ` - enum Role { - USER - ADMIN - } + it('filter operators non-field access', async () => { + const userInit = `{ id: 'user1', email: 'test@zenstack.dev', roles: [Role.ADMIN] }`; + const prelude = ` + enum Role { + USER + ADMIN + } + + model User { + id String @id + email String + roles Role[] + } + `; - model User { - id String @id - email String - roles Role[] - } - `; - - await check( - ` - ${prelude} - model Test { - id String @id - @@allow('all', ADMIN in auth().roles) - } - `, - (model) => model.attributes[0].args[1].value, - `{zenstack_guard:(user?.roles?.includes(Role.ADMIN)??false)}`, - userInit - ); - - await check( - ` - ${prelude} - model Test { - id String @id - roles Role[] - @@allow('all', ADMIN in roles) - } - `, - (model) => model.attributes[0].args[1].value, - `{roles:{has:Role.ADMIN}}`, - userInit - ); - - await check( - ` - ${prelude} - model Test { - id String @id - @@allow('all', contains(auth().email, 'test')) - } - `, - (model) => model.attributes[0].args[1].value, - `{zenstack_guard:(user?.email?.includes('test')??false)}`, - userInit - ); - - await check( - ` - ${prelude} - model Test { - id String @id - @@allow('all', contains(auth().email, 'test', true)) - } - `, - (model) => model.attributes[0].args[1].value, - `{zenstack_guard:(user?.email?.toLowerCase().includes('test'?.toLowerCase())??false)}`, - userInit - ); - - await check( - ` - ${prelude} - model Test { - id String @id - @@allow('all', startsWith(auth().email, 'test')) - } - `, - (model) => model.attributes[0].args[1].value, - `{zenstack_guard:(user?.email?.startsWith('test')??false)}`, - userInit - ); - - await check( - ` - ${prelude} - model Test { - id String @id - @@allow('all', endsWith(auth().email, 'test')) - } - `, - (model) => model.attributes[0].args[1].value, - `{zenstack_guard:(user?.email?.endsWith('test')??false)}`, - userInit - ); - - await check( - ` - ${prelude} - model Test { - id String @id - @@allow('all', has(auth().roles, ADMIN)) - } - `, - (model) => model.attributes[0].args[1].value, - `{zenstack_guard:(user?.roles?.includes(Role.ADMIN)??false)}`, - userInit - ); - - await check( - ` - ${prelude} - model Test { - id String @id - @@allow('all', hasEvery(auth().roles, [ADMIN, USER])) - } - `, - (model) => model.attributes[0].args[1].value, - `{zenstack_guard:([Role.ADMIN,Role.USER]?.every((item)=>user?.roles?.includes(item))??false)}`, - userInit - ); - - await check( - ` - ${prelude} - model Test { - id String @id - @@allow('all', hasSome(auth().roles, [USER, ADMIN])) - } - `, - (model) => model.attributes[0].args[1].value, - `{zenstack_guard:([Role.USER,Role.ADMIN]?.some((item)=>user?.roles?.includes(item))??false)}`, - userInit - ); - - await check( - ` - ${prelude} - model Test { - id String @id - @@allow('all', isEmpty(auth().roles)) - } - `, - (model) => model.attributes[0].args[1].value, - `{zenstack_guard:(user?.roles?.length===0??false)}`, - userInit - ); + await check( + ` + ${prelude} + model Test { + id String @id + @@allow('all', ADMIN in auth().roles) + } + `, + (model) => model.attributes[0].args[1].value, + `{zenstack_guard:(user?.roles?.includes(Role.ADMIN)??false)}`, + userInit + ); + + await check( + ` + ${prelude} + model Test { + id String @id + roles Role[] + @@allow('all', ADMIN in roles) + } + `, + (model) => model.attributes[0].args[1].value, + `{roles:{has:Role.ADMIN}}`, + userInit + ); + + await check( + ` + ${prelude} + model Test { + id String @id + @@allow('all', contains(auth().email, 'test')) + } + `, + (model) => model.attributes[0].args[1].value, + `{zenstack_guard:(user?.email?.includes('test')??false)}`, + userInit + ); + + await check( + ` + ${prelude} + model Test { + id String @id + @@allow('all', contains(auth().email, 'test', true)) + } + `, + (model) => model.attributes[0].args[1].value, + `{zenstack_guard:(user?.email?.toLowerCase().includes('test'?.toLowerCase())??false)}`, + userInit + ); + + await check( + ` + ${prelude} + model Test { + id String @id + @@allow('all', startsWith(auth().email, 'test')) + } + `, + (model) => model.attributes[0].args[1].value, + `{zenstack_guard:(user?.email?.startsWith('test')??false)}`, + userInit + ); + + await check( + ` + ${prelude} + model Test { + id String @id + @@allow('all', endsWith(auth().email, 'test')) + } + `, + (model) => model.attributes[0].args[1].value, + `{zenstack_guard:(user?.email?.endsWith('test')??false)}`, + userInit + ); + + await check( + ` + ${prelude} + model Test { + id String @id + @@allow('all', has(auth().roles, ADMIN)) + } + `, + (model) => model.attributes[0].args[1].value, + `{zenstack_guard:(user?.roles?.includes(Role.ADMIN)??false)}`, + userInit + ); + + await check( + ` + ${prelude} + model Test { + id String @id + @@allow('all', hasEvery(auth().roles, [ADMIN, USER])) + } + `, + (model) => model.attributes[0].args[1].value, + `{zenstack_guard:([Role.ADMIN,Role.USER]?.every((item)=>user?.roles?.includes(item))??false)}`, + userInit + ); + + await check( + ` + ${prelude} + model Test { + id String @id + @@allow('all', hasSome(auth().roles, [USER, ADMIN])) + } + `, + (model) => model.attributes[0].args[1].value, + `{zenstack_guard:([Role.USER,Role.ADMIN]?.some((item)=>user?.roles?.includes(item))??false)}`, + userInit + ); + + await check( + ` + ${prelude} + model Test { + id String @id + @@allow('all', isEmpty(auth().roles)) + } + `, + (model) => model.attributes[0].args[1].value, + `{zenstack_guard:(user?.roles?.length===0??false)}`, + userInit + ); + }); + + it('now() function', async () => { + await check( + ` + model Test { + id String @id + createdAt DateTime @default(now()) + @@allow('all', createdAt <= now()) + } + `, + (model) => model.attributes[0].args[1].value, + `{ createdAt: { lte: (new Date()) } }` + ); + }); }); async function check(schema: string, getExpr: (model: DataModel) => Expression, expected: string, userInit?: string) { diff --git a/tests/integration/tests/e2e/mist-function-coverage.test.ts b/tests/integration/tests/e2e/mist-function-coverage.test.ts new file mode 100644 index 000000000..2bf9dd08f --- /dev/null +++ b/tests/integration/tests/e2e/mist-function-coverage.test.ts @@ -0,0 +1,33 @@ +import { loadSchema } from '@zenstackhq/testtools'; + +describe('Misc Function Coverage Tests', () => { + it('now() function', async () => { + const { withPresets } = await loadSchema( + ` + model Foo { + id String @id @default(cuid()) + dt DateTime @default(now()) + @@allow('create,read', true) + @@allow('update', now() >= dt && future().dt > now()) + } + ` + ); + + const db = withPresets(); + const now = new Date(); + + await db.foo.create({ data: { id: '1', dt: new Date(now.getTime() + 1000) } }); + // violates `dt <= now()` + await expect(db.foo.update({ where: { id: '1' }, data: { dt: now } })).toBeRejectedByPolicy(); + + await db.foo.create({ data: { id: '2', dt: now } }); + // violates `future().dt > now()` + await expect(db.foo.update({ where: { id: '2' }, data: { dt: now } })).toBeRejectedByPolicy(); + + // success + await expect( + db.foo.update({ where: { id: '2' }, data: { dt: new Date(now.getTime() + 1000) } }) + ).toResolveTruthy(); + expect(await db.foo.findUnique({ where: { id: '2' } })).toMatchObject({ dt: new Date(now.getTime() + 1000) }); + }); +});