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