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/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/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/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}`); }); diff --git a/packages/zod/test/factory.test.ts b/packages/zod/test/factory.test.ts index a0bc7592c..ab25e9e58 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); }); @@ -457,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); }); @@ -518,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); }); @@ -536,6 +556,7 @@ describe('SchemaFactory - makeTypeSchema', () => { city: 'Springfield', zip: null, extra: 'field', + type: 'RESIDENTIAL', }); expect(result.success).toBe(false); }); @@ -543,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); }); @@ -558,25 +591,70 @@ 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); }); + 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: '12345', + 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({ @@ -584,6 +662,7 @@ describe('SchemaFactory - makeTypeSchema', () => { street: '123 Main', city: 'Springfield', zip: '123', + type: 'RESIDENTIAL', }); expect(result.success).toBe(false); }); @@ -595,6 +674,7 @@ describe('SchemaFactory - makeTypeSchema', () => { street: '123 Main', city: 'Springfield', zip: '123456', + type: 'RESIDENTIAL', }); expect(result.success).toBe(false); }); @@ -606,6 +686,7 @@ describe('SchemaFactory - makeTypeSchema', () => { street: '123 Main', city: 'Springfield', zip: '123', + type: 'RESIDENTIAL', }); expect(result.success).toBe(false); if (!result.success) { @@ -620,6 +701,7 @@ describe('SchemaFactory - makeTypeSchema', () => { street: '123 Main', city: 'Springfield', zip: '123', + type: 'RESIDENTIAL', }); expect(result.success).toBe(false); if (!result.success) { @@ -629,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); }); @@ -653,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); }); 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/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/); + }); +}); 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