diff --git a/package.json b/package.json index dbd80b93b..a8af271b3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zenstack-monorepo", - "version": "1.10.0", + "version": "1.10.1", "description": "", "scripts": { "build": "pnpm -r build", diff --git a/packages/ide/jetbrains/build.gradle.kts b/packages/ide/jetbrains/build.gradle.kts index 2e7742364..578f2334e 100644 --- a/packages/ide/jetbrains/build.gradle.kts +++ b/packages/ide/jetbrains/build.gradle.kts @@ -9,7 +9,7 @@ plugins { } group = "dev.zenstack" -version = "1.10.0" +version = "1.10.1" repositories { mavenCentral() diff --git a/packages/ide/jetbrains/package.json b/packages/ide/jetbrains/package.json index b380dc39f..fcb014277 100644 --- a/packages/ide/jetbrains/package.json +++ b/packages/ide/jetbrains/package.json @@ -1,6 +1,6 @@ { "name": "jetbrains", - "version": "1.10.0", + "version": "1.10.1", "displayName": "ZenStack JetBrains IDE Plugin", "description": "ZenStack JetBrains IDE plugin", "homepage": "https://zenstack.dev", diff --git a/packages/language/package.json b/packages/language/package.json index 562ff434e..e0a904034 100644 --- a/packages/language/package.json +++ b/packages/language/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/language", - "version": "1.10.0", + "version": "1.10.1", "displayName": "ZenStack modeling language compiler", "description": "ZenStack modeling language compiler", "homepage": "https://zenstack.dev", diff --git a/packages/misc/redwood/package.json b/packages/misc/redwood/package.json index 2c04bf073..ff15cb51b 100644 --- a/packages/misc/redwood/package.json +++ b/packages/misc/redwood/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/redwood", "displayName": "ZenStack RedwoodJS Integration", - "version": "1.10.0", + "version": "1.10.1", "description": "CLI and runtime for integrating ZenStack with RedwoodJS projects.", "repository": { "type": "git", diff --git a/packages/plugins/openapi/package.json b/packages/plugins/openapi/package.json index c13ecd0c0..0aff04595 100644 --- a/packages/plugins/openapi/package.json +++ b/packages/plugins/openapi/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/openapi", "displayName": "ZenStack Plugin and Runtime for OpenAPI", - "version": "1.10.0", + "version": "1.10.1", "description": "ZenStack plugin and runtime supporting OpenAPI", "main": "index.js", "repository": { diff --git a/packages/plugins/swr/package.json b/packages/plugins/swr/package.json index 733cd6687..813b750f0 100644 --- a/packages/plugins/swr/package.json +++ b/packages/plugins/swr/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/swr", "displayName": "ZenStack plugin for generating SWR hooks", - "version": "1.10.0", + "version": "1.10.1", "description": "ZenStack plugin for generating SWR hooks", "main": "index.js", "repository": { diff --git a/packages/plugins/tanstack-query/package.json b/packages/plugins/tanstack-query/package.json index 05a401f30..49edb485d 100644 --- a/packages/plugins/tanstack-query/package.json +++ b/packages/plugins/tanstack-query/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/tanstack-query", "displayName": "ZenStack plugin for generating tanstack-query hooks", - "version": "1.10.0", + "version": "1.10.1", "description": "ZenStack plugin for generating tanstack-query hooks", "main": "index.js", "exports": { diff --git a/packages/plugins/trpc/package.json b/packages/plugins/trpc/package.json index 44422a105..8dbf8719b 100644 --- a/packages/plugins/trpc/package.json +++ b/packages/plugins/trpc/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/trpc", "displayName": "ZenStack plugin for tRPC", - "version": "1.10.0", + "version": "1.10.1", "description": "ZenStack plugin for tRPC", "main": "index.js", "repository": { diff --git a/packages/runtime/package.json b/packages/runtime/package.json index d4fb0b2a8..f77b44d13 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/runtime", "displayName": "ZenStack Runtime Library", - "version": "1.10.0", + "version": "1.10.1", "description": "Runtime of ZenStack for both client-side and server-side environments.", "repository": { "type": "git", diff --git a/packages/schema/package.json b/packages/schema/package.json index 0edd09d95..cf6598db3 100644 --- a/packages/schema/package.json +++ b/packages/schema/package.json @@ -3,7 +3,7 @@ "publisher": "zenstack", "displayName": "ZenStack Language Tools", "description": "Build scalable web apps with minimum code by defining authorization and validation rules inside the data schema that closer to the database", - "version": "1.10.0", + "version": "1.10.1", "author": { "name": "ZenStack Team" }, diff --git a/packages/schema/src/plugins/zod/utils/schema-gen.ts b/packages/schema/src/plugins/zod/utils/schema-gen.ts index 889ab1674..a13a316b4 100644 --- a/packages/schema/src/plugins/zod/utils/schema-gen.ts +++ b/packages/schema/src/plugins/zod/utils/schema-gen.ts @@ -4,6 +4,7 @@ import { getAttributeArg, getAttributeArgLiteral, getLiteral, + isDataModelFieldReference, isFromStdlib, } from '@zenstackhq/sdk'; import { @@ -205,10 +206,17 @@ export function makeValidationRefinements(model: DataModel) { const message = messageArg ? `, { message: ${JSON.stringify(messageArg)} }` : ''; try { - const expr = new TypeScriptExpressionTransformer({ + let expr = new TypeScriptExpressionTransformer({ context: ExpressionContext.ValidationRule, fieldReferenceContext: 'value', }).transform(valueArg); + + if (isDataModelFieldReference(valueArg)) { + // if the expression is a simple field reference, treat undefined + // as true since the all fields are optional in validation context + expr = `${expr} ?? true`; + } + return `.refine((value: any) => ${expr}${message})`; } catch (err) { if (err instanceof TypeScriptExpressionTransformerError) { diff --git a/packages/schema/src/utils/typescript-expression-transformer.ts b/packages/schema/src/utils/typescript-expression-transformer.ts index cd868d76c..ee63b718a 100644 --- a/packages/schema/src/utils/typescript-expression-transformer.ts +++ b/packages/schema/src/utils/typescript-expression-transformer.ts @@ -17,7 +17,7 @@ import { ThisExpr, UnaryExpr, } from '@zenstackhq/language/ast'; -import { ExpressionContext, getLiteral, isFromStdlib, isFutureExpr } from '@zenstackhq/sdk'; +import { ExpressionContext, getLiteral, isDataModelFieldReference, isFromStdlib, isFutureExpr } from '@zenstackhq/sdk'; import { match, P } from 'ts-pattern'; import { getIdFields } from './ast-utils'; @@ -258,7 +258,13 @@ export class TypeScriptExpressionTransformer { } private ensureBoolean(expr: string) { - return `(${expr} ?? false)`; + if (this.options.context === ExpressionContext.ValidationRule) { + // all fields are optional in a validation context, so we treat undefined + // as boolean true + return `(${expr} ?? true)`; + } else { + return `(${expr} ?? false)`; + } } // #endregion @@ -300,8 +306,18 @@ export class TypeScriptExpressionTransformer { } } - private unary(expr: UnaryExpr, normalizeUndefined: boolean): string { - return `(${expr.operator} ${this.transform(expr.operand, normalizeUndefined)})`; + private unary(expr: UnaryExpr, normalizeUndefined: boolean) { + const operand = this.transform(expr.operand, normalizeUndefined); + let result = `(${expr.operator} ${operand})`; + if ( + expr.operator === '!' && + this.options.context === ExpressionContext.ValidationRule && + isDataModelFieldReference(expr.operand) + ) { + // in a validation context, we treat unary involving undefined as boolean true + result = `(${operand} !== undefined ? (${result}): true)`; + } + return result; } private isModelType(expr: Expression) { @@ -316,16 +332,24 @@ export class TypeScriptExpressionTransformer { left = `(${left}?.id ?? null)`; right = `(${right}?.id ?? null)`; } - const _default = `(${left} ${expr.operator} ${right})`; + + 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)`; + } + } return match(expr.operator) - .with( - 'in', - () => - `(${this.transform(expr.right, false)}?.includes(${this.transform( - expr.left, - normalizeUndefined - )}) ?? false)` + .with('in', () => + this.ensureBoolean( + `${this.transform(expr.right, false)}?.includes(${this.transform(expr.left, normalizeUndefined)})` + ) ) .with(P.union('==', '!='), () => { if (isThisExpr(expr.left) || isThisExpr(expr.right)) { @@ -363,8 +387,8 @@ export class TypeScriptExpressionTransformer { const predicate = innerTransformer.transform(expr.right, normalizeUndefined); return match(operator) - .with('?', () => `!!((${operand})?.some((_item: any) => ${predicate}))`) - .with('!', () => `!!((${operand})?.every((_item: any) => ${predicate}))`) + .with('?', () => this.ensureBoolean(`(${operand})?.some((_item: any) => ${predicate})`)) + .with('!', () => this.ensureBoolean(`(${operand})?.every((_item: any) => ${predicate})`)) .with('^', () => `!((${operand})?.some((_item: any) => ${predicate}))`) .exhaustive(); } diff --git a/packages/sdk/package.json b/packages/sdk/package.json index d09ea775c..71d0e1144 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/sdk", - "version": "1.10.0", + "version": "1.10.1", "description": "ZenStack plugin development SDK", "main": "index.js", "scripts": { diff --git a/packages/server/package.json b/packages/server/package.json index 37b7c4fd3..76a711e8c 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/server", - "version": "1.10.0", + "version": "1.10.1", "displayName": "ZenStack Server-side Adapters", "description": "ZenStack server-side adapters", "homepage": "https://zenstack.dev", diff --git a/packages/testtools/package.json b/packages/testtools/package.json index 1ee34f784..ae102ca3d 100644 --- a/packages/testtools/package.json +++ b/packages/testtools/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/testtools", - "version": "1.10.0", + "version": "1.10.1", "description": "ZenStack Test Tools", "main": "index.js", "private": true, diff --git a/packages/testtools/src/schema.ts b/packages/testtools/src/schema.ts index c570c6a30..88ffa9ba8 100644 --- a/packages/testtools/src/schema.ts +++ b/packages/testtools/src/schema.ts @@ -105,7 +105,7 @@ plugin policy { plugin zod { provider = '@core/zod' - // preserveTsFiles = true + preserveTsFiles = true modelOnly = ${!options.fullZod} } `; diff --git a/tests/integration/tests/cli/plugins.test.ts b/tests/integration/tests/cli/plugins.test.ts index 716ac224e..6efb3bad4 100644 --- a/tests/integration/tests/cli/plugins.test.ts +++ b/tests/integration/tests/cli/plugins.test.ts @@ -116,6 +116,7 @@ describe('CLI Plugins Tests', () => { strict: true, lib: ['esnext', 'dom'], esModuleInterop: true, + skipLibCheck: true, }, }) ); diff --git a/tests/integration/tests/enhancements/with-policy/field-validation.test.ts b/tests/integration/tests/enhancements/with-policy/field-validation.test.ts index bb505ca55..d34c7183b 100644 --- a/tests/integration/tests/enhancements/with-policy/field-validation.test.ts +++ b/tests/integration/tests/enhancements/with-policy/field-validation.test.ts @@ -609,3 +609,157 @@ describe('With Policy: field validation', () => { expect(u.tasks[0]).toMatchObject({ slug: 'slug2' }); }); }); + +describe('With Policy: model-level validation', () => { + it('create', async () => { + const { enhance } = await loadSchema(` + model Model { + id Int @id @default(autoincrement()) + x Int + y Int + + @@validate(x > 0) + @@validate(x >= y) + @@allow('all', true) + } + `); + + const db = enhance(); + + await expect(db.model.create({ data: { x: 0, y: 0 } })).toBeRejectedByPolicy(); + await expect(db.model.create({ data: { x: 1, y: 2 } })).toBeRejectedByPolicy(); + await expect(db.model.create({ data: { x: 2, y: 1 } })).toResolveTruthy(); + }); + + it('update', async () => { + const { enhance } = await loadSchema(` + model Model { + id Int @id @default(autoincrement()) + x Int + y Int + + @@validate(x >= y) + @@allow('all', true) + } + `); + + const db = enhance(); + + await expect(db.model.create({ data: { x: 2, y: 1 } })).toResolveTruthy(); + await expect(db.model.create({ data: { x: 1, y: 2 } })).toBeRejectedByPolicy(); + }); + + it('int optionality', async () => { + const { enhance } = await loadSchema(` + model Model { + id Int @id @default(autoincrement()) + x Int? + + @@validate(x > 0) + @@allow('all', true) + } + `); + + const db = enhance(); + + await expect(db.model.create({ data: { x: 0 } })).toBeRejectedByPolicy(); + await expect(db.model.create({ data: { x: 1 } })).toResolveTruthy(); + await expect(db.model.create({ data: {} })).toResolveTruthy(); + }); + + it('boolean optionality', async () => { + const { enhance } = await loadSchema(` + model Model { + id Int @id @default(autoincrement()) + x Boolean? + + @@validate(x) + @@allow('all', true) + } + `); + + const db = enhance(); + + await expect(db.model.create({ data: { x: false } })).toBeRejectedByPolicy(); + await expect(db.model.create({ data: { x: true } })).toResolveTruthy(); + await expect(db.model.create({ data: {} })).toResolveTruthy(); + }); + + it('optionality with comparison', async () => { + const { enhance } = await loadSchema(` + model Model { + id Int @id @default(autoincrement()) + x Int? + y Int? + + @@validate(x > y) + @@allow('all', true) + } + `); + + const db = enhance(); + + await expect(db.model.create({ data: { x: 1, y: 2 } })).toBeRejectedByPolicy(); + await expect(db.model.create({ data: { x: 1 } })).toResolveTruthy(); + await expect(db.model.create({ data: { y: 1 } })).toResolveTruthy(); + await expect(db.model.create({ data: {} })).toResolveTruthy(); + }); + + it('optionality with complex expression', async () => { + const { enhance } = await loadSchema(` + model Model { + id Int @id @default(autoincrement()) + x Int? + y Int? + + @@validate(y > 1 && x > y) + @@allow('all', true) + } + `); + + const db = enhance(); + + await expect(db.model.create({ data: { y: 1 } })).toBeRejectedByPolicy(); + await expect(db.model.create({ data: { y: 2 } })).toResolveTruthy(); + await expect(db.model.create({ data: {} })).toResolveTruthy(); + await expect(db.model.create({ data: { x: 1, y: 2 } })).toBeRejectedByPolicy(); + await expect(db.model.create({ data: { x: 3, y: 2 } })).toResolveTruthy(); + }); + + it('optionality with negation', async () => { + const { enhance } = await loadSchema(` + model Model { + id Int @id @default(autoincrement()) + x Boolean? + + @@validate(!x) + @@allow('all', true) + } + `); + + const db = enhance(); + + await expect(db.model.create({ data: { x: true } })).toBeRejectedByPolicy(); + await expect(db.model.create({ data: { x: false } })).toResolveTruthy(); + await expect(db.model.create({ data: {} })).toResolveTruthy(); + }); + + it('update implied optionality', async () => { + const { enhance } = await loadSchema(` + model Model { + id Int @id @default(autoincrement()) + x Int + y Int + + @@validate(x > y) + @@allow('all', true) + } + `); + + const db = enhance(); + + await expect(db.model.create({ data: { id: 1, x: 2, y: 1 } })).toResolveTruthy(); + await expect(db.model.update({ where: { id: 1 }, data: { y: 1 } })).toResolveTruthy(); + await expect(db.model.update({ where: { id: 1 }, data: {} })).toResolveTruthy(); + }); +}); diff --git a/tests/integration/tests/regression/issue-1078.test.ts b/tests/integration/tests/regression/issue-1078.test.ts new file mode 100644 index 000000000..4f8ad6527 --- /dev/null +++ b/tests/integration/tests/regression/issue-1078.test.ts @@ -0,0 +1,52 @@ +import { loadSchema } from '@zenstackhq/testtools'; + +describe('issue 1078', () => { + it('regression', async () => { + const { prisma, enhance } = await loadSchema( + ` + model Counter { + id String @id + + name String + value Int + + @@validate(value >= 0) + @@allow('all', true) + } + ` + ); + + const db = enhance(); + + const counter = await db.counter.create({ + data: { id: '1', name: 'It should create', value: 1 }, + }); + + //! This query fails validation + const updated = await db.counter.update({ + where: { id: '1' }, + data: { name: 'It should update' }, + }); + }); + + it('read', async () => { + const { prisma, enhance } = await loadSchema( + ` + model Post { + id Int @id() @default(autoincrement()) + title String @allow('read', true, true) + content String + } + `, + { logPrismaQuery: true } + ); + + const db = enhance(); + + const post = await prisma.post.create({ data: { title: 'Post1', content: 'Content' } }); + await expect(db.post.findUnique({ where: { id: post.id } })).toResolveNull(); + await expect(db.post.findUnique({ where: { id: post.id }, select: { title: true } })).resolves.toEqual({ + title: 'Post1', + }); + }); +});