From 5f95820f3eca84b7db977eb06f8d1e73d72c43af Mon Sep 17 00:00:00 2001 From: sanny-io Date: Mon, 17 Aug 2026 12:41:19 +0000 Subject: [PATCH 1/9] feat(language): implicitly convert `enum` references to arrays --- packages/language/src/generated/ast.ts | 4 ++-- packages/language/src/generated/grammar.ts | 6 ++++++ packages/language/src/zmodel-linker.ts | 6 ++++++ packages/language/src/zmodel.langium | 2 +- packages/sdk/src/ts-schema-generator.ts | 10 ++++++++++ 5 files changed, 25 insertions(+), 3 deletions(-) diff --git a/packages/language/src/generated/ast.ts b/packages/language/src/generated/ast.ts index 545fdb944..c5ecbe0fd 100644 --- a/packages/language/src/generated/ast.ts +++ b/packages/language/src/generated/ast.ts @@ -902,7 +902,7 @@ export function isReferenceExpr(item: unknown): item is ReferenceExpr { return reflection.isInstance(item, ReferenceExpr.$type); } -export type ReferenceTarget = CollectionPredicateBinding | DataField | EnumField | FunctionParam; +export type ReferenceTarget = CollectionPredicateBinding | DataField | Enum | EnumField | FunctionParam; export const ReferenceTarget = { $type: 'ReferenceTarget' @@ -1427,7 +1427,7 @@ export class ZModelAstReflection extends langium.AbstractAstReflection { name: Enum.name } }, - superTypes: [AbstractDeclaration.$type, TypeDeclaration.$type] + superTypes: [AbstractDeclaration.$type, ReferenceTarget.$type, TypeDeclaration.$type] }, EnumField: { name: EnumField.$type, diff --git a/packages/language/src/generated/grammar.ts b/packages/language/src/generated/grammar.ts index 0f7f341a4..ac3ae2b27 100644 --- a/packages/language/src/generated/grammar.ts +++ b/packages/language/src/generated/grammar.ts @@ -4008,6 +4008,12 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "typeRef": { "$ref": "#/rules@30" } + }, + { + "$type": "SimpleType", + "typeRef": { + "$ref": "#/rules@46" + } } ] } diff --git a/packages/language/src/zmodel-linker.ts b/packages/language/src/zmodel-linker.ts index 6766a6afc..0908f7c20 100644 --- a/packages/language/src/zmodel-linker.ts +++ b/packages/language/src/zmodel-linker.ts @@ -265,6 +265,12 @@ export class ZModelLinker extends DefaultLinker { } else if (isDataField(target) || isFunctionParam(target)) { // other references are resolved to their declared type this.resolveToDeclaredType(node, target.type); + } else if (isEnum(target)) { + node.$resolvedType = { + decl: target, + array: true, + nullable: false, + }; } } } diff --git a/packages/language/src/zmodel.langium b/packages/language/src/zmodel.langium index 4f39ea28e..6856e6183 100644 --- a/packages/language/src/zmodel.langium +++ b/packages/language/src/zmodel.langium @@ -66,7 +66,7 @@ ConfigArrayExpr: ConfigExpr: LiteralExpr | InvocationExpr | ConfigArrayExpr; -type ReferenceTarget = FunctionParam | DataField | EnumField | CollectionPredicateBinding; +type ReferenceTarget = FunctionParam | DataField | EnumField | CollectionPredicateBinding | Enum; ThisExpr: value='this'; diff --git a/packages/sdk/src/ts-schema-generator.ts b/packages/sdk/src/ts-schema-generator.ts index cfa261ad5..68d316c0c 100644 --- a/packages/sdk/src/ts-schema-generator.ts +++ b/packages/sdk/src/ts-schema-generator.ts @@ -1476,6 +1476,16 @@ export class TsSchemaGenerator { .when(isCollectionPredicateBinding, () => this.createExpressionUtilsCall('binding', [this.createLiteralNode(expr.target.$refText)]), ) + .when(isEnum, () => + this.createExpressionUtilsCall('array', [ + this.createLiteralNode(expr.target.$refText), + ts.factory.createArrayLiteralExpression( + (target as Enum).fields.map((field) => + this.createLiteralExpression('StringLiteral', field.name), + ), + ), + ]), + ) .otherwise(() => { throw Error(`Unsupported reference type: ${expr.target.$refText}`); }); From 20cedcc2bbee6f20267b93422ff9f8b746670407 Mon Sep 17 00:00:00 2001 From: sanny-io Date: Mon, 17 Aug 2026 12:42:10 +0000 Subject: [PATCH 2/9] chore: generate schemas --- packages/zod/test/schema/schema.ts | 12 +++++ packages/zod/test/schema/schema.zmodel | 7 +++ tests/e2e/orm/schemas/enum/schema.ts | 66 ++++++++++++++++++++++++ tests/e2e/orm/schemas/enum/schema.zmodel | 21 ++++++++ 4 files changed, 106 insertions(+) create mode 100644 tests/e2e/orm/schemas/enum/schema.ts create mode 100644 tests/e2e/orm/schemas/enum/schema.zmodel diff --git a/packages/zod/test/schema/schema.ts b/packages/zod/test/schema/schema.ts index fa7bc045c..e377af1b8 100644 --- a/packages/zod/test/schema/schema.ts +++ b/packages/zod/test/schema/schema.ts @@ -343,9 +343,14 @@ export class SchemaType implements SchemaDef { name: "zip", type: "String", optional: true + }, + type: { + name: "type", + type: "String" } }, attributes: [ + { name: "@@validate", args: [{ name: "value", value: ExpressionUtils.binary(ExpressionUtils.field("type"), "in", ExpressionUtils.array("AddressType", [ExpressionUtils.literal("RESIDENTIAL"), ExpressionUtils.literal("COMMERCIAL")])) }] }, { name: "@@validate", args: [{ name: "value", value: ExpressionUtils.binary(ExpressionUtils.binary(ExpressionUtils.field("zip"), "==", ExpressionUtils._null()), "||", ExpressionUtils.binary(ExpressionUtils.call("length", [ExpressionUtils.field("zip")]), "==", ExpressionUtils.literal(5))) }, { name: "message", value: ExpressionUtils.literal("Zip code must be exactly 5 characters") }, { name: "path", value: ExpressionUtils.array("String", [ExpressionUtils.literal("zip")]) }] }, { name: "@@meta", args: [{ name: "name", value: ExpressionUtils.literal("description") }, { name: "value", value: ExpressionUtils.literal("A mailing address") }] } ] as readonly AttributeApplication[] @@ -362,6 +367,13 @@ export class SchemaType implements SchemaDef { attributes: [ { name: "@@meta", args: [{ name: "name", value: ExpressionUtils.literal("description") }, { name: "value", value: ExpressionUtils.literal("User account status") }] } ] as readonly AttributeApplication[] + }, + AddressType: { + name: "AddressType", + values: { + RESIDENTIAL: "RESIDENTIAL", + COMMERCIAL: "COMMERCIAL" + } } } as const; authType = "User" as const; diff --git a/packages/zod/test/schema/schema.zmodel b/packages/zod/test/schema/schema.zmodel index e7deb27aa..ba1475d80 100644 --- a/packages/zod/test/schema/schema.zmodel +++ b/packages/zod/test/schema/schema.zmodel @@ -10,12 +10,19 @@ enum Status { @@meta("description", "User account status") } +enum AddressType { + RESIDENTIAL + COMMERCIAL +} + type Address { residents String[] street String @meta("description", "Street address line") city String @length(2) zip String? + type String + @@validate(type in AddressType) @@validate(zip == null || length(zip) == 5, "Zip code must be exactly 5 characters", ["zip"]) @@meta("description", "A mailing address") } diff --git a/tests/e2e/orm/schemas/enum/schema.ts b/tests/e2e/orm/schemas/enum/schema.ts new file mode 100644 index 000000000..99cb511be --- /dev/null +++ b/tests/e2e/orm/schemas/enum/schema.ts @@ -0,0 +1,66 @@ +////////////////////////////////////////////////////////////////////////////////////////////// +// 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: "Int", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("autoincrement") as FieldDefault + } + }, + idFields: ["id"], + uniqueFields: { + id: { type: "Int" } + } + }, + Post: { + name: "Post", + fields: { + id: { + name: "id", + type: "String", + id: true, + attributes: [{ name: "@id" }] as readonly AttributeApplication[] + }, + status: { + name: "status", + type: "String" + } + }, + attributes: [ + { name: "@@validate", args: [{ name: "value", value: ExpressionUtils.binary(ExpressionUtils.field("status"), "in", ExpressionUtils.array("PostStatus", [ExpressionUtils.literal("DRAFT"), ExpressionUtils.literal("ACTIVE"), ExpressionUtils.literal("CANCELLED")])) }] } + ] as readonly AttributeApplication[], + idFields: ["id"], + uniqueFields: { + id: { type: "String" } + } + } + } as const; + enums = { + PostStatus: { + name: "PostStatus", + values: { + DRAFT: "DRAFT", + ACTIVE: "ACTIVE", + CANCELLED: "CANCELLED" + } + } + } as const; + authType = "User" as const; + plugins = {}; +} +export const schema = new SchemaType(); diff --git a/tests/e2e/orm/schemas/enum/schema.zmodel b/tests/e2e/orm/schemas/enum/schema.zmodel new file mode 100644 index 000000000..d95f40bdd --- /dev/null +++ b/tests/e2e/orm/schemas/enum/schema.zmodel @@ -0,0 +1,21 @@ +datasource db { + provider = 'sqlite' + url = 'file:./dev.db' +} + +enum PostStatus { + DRAFT + ACTIVE + CANCELLED +} + +model User { + id Int @id @default(autoincrement()) +} + +model Post { + id String @id + status String + + @@validate(status in PostStatus) +} \ No newline at end of file From 59bbadcf35cffc030c817736f5f8a56edd913280 Mon Sep 17 00:00:00 2001 From: sanny-io Date: Mon, 17 Aug 2026 12:43:48 +0000 Subject: [PATCH 3/9] chore: add tests --- packages/cli/test/ts-schema-gen.test.ts | 59 +++++++++++++++++ .../test/attribute-application.test.ts | 26 ++++++++ packages/zod/test/factory.test.ts | 45 ++++++++++++- tests/e2e/orm/client-api/enum.test.ts | 63 +++++++++++++++++++ 4 files changed, 191 insertions(+), 2 deletions(-) create mode 100644 tests/e2e/orm/client-api/enum.test.ts diff --git a/packages/cli/test/ts-schema-gen.test.ts b/packages/cli/test/ts-schema-gen.test.ts index 38a0e5cc6..5b337ce7b 100644 --- a/packages/cli/test/ts-schema-gen.test.ts +++ b/packages/cli/test/ts-schema-gen.test.ts @@ -736,4 +736,63 @@ model Post { plugins: {}, }); }); + + it('supports implicit conversions from enums to arrays', async () => { + const { schema } = await generateTsSchema(` +enum PostStatus { + DRAFT + ACTIVE + CANCELLED +} + +model User { + id Int @id @default(autoincrement()) +} + +model Post { + id String @id + status String + + @@validate(status in PostStatus) +} + `); + + expect(schema.models['Post']?.attributes).toMatchObject([ + { + name: '@@validate', + args: [ + { + name: 'value', + value: { + kind: 'binary', + op: 'in', + left: { + kind: 'field', + field: 'status', + }, + right: { + kind: 'array', + type: 'PostStatus', + items: [ + { + kind: 'literal', + value: 'DRAFT', + }, + { + kind: 'literal', + value: 'ACTIVE', + }, + { + kind: 'literal', + value: 'CANCELLED', + }, + ], + }, + binding: undefined, + }, + }, + ], + }, + ]); + }); }); diff --git a/packages/language/test/attribute-application.test.ts b/packages/language/test/attribute-application.test.ts index 3e74e2484..9a741f754 100644 --- a/packages/language/test/attribute-application.test.ts +++ b/packages/language/test/attribute-application.test.ts @@ -816,4 +816,30 @@ describe('Attribute application validation tests', () => { /relation "bar" is not optional/, ); }); + + it('@@validate accepts implicit array conversions from enum references', async () => { + await loadSchema(` + datasource db { + provider = 'sqlite' + url = 'file:./dev.db' + } + + enum PostStatus { + DRAFT + ACTIVE + CANCELLED + } + + model User { + id Int @id @default(autoincrement()) + } + + model Post { + id String @id + status String + + @@validate(status in PostStatus) + } + `); + }); }); diff --git a/packages/zod/test/factory.test.ts b/packages/zod/test/factory.test.ts index a0bc7592c..2462ed206 100644 --- a/packages/zod/test/factory.test.ts +++ b/packages/zod/test/factory.test.ts @@ -86,6 +86,7 @@ describe('SchemaFactory - makeModelSchema', () => { expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf
(); // relation fields are NOT present by default — use include/select to opt in @@ -439,7 +440,7 @@ describe('SchemaFactory - makeModelSchema', () => { const userSchema = factory.makeModelSchema('User'); const result = userSchema.safeParse({ ...validUser, - address: { residents: [], street: '123 Main St', city: 'Springfield', zip: null }, + address: { residents: [], street: '123 Main St', city: 'Springfield', zip: null, type: 'RESIDENTIAL' }, }); expect(result.success).toBe(true); }); @@ -448,7 +449,13 @@ describe('SchemaFactory - makeModelSchema', () => { const userSchema = factory.makeModelSchema('User'); const result = userSchema.safeParse({ ...validUser, - address: { residents: [], street: '123 Main St', city: 'Springfield', zip: '12345' }, + address: { + residents: [], + street: '123 Main St', + city: 'Springfield', + zip: '12345', + type: 'RESIDENTIAL', + }, }); expect(result.success).toBe(true); }); @@ -577,6 +584,39 @@ describe('SchemaFactory - makeTypeSchema', () => { ).toBe(true); }); + it('passes when field value matches implicitly converted enum', () => { + const addressSchema = factory.makeTypeSchema('Address'); + let result = addressSchema.safeParse({ + residents: [], + street: '123 Main', + city: 'Springfield', + zip: '12345', + type: 'RESIDENTIAL', + }); + expect(result.success).toBe(true); + + result = addressSchema.safeParse({ + residents: [], + street: '123 Main', + city: 'Springfield', + zip: '12345', + type: 'COMMERCIAL', + }); + expect(result.success).toBe(true); + }); + + it('fails when field value does not match implicitly converted enum', () => { + const addressSchema = factory.makeTypeSchema('Address'); + const result = addressSchema.safeParse({ + residents: [], + street: '123 Main', + city: 'Springfield', + zip: '123', + type: 'UNKNOWN', + }); + expect(result.success).toBe(false); + }); + it('fails when zip is fewer than 5 characters', () => { const addressSchema = factory.makeTypeSchema('Address'); const result = addressSchema.safeParse({ @@ -620,6 +660,7 @@ describe('SchemaFactory - makeTypeSchema', () => { street: '123 Main', city: 'Springfield', zip: '123', + type: 'RESIDENTIAL', }); expect(result.success).toBe(false); if (!result.success) { diff --git a/tests/e2e/orm/client-api/enum.test.ts b/tests/e2e/orm/client-api/enum.test.ts new file mode 100644 index 000000000..72fb4ce1f --- /dev/null +++ b/tests/e2e/orm/client-api/enum.test.ts @@ -0,0 +1,63 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import type { ClientContract } from '@zenstackhq/orm'; +import { schema } from '../schemas/enum/schema'; +import { createTestClient } from '@zenstackhq/testtools'; + +describe('Enum tests', () => { + let client: ClientContract; + + beforeEach(async () => { + client = await createTestClient(schema); + }); + + afterEach(async () => { + await client?.$disconnect(); + }); + + it('works when implicitly converted to arrays', async () => { + await expect( + client.post.create({ + data: { + id: '1', + status: 'ACTIVE', + }, + }), + ).resolves.toMatchObject({ + id: '1', + status: 'ACTIVE', + }); + + await expect( + client.post.create({ + data: { + id: '2', + status: 'DRAFT', + }, + }), + ).resolves.toMatchObject({ + id: '2', + status: 'DRAFT', + }); + + await expect( + client.post.create({ + data: { + id: '3', + status: 'CANCELLED', + }, + }), + ).resolves.toMatchObject({ + id: '3', + status: 'CANCELLED', + }); + + await expect( + client.post.create({ + data: { + id: '3', + status: 'UNKNOWN', + }, + }), + ).rejects.toThrow(/Validation error/); + }); +}); From cba0348a8e740dedab4900679d8686853305680c Mon Sep 17 00:00:00 2001 From: sanny-io Date: Mon, 17 Aug 2026 12:49:17 +0000 Subject: [PATCH 4/9] chore: fix erroneous test pass --- packages/zod/test/factory.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/zod/test/factory.test.ts b/packages/zod/test/factory.test.ts index 2462ed206..a5358f134 100644 --- a/packages/zod/test/factory.test.ts +++ b/packages/zod/test/factory.test.ts @@ -611,7 +611,7 @@ describe('SchemaFactory - makeTypeSchema', () => { residents: [], street: '123 Main', city: 'Springfield', - zip: '123', + zip: '12345', type: 'UNKNOWN', }); expect(result.success).toBe(false); From 08e706a11a619667ded35da3695499d2d7949764 Mon Sep 17 00:00:00 2001 From: sanny-io Date: Mon, 17 Aug 2026 12:53:27 +0000 Subject: [PATCH 5/9] chore: fix broken tests --- packages/zod/test/factory.test.ts | 79 ++++++++++++++++++++++++++----- 1 file changed, 66 insertions(+), 13 deletions(-) diff --git a/packages/zod/test/factory.test.ts b/packages/zod/test/factory.test.ts index a5358f134..ab25e9e58 100644 --- a/packages/zod/test/factory.test.ts +++ b/packages/zod/test/factory.test.ts @@ -464,7 +464,14 @@ describe('SchemaFactory - makeModelSchema', () => { const userSchema = factory.makeModelSchema('User'); const result = userSchema.safeParse({ ...validUser, - address: { residents: [], street: '123 Main St', city: 'Springfield', zip: null, extra: 'field' }, + address: { + residents: [], + street: '123 Main St', + city: 'Springfield', + zip: null, + extra: 'field', + type: 'RESIDENTIAL', + }, }); expect(result.success).toBe(false); }); @@ -525,7 +532,13 @@ describe('SchemaFactory - makeTypeSchema', () => { it('generates schema for Address typedef', () => { const addressSchema = factory.makeTypeSchema('Address'); expect( - addressSchema.safeParse({ residents: [], street: '123 Main', city: 'Springfield', zip: null }).success, + addressSchema.safeParse({ + residents: [], + street: '123 Main', + city: 'Springfield', + zip: null, + type: 'RESIDENTIAL', + }).success, ).toBe(true); }); @@ -543,6 +556,7 @@ describe('SchemaFactory - makeTypeSchema', () => { city: 'Springfield', zip: null, extra: 'field', + type: 'RESIDENTIAL', }); expect(result.success).toBe(false); }); @@ -550,14 +564,26 @@ describe('SchemaFactory - makeTypeSchema', () => { it('accepts Address with optional zip as null', () => { const addressSchema = factory.makeTypeSchema('Address'); expect( - addressSchema.safeParse({ residents: [], street: '123 Main', city: 'Springfield', zip: null }).success, + addressSchema.safeParse({ + residents: [], + street: '123 Main', + city: 'Springfield', + zip: null, + type: 'RESIDENTIAL', + }).success, ).toBe(true); }); it('accepts Address with optional zip as a string', () => { const addressSchema = factory.makeTypeSchema('Address'); expect( - addressSchema.safeParse({ residents: [], street: '123 Main', city: 'Springfield', zip: '12345' }).success, + addressSchema.safeParse({ + residents: [], + street: '123 Main', + city: 'Springfield', + zip: '12345', + type: 'RESIDENTIAL', + }).success, ).toBe(true); }); @@ -565,22 +591,34 @@ describe('SchemaFactory - makeTypeSchema', () => { it('passes when zip is null', () => { const addressSchema = factory.makeTypeSchema('Address'); expect( - addressSchema.safeParse({ residents: [], street: '123 Main', city: 'Springfield', zip: null }).success, + addressSchema.safeParse({ + residents: [], + street: '123 Main', + city: 'Springfield', + zip: null, + type: 'RESIDENTIAL', + }).success, ).toBe(true); }); it('passes when zip is omitted', () => { const addressSchema = factory.makeTypeSchema('Address'); - expect(addressSchema.safeParse({ residents: [], street: '123 Main', city: 'Springfield' }).success).toBe( - true, - ); + expect( + addressSchema.safeParse({ residents: [], street: '123 Main', city: 'Springfield', type: 'RESIDENTIAL' }) + .success, + ).toBe(true); }); it('passes when zip is exactly 5 characters', () => { const addressSchema = factory.makeTypeSchema('Address'); expect( - addressSchema.safeParse({ residents: [], street: '123 Main', city: 'Springfield', zip: '90210' }) - .success, + addressSchema.safeParse({ + residents: [], + street: '123 Main', + city: 'Springfield', + zip: '90210', + type: 'RESIDENTIAL', + }).success, ).toBe(true); }); @@ -624,6 +662,7 @@ describe('SchemaFactory - makeTypeSchema', () => { street: '123 Main', city: 'Springfield', zip: '123', + type: 'RESIDENTIAL', }); expect(result.success).toBe(false); }); @@ -635,6 +674,7 @@ describe('SchemaFactory - makeTypeSchema', () => { street: '123 Main', city: 'Springfield', zip: '123456', + type: 'RESIDENTIAL', }); expect(result.success).toBe(false); }); @@ -646,6 +686,7 @@ describe('SchemaFactory - makeTypeSchema', () => { street: '123 Main', city: 'Springfield', zip: '123', + type: 'RESIDENTIAL', }); expect(result.success).toBe(false); if (!result.success) { @@ -670,7 +711,13 @@ describe('SchemaFactory - makeTypeSchema', () => { it('fails when city is too short', () => { const addressSchema = factory.makeTypeSchema('Address'); - const result = addressSchema.safeParse({ residents: [], street: '123 Main', city: '', zip: '12345' }); + const result = addressSchema.safeParse({ + residents: [], + street: '123 Main', + city: '', + zip: '12345', + type: 'RESIDENTIAL', + }); expect(result.success).toBe(false); }); @@ -694,13 +741,19 @@ describe('SchemaFactory - makeTypeSchema', () => { avatar: null, metadata: null, status: 'ACTIVE', - address: { residents: [], street: '123 Main', city: 'Springfield', zip: '90210' }, + address: { residents: [], street: '123 Main', city: 'Springfield', zip: '90210', type: 'RESIDENTIAL' }, }; expect(userSchema.safeParse(validUser).success).toBe(true); expect( userSchema.safeParse({ ...validUser, - address: { residents: ['Alice'], street: '123 Main', city: 'Springfield', zip: '123' }, + address: { + residents: ['Alice'], + street: '123 Main', + city: 'Springfield', + zip: '123', + type: 'RESIDENTIAL', + }, }).success, ).toBe(false); }); From b5f188ebab937c77edced766ebf9cc455b858dc6 Mon Sep 17 00:00:00 2001 From: sanny-io Date: Fri, 2 Oct 2026 09:28:25 +0000 Subject: [PATCH 6/9] fix: reject usage outside of policies and validations --- .../src/validators/expression-validator.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/language/src/validators/expression-validator.ts b/packages/language/src/validators/expression-validator.ts index 3efe6d916..a3b3bcc59 100644 --- a/packages/language/src/validators/expression-validator.ts +++ b/packages/language/src/validators/expression-validator.ts @@ -4,6 +4,7 @@ import { Expression, isArrayExpr, isCollectionPredicateBinding, + isDataFieldAttribute, isDataModel, isDataModelAttribute, isEnum, @@ -87,6 +88,11 @@ export default class ExpressionValidator implements AstValidator { node: expr, }); } + if (isEnum(expr.target.ref) && !this.isInPolicyOrValidationAttribute(expr)) { + accept('error', 'Enum reference can only be used with policy and validation attributes', { + node: expr, + }); + } } private validateMemberAccessExpr(expr: MemberAccessExpr, accept: ValidationAcceptor) { @@ -281,6 +287,14 @@ export default class ExpressionValidator implements AstValidator { return findUpAst(node, (n) => isDataModelAttribute(n) && n.decl.$refText === '@@validate'); } + private isInPolicyOrValidationAttribute(node: AstNode) { + const attrs = ['@allow', '@@allow', '@deny', '@@deny', '@@validate']; + return findUpAst( + node, + (n) => (isDataModelAttribute(n) || isDataFieldAttribute(n)) && attrs.includes(n.decl.$refText), + ); + } + private isNotModelFieldExpr(expr: Expression): boolean { return ( // literal From 98d3fe0908349b1ced16af881068169a44b7fd05 Mon Sep 17 00:00:00 2001 From: sanny-io Date: Fri, 2 Oct 2026 09:28:56 +0000 Subject: [PATCH 7/9] fix: support mapped enum field names --- packages/sdk/src/ts-schema-generator.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/sdk/src/ts-schema-generator.ts b/packages/sdk/src/ts-schema-generator.ts index 5ed8467c9..9f389d059 100644 --- a/packages/sdk/src/ts-schema-generator.ts +++ b/packages/sdk/src/ts-schema-generator.ts @@ -10,6 +10,7 @@ import { DataModel, DataModelAttribute, Enum, + EnumField, Expression, InvocationExpr, isArrayExpr, @@ -33,6 +34,7 @@ import { MemberAccessExpr, Procedure, ReferenceExpr, + StringLiteral, TypeDef, UnaryExpr, type Model, @@ -1373,7 +1375,7 @@ export class TsSchemaGenerator { this.createLiteralNode(expr.target.$refText), ts.factory.createArrayLiteralExpression( (target as Enum).fields.map((field) => - this.createLiteralExpression('StringLiteral', field.name), + this.createLiteralExpression('StringLiteral', this.getEnumFieldName(field)), ), ), ]), @@ -1764,4 +1766,12 @@ export class TsSchemaGenerator { const result = printer.printList(ts.ListFormat.MultiLine, ts.factory.createNodeArray(statements), sourceFile); fs.writeFileSync(outputFile, result); } + + private getEnumFieldName(field: EnumField) { + const mapAttr = field.attributes?.find((a) => a.decl.$refText === '@map'); + if (!mapAttr || !mapAttr.args?.[0]) { + return field.name; + } + return (mapAttr.args[0].value as StringLiteral).value; + } } From e08879c67bf87c5df8e30ff42fcff36beea96cc1 Mon Sep 17 00:00:00 2001 From: sanny-io Date: Fri, 2 Oct 2026 09:29:08 +0000 Subject: [PATCH 8/9] chore: add tests --- packages/cli/test/ts-schema-gen.test.ts | 59 ++++++++++++ .../test/attribute-application.test.ts | 26 ------ packages/language/test/enum.test.ts | 92 +++++++++++++++++++ 3 files changed, 151 insertions(+), 26 deletions(-) create mode 100644 packages/language/test/enum.test.ts diff --git a/packages/cli/test/ts-schema-gen.test.ts b/packages/cli/test/ts-schema-gen.test.ts index 8d1ed07d0..9cc1ba19e 100644 --- a/packages/cli/test/ts-schema-gen.test.ts +++ b/packages/cli/test/ts-schema-gen.test.ts @@ -911,6 +911,65 @@ model Post { ]); }); + it('supports implicit conversions from enums to arrays with mapped values', async () => { + const { schema } = await generateTsSchema(` +enum PostStatus { + DRAFT @map('draft') + ACTIVE @map('active') + CANCELLED @map('cancelled') +} + +model User { + id Int @id @default(autoincrement()) +} + +model Post { + id String @id + status String + + @@validate(status in PostStatus) +} + `); + + expect(schema.models['Post']?.attributes).toMatchObject([ + { + name: '@@validate', + args: [ + { + name: 'value', + value: { + kind: 'binary', + op: 'in', + left: { + kind: 'field', + field: 'status', + }, + right: { + kind: 'array', + type: 'PostStatus', + items: [ + { + kind: 'literal', + value: 'draft', + }, + { + kind: 'literal', + value: 'active', + }, + { + kind: 'literal', + value: 'cancelled', + }, + ], + }, + binding: undefined, + }, + }, + ], + }, + ]); + }); + it('supports @@strict for type defs', async () => { const { schema } = await generateTsSchema(` model User { diff --git a/packages/language/test/attribute-application.test.ts b/packages/language/test/attribute-application.test.ts index 4d984d8e5..9abe8ffe9 100644 --- a/packages/language/test/attribute-application.test.ts +++ b/packages/language/test/attribute-application.test.ts @@ -936,32 +936,6 @@ describe('Attribute application validation tests', () => { ); }); - it('@@validate accepts implicit array conversions from enum references', async () => { - await loadSchema(` - datasource db { - provider = 'sqlite' - url = 'file:./dev.db' - } - - enum PostStatus { - DRAFT - ACTIVE - CANCELLED - } - - model User { - id Int @id @default(autoincrement()) - } - - model Post { - id String @id - status String - - @@validate(status in PostStatus) - } - `); - }); - describe('@@strict attribute', () => { it('accepts type defs', async () => { await loadSchema(` diff --git a/packages/language/test/enum.test.ts b/packages/language/test/enum.test.ts new file mode 100644 index 000000000..bea678dff --- /dev/null +++ b/packages/language/test/enum.test.ts @@ -0,0 +1,92 @@ +import { describe, it } from 'vitest'; +import { loadSchema, loadSchemaWithError } from './utils'; + +describe('Enum tests', () => { + describe('implicit array conversions', () => { + it('supports usage with policy and validation attributes', async () => { + await loadSchema(` + datasource db { + provider = 'sqlite' + url = 'file:./dev.db' + } + + enum PostStatus { + DRAFT + ACTIVE + CANCELLED + } + + model User { + id Int @id @default(autoincrement()) + } + + model Post { + id String @id + status String @allow('update', status in PostStatus) @deny('read', !(status in PostStatus)) + + @@allow('read', status in PostStatus) + @@deny('read', status in PostStatus) + @@validate(status in PostStatus) + } + `); + }); + + it('rejects usage with non-policy and non-validation attributes', async () => { + await loadSchemaWithError( + ` + datasource db { + provider = 'sqlite' + url = 'file:./dev.db' + } + + enum PostStatus { + DRAFT + ACTIVE + CANCELLED + } + + model User { + id Int @id @default(autoincrement()) + } + + model Post { + id String @id + status PostStatus[] @default(PostStatus) + } + `, + /Enum reference can only be used with policy and validation attributes/, + ); + }); + + it('rejects usage in other contexts', async () => { + await loadSchemaWithError( + ` + datasource db { + provider = 'sqlite' + url = 'file:./dev.db' + } + + enum PostStatus { + DRAFT + ACTIVE + CANCELLED + } + + model User { + id Int @id @default(autoincrement()) + } + + model Post { + id String @id + status String + } + + function Test(status: String): Void { + status in PostStatus + } + `, + /Enum reference can only be used with policy and validation attributes/, + ); + }); + }); +}); From 6ee198ac2494d7cfbf2c83ba4219c27f1d65bb1f Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Sun, 4 Oct 2026 20:22:29 -0600 Subject: [PATCH 9/9] fix: emit enum names for implicit enum arrays and map them for policy SQL Enum values are represented by their names at runtime (TS values, auth(), Zod validation), so the implicit `field in Enum` expansion must emit enum member names rather than `@map`-ed values. The ORM name mapper translates names to database values at SQL execution time. Also fix a pre-existing gap for `field in [A, B]` on `@map`-ed enum columns in policies: - policy transformer: emit a plain SQL `IN (...)` list for literal arrays on every dialect, instead of `CAST(col AS text) = ANY(ARRAY[...])` on PostgreSQL, so the comparison stays index-friendly and reaches the name mapper - name mapper: translate enum values inside a `ValueListNode` right operand Co-Authored-By: Claude Fable 5.1 --- packages/cli/test/ts-schema-gen.test.ts | 10 ++- .../orm/src/client/executor/name-mapper.ts | 17 +++- .../policy/src/expression-transformer.ts | 24 ++++- packages/sdk/src/ts-schema-generator.ts | 12 +-- tests/e2e/orm/client-api/enum.test.ts | 2 +- tests/regression/test/issue-1211.test.ts | 89 +++++++++++++++++++ 6 files changed, 135 insertions(+), 19 deletions(-) create mode 100644 tests/regression/test/issue-1211.test.ts diff --git a/packages/cli/test/ts-schema-gen.test.ts b/packages/cli/test/ts-schema-gen.test.ts index 9cc1ba19e..e84b1e054 100644 --- a/packages/cli/test/ts-schema-gen.test.ts +++ b/packages/cli/test/ts-schema-gen.test.ts @@ -911,7 +911,9 @@ model Post { ]); }); - it('supports implicit conversions from enums to arrays with mapped values', async () => { + it('emits enum names (not @map values) when converting enums to arrays', async () => { + // enum values are represented by their names at runtime (TS values, auth(), Zod); + // the ORM's name mapper translates them to `@map`-ed values at SQL execution time const { schema } = await generateTsSchema(` enum PostStatus { DRAFT @map('draft') @@ -950,15 +952,15 @@ model Post { items: [ { kind: 'literal', - value: 'draft', + value: 'DRAFT', }, { kind: 'literal', - value: 'active', + value: 'ACTIVE', }, { kind: 'literal', - value: 'cancelled', + value: 'CANCELLED', }, ], }, diff --git a/packages/orm/src/client/executor/name-mapper.ts b/packages/orm/src/client/executor/name-mapper.ts index 7cb5c72ec..f26374275 100644 --- a/packages/orm/src/client/executor/name-mapper.ts +++ b/packages/orm/src/client/executor/name-mapper.ts @@ -283,7 +283,9 @@ export class QueryNameMapper extends OperationNodeTransformer { if ( ReferenceNode.is(node.leftOperand) && ColumnNode.is(node.leftOperand.column) && - (ValueNode.is(node.rightOperand) || PrimitiveValueListNode.is(node.rightOperand)) + (ValueNode.is(node.rightOperand) || + PrimitiveValueListNode.is(node.rightOperand) || + ValueListNode.is(node.rightOperand)) ) { const columnNode = node.leftOperand.column; @@ -311,6 +313,19 @@ export class QueryNameMapper extends OperationNodeTransformer { valueNode.values, ), ); + } else if (ValueListNode.is(valueNode)) { + // list value: column IN (EnumValue, EnumValue2) + resultValue = ValueListNode.create( + valueNode.values.map((v) => + ValueNode.is(v) + ? (this.processEnumMappingForValue( + resolvedScope.model!, + columnNode, + v, + ) as OperationNode) + : v, + ), + ); } return super.transformBinaryOperation( diff --git a/packages/plugins/policy/src/expression-transformer.ts b/packages/plugins/policy/src/expression-transformer.ts index c885b504c..ea5d0f1ee 100644 --- a/packages/plugins/policy/src/expression-transformer.ts +++ b/packages/plugins/policy/src/expression-transformer.ts @@ -274,14 +274,29 @@ export class ExpressionTransformer { const { normalizedLeft, normalizedRight } = this.normalizeBinaryOperationOperands(expr, context); const left = this.transform(normalizedLeft, context); - const right = this.transform(normalizedRight, context); if (op === 'in') { if (this.isNullNode(left)) { return this.transformValue(false, 'Boolean'); } else { + if (this.isLiteralArray(normalizedRight)) { + // `in` with a list of literal values, e.g. `field in [1, 2, 3]` or + // `field in [ENUM_A, ENUM_B]`: emit a plain SQL `IN (...)` list on every + // dialect (instead of a dialect-specific array value) so that it stays + // index-friendly and the name mapper can translate `@map`-ed enum values + if (normalizedRight.items.length === 0) { + return this.transformValue(false, 'Boolean'); + } + return BinaryOperationNode.create( + left, + OperatorNode.create('in'), + ValueListNode.create(normalizedRight.items.map((item) => this.transform(item, context))), + ); + } + + const right = this.transform(normalizedRight, context); if (ValueListNode.is(right)) { - // simple `in` operator with a list of values, e.g. `field in [1, 2, 3]` + // simple `in` operator with a list of values, e.g. `field in [auth().x, 2]` return BinaryOperationNode.create(left, OperatorNode.create('in'), right); } else { // array contains @@ -317,6 +332,7 @@ export class ExpressionTransformer { } } + const right = this.transform(normalizedRight, context); if (this.isNullNode(right)) { return this.transformNullCheck(left, expr.op); } else if (this.isNullNode(left)) { @@ -358,6 +374,10 @@ export class ExpressionTransformer { } } + private isLiteralArray(expr: Expression): expr is ArrayExpression { + return expr.kind === 'array' && expr.items.every((item) => item.kind === 'literal'); + } + private normalizeBinaryOperationOperands(expr: BinaryExpression, context: ExpressionTransformerContext) { // If relation fields are used directly in a comparison, normalize both sides to the // first id field (used for multiple). This applies whether the relation is SQL-backed diff --git a/packages/sdk/src/ts-schema-generator.ts b/packages/sdk/src/ts-schema-generator.ts index 9f389d059..5ed8467c9 100644 --- a/packages/sdk/src/ts-schema-generator.ts +++ b/packages/sdk/src/ts-schema-generator.ts @@ -10,7 +10,6 @@ import { DataModel, DataModelAttribute, Enum, - EnumField, Expression, InvocationExpr, isArrayExpr, @@ -34,7 +33,6 @@ import { MemberAccessExpr, Procedure, ReferenceExpr, - StringLiteral, TypeDef, UnaryExpr, type Model, @@ -1375,7 +1373,7 @@ export class TsSchemaGenerator { this.createLiteralNode(expr.target.$refText), ts.factory.createArrayLiteralExpression( (target as Enum).fields.map((field) => - this.createLiteralExpression('StringLiteral', this.getEnumFieldName(field)), + this.createLiteralExpression('StringLiteral', field.name), ), ), ]), @@ -1766,12 +1764,4 @@ export class TsSchemaGenerator { const result = printer.printList(ts.ListFormat.MultiLine, ts.factory.createNodeArray(statements), sourceFile); fs.writeFileSync(outputFile, result); } - - private getEnumFieldName(field: EnumField) { - const mapAttr = field.attributes?.find((a) => a.decl.$refText === '@map'); - if (!mapAttr || !mapAttr.args?.[0]) { - return field.name; - } - return (mapAttr.args[0].value as StringLiteral).value; - } } diff --git a/tests/e2e/orm/client-api/enum.test.ts b/tests/e2e/orm/client-api/enum.test.ts index 72fb4ce1f..e7b8a4705 100644 --- a/tests/e2e/orm/client-api/enum.test.ts +++ b/tests/e2e/orm/client-api/enum.test.ts @@ -54,7 +54,7 @@ describe('Enum tests', () => { await expect( client.post.create({ data: { - id: '3', + id: '4', status: 'UNKNOWN', }, }), diff --git a/tests/regression/test/issue-1211.test.ts b/tests/regression/test/issue-1211.test.ts new file mode 100644 index 000000000..6b5dd5d0f --- /dev/null +++ b/tests/regression/test/issue-1211.test.ts @@ -0,0 +1,89 @@ +import { createPolicyTestClient, createTestClient } from '@zenstackhq/testtools'; +import { describe, expect, it } from 'vitest'; + +// https://github.com/zenstackhq/zenstack/issues/1211 +describe('Regression for issue 1211', () => { + const ENUM = ` +enum PostStatus { + DRAFT @map('draft') + ACTIVE @map('active') + CANCELLED @map('cancelled') +} +`; + + it('validates enum-typed field against an enum with @map-ed values', async () => { + const db = await createTestClient(` +${ENUM} +model Post { + id Int @id @default(autoincrement()) + status PostStatus + @@validate(status in PostStatus) +} +`); + await expect(db.post.create({ data: { status: 'DRAFT' } })).resolves.toMatchObject({ status: 'DRAFT' }); + }); + + it('validates string field against an enum', async () => { + const db = await createTestClient(` +enum PostStatus { + DRAFT + ACTIVE +} +model Post { + id Int @id @default(autoincrement()) + status String + @@validate(status in PostStatus) +} +`); + await expect(db.post.create({ data: { status: 'ACTIVE' } })).resolves.toMatchObject({ status: 'ACTIVE' }); + await expect(db.post.create({ data: { status: 'UNKNOWN' } })).toBeRejectedByValidation(); + }); + + it('checks enum-typed field against an enum with @map-ed values in policies', async () => { + const db = await createPolicyTestClient(` +${ENUM} +model Post { + id Int @id @default(autoincrement()) + status PostStatus + @@allow('create', true) + @@allow('read', status in PostStatus) +} +`); + await db.$unuseAll().post.create({ data: { status: 'DRAFT' } }); + await expect(db.post.findMany()).resolves.toMatchObject([{ status: 'DRAFT' }]); + }); + + it('checks enum-typed field against an explicit enum array with @map-ed values in policies', async () => { + const db = await createPolicyTestClient(` +${ENUM} +model Post { + id Int @id @default(autoincrement()) + status PostStatus + @@allow('create', true) + @@allow('read', status in [DRAFT, ACTIVE]) +} +`); + await db.$unuseAll().post.create({ data: { status: 'DRAFT' } }); + await db.$unuseAll().post.create({ data: { status: 'CANCELLED' } }); + await expect(db.post.findMany()).resolves.toMatchObject([{ status: 'DRAFT' }]); + }); + + it('checks auth() enum member against an enum with @map-ed values in policies', async () => { + const db = await createPolicyTestClient(` +${ENUM} +model User { + id Int @id @default(autoincrement()) + status PostStatus + @@allow('all', true) +} +model Post { + id Int @id @default(autoincrement()) + @@allow('create', true) + @@allow('read', auth().status in PostStatus) +} +`); + await db.$unuseAll().post.create({ data: {} }); + await expect(db.$setAuth({ id: 1, status: 'DRAFT' }).post.findMany()).resolves.toHaveLength(1); + await expect(db.$setAuth({ id: 1, status: 'UNKNOWN' }).post.findMany()).resolves.toHaveLength(0); + }); +});