diff --git a/package.json b/package.json index 1e6c7ae20..5b22e84ff 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "zenstack-v3", "displayName": "ZenStack", "description": "ZenStack", - "version": "3.9.3", + "version": "3.9.4", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/auth-adapters/better-auth/package.json b/packages/auth-adapters/better-auth/package.json index 6f99bea89..79c08f48d 100644 --- a/packages/auth-adapters/better-auth/package.json +++ b/packages/auth-adapters/better-auth/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/better-auth", "displayName": "ZenStack Better Auth Adapter", "description": "ZenStack Better Auth Adapter. This adapter is modified from better-auth's Prisma adapter.", - "version": "3.9.3", + "version": "3.9.4", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/cli/package.json b/packages/cli/package.json index bafccb1ab..ea9e43cfc 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/cli", "displayName": "ZenStack CLI", "description": "FullStack database toolkit with built-in access control and automatic API generation.", - "version": "3.9.3", + "version": "3.9.4", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/cli/src/proxy.ts b/packages/cli/src/proxy.ts index 56c389d19..6703e381a 100644 --- a/packages/cli/src/proxy.ts +++ b/packages/cli/src/proxy.ts @@ -37,10 +37,10 @@ export function normalizePublicKey(key: string): string { return `-----BEGIN PUBLIC KEY-----\n${b64}\n-----END PUBLIC KEY-----`; } -export interface CreateProxyAppOptions { - client: ClientContract; - schema: SchemaDef; - authDb?: ClientContract; +export interface CreateProxyAppOptions { + client: ClientContract; + schema: Schema; + authDb?: ClientContract; auth?: { studioAuthKey?: string; /** Seconds within which a signed request is considered valid. Defaults to 60. */ @@ -49,37 +49,7 @@ export interface CreateProxyAppOptions { cors?: Parameters[0]; } -export function createProxyApp(options: CreateProxyAppOptions): Hono; -export function createProxyApp( - client: ClientContract, - schema: SchemaDef, - authDb?: ClientContract, - auth?: { - studioAuthKey?: string; - signatureToleranceSecs?: number; - }, -): Hono; -export function createProxyApp( - optionsOrClient: CreateProxyAppOptions | ClientContract, - schema?: SchemaDef, - authDb?: ClientContract, - auth?: { - studioAuthKey?: string; - signatureToleranceSecs?: number; - }, -): Hono { - let options: CreateProxyAppOptions; - if ('client' in optionsOrClient && 'schema' in optionsOrClient) { - options = optionsOrClient as CreateProxyAppOptions; - } else { - options = { - client: optionsOrClient as ClientContract, - schema: schema!, - authDb, - auth, - }; - } - +export function createProxyApp(options: CreateProxyAppOptions): Hono { const app = new Hono(); app.use('*', cors(options.cors)); @@ -93,7 +63,7 @@ export function createProxyApp( app.use( '/api/model/*', - createHonoHandler({ + createHonoHandler({ apiHandler: new RPCApiHandler({ schema: options.schema }), getClient: (c) => resolveClient(options.client, options.authDb ?? options.client, c, !!options.auth?.studioAuthKey), @@ -174,12 +144,12 @@ export function createSignatureMiddleware(publicKey: string, toleranceSeconds: n }; } -export function resolveClient( - client: ClientContract, - authDb: ClientContract, +export function resolveClient( + client: ClientContract, + authDb: ClientContract, c: Context, isAuthKeyEnabled: boolean, -): ClientContract { +): ClientContract { const authHeader = c.req.header('authorization'); if (!isAuthKeyEnabled && !authHeader) { @@ -204,6 +174,6 @@ export function resolveClient( if (claim.type === 'superUser') { return client; } else { - return authDb.$setAuth(claim.data as any) as ClientContract; + return authDb.$setAuth(claim.data as any) as ClientContract; } } diff --git a/packages/cli/test/db/pull.test.ts b/packages/cli/test/db/pull.test.ts index 920332208..101a738db 100644 --- a/packages/cli/test/db/pull.test.ts +++ b/packages/cli/test/db/pull.test.ts @@ -652,6 +652,7 @@ enum Status { score Float @gte(0.0) rating Decimal @lt(10) rank BigInt @lte(999) + extId String @uuid }`, ); runCli('db push', workDir); diff --git a/packages/cli/test/proxy.test.ts b/packages/cli/test/proxy.test.ts index 2e523b69a..bf4aae283 100644 --- a/packages/cli/test/proxy.test.ts +++ b/packages/cli/test/proxy.test.ts @@ -65,9 +65,14 @@ async function createPolicyApp(zmodel: string) { const authDb = client.$use(new PolicyPlugin()); return { client, - app: createProxyApp(client, client.$schema, authDb, { - studioAuthKey: TEST_PUBLIC_KEY, - signatureToleranceSecs: 60, + app: createProxyApp({ + client, + schema: client.$schema, + authDb, + auth: { + studioAuthKey: TEST_PUBLIC_KEY, + signatureToleranceSecs: 60, + }, }), }; } @@ -106,7 +111,11 @@ describe('CLI proxy tests', () => { const client = await createTestClient(zmodel); const authDb = client.$use(new PolicyPlugin()); - const app = createProxyApp(client, client.$schema, authDb); + const app = createProxyApp({ + client, + schema: client.$schema, + authDb, + }); const baseUrl = await startAt(app); const r = await fetch(`${baseUrl}/api/schema`); @@ -169,7 +178,11 @@ describe('CLI proxy tests', () => { }); const authDb = client.$use(new PolicyPlugin()); - const app = createProxyApp(client, client.$schema, authDb); + const app = createProxyApp({ + client, + schema: client.$schema, + authDb, + }); const baseUrl = await startAt(app); // Create a user via the proxy API. @@ -217,7 +230,11 @@ describe('CLI proxy tests', () => { const client = await createTestClient(zmodel); const authDb = client.$use(new PolicyPlugin()); - const app = createProxyApp(client, client.$schema, authDb); + const app = createProxyApp({ + client, + schema: client.$schema, + authDb, + }); const baseUrl = await startAt(app); const txRes = await fetch(`${baseUrl}/api/model/$transaction/sequential`, { @@ -397,7 +414,11 @@ describe('CLI proxy tests', () => { // No studioAuthKey — backwards-compatible mode const client = await createTestClient(zmodel); const authDb = client.$use(new PolicyPlugin()); - const app = createProxyApp(client, client.$schema, authDb); + const app = createProxyApp({ + client, + schema: client.$schema, + authDb, + }); const baseUrl = await startAt(app); // No signature header — should still work @@ -426,9 +447,14 @@ describe('CLI proxy tests', () => { const client = await createTestClient(zmodel); const authDb = client.$use(new PolicyPlugin()); // Pass the key as raw base64 DER — no PEM markers - const app = createProxyApp(client, client.$schema, authDb, { - studioAuthKey: TEST_PUBLIC_KEY_DER, - signatureToleranceSecs: 60, + const app = createProxyApp({ + client, + schema: client.$schema, + authDb, + auth: { + studioAuthKey: TEST_PUBLIC_KEY_DER, + signatureToleranceSecs: 60, + }, }); const baseUrl = await startAt(app); @@ -449,9 +475,14 @@ describe('CLI proxy tests', () => { // No studioAuthKey option — would normally fall back to env var via run(); // here we verify the middleware still works when the resolved key is provided. const authDb = client.$use(new PolicyPlugin()); - const app = createProxyApp(client, client.$schema, authDb, { - studioAuthKey: process.env['ZENSTACK_STUDIO_AUTH_KEY'], - signatureToleranceSecs: 60, + const app = createProxyApp({ + client, + schema: client.$schema, + authDb, + auth: { + studioAuthKey: process.env['ZENSTACK_STUDIO_AUTH_KEY'], + signatureToleranceSecs: 60, + }, }); const baseUrl = await startAt(app); @@ -480,9 +511,14 @@ describe('CLI proxy tests', () => { it('should reject a request whose timestamp is older than the tolerance window', async () => { const client = await createTestClient(zmodel); const authDb = client.$use(new PolicyPlugin()); - const app = createProxyApp(client, client.$schema, authDb, { - studioAuthKey: TEST_PUBLIC_KEY, - signatureToleranceSecs: 60, + const app = createProxyApp({ + client, + schema: client.$schema, + authDb, + auth: { + studioAuthKey: TEST_PUBLIC_KEY, + signatureToleranceSecs: 60, + }, }); const baseUrl = await startAt(app); @@ -507,9 +543,14 @@ describe('CLI proxy tests', () => { it('should reject a request whose timestamp is too far in the future', async () => { const client = await createTestClient(zmodel); const authDb = client.$use(new PolicyPlugin()); - const app = createProxyApp(client, client.$schema, authDb, { - studioAuthKey: TEST_PUBLIC_KEY, - signatureToleranceSecs: 60, + const app = createProxyApp({ + client, + schema: client.$schema, + authDb, + auth: { + studioAuthKey: TEST_PUBLIC_KEY, + signatureToleranceSecs: 60, + }, }); const baseUrl = await startAt(app); @@ -535,9 +576,14 @@ describe('CLI proxy tests', () => { const client = await createTestClient(zmodel); // Custom tolerance of 300 seconds const authDb = client.$use(new PolicyPlugin()); - const app = createProxyApp(client, client.$schema, authDb, { - studioAuthKey: TEST_PUBLIC_KEY, - signatureToleranceSecs: 300, + const app = createProxyApp({ + client, + schema: client.$schema, + authDb, + auth: { + studioAuthKey: TEST_PUBLIC_KEY, + signatureToleranceSecs: 300, + }, }); const baseUrl = await startAt(app); @@ -561,9 +607,14 @@ describe('CLI proxy tests', () => { const client = await createTestClient(zmodel); const authDb = client.$use(new PolicyPlugin()); // Very tight tolerance of 5 seconds - const app = createProxyApp(client, client.$schema, authDb, { - studioAuthKey: TEST_PUBLIC_KEY, - signatureToleranceSecs: 5, + const app = createProxyApp({ + client, + schema: client.$schema, + authDb, + auth: { + studioAuthKey: TEST_PUBLIC_KEY, + signatureToleranceSecs: 5, + }, }); const baseUrl = await startAt(app); @@ -597,9 +648,14 @@ describe('CLI proxy tests', () => { it('should reject a valid signature if it was produced without the Authorization token', async () => { const client = await createTestClient(zmodel); const authDb = client.$use(new PolicyPlugin()); - const app = createProxyApp(client, client.$schema, authDb, { - studioAuthKey: TEST_PUBLIC_KEY, - signatureToleranceSecs: 60, + const app = createProxyApp({ + client, + schema: client.$schema, + authDb, + auth: { + studioAuthKey: TEST_PUBLIC_KEY, + signatureToleranceSecs: 60, + }, }); const baseUrl = await startAt(app); @@ -627,9 +683,14 @@ describe('CLI proxy tests', () => { it('should accept a request where the signature covers the Authorization token', async () => { const client = await createTestClient(zmodel); const authDb = client.$use(new PolicyPlugin()); - const app = createProxyApp(client, client.$schema, authDb, { - studioAuthKey: TEST_PUBLIC_KEY, - signatureToleranceSecs: 60, + const app = createProxyApp({ + client, + schema: client.$schema, + authDb, + auth: { + studioAuthKey: TEST_PUBLIC_KEY, + signatureToleranceSecs: 60, + }, }); const baseUrl = await startAt(app); diff --git a/packages/clients/client-helpers/package.json b/packages/clients/client-helpers/package.json index 2aad5c83e..8c2a56eb3 100644 --- a/packages/clients/client-helpers/package.json +++ b/packages/clients/client-helpers/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/client-helpers", "displayName": "ZenStack Client Helpers", "description": "Helpers for implementing clients that consume ZenStack's CRUD service", - "version": "3.9.3", + "version": "3.9.4", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/clients/fetch-client/package.json b/packages/clients/fetch-client/package.json index 8ac360f1e..8c8d0fef3 100644 --- a/packages/clients/fetch-client/package.json +++ b/packages/clients/fetch-client/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/fetch-client", "displayName": "ZenStack Fetch Client", "description": "Simple fetch-based client for consuming ZenStack's RPC-style CRUD API", - "version": "3.9.3", + "version": "3.9.4", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/clients/tanstack-query/package.json b/packages/clients/tanstack-query/package.json index 91ffaa8d2..138757afb 100644 --- a/packages/clients/tanstack-query/package.json +++ b/packages/clients/tanstack-query/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/tanstack-query", "displayName": "ZenStack TanStack Query Integration", "description": "TanStack Query Client for consuming ZenStack v3's CRUD service", - "version": "3.9.3", + "version": "3.9.4", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/common-helpers/package.json b/packages/common-helpers/package.json index 9511d797a..ff992909a 100644 --- a/packages/common-helpers/package.json +++ b/packages/common-helpers/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/common-helpers", "displayName": "ZenStack Common Helpers", "description": "ZenStack Common Helpers", - "version": "3.9.3", + "version": "3.9.4", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/config/eslint-config/package.json b/packages/config/eslint-config/package.json index 90af2ab02..e65b1283c 100644 --- a/packages/config/eslint-config/package.json +++ b/packages/config/eslint-config/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/eslint-config", - "version": "3.9.3", + "version": "3.9.4", "type": "module", "private": true, "license": "MIT" diff --git a/packages/config/tsdown-config/package.json b/packages/config/tsdown-config/package.json index f4d1a663c..3b5fa089c 100644 --- a/packages/config/tsdown-config/package.json +++ b/packages/config/tsdown-config/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/tsdown-config", - "version": "3.9.3", + "version": "3.9.4", "private": true, "type": "module", "license": "MIT", diff --git a/packages/config/typescript-config/package.json b/packages/config/typescript-config/package.json index 01fa90127..7c991a981 100644 --- a/packages/config/typescript-config/package.json +++ b/packages/config/typescript-config/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/typescript-config", - "version": "3.9.3", + "version": "3.9.4", "private": true, "license": "MIT" } diff --git a/packages/config/vitest-config/package.json b/packages/config/vitest-config/package.json index 29f35e5b3..7304c5014 100644 --- a/packages/config/vitest-config/package.json +++ b/packages/config/vitest-config/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/vitest-config", "type": "module", - "version": "3.9.3", + "version": "3.9.4", "private": true, "license": "MIT", "exports": { diff --git a/packages/create-zenstack/package.json b/packages/create-zenstack/package.json index b6a036a6f..3948cae8e 100644 --- a/packages/create-zenstack/package.json +++ b/packages/create-zenstack/package.json @@ -2,7 +2,7 @@ "name": "create-zenstack", "displayName": "Create ZenStack", "description": "Create a new ZenStack project", - "version": "3.9.3", + "version": "3.9.4", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/ide/vscode/package.json b/packages/ide/vscode/package.json index 22e9719e7..07342b5b0 100644 --- a/packages/ide/vscode/package.json +++ b/packages/ide/vscode/package.json @@ -1,7 +1,7 @@ { "name": "zenstack-v3", "publisher": "zenstack", - "version": "3.9.3", + "version": "3.9.4", "displayName": "ZenStack V3 Language Tools", "description": "VSCode extension for ZenStack (v3) ZModel language", "private": true, diff --git a/packages/language/package.json b/packages/language/package.json index db8a41558..86f934bc0 100644 --- a/packages/language/package.json +++ b/packages/language/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/language", "displayName": "ZenStack Language Tooling", "description": "ZenStack ZModel language specification", - "version": "3.9.3", + "version": "3.9.4", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/language/res/stdlib.zmodel b/packages/language/res/stdlib.zmodel index a8970a1d0..5ec7d7e02 100644 --- a/packages/language/res/stdlib.zmodel +++ b/packages/language/res/stdlib.zmodel @@ -542,6 +542,11 @@ attribute @contains(_ text: String, _ message: String?) @@@targetField([StringFi */ attribute @regex(_ regex: String, _ message: String?) @@@targetField([StringField]) @@@validation @@@lite +/** + * Validates a string field value is a valid UUID. + */ +attribute @uuid(_ version: Int?, _ message: String?) @@@targetField([StringField]) @@@validation @@@lite + /** * Validates a string field value is a valid email address. */ @@ -649,6 +654,12 @@ function isDate(field: String): Boolean { function isTime(field: String, precision: Int?): Boolean { } @@@expressionContext([ValidationRule]) +/** + * Validates a string field value is a valid UUID. + */ +function isUuid(field: String, version: Int?): Boolean { +} @@@expressionContext([ValidationRule]) + /** * Validates a string field value is a valid url. */ diff --git a/packages/language/src/utils.ts b/packages/language/src/utils.ts index 6c184babd..aa2ecfe3e 100644 --- a/packages/language/src/utils.ts +++ b/packages/language/src/utils.ts @@ -19,6 +19,7 @@ import { isLiteralExpr, isMemberAccessExpr, isModel, + isNumberLiteral, isObjectExpr, isPlugin, isReferenceExpr, @@ -78,6 +79,13 @@ export function getStringLiteral(node: AstNode | undefined): string | undefined return isStringLiteral(node) ? node.value : undefined; } +/** + * Try getting number value from a potential number literal expression + */ +export function getNumberLiteral(node: AstNode | undefined): number | undefined { + return isNumberLiteral(node) ? Number(node.value) : undefined; +} + const isoDateTimeRegex = /^\d{4}(-\d\d(-\d\d(T\d\d:\d\d(:\d\d)?(\.\d+)?(([+-]\d\d:\d\d)|Z)?)?)?)?$/i; /** diff --git a/packages/language/src/validators/attribute-application-validator.ts b/packages/language/src/validators/attribute-application-validator.ts index daaaf7b42..cdb0ed969 100644 --- a/packages/language/src/validators/attribute-application-validator.ts +++ b/packages/language/src/validators/attribute-application-validator.ts @@ -29,6 +29,7 @@ import { getAttributeArg, getContainingDataModel, getDataSourceProvider, + getNumberLiteral, getStringLiteral, hasAttribute, isAuthOrAuthMemberAccess, @@ -460,6 +461,18 @@ export default class AttributeApplicationValidator implements AstValidator arg.$resolvedParam?.name === 'version'); + if (!versionArg) { + return; + } + const version = getNumberLiteral(versionArg.value); + if (version !== undefined && version !== 4 && version !== 7) { + accept('error', `\`@uuid\` version must be \`4\` or \`7\``, { node: versionArg }); + } + } + @check('@@schema') private _checkSchema(attr: AttributeApplication, accept: ValidationAcceptor) { const schemaName = getStringLiteral(attr.args[0]?.value); diff --git a/packages/language/src/validators/function-invocation-validator.ts b/packages/language/src/validators/function-invocation-validator.ts index 8ce1035e3..9f722b490 100644 --- a/packages/language/src/validators/function-invocation-validator.ts +++ b/packages/language/src/validators/function-invocation-validator.ts @@ -209,6 +209,20 @@ export default class FunctionInvocationValidator implements AstValidator(versionArg); + if (version !== undefined && version !== 4 && version !== 7) { + accept('error', 'second argument must be 4 or 7', { + node: expr.args[1]!, + }); + } + } + } + @func('cuid') private _checkCuid(expr: InvocationExpr, accept: ValidationAcceptor) { // first argument must be 1 or 2 if provided diff --git a/packages/language/test/attribute-application.test.ts b/packages/language/test/attribute-application.test.ts index aaec9f987..9abe8ffe9 100644 --- a/packages/language/test/attribute-application.test.ts +++ b/packages/language/test/attribute-application.test.ts @@ -588,6 +588,125 @@ describe('Attribute application validation tests', () => { }); }); + describe('Field-level @uuid attribute', () => { + it('does not require a version arg', async () => { + await loadSchema( + ` + datasource db { + provider = 'sqlite' + url = 'file:./dev.db' + } + + model User { + id String @id @uuid + } + `, + ); + }); + + it('accepts supported version args', async () => { + await loadSchema( + ` + datasource db { + provider = 'sqlite' + url = 'file:./dev.db' + } + + model User { + id String @id @uuid(4) + } + `, + ); + + await loadSchema( + ` + datasource db { + provider = 'sqlite' + url = 'file:./dev.db' + } + + model User { + id String @id @uuid(7) + } + `, + ); + }); + + it('rejects unsupported version args', async () => { + await loadSchemaWithError( + ` + datasource db { + provider = 'sqlite' + url = 'file:./dev.db' + } + + model User { + id String @id @uuid(1) + } + `, + /`@uuid` version must be `4` or `7`/, + ); + + await loadSchemaWithError( + ` + datasource db { + provider = 'sqlite' + url = 'file:./dev.db' + } + + model User { + id String @id @uuid(2) + } + `, + /`@uuid` version must be `4` or `7`/, + ); + }); + + it('resolves the version arg by name regardless of argument order', async () => { + await loadSchema( + ` + datasource db { + provider = 'sqlite' + url = 'file:./dev.db' + } + + model User { + id String @id @uuid(message: 'invalid uuid', version: 7) + } + `, + ); + + await loadSchemaWithError( + ` + datasource db { + provider = 'sqlite' + url = 'file:./dev.db' + } + + model User { + id String @id @uuid(message: 'invalid uuid', version: 1) + } + `, + /`@uuid` version must be `4` or `7`/, + ); + }); + + it('does not treat a message-only arg as a version', async () => { + await loadSchema( + ` + datasource db { + provider = 'sqlite' + url = 'file:./dev.db' + } + + model User { + id String @id @uuid(message: 'invalid uuid') + } + `, + ); + }); + }); + describe('Native type mapping attributes', () => { describe('sqlite', () => { it('rejects when any native type mapping attribute is used', async () => { diff --git a/packages/language/test/function-invocation.test.ts b/packages/language/test/function-invocation.test.ts index ff6bb45ef..68d20a092 100644 --- a/packages/language/test/function-invocation.test.ts +++ b/packages/language/test/function-invocation.test.ts @@ -414,4 +414,52 @@ describe('Function Invocation Tests', () => { ); }); }); + + describe('isUuid() version validation', () => { + it('should accept valid uuid versions', async () => { + await loadSchema(` + datasource db { + provider = 'sqlite' + url = 'file:./dev.db' + } + + model User { + id String @id @default(uuid(4)) + + @@validate(isUuid(id, 4)) + } + `); + + await loadSchema(` + datasource db { + provider = 'sqlite' + url = 'file:./dev.db' + } + + model User { + id String @id @default(uuid(7)) + + @@validate(isUuid(id, 7)) + } + `); + }); + + it('should reject invalid uuid versions', async () => { + await loadSchemaWithError( + ` + datasource db { + provider = 'sqlite' + url = 'file:./dev.db' + } + + model User { + id String @id @default(uuid()) + + @@validate(isUuid(id, 1)) + } + `, + 'second argument must be 4 or 7', + ); + }); + }); }); diff --git a/packages/orm/package.json b/packages/orm/package.json index daefd9a76..e2455a73f 100644 --- a/packages/orm/package.json +++ b/packages/orm/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/orm", "displayName": "ZenStack ORM", "description": "ZenStack ORM", - "version": "3.9.3", + "version": "3.9.4", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/orm/src/client/client-impl.ts b/packages/orm/src/client/client-impl.ts index 74b6305a5..47dc82c2f 100644 --- a/packages/orm/src/client/client-impl.ts +++ b/packages/orm/src/client/client-impl.ts @@ -122,9 +122,13 @@ export class ClientImpl { this.auth = baseClient.auth; this.slowQueries = baseClient.slowQueries; } else { - const driver = new ZenStackDriver(options.dialect.createDriver(), new Log(this.$options.log ?? [])); - const compiler = options.dialect.createQueryCompiler(); const adapter = options.dialect.createAdapter(); + const driver = new ZenStackDriver( + options.dialect.createDriver(), + new Log(this.$options.log ?? []), + adapter, + ); + const compiler = options.dialect.createQueryCompiler(); const connectionProvider = new DefaultConnectionProvider(driver); this.kyselyProps = { @@ -185,26 +189,28 @@ export class ClientImpl { 'computedFields' in options ? (options.computedFields as Record | undefined) : undefined; for (const [modelName, modelDef] of Object.entries(this.$schema.models)) { - if (modelDef.computedFields) { - for (const fieldName of Object.keys(modelDef.computedFields)) { - // check both uncapitalized (current) and original (backward compat) model name - const modelConfig = - computedFieldsConfig?.[lowerCaseFirst(modelName)] ?? computedFieldsConfig?.[modelName]; - const fieldConfig = modelConfig?.[fieldName]; - // Check if the computed field has a configuration - if (fieldConfig === null || fieldConfig === undefined) { - throw createConfigError( - `Computed field "${fieldName}" in model "${modelName}" does not have a configuration. ` + - `Please provide an implementation in the computedFields option.`, - ); - } - // Check that the configuration is a function - if (typeof fieldConfig !== 'function') { - throw createConfigError( - `Computed field "${fieldName}" in model "${modelName}" has an invalid configuration: ` + - `expected a function but received ${typeof fieldConfig}.`, - ); - } + for (const [fieldName, fieldDef] of Object.entries(modelDef.fields)) { + // a computed field inherited from a delegate base is configured on the base model + if (!fieldDef.computed || fieldDef.originModel) { + continue; + } + // check both uncapitalized (current) and original (backward compat) model name + const modelConfig = + computedFieldsConfig?.[lowerCaseFirst(modelName)] ?? computedFieldsConfig?.[modelName]; + const fieldConfig = modelConfig?.[fieldName]; + // Check if the computed field has a configuration + if (fieldConfig === null || fieldConfig === undefined) { + throw createConfigError( + `Computed field "${fieldName}" in model "${modelName}" does not have a configuration. ` + + `Please provide an implementation in the computedFields option.`, + ); + } + // Check that the configuration is a function + if (typeof fieldConfig !== 'function') { + throw createConfigError( + `Computed field "${fieldName}" in model "${modelName}" has an invalid configuration: ` + + `expected a function but received ${typeof fieldConfig}.`, + ); } } } diff --git a/packages/orm/src/client/crud-types.ts b/packages/orm/src/client/crud-types.ts index 9541c90b7..b92854807 100644 --- a/packages/orm/src/client/crud-types.ts +++ b/packages/orm/src/client/crud-types.ts @@ -1157,24 +1157,22 @@ export type FtsRelevanceOrderBy R` — the same source - * `ComputedFieldsOptions` reads, so the implementation signature and the query-time args - * can never drift apart. Resolves to `never` for non-parameterized fields. + * The query-time arguments object of a parameterized computed field, derived from the field's + * `params` metadata in the schema — the same source the runtime forwards to the implementation + * and the zod factory validates against, so typing and validation can never drift apart. Param + * types resolve the way procedure params do: scalars to their TS types, enums to their value + * union, type defs to their object shape. `ComputedFieldsOptions` reads this too, so the + * implementation signature always matches the query input. Resolves to `never` for + * non-parameterized fields. */ export type ComputedFieldArgs< Schema extends SchemaDef, Model extends GetModels, Field extends GetModelFields, -> = 'computedFields' extends keyof GetModel - ? Field extends keyof GetModel['computedFields'] - ? GetModel['computedFields'][Field] extends (...args: infer P) => any - ? P extends [any, infer Args] - ? Args - : never - : never - : never - : never; +> = + GetModelField extends { computed: true; params: infer Params } + ? MapParamsObject + : never; /** * Whether `Field` is a parameterized computed field (its args object is not `never`). @@ -2897,22 +2895,25 @@ export type GetProcedure = keyof { +// The `params` metadata record (`{ name, type, array?, optional? }` per key) is shared by +// procedures and parameterized computed fields; these helpers map it to the TS args object. + +type _OptionalParamNames = keyof { [K in keyof Params as Params[K] extends { optional: true } ? K : never]: K; }; -type _RequiredProcedureParamNames = keyof { +type _RequiredParamNames = keyof { [K in keyof Params as Params[K] extends { optional: true } ? never : K]: K; }; -type _HasRequiredProcedureParams = _RequiredProcedureParamNames extends never ? false : true; +type _HasRequiredParams = _RequiredParamNames extends never ? false : true; -type MapProcedureArgsObject = Simplify< +type MapParamsObject = Simplify< Optional< { - [K in keyof Params]: MapProcedureParam; + [K in keyof Params]: MapParam; }, - _OptionalProcedureParamNames + _OptionalParamNames > >; @@ -2923,11 +2924,11 @@ export type ProcedureEnvelope< > = keyof Params extends never ? // no params { args?: Record } - : _HasRequiredProcedureParams extends true + : _HasRequiredParams extends true ? // has required params - { args: MapProcedureArgsObject } + { args: MapParamsObject } : // no required params - { args?: MapProcedureArgsObject }; + { args?: MapParamsObject }; type ProcedureHandlerCtx> = { client: ClientContract; @@ -2937,7 +2938,7 @@ type ProcedureHandlerCtx> = ( - ...args: _HasRequiredProcedureParams> extends true + ...args: _HasRequiredParams> extends true ? [input: ProcedureEnvelope] : [input?: ProcedureEnvelope] ) => MaybePromise>>; @@ -2955,7 +2956,7 @@ type MapProcedureReturn = Proc extends { returnT : MapType : never; -type MapProcedureParam = P extends { type: infer U } +type MapParam = P extends { type: infer U } ? OrUndefinedIf< P extends { array: true } ? Array> : MapType, P extends { optional: true } ? true : false diff --git a/packages/orm/src/client/crud/dialects/base-dialect.ts b/packages/orm/src/client/crud/dialects/base-dialect.ts index 5d71fe78f..4210f99cf 100644 --- a/packages/orm/src/client/crud/dialects/base-dialect.ts +++ b/packages/orm/src/client/crud/dialects/base-dialect.ts @@ -1700,8 +1700,8 @@ export abstract class BaseCrudDialect { modelDef: ModelDef, payload: boolean | FindArgs, any, true>, ) { - if (modelDef.computedFields) { - // computed fields requires explicit select + if (Object.values(modelDef.fields).some((f) => f.computed)) { + // computed fields require explicit select return false; } diff --git a/packages/orm/src/client/executor/connection-mutex.ts b/packages/orm/src/client/executor/connection-mutex.ts new file mode 100644 index 000000000..924a6294b --- /dev/null +++ b/packages/orm/src/client/executor/connection-mutex.ts @@ -0,0 +1,30 @@ +/** + * This mutex is used to ensure that only one operation at a time can + * acquire a connection from the driver. This is necessary when the + * driver only has a single connection, like SQLite and PGlite. + * + * @see {@link https://github.com/kysely-org/kysely/blob/478ec67b2de2568f5590a015d3e120644e81bd87/src/driver/connection-mutex.ts|Kysely Source} + */ +export class ConnectionMutex { + #promise?: Promise; + #resolve?: () => void; + + async obtainLock(): Promise { + while (this.#promise) { + await this.#promise; + } + + this.#promise = new Promise((resolve) => { + this.#resolve = resolve; + }); + } + + releaseLock(): void { + const resolve = this.#resolve; + + this.#promise = undefined; + this.#resolve = undefined; + + resolve?.(); + } +} diff --git a/packages/orm/src/client/executor/name-mapper.ts b/packages/orm/src/client/executor/name-mapper.ts index a5891a7e4..3157537c9 100644 --- a/packages/orm/src/client/executor/name-mapper.ts +++ b/packages/orm/src/client/executor/name-mapper.ts @@ -817,7 +817,10 @@ export class QueryNameMapper extends OperationNodeTransformer { private processEnumSelection(selection: SelectionNodeChild, fieldName: string) { const { alias, node } = stripAlias(selection); - const fieldScope = this.resolveFieldFromScopes(fieldName); + const fieldScope = this.resolveFieldFromScopes( + fieldName, + ReferenceNode.is(node) ? node.table?.table?.identifier.name : undefined, + ); if (!fieldScope || !fieldScope.model) { return selection; } diff --git a/packages/orm/src/client/executor/zenstack-driver.ts b/packages/orm/src/client/executor/zenstack-driver.ts index 747acdeda..90e9fef3c 100644 --- a/packages/orm/src/client/executor/zenstack-driver.ts +++ b/packages/orm/src/client/executor/zenstack-driver.ts @@ -1,4 +1,13 @@ -import type { CompiledQuery, DatabaseConnection, Driver, Log, QueryResult, TransactionSettings } from 'kysely'; +import type { + CompiledQuery, + DatabaseConnection, + DialectAdapter, + Driver, + Log, + QueryResult, + TransactionSettings, +} from 'kysely'; +import { ConnectionMutex } from './connection-mutex'; /** * Copied from kysely's RuntimeDriver @@ -6,6 +15,7 @@ import type { CompiledQuery, DatabaseConnection, Driver, Log, QueryResult, Trans export class ZenStackDriver implements Driver { readonly #driver: Driver; readonly #log: Log; + readonly #connectionMutex?: ConnectionMutex; #initPromise?: Promise; #initDone: boolean; @@ -13,10 +23,14 @@ export class ZenStackDriver implements Driver { #connections = new WeakSet(); #txConnections = new WeakMap Promise>>(); - constructor(driver: Driver, log: Log) { + constructor(driver: Driver, log: Log, adapter: DialectAdapter) { this.#initDone = false; this.#driver = driver; this.#log = log; + + if (!adapter.supportsMultipleConnections) { + this.#connectionMutex = new ConnectionMutex(); + } } async init(): Promise { @@ -48,21 +62,30 @@ export class ZenStackDriver implements Driver { await this.init(); } - const connection = await this.#driver.acquireConnection(); + await this.#connectionMutex?.obtainLock(); - if (!this.#connections.has(connection)) { - if (this.#needsLogging()) { - this.#addLogging(connection); - } + try { + const connection = await this.#driver.acquireConnection(); + if (!this.#connections.has(connection)) { + if (this.#needsLogging()) { + this.#addLogging(connection); + } - this.#connections.add(connection); + this.#connections.add(connection); + } + return connection; + } catch (error) { + this.#connectionMutex?.releaseLock(); + throw error; } - - return connection; } async releaseConnection(connection: DatabaseConnection): Promise { - await this.#driver.releaseConnection(connection); + try { + await this.#driver.releaseConnection(connection); + } finally { + this.#connectionMutex?.releaseLock(); + } } async beginTransaction(connection: DatabaseConnection, settings: TransactionSettings): Promise { diff --git a/packages/orm/src/client/options.ts b/packages/orm/src/client/options.ts index 29f60281f..64f018211 100644 --- a/packages/orm/src/client/options.ts +++ b/packages/orm/src/client/options.ts @@ -1,12 +1,23 @@ -import type { GetModel, GetModelFields, GetModels, ProcedureDef, ScalarFields, SchemaDef } from '@zenstackhq/schema'; +import type { + FieldIsArray, + GetModelField, + GetModelFields, + GetModelFieldType, + GetModels, + ModelFieldIsOptional, + ProcedureDef, + ScalarFields, + SchemaDef, +} from '@zenstackhq/schema'; import type { Dialect, Expression, ExpressionBuilder, KyselyConfig, OperandExpression } from 'kysely'; import type { FilterPropertyToKind } from './constants'; import type { ClientContract, CRUD_EXT } from './contract'; -import type { GetProcedureNames, ProcedureHandlerFunc } from './crud-types'; +import type { ComputedFieldArgs, FieldHasComputedArgs, GetProcedureNames, ProcedureHandlerFunc } from './crud-types'; import type { BaseCrudDialect } from './crud/dialects/base-dialect'; import type { AllCrudOperations } from './crud/operations/base'; import type { AnyPlugin } from './plugin'; import type { ToKyselySchema } from './query-builder'; +import type { WrapType } from '../utils/type-utils'; export type ZModelFunctionContext = { /** @@ -299,26 +310,73 @@ export type ComputedFieldContext = { client: ClientContract; }; +/** + * The computed fields a model declares itself, keyed by name. A computed field inherited from a + * delegate base is excluded: it's configured once, on the base model. + */ +type OwnComputedFields> = keyof { + [Field in GetModelFields as GetModelField extends { computed: true } + ? GetModelField extends { originModel: string } + ? never + : Field + : never]: Field; +}; + +/** + * Implementations of the schema's computed fields, keyed by (uncapitalized) model name and then + * by field name. Everything is derived from the field definitions: which fields need an + * implementation, the query-time `args` of a parameterized field (from its `params` metadata, + * the same source the query input types use), and the value type the expression must produce. + */ export type ComputedFieldsOptions = { - [Model in GetModels as 'computedFields' extends keyof GetModel - ? Uncapitalize - : never]: { - [Field in keyof Schema['models'][Model]['computedFields']]: Schema['models'][Model]['computedFields'][Field] extends infer Func - ? Func extends (...args: infer Params) => infer R - ? ( - // inject a first parameter for expression builder - p: ExpressionBuilder, Model>, - // runtime-provided context (the generated stub only declares - // `modelAlias`; the runtime passes the full context) - context: ComputedFieldContext, - // query-time args of a parameterized field, from the stub - ...args: Params extends [any, ...infer Rest] ? Rest : [] - ) => OperandExpression // wrap the return type with Kysely `OperandExpression` - : never - : never; + [Model in GetModels as [OwnComputedFields] extends [never] ? never : Uncapitalize]: { + [Field in OwnComputedFields]: ( + // inject a first parameter for expression builder + p: ExpressionBuilder, Model>, + // runtime-provided context + context: ComputedFieldContext, + // query-time args of a parameterized field + ...args: ComputedFieldImplArgs + ) => OperandExpression>; }; }; +/** + * The trailing parameter list of a computed field implementation: `[args]` for a parameterized + * field, empty otherwise. + */ +type ComputedFieldImplArgs< + Schema extends SchemaDef, + Model extends GetModels, + Field extends GetModelFields, +> = FieldHasComputedArgs extends true ? [args: ComputedFieldArgs] : []; + +/** + * The value type a computed field's expression must produce, from the field's declared type. + * Scalars map to their JS types (`Decimal` is accepted as `number`); `DateTime`, `Json`, + * `Bytes`, enums and type defs are `unknown`, since their database-level representation differs + * from the ORM result type. An optional field also accepts `null`, a list field an array. + */ +type ComputedFieldResultType< + Schema extends SchemaDef, + Model extends GetModels, + Field extends GetModelFields, +> = WrapType< + ComputedFieldBaseType>, + ModelFieldIsOptional, + FieldIsArray +>; + +type ComputedFieldBaseType = T extends 'String' + ? string + : T extends 'Boolean' + ? boolean + : T extends 'Int' | 'Float' | 'Decimal' + ? number + : T extends 'BigInt' + ? bigint + : unknown; + export type HasComputedFields = string extends GetModels ? false : keyof ComputedFieldsOptions extends never ? false : true; diff --git a/packages/orm/test/schema/schema.ts b/packages/orm/test/schema/schema.ts index e0dff2a49..c5d6e1f65 100644 --- a/packages/orm/test/schema/schema.ts +++ b/packages/orm/test/schema/schema.ts @@ -185,13 +185,6 @@ export class SchemaType implements SchemaDef { idFields: ["id"], uniqueFields: { id: { type: "String" } - }, - computedFields: { - finalPrice(_context: { - modelAlias: string; - }): number { - throw new Error("This is a stub for computed field"); - } } }, Asset: { diff --git a/packages/plugins/policy/package.json b/packages/plugins/policy/package.json index bbd07520f..d2318c285 100644 --- a/packages/plugins/policy/package.json +++ b/packages/plugins/policy/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/plugin-policy", "displayName": "ZenStack Access Policy Plugin", "description": "ZenStack plugin that enforces access control policies defined in the schema", - "version": "3.9.3", + "version": "3.9.4", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/plugins/soft-delete/package.json b/packages/plugins/soft-delete/package.json index 7bea6c37b..b5d1cbab8 100644 --- a/packages/plugins/soft-delete/package.json +++ b/packages/plugins/soft-delete/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/plugin-soft-delete", "displayName": "ZenStack Soft Delete Plugin", "description": "ZenStack plugin that implements soft-delete by intercepting Kysely queries", - "version": "3.9.3", + "version": "3.9.4", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/schema/package.json b/packages/schema/package.json index 6bd9dd7f2..fa086873c 100644 --- a/packages/schema/package.json +++ b/packages/schema/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/schema", "displayName": "ZenStack Schema Object Model", "description": "TypeScript representation of ZModel schema", - "version": "3.9.3", + "version": "3.9.4", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/schema/src/schema.ts b/packages/schema/src/schema.ts index 3953ec724..b6689dace 100644 --- a/packages/schema/src/schema.ts +++ b/packages/schema/src/schema.ts @@ -31,7 +31,6 @@ export type ModelDef = { attributes?: readonly AttributeApplication[]; uniqueFields: Record; idFields: readonly string[]; - computedFields?: Record; isDelegate?: boolean; subModels?: readonly string[]; isView?: boolean; diff --git a/packages/sdk/package.json b/packages/sdk/package.json index c4925af52..5b8b03760 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/sdk", "displayName": "ZenStack SDK", "description": "Utilities for building ZenStack plugins", - "version": "3.9.3", + "version": "3.9.4", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/sdk/src/ts-schema-generator.ts b/packages/sdk/src/ts-schema-generator.ts index 1ab6befbc..b3c70eaca 100644 --- a/packages/sdk/src/ts-schema-generator.ts +++ b/packages/sdk/src/ts-schema-generator.ts @@ -11,7 +11,6 @@ import { DataModelAttribute, Enum, Expression, - FunctionParamType, InvocationExpr, isArrayExpr, isBinaryExpr, @@ -469,14 +468,6 @@ export class TsSchemaGenerator { ...(dm.isView ? [ts.factory.createPropertyAssignment('isView', ts.factory.createTrue())] : []), ]; - const computedFields = allFields.filter((f) => hasAttribute(f, '@computed') && !getDelegateOriginModel(f, dm)); - - if (computedFields.length > 0) { - fields.push( - ts.factory.createPropertyAssignment('computedFields', this.createComputedFieldsObject(computedFields)), - ); - } - return ts.factory.createObjectLiteralExpression(fields, true); } @@ -553,85 +544,10 @@ export class TsSchemaGenerator { return ts.factory.createObjectLiteralExpression(fields, true); } - private createComputedFieldsObject(fields: DataField[]) { - return ts.factory.createObjectLiteralExpression( - fields.map((field) => { - const params: ts.ParameterDeclaration[] = [ - // parameter: `_context: { modelAlias: string }` - ts.factory.createParameterDeclaration( - undefined, - undefined, - '_context', - undefined, - ts.factory.createTypeLiteralNode([ - ts.factory.createPropertySignature( - undefined, - 'modelAlias', - undefined, - ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), - ), - ]), - undefined, - ), - ]; - - // For a parameterized computed field, add `args: { : }`. - // The field's params flow into this stub's signature so that - // `Parameters` carries the args type for both the - // implementation (ComputedFieldsOptions) and the query input types. - if (field.params.length > 0) { - params.push( - ts.factory.createParameterDeclaration( - undefined, - undefined, - 'args', - undefined, - ts.factory.createTypeLiteralNode( - field.params.map((param) => - ts.factory.createPropertySignature( - undefined, - param.name, - param.optional - ? ts.factory.createToken(ts.SyntaxKind.QuestionToken) - : undefined, - ts.factory.createTypeReferenceNode( - this.mapFunctionParamTypeToTSType(param.type), - ), - ), - ), - ), - undefined, - ), - ); - } - - return ts.factory.createMethodDeclaration( - undefined, - undefined, - field.name, - undefined, - undefined, - params, - ts.factory.createTypeReferenceNode(this.mapFieldTypeToTSType(field.type)), - ts.factory.createBlock( - [ - ts.factory.createThrowStatement( - ts.factory.createNewExpression(ts.factory.createIdentifier('Error'), undefined, [ - ts.factory.createStringLiteral('This is a stub for computed field'), - ]), - ), - ], - true, - ), - ); - }), - true, - ); - } - // Emits the `params` metadata for a parameterized computed field. Shape mirrors - // `ProcedureParam` (`Record`) and is - // read at runtime (to forward args) and by the zod input-validation factory. + // `ProcedureParam` (`Record`). It is read at + // runtime (to forward args), by the zod input-validation factory, and by the ORM types that + // derive the query-time `args` and the implementation signature from it. private createFieldParamsObject(params: DataFieldParam[]) { return ts.factory.createObjectLiteralExpression( params.map((param) => @@ -656,25 +572,6 @@ export class TsSchemaGenerator { ); } - private mapFunctionParamTypeToTSType(type: FunctionParamType): string { - let result = match(type.type) - .with('String', () => 'string') - .with('Boolean', () => 'boolean') - .with('Int', () => 'number') - .with('Float', () => 'number') - .with('BigInt', () => 'bigint') - .with('Decimal', () => 'number') - .with('DateTime', () => 'Date') - // non-scalar references (enums/type defs/models) aren't in scope in the generated - // schema file, so fall back to `unknown` — same convention as computed-field return - // types (`mapFieldTypeToTSType`). Runtime zod still validates these precisely. - .otherwise(() => 'unknown'); - if (type.array) { - result = `${result}[]`; - } - return result; - } - private createUpdatedAtObject(ignoreArg: AttributeArg) { return ts.factory.createObjectLiteralExpression([ ts.factory.createPropertyAssignment( @@ -688,24 +585,6 @@ export class TsSchemaGenerator { ]); } - private mapFieldTypeToTSType(type: DataFieldType) { - let result = match(type.type) - .with('String', () => 'string') - .with('Boolean', () => 'boolean') - .with('Int', () => 'number') - .with('Float', () => 'number') - .with('BigInt', () => 'bigint') - .with('Decimal', () => 'number') - .otherwise(() => 'unknown'); - if (type.array) { - result = `${result}[]`; - } - if (type.optional) { - result = `${result} | null`; - } - return result; - } - private createDataFieldObject(field: DataField, contextModel: DataModel | undefined, lite: boolean) { const objectFields = [ // name diff --git a/packages/server/package.json b/packages/server/package.json index 38cafa65c..570bf26b2 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/server", "displayName": "ZenStack Automatic CRUD Server", "description": "ZenStack automatic CRUD API handlers and server adapters for popular frameworks", - "version": "3.9.3", + "version": "3.9.4", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/testtools/package.json b/packages/testtools/package.json index f74273ed2..6dbd9b46f 100644 --- a/packages/testtools/package.json +++ b/packages/testtools/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/testtools", "displayName": "ZenStack Test Tools", "description": "ZenStack Test Tools", - "version": "3.9.3", + "version": "3.9.4", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/zod/package.json b/packages/zod/package.json index 3605da60a..766889196 100644 --- a/packages/zod/package.json +++ b/packages/zod/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/zod", "displayName": "ZenStack Zod Integration", "description": "Automatically deriving Zod schemas from ZModel schemas", - "version": "3.9.3", + "version": "3.9.4", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/zod/src/utils.ts b/packages/zod/src/utils.ts index a25202187..db773096e 100644 --- a/packages/zod/src/utils.ts +++ b/packages/zod/src/utils.ts @@ -17,6 +17,7 @@ const stringFuncZodMap = { isEmail: 'email', isUrl: 'url', isPhone: 'e164', + isUuid: 'uuid', isDate: 'date', isTime: 'time', isDateTime: 'datetime', @@ -29,6 +30,18 @@ function getArgValue(expr: Expression | und return expr.value as T; } +function getNamedAttributeArgValue( + attr: AttributeApplication, + name: string, +): T | undefined { + const named = attr.args?.find((a) => a.name === name); + if (named) { + return getArgValue(named.value); + } else { + return undefined; + } +} + export function addStringValidation( schema: z.ZodString, attributes: readonly AttributeApplication[] | undefined, @@ -79,6 +92,17 @@ export function addStringValidation( } break; } + case '@uuid': { + const version = getNamedAttributeArgValue(attr, 'version'); + if (version === 4) { + result = result.uuidv4(); + } else if (version === 7) { + result = result.uuidv7(); + } else { + result = result.uuid(); + } + break; + } case '@email': result = result.email(); break; @@ -89,7 +113,7 @@ export function addStringValidation( result = result.date(); break; case '@time': { - const precision = getArgValue(attr.args?.[0]?.value); + const precision = getNamedAttributeArgValue(attr, 'precision'); result = result.time({ precision }); break; } @@ -555,6 +579,7 @@ function evalCall(data: any, expr: CallExpression) { case 'isEmail': case 'isUrl': case 'isPhone': + case 'isUuid': case 'isDate': case 'isTime': case 'isDateTime': { @@ -569,6 +594,13 @@ function evalCall(data: any, expr: CallExpression) { `"isTime" optional second argument must be a number`, ); return z.iso.time({ precision }).safeParse(fieldArg).success; + } else if (f === 'isUuid') { + const version = getArgValue(expr.args?.[1]); + invariant( + version === null || version == undefined || version === 4 || version === 7, + `"isUuid" optional second argument must 4 or 7`, + ); + return z.uuid({ version: version ? `v${version}` : undefined }).safeParse(fieldArg).success; } const fn = stringFuncZodMap[f]; return z.string()[fn]().safeParse(fieldArg).success; diff --git a/packages/zod/test/factory.test.ts b/packages/zod/test/factory.test.ts index 19c5e9bce..8a21945f5 100644 --- a/packages/zod/test/factory.test.ts +++ b/packages/zod/test/factory.test.ts @@ -32,6 +32,7 @@ describe.each([ metadata: null, status: 'ACTIVE', address: null, + extId: null, }; // A fully valid Post object (without relations) @@ -290,6 +291,21 @@ describe.each([ expect(result.success).toBe(true); }); + it('rejects invalid uuid for @uuid field', () => { + const userSchema = factory.makeModelSchema('User'); + const result = userSchema.safeParse({ ...validUser, extId: 'not-a-uuid' }); + expect(result.success).toBe(false); + }); + + it('accepts valid uuid for @uuid field', () => { + const userSchema = factory.makeModelSchema('User'); + const result = userSchema.safeParse({ + ...validUser, + extId: '20ef31c8-a2c6-4dca-b87b-838e364ab4b3', + }); + expect(result.success).toBe(true); + }); + it('rejects invalid date for @date field', () => { const userSchema = factory.makeModelSchema('User'); const result = userSchema.safeParse({ ...validUser, birthdate: 'not-a-date' }); diff --git a/packages/zod/test/schema/schema-lite.ts b/packages/zod/test/schema/schema-lite.ts index c1f44d019..1be47cfca 100644 --- a/packages/zod/test/schema/schema-lite.ts +++ b/packages/zod/test/schema/schema-lite.ts @@ -18,6 +18,7 @@ export class SchemaType implements SchemaDef { name: "id", type: "String", id: true, + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("cuid") }] }] as readonly AttributeApplication[], default: ExpressionUtils.call("cuid") as FieldDefault }, email: { @@ -82,6 +83,12 @@ export class SchemaType implements SchemaDef { optional: true, attributes: [{ name: "@time" }] as readonly AttributeApplication[] }, + extId: { + name: "extId", + type: "String", + optional: true, + attributes: [{ name: "@uuid" }] as readonly AttributeApplication[] + }, createdAt: { name: "createdAt", type: "DateTime", @@ -129,6 +136,7 @@ export class SchemaType implements SchemaDef { name: "id", type: "String", id: true, + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("cuid") }] }] as readonly AttributeApplication[], default: ExpressionUtils.call("cuid") as FieldDefault }, title: { @@ -171,6 +179,7 @@ export class SchemaType implements SchemaDef { name: "id", type: "String", id: true, + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("cuid") }] }] as readonly AttributeApplication[], default: ExpressionUtils.call("cuid") as FieldDefault }, name: { @@ -184,6 +193,7 @@ export class SchemaType implements SchemaDef { discount: { name: "discount", type: "Float", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.literal(0) }] }] as readonly AttributeApplication[], default: 0 as FieldDefault }, finalPrice: { @@ -195,13 +205,6 @@ export class SchemaType implements SchemaDef { idFields: ["id"], uniqueFields: { id: { type: "String" } - }, - computedFields: { - finalPrice(_context: { - modelAlias: string; - }): number { - throw new Error("This is a stub for computed field"); - } } }, Asset: { @@ -211,11 +214,13 @@ export class SchemaType implements SchemaDef { name: "id", type: "Int", id: true, + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }] as readonly AttributeApplication[], default: ExpressionUtils.call("autoincrement") as FieldDefault }, createdAt: { name: "createdAt", type: "DateTime", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }] as readonly AttributeApplication[], default: ExpressionUtils.call("now") as FieldDefault }, assetType: { @@ -239,12 +244,14 @@ export class SchemaType implements SchemaDef { name: "id", type: "Int", id: true, + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }] as readonly AttributeApplication[], default: ExpressionUtils.call("autoincrement") as FieldDefault }, createdAt: { name: "createdAt", type: "DateTime", originModel: "Asset", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }] as readonly AttributeApplication[], default: ExpressionUtils.call("now") as FieldDefault }, assetType: { @@ -275,12 +282,14 @@ export class SchemaType implements SchemaDef { name: "id", type: "Int", id: true, + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }] as readonly AttributeApplication[], default: ExpressionUtils.call("autoincrement") as FieldDefault }, createdAt: { name: "createdAt", type: "DateTime", originModel: "Asset", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }] as readonly AttributeApplication[], default: ExpressionUtils.call("now") as FieldDefault }, assetType: { diff --git a/packages/zod/test/schema/schema.ts b/packages/zod/test/schema/schema.ts index fa7bc045c..3442d3920 100644 --- a/packages/zod/test/schema/schema.ts +++ b/packages/zod/test/schema/schema.ts @@ -83,6 +83,12 @@ export class SchemaType implements SchemaDef { optional: true, attributes: [{ name: "@time" }] as readonly AttributeApplication[] }, + extId: { + name: "extId", + type: "String", + optional: true, + attributes: [{ name: "@uuid" }] as readonly AttributeApplication[] + }, createdAt: { name: "createdAt", type: "DateTime", @@ -202,13 +208,6 @@ export class SchemaType implements SchemaDef { idFields: ["id"], uniqueFields: { id: { type: "String" } - }, - computedFields: { - finalPrice(_context: { - modelAlias: string; - }): number { - throw new Error("This is a stub for computed field"); - } } }, Asset: { diff --git a/packages/zod/test/schema/schema.zmodel b/packages/zod/test/schema/schema.zmodel index e7deb27aa..6aa265ad0 100644 --- a/packages/zod/test/schema/schema.zmodel +++ b/packages/zod/test/schema/schema.zmodel @@ -34,6 +34,7 @@ model User { active Boolean birthdate String? @date localTime String? @time + extId String? @uuid createdAt DateTime? avatar Bytes? metadata Json? diff --git a/packages/zod/test/string-validation.test.ts b/packages/zod/test/string-validation.test.ts new file mode 100644 index 000000000..8550c9110 --- /dev/null +++ b/packages/zod/test/string-validation.test.ts @@ -0,0 +1,55 @@ +import { ExpressionUtils, type AttributeApplication } from '@zenstackhq/schema'; +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; +import { addStringValidation } from '../src/utils'; + +function attr(name: string, args: { name?: string; value: string | number | boolean }[]): AttributeApplication { + return { name, args: args.map((a) => ({ name: a.name, value: ExpressionUtils.literal(a.value) })) }; +} + +function validate(attribute: AttributeApplication, value: string) { + return addStringValidation(z.string(), [attribute]).safeParse(value).success; +} + +describe('string validation attributes', () => { + const uuidV4 = '20ef31c8-a2c6-4dca-b87b-838e364ab4b3'; + const uuidV7 = '0199a1b2-c3d4-7abc-8def-0123456789ab'; + + it('accepts any uuid version when no version is given', () => { + expect(validate(attr('@uuid', []), uuidV4)).toBe(true); + expect(validate(attr('@uuid', []), uuidV7)).toBe(true); + expect(validate(attr('@uuid', []), 'not-a-uuid')).toBe(false); + }); + + it('respects a named version arg regardless of argument order', () => { + const versionFirst = attr('@uuid', [ + { name: 'version', value: 7 }, + { name: 'message', value: 'custom' }, + ]); + expect(validate(versionFirst, uuidV7)).toBe(true); + expect(validate(versionFirst, uuidV4)).toBe(false); + + const messageFirst = attr('@uuid', [ + { name: 'message', value: 'custom' }, + { name: 'version', value: 7 }, + ]); + expect(validate(messageFirst, uuidV7)).toBe(true); + expect(validate(messageFirst, uuidV4)).toBe(false); + + const v4 = attr('@uuid', [ + { name: 'message', value: 'custom' }, + { name: 'version', value: 4 }, + ]); + expect(validate(v4, uuidV4)).toBe(true); + expect(validate(v4, uuidV7)).toBe(false); + }); + + it('resolves @time precision from a named arg in any order', () => { + const messageFirst = attr('@time', [ + { name: 'message', value: 'custom' }, + { name: 'precision', value: 3 }, + ]); + expect(validate(messageFirst, '12:00:00.123')).toBe(true); + expect(validate(messageFirst, '12:00:00')).toBe(false); + }); +}); diff --git a/samples/orm/package.json b/samples/orm/package.json index 7c95a6fdf..ad150236e 100644 --- a/samples/orm/package.json +++ b/samples/orm/package.json @@ -1,6 +1,6 @@ { "name": "sample-orm", - "version": "3.9.3", + "version": "3.9.4", "description": "", "main": "index.js", "private": true, diff --git a/samples/orm/zenstack/schema.ts b/samples/orm/zenstack/schema.ts index 6b7df5b25..d20cd0dd6 100644 --- a/samples/orm/zenstack/schema.ts +++ b/samples/orm/zenstack/schema.ts @@ -77,13 +77,6 @@ export class SchemaType implements SchemaDef { uniqueFields: { id: { type: "String" }, email: { type: "String" } - }, - computedFields: { - postCount(_context: { - modelAlias: string; - }): number { - throw new Error("This is a stub for computed field"); - } } }, Profile: { diff --git a/samples/taskforge/package.json b/samples/taskforge/package.json index 551a0466f..6e00d7b15 100644 --- a/samples/taskforge/package.json +++ b/samples/taskforge/package.json @@ -1,6 +1,6 @@ { "name": "taskforge", - "version": "3.9.3", + "version": "3.9.4", "type": "module", "private": true, "description": "A CLI for a team collaboration / project-tracking platform, built on ZenStack v3 (ORM) and better-auth.", diff --git a/samples/taskforge/zenstack/schema.ts b/samples/taskforge/zenstack/schema.ts index 0effd9be0..d1491c9cb 100644 --- a/samples/taskforge/zenstack/schema.ts +++ b/samples/taskforge/zenstack/schema.ts @@ -970,13 +970,6 @@ export class SchemaType implements SchemaDef { uniqueFields: { id: { type: "String" }, organizationId_slug: { organizationId: { type: "String" }, slug: { type: "String" } } - }, - computedFields: { - openIssueCount(_context: { - modelAlias: string; - }): number { - throw new Error("This is a stub for computed field"); - } } }, ProjectMember: { @@ -1261,13 +1254,6 @@ export class SchemaType implements SchemaDef { uniqueFields: { id: { type: "String" }, projectId_number: { projectId: { type: "String" }, number: { type: "Int" } } - }, - computedFields: { - commentCount(_context: { - modelAlias: string; - }): number { - throw new Error("This is a stub for computed field"); - } } }, Label: { diff --git a/tests/e2e/orm/client-api/computed-fields.test.ts b/tests/e2e/orm/client-api/computed-fields.test.ts index 01f74c662..89873f797 100644 --- a/tests/e2e/orm/client-api/computed-fields.test.ts +++ b/tests/e2e/orm/client-api/computed-fields.test.ts @@ -1067,4 +1067,212 @@ model Post { isSpecial: true, }); }); + it('works with enum and type-def parameters on parameterized computed fields', async () => { + const db = await createTestClient( + ` +enum Status { + ACTIVE + INACTIVE +} + +type ViewFilter { + minViews Int +} + +model User { + id Int @id @default(autoincrement()) + name String + posts Post[] + postCountByStatus(status: Status) Int @computed + popularPostCount(filter: ViewFilter) Int @computed +} + +model Post { + id Int @id @default(autoincrement()) + status Status @default(ACTIVE) + viewCount Int @default(0) + author User @relation(fields: [authorId], references: [id]) + authorId Int +} +`, + { + computedFields: { + User: { + // counts the user's posts in the query-time `status` + postCountByStatus: (eb: any, ctx: any, args: any) => + eb + .selectFrom('Post') + .whereRef('Post.authorId', '=', sql.ref(`${ctx.modelAlias}.id`)) + .where('Post.status', '=', args.status) + .select(({ fn }: any) => fn.countAll().as('cnt')), + // counts the user's posts whose viewCount >= the query-time `filter.minViews` + popularPostCount: (eb: any, ctx: any, args: any) => + eb + .selectFrom('Post') + .whereRef('Post.authorId', '=', sql.ref(`${ctx.modelAlias}.id`)) + .where('Post.viewCount', '>=', args.filter.minViews) + .select(({ fn }: any) => fn.countAll().as('cnt')), + }, + }, + } as any, + ); + + await db.user.create({ + data: { + id: 1, + name: 'Alice', + posts: { + create: [ + { status: 'ACTIVE', viewCount: 300 }, + { status: 'INACTIVE', viewCount: 50 }, + { status: 'INACTIVE', viewCount: 120 }, + ], + }, + }, + }); + + // `count(*)` is a bigint on Postgres, which the `pg` driver returns as a string, so + // normalize before comparing + const counts = await db.user.findFirst({ + select: { + postCountByStatus: { args: { status: 'INACTIVE' } }, + popularPostCount: { args: { filter: { minViews: 100 } } }, + }, + }); + expect(Object.keys(counts!).sort()).toEqual(['popularPostCount', 'postCountByStatus']); + expect(Number(counts!.postCountByStatus)).toBe(2); + expect(Number(counts!.popularPostCount)).toBe(2); + + await expect( + db.user.findFirst({ where: { postCountByStatus: { args: { status: 'ACTIVE' }, equals: 1 } } }), + ).resolves.toMatchObject({ id: 1 }); + await expect( + db.user.findFirst({ where: { postCountByStatus: { args: { status: 'ACTIVE' }, gt: 1 } } }), + ).toResolveNull(); + + // `args` are validated against the declared param types: an unknown enum value and a + // type-def payload of the wrong shape are rejected as invalid input (`as any` bypasses + // the matching compile-time checks) + await expect( + db.user.findFirst({ select: { postCountByStatus: { args: { status: 'WRONG' } } } } as any), + ).toBeRejectedByValidation(); + await expect( + db.user.findFirst({ where: { postCountByStatus: { args: { status: 'WRONG' }, gt: 0 } } } as any), + ).toBeRejectedByValidation(); + await expect( + db.user.findFirst({ select: { popularPostCount: { args: { filter: { minViews: 'x' } } } } } as any), + ).toBeRejectedByValidation(); + await expect( + db.user.findFirst({ select: { popularPostCount: { args: { filter: {} } } } } as any), + ).toBeRejectedByValidation(); + }); + + it('is typed correctly for parameterized computed fields with enum and type-def params', async () => { + await createTestClient( + ` +enum Status { + ACTIVE + INACTIVE +} + +type ViewFilter { + minViews Int +} + +model User { + id Int @id @default(autoincrement()) + name String + postCountByStatus(status: Status) Int @computed + popularPostCount(filter: ViewFilter, factor: Int?) Int @computed +} +`, + { + computedFields: { + user: { + postCountByStatus: (eb: any) => eb.lit(0), + popularPostCount: (eb: any) => eb.lit(0), + }, + }, + extraSourceFiles: { + main: ` +import { ZenStackClient } from '@zenstackhq/orm'; +import { schema } from './schema'; +import type { UserSelect, UserWhereInput } from './input'; + +const client = new ZenStackClient(schema, { + dialect: {} as any, + computedFields: { + user: { + postCountByStatus: (eb, _ctx, args) => { + // an enum param is typed as the enum's value union + const status: 'ACTIVE' | 'INACTIVE' = args.status; + // @ts-expect-error not a Status value + const wrong: 'WRONG' = args.status; + void status; + void wrong; + return eb.lit(0); + }, + popularPostCount: (eb, _ctx, args) => { + // a type-def param is typed as its object shape; an optional param is optional + const minViews: number = args.filter.minViews; + const factor: number | undefined = args.factor; + void minViews; + void factor; + return eb.lit(0); + }, + }, + }, +}); + +// the expression must produce the field's declared type +new ZenStackClient(schema, { + dialect: {} as any, + computedFields: { + user: { + // @ts-expect-error an Int field needs a number expression + postCountByStatus: (eb) => eb.val('not a number'), + popularPostCount: (eb) => eb.lit(0), + }, + }, +}); + +async function main() { + // valid args compile everywhere the field can be used + await client.user.findMany({ + select: { + postCountByStatus: { args: { status: 'ACTIVE' } }, + popularPostCount: { args: { filter: { minViews: 1 } } }, + }, + where: { postCountByStatus: { args: { status: 'INACTIVE' }, gt: 0 } }, + orderBy: { popularPostCount: { args: { filter: { minViews: 1 }, factor: 2 }, sort: 'desc' } }, + }); + + // @ts-expect-error not a Status value + await client.user.findMany({ select: { postCountByStatus: { args: { status: 'WRONG' } } } }); + // @ts-expect-error not a Status value + await client.user.findMany({ where: { postCountByStatus: { args: { status: 'WRONG' }, gt: 0 } } }); + // @ts-expect-error not a Status value + await client.user.findMany({ orderBy: { postCountByStatus: { args: { status: 'WRONG' }, sort: 'asc' } } }); + // @ts-expect-error wrong type-def field type + await client.user.findMany({ select: { popularPostCount: { args: { filter: { minViews: 'x' } } } } }); + // @ts-expect-error missing required type-def field + await client.user.findMany({ select: { popularPostCount: { args: { filter: {} } } } }); + // @ts-expect-error missing required arg + await client.user.findMany({ select: { popularPostCount: { args: { factor: 1 } } } }); + + // the generated input types carry the same typing + // @ts-expect-error not a Status value + const select: UserSelect = { postCountByStatus: { args: { status: 'WRONG' } } }; + // @ts-expect-error not a Status value + const where: UserWhereInput = { postCountByStatus: { args: { status: 'WRONG' }, gt: 0 } }; + void select; + void where; +} + +void main; +`, + }, + }, + ); + }); }); diff --git a/tests/e2e/orm/schemas/typing/schema.ts b/tests/e2e/orm/schemas/typing/schema.ts index 4c01c01b1..bd5d1e73b 100644 --- a/tests/e2e/orm/schemas/typing/schema.ts +++ b/tests/e2e/orm/schemas/typing/schema.ts @@ -72,6 +72,15 @@ export class SchemaType implements SchemaDef { attributes: [{ name: "@computed" }] as readonly AttributeApplication[], computed: true }, + hasStatus: { + name: "hasStatus", + type: "Boolean", + attributes: [{ name: "@computed" }] as readonly AttributeApplication[], + computed: true, + params: { + status: { name: "status", type: "Status" } + } + }, identity: { name: "identity", type: "Identity", @@ -83,13 +92,6 @@ export class SchemaType implements SchemaDef { uniqueFields: { id: { type: "Int" }, email: { type: "String" } - }, - computedFields: { - postCount(_context: { - modelAlias: string; - }): number { - throw new Error("This is a stub for computed field"); - } } }, Post: { diff --git a/tests/e2e/orm/schemas/typing/schema.zmodel b/tests/e2e/orm/schemas/typing/schema.zmodel index 32209ceb6..2dd204695 100644 --- a/tests/e2e/orm/schemas/typing/schema.zmodel +++ b/tests/e2e/orm/schemas/typing/schema.zmodel @@ -34,6 +34,7 @@ model User { posts Post[] profile Profile? postCount Int @computed + hasStatus(status: Status) Boolean @computed identity Identity? @json } diff --git a/tests/e2e/orm/schemas/typing/typecheck.ts b/tests/e2e/orm/schemas/typing/typecheck.ts index f53221c8f..80f00ccdf 100644 --- a/tests/e2e/orm/schemas/typing/typecheck.ts +++ b/tests/e2e/orm/schemas/typing/typecheck.ts @@ -2,6 +2,7 @@ import { ZenStackClient, type Subset } from '@zenstackhq/orm'; import SQLite from 'better-sqlite3'; import { SqliteDialect } from 'kysely'; import { Role, Status, type Identity, type IdentityProvider } from './models'; +import type { UserSelect, UserWhereInput } from './input'; import { schema } from './schema'; const client = new ZenStackClient(schema, { @@ -13,6 +14,8 @@ const client = new ZenStackClient(schema, { .selectFrom('Post') .whereRef('Post.authorId', '=', 'id') .select(({ fn }) => fn.countAll().as('postCount')), + // typing-only stub: the query-time `status` arg is typed as the `Status` enum + hasStatus: (eb, _ctx, args) => eb.lit(args.status === Status.ACTIVE), }, }, }); @@ -26,6 +29,8 @@ const strictClient = new ZenStackClient(schema, { .selectFrom('Post') .whereRef('Post.authorId', '=', 'id') .select(({ fn }) => fn.countAll().as('postCount')), + // typing-only stub: the query-time `status` arg is typed as the `Status` enum + hasStatus: (eb, _ctx, args) => eb.lit(args.status === Status.ACTIVE), }, }, typing: { exactQueryArgs: true }, @@ -45,6 +50,39 @@ async function main() { } async function find() { + // a parameterized computed field's `args` are typed from its declared params: an enum param + // accepts only the enum's values, and `args` is required wherever the field is used + const withArgs = await client.user.findFirst({ + select: { id: true, hasStatus: { args: { status: Status.ACTIVE } } }, + where: { hasStatus: { args: { status: 'INACTIVE' }, equals: true } }, + orderBy: { hasStatus: { args: { status: Status.BANNED }, sort: 'desc' } }, + }); + const hasStatus: boolean | undefined = withArgs?.hasStatus; + void hasStatus; + await client.user.findMany({ + // @ts-expect-error not a Status value + select: { hasStatus: { args: { status: 'WRONG' } } }, + }); + await client.user.findMany({ + // @ts-expect-error not a Status value + where: { hasStatus: { args: { status: 'WRONG' }, equals: true } }, + }); + await client.user.findMany({ + // @ts-expect-error not a Status value + orderBy: { hasStatus: { args: { status: 'WRONG' }, sort: 'asc' } }, + }); + await client.user.findMany({ + // @ts-expect-error args are required for a parameterized computed field + select: { hasStatus: true }, + }); + // the generated input types carry the same typing + // @ts-expect-error not a Status value + const selectWithWrongArgs: UserSelect = { hasStatus: { args: { status: 'WRONG' } } }; + // @ts-expect-error not a Status value + const whereWithWrongArgs: UserWhereInput = { hasStatus: { args: { status: 'WRONG' }, equals: true } }; + void selectWithWrongArgs; + void whereWithWrongArgs; + await client.user.findMany({ where: { posts: { diff --git a/tests/e2e/orm/validation/custom-validation.test.ts b/tests/e2e/orm/validation/custom-validation.test.ts index 47f12049d..0523c9fc5 100644 --- a/tests/e2e/orm/validation/custom-validation.test.ts +++ b/tests/e2e/orm/validation/custom-validation.test.ts @@ -15,6 +15,7 @@ describe('Custom validation tests', () => { str6 String? str7 String? str8 String? + str9 String? int1 Int? list1 Int[] list2 Int[] @@ -41,6 +42,8 @@ describe('Custom validation tests', () => { @@validate(str8 == null || isTime(str8), 'invalid str8') + @@validate(str9 == null || isUuid(str9), 'invalid str9') + @@validate(list1 == null || (has(list1, 1) && hasSome(list1, [2, 3]) && hasEvery(list1, [4, 5])), 'invalid list1') @@validate(list2 == null || isEmpty(list2), 'invalid list2', ['x', 'y']) @@ -95,6 +98,9 @@ describe('Custom validation tests', () => { // violates time await expect(_t({ str8: 'not-a-time' })).toBeRejectedByValidation(['invalid str8']); + // violates uuid + await expect(_t({ str9: 'not-a-uuid' })).toBeRejectedByValidation(['invalid str9']); + // violates has await expect(_t({ list1: [2, 3, 4, 5] })).toBeRejectedByValidation(['invalid list1']); @@ -128,6 +134,7 @@ describe('Custom validation tests', () => { str6: '+15555555555', str7: '2000-01-01', str8: '03:15:00', + str9: '20ef31c8-a2c6-4dca-b87b-838e364ab4b3', int1: 2, list1: [1, 2, 4, 5], list2: [], diff --git a/tests/e2e/orm/validation/toplevel.test.ts b/tests/e2e/orm/validation/toplevel.test.ts index fbfdbd92a..2cc3b3a8d 100644 --- a/tests/e2e/orm/validation/toplevel.test.ts +++ b/tests/e2e/orm/validation/toplevel.test.ts @@ -18,6 +18,9 @@ describe('Toplevel field validation tests', () => { str8 String? @date str9 String? @time str10 String? @time(-1) + str11 String? @uuid + str12 String? @uuid(7) + str13 String? @uuid(4) } `, ); @@ -111,6 +114,34 @@ describe('Toplevel field validation tests', () => { // satisfies @time(-1) await expect(_t({ str10: '03:15' })).toResolveTruthy(); + + // violates @uuid + await expect(_t({ str11: 'not-a-uuid' })).toBeRejectedByValidation(['Invalid UUID']); + + // satisfies @uuid + await expect(_t({ str11: '20ef31c8-a2c6-4dca-b87b-838e364ab4b3' })).toResolveTruthy(); + + // satisfies @uuid, which is not pinned to a version, with a v7 value + await expect(_t({ str11: '019ff964-2f1d-7668-9a76-8648f2af9146' })).toResolveTruthy(); + + // violates @uuid(7) + await expect(_t({ str12: 'not-a-uuid' })).toBeRejectedByValidation(['Invalid UUID']); + + // violates @uuid(7) with a v4 value + await expect(_t({ str12: '20ef31c8-a2c6-4dca-b87b-838e364ab4b3' })).toBeRejectedByValidation([ + 'Invalid UUID', + ]); + + // satisfies @uuid(7) + await expect(_t({ str12: '019ff964-2f1d-7668-9a76-8648f2af9146' })).toResolveTruthy(); + + // violates @uuid(4) with a v7 value + await expect(_t({ str13: '019ff964-2f1d-7668-9a76-8648f2af9146' })).toBeRejectedByValidation([ + 'Invalid UUID', + ]); + + // satisfies @uuid(4) + await expect(_t({ str13: '20ef31c8-a2c6-4dca-b87b-838e364ab4b3' })).toResolveTruthy(); } }); diff --git a/tests/e2e/package.json b/tests/e2e/package.json index f27191aff..4d6e24166 100644 --- a/tests/e2e/package.json +++ b/tests/e2e/package.json @@ -1,6 +1,6 @@ { "name": "e2e", - "version": "3.9.3", + "version": "3.9.4", "private": true, "type": "module", "scripts": { diff --git a/tests/regression/package.json b/tests/regression/package.json index 381c1b1d2..a901ebee5 100644 --- a/tests/regression/package.json +++ b/tests/regression/package.json @@ -1,6 +1,6 @@ { "name": "regression", - "version": "3.9.3", + "version": "3.9.4", "private": true, "type": "module", "scripts": { diff --git a/tests/regression/test/issue-2788/regression.test.ts b/tests/regression/test/issue-2788/regression.test.ts new file mode 100644 index 000000000..22697fa75 --- /dev/null +++ b/tests/regression/test/issue-2788/regression.test.ts @@ -0,0 +1,68 @@ +import { createTestClient } from '@zenstackhq/testtools'; +import { describe, expect, it } from 'vitest'; +import { schema } from './schema'; + +// https://github.com/zenstackhq/zenstack/issues/2788 + +describe('Regression for issue #2788', () => { + it('does not error during concurrent upserts', async () => { + const db = await createTestClient(schema); + const user = await db.user.create({ + data: { + email: 'test@zenstack.dev', + posts: { + create: [ + { + title: 'Post 1', + content: 'This is a test post', + }, + ], + }, + }, + include: { posts: true }, + }); + + let posts: any[] = await db.post.findMany(); + + posts[0].title = 'Post 1 Updated'; + + posts.push({ + id: 'cmstai1q2000104js3i7s2d8l', + title: 'Post 2', + content: 'This is a test post', + authorId: user.id, + }); + + await Promise.all( + posts.map(async (p) => { + await db.post.upsert({ + where: { id: p.id }, + update: { ...p }, + create: { title: p.title!, content: p.content!, authorId: p.authorId! }, + }); + }), + ); + + posts = await db.post.findMany(); + + expect(posts.find((p) => p.title === 'Post 1 Updated')).toMatchObject({ + title: 'Post 1 Updated', + content: 'This is a test post', + published: false, + }); + + expect(posts.find((p) => p.title === 'Post 2')).toMatchObject({ + title: 'Post 2', + content: 'This is a test post', + published: false, + }); + + await expect( + db.post.findUnique({ + where: { + id: 'cmstai1q2000104js3i7s2d8l', + }, + }), + ).resolves.toBeNull(); + }); +}); diff --git a/tests/regression/test/issue-2788/schema.ts b/tests/regression/test/issue-2788/schema.ts new file mode 100644 index 000000000..30a40da4c --- /dev/null +++ b/tests/regression/test/issue-2788/schema.ts @@ -0,0 +1,103 @@ +////////////////////////////////////////////////////////////////////////////////////////////// +// DO NOT MODIFY THIS FILE // +// This file is automatically generated by ZenStack CLI and should not be manually updated. // +////////////////////////////////////////////////////////////////////////////////////////////// + +/* eslint-disable */ + +import { type SchemaDef, type AttributeApplication, type FieldDefault, ExpressionUtils } from "@zenstackhq/schema"; +export class SchemaType implements SchemaDef { + provider = { + type: "sqlite" + } as const; + models = { + User: { + name: "User", + fields: { + id: { + name: "id", + type: "String", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("cuid") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("cuid") as FieldDefault + }, + email: { + name: "email", + type: "String", + unique: true, + attributes: [{ name: "@unique" }, { name: "@email" }, { name: "@length", args: [{ name: "min", value: ExpressionUtils.literal(6) }, { name: "max", value: ExpressionUtils.literal(32) }] }] as readonly AttributeApplication[] + }, + posts: { + name: "posts", + type: "Post", + array: true, + relation: { opposite: "author" } + } + }, + idFields: ["id"], + uniqueFields: { + id: { type: "String" }, + email: { type: "String" } + } + }, + Post: { + name: "Post", + fields: { + id: { + name: "id", + type: "String", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("cuid") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("cuid") as FieldDefault + }, + createdAt: { + name: "createdAt", + type: "DateTime", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("now") as FieldDefault + }, + updatedAt: { + name: "updatedAt", + type: "DateTime", + updatedAt: true, + attributes: [{ name: "@updatedAt" }] as readonly AttributeApplication[] + }, + title: { + name: "title", + type: "String", + attributes: [{ name: "@length", args: [{ name: "min", value: ExpressionUtils.literal(1) }, { name: "max", value: ExpressionUtils.literal(256) }] }] as readonly AttributeApplication[] + }, + content: { + name: "content", + type: "String" + }, + published: { + name: "published", + type: "Boolean", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.literal(false) }] }] as readonly AttributeApplication[], + default: false as FieldDefault + }, + author: { + name: "author", + type: "User", + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array("String", [ExpressionUtils.field("authorId")]) }, { name: "references", value: ExpressionUtils.array("String", [ExpressionUtils.field("id")]) }, { name: "onDelete", value: ExpressionUtils.literal("Cascade") }] }] as readonly AttributeApplication[], + relation: { opposite: "posts", fields: ["authorId"], references: ["id"], onDelete: "Cascade" } + }, + authorId: { + name: "authorId", + type: "String", + foreignKeyFor: [ + "author" + ] as readonly string[] + } + }, + idFields: ["id"], + uniqueFields: { + id: { type: "String" } + } + } + } as const; + authType = "User" as const; + plugins = {}; +} +export const schema = new SchemaType(); diff --git a/tests/regression/test/issue-2788/schema.zmodel b/tests/regression/test/issue-2788/schema.zmodel new file mode 100644 index 000000000..5877e9b02 --- /dev/null +++ b/tests/regression/test/issue-2788/schema.zmodel @@ -0,0 +1,26 @@ +// This is a sample model to get you started. + +/// A sample data source using local sqlite db. +datasource db { + provider = 'sqlite' + url = 'file:./dev.db' +} + +/// User model +model User { + id String @id @default(cuid()) + email String @unique @email @length(6, 32) + posts Post[] +} + +/// Post model +model Post { + id String @id @default(cuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + title String @length(1, 256) + content String + published Boolean @default(false) + author User @relation(fields: [authorId], references: [id], onDelete: Cascade) + authorId String +} \ No newline at end of file diff --git a/tests/regression/test/issue-2825.test.ts b/tests/regression/test/issue-2825.test.ts new file mode 100644 index 000000000..b5526e3f8 --- /dev/null +++ b/tests/regression/test/issue-2825.test.ts @@ -0,0 +1,40 @@ +import { createTestClient } from '@zenstackhq/testtools'; +import { describe, it } from 'vitest'; + +// https://github.com/zenstackhq/zenstack/issues/2825 +describe('Regression for issue #2825', () => { + it('handle same column name with enum type', async () => { + const schema = ` +enum UserStatus { + OK1 @map("ok1") + NO1 @map("no1") + + @@map("user_status") +} + +enum PostStatus { + OK2 @map("ok2") + NO2 @map("no2") + + @@map("post_status") +} + +model User { + id Int @id @default(autoincrement()) + status UserStatus? + posts Post[] +} + +model Post { + id Int @id @default(autoincrement()) + status PostStatus? + author User? @relation(fields: [authorId], references: [id]) + authorId Int +} +`; + + const db = await createTestClient(schema, { usePrismaPush: true, provider: 'postgresql' }); + + await db.$qb.selectFrom('User as u').innerJoin('Post as p', 'p.authorId', 'u.id').select('u.status').execute(); + }); +}); diff --git a/tests/runtimes/bun/package.json b/tests/runtimes/bun/package.json index 1aa24913e..a89f63088 100644 --- a/tests/runtimes/bun/package.json +++ b/tests/runtimes/bun/package.json @@ -1,6 +1,6 @@ { "name": "bun-e2e", - "version": "3.9.3", + "version": "3.9.4", "private": true, "type": "module", "scripts": { diff --git a/tests/runtimes/edge-runtime/package.json b/tests/runtimes/edge-runtime/package.json index 63e6dd5fa..3ff29dbb3 100644 --- a/tests/runtimes/edge-runtime/package.json +++ b/tests/runtimes/edge-runtime/package.json @@ -1,6 +1,6 @@ { "name": "edge-runtime-e2e", - "version": "3.9.3", + "version": "3.9.4", "private": true, "type": "module", "scripts": {