From c0c95dd143cb140d3b40e22adabdb722b1d1e59b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Mar 2026 10:26:19 +0000 Subject: [PATCH] feat: add db.type='enum' support to select field Adds native Prisma enum storage for the select field, matching Keystone 6's enum select behaviour. When db.type is 'enum', the Prisma generator emits an enum block (e.g. PostStatus) and uses the enum type in the model instead of String. Default values are unquoted per Prisma enum syntax. - SelectField type gains db.type ('string'|'enum'), db.map, and defaultValue - getPrismaType signature extended with optional listName and enumValues return - select() builder validates enum values are valid Prisma identifiers - Prisma generator collects enum definitions in a first pass and emits blocks - Tests added: select field unit tests, schema validation tests, generator tests https://claude.ai/code/session_0148fU5k5gN6nr4mAtGpjUZD --- .changeset/silver-mice-dream.md | 53 ++++ .../__snapshots__/prisma.test.ts.snap | 26 ++ packages/cli/src/generator/prisma.test.ts | 299 +++++++++++++++++- packages/cli/src/generator/prisma.ts | 40 ++- packages/core/src/config/types.ts | 24 +- packages/core/src/fields/index.ts | 52 ++- packages/core/src/fields/select.test.ts | 237 ++++++++++++++ packages/core/src/validation/schema.test.ts | 51 +++ 8 files changed, 773 insertions(+), 9 deletions(-) create mode 100644 .changeset/silver-mice-dream.md create mode 100644 packages/core/src/fields/select.test.ts diff --git a/.changeset/silver-mice-dream.md b/.changeset/silver-mice-dream.md new file mode 100644 index 00000000..9a732ee4 --- /dev/null +++ b/.changeset/silver-mice-dream.md @@ -0,0 +1,53 @@ +--- +'@opensaas/stack-core': minor +'@opensaas/stack-cli': minor +--- + +Add `db.type: 'enum'` support to the `select` field for native database enum storage + +The `select` field now supports `db.type: 'enum'` to store values as a native Prisma enum type rather than a plain string. This generates an `enum` block in the Prisma schema and uses the enum type in the model, matching Keystone 6's enum select behaviour. + +```typescript +import { select } from '@opensaas/stack-core/fields' + +lists: { + Post: list({ + fields: { + status: select({ + options: [ + { label: 'Draft', value: 'draft' }, + { label: 'Published', value: 'published' }, + { label: 'Archived', value: 'archived' }, + ], + db: { type: 'enum' }, // generates a Prisma enum + defaultValue: 'draft', + }), + }, + }), +} +``` + +This generates the following Prisma schema: + +```prisma +enum PostStatus { + draft + published + archived +} + +model Post { + id String @id @default(cuid()) + status PostStatus @default(draft) + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt +} +``` + +**Notes:** + +- The enum name is derived from `` in PascalCase (e.g. `PostStatus`, `UserRole`) +- Default values use unquoted Prisma enum syntax (`@default(draft)` not `@default("draft")`) +- Enum option values must be valid Prisma identifiers: start with a letter, contain only letters, digits, and underscores (e.g. `in_progress` is valid, `in-progress` is not) +- The TypeScript union type (`'draft' | 'published'`) is generated identically to a string select field +- Omitting `db.type` or setting `db.type: 'string'` (the default) preserves the existing `String` column behaviour diff --git a/packages/cli/src/generator/__snapshots__/prisma.test.ts.snap b/packages/cli/src/generator/__snapshots__/prisma.test.ts.snap index fddf598e..2e61b855 100644 --- a/packages/cli/src/generator/__snapshots__/prisma.test.ts.snap +++ b/packages/cli/src/generator/__snapshots__/prisma.test.ts.snap @@ -174,3 +174,29 @@ datasource db { } " `; + +exports[`Prisma Schema Generator > select field with enum type > should match snapshot for enum select field 1`] = ` +"generator client { + provider = "prisma-client" + output = "../.opensaas/prisma-client" +} + +datasource db { + provider = "sqlite" +} + +enum PostStatus { + draft + published + archived +} + +model Post { + id String @id @default(cuid()) + title String + status PostStatus @default(draft) + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt +} +" +`; diff --git a/packages/cli/src/generator/prisma.test.ts b/packages/cli/src/generator/prisma.test.ts index 5dcf0c46..9731a971 100644 --- a/packages/cli/src/generator/prisma.test.ts +++ b/packages/cli/src/generator/prisma.test.ts @@ -1,7 +1,14 @@ import { describe, it, expect } from 'vitest' import { generatePrismaSchema } from './prisma.js' import type { OpenSaasConfig } from '@opensaas/stack-core' -import { text, integer, relationship, checkbox, timestamp } from '@opensaas/stack-core/fields' +import { + text, + integer, + relationship, + checkbox, + timestamp, + select, +} from '@opensaas/stack-core/fields' describe('Prisma Schema Generator', () => { describe('generatePrismaSchema', () => { @@ -1247,4 +1254,294 @@ describe('Prisma Schema Generator', () => { expect(schema).not.toContain('from_Post_tags') }) }) + + describe('select field with enum type', () => { + it('should generate enum block and use enum type for enum select field', () => { + const config: OpenSaasConfig = { + db: { provider: 'sqlite' }, + lists: { + Post: { + fields: { + title: text(), + status: select({ + options: [ + { label: 'Draft', value: 'draft' }, + { label: 'Published', value: 'published' }, + ], + db: { type: 'enum' }, + }), + }, + }, + }, + } + + const schema = generatePrismaSchema(config) + + // Should generate enum block + expect(schema).toContain('enum PostStatus {') + expect(schema).toContain(' draft') + expect(schema).toContain(' published') + // Should use enum type in model + expect(schema).toContain('PostStatus') + // Should NOT use String type for this field + expect(schema).not.toMatch(/status\s+String/) + }) + + it('should use unquoted default value for enum select field', () => { + const config: OpenSaasConfig = { + db: { provider: 'sqlite' }, + lists: { + Post: { + fields: { + title: text(), + status: select({ + options: [ + { label: 'Draft', value: 'draft' }, + { label: 'Published', value: 'published' }, + ], + db: { type: 'enum' }, + defaultValue: 'draft', + }), + }, + }, + }, + } + + const schema = generatePrismaSchema(config) + + // Default value should be unquoted (Prisma enum syntax) + expect(schema).toContain('@default(draft)') + // Should NOT have quoted default (that would be string syntax) + expect(schema).not.toContain('@default("draft")') + }) + + it('should add ? modifier for optional enum select field', () => { + const config: OpenSaasConfig = { + db: { provider: 'sqlite' }, + lists: { + Post: { + fields: { + status: select({ + options: [ + { label: 'Draft', value: 'draft' }, + { label: 'Published', value: 'published' }, + ], + db: { type: 'enum' }, + }), + }, + }, + }, + } + + const schema = generatePrismaSchema(config) + + // Optional field should have ? + expect(schema).toContain('PostStatus?') + }) + + it('should not add ? modifier for required enum select field', () => { + const config: OpenSaasConfig = { + db: { provider: 'sqlite' }, + lists: { + Post: { + fields: { + status: select({ + options: [ + { label: 'Draft', value: 'draft' }, + { label: 'Published', value: 'published' }, + ], + db: { type: 'enum' }, + validation: { isRequired: true }, + }), + }, + }, + }, + } + + const schema = generatePrismaSchema(config) + + // Required field should NOT have ? + expect(schema).not.toContain('PostStatus?') + expect(schema).toContain('PostStatus') + }) + + it('should generate separate enum blocks for different lists with same field name', () => { + const config: OpenSaasConfig = { + db: { provider: 'sqlite' }, + lists: { + Post: { + fields: { + status: select({ + options: [ + { label: 'Draft', value: 'draft' }, + { label: 'Published', value: 'published' }, + ], + db: { type: 'enum' }, + }), + }, + }, + Comment: { + fields: { + status: select({ + options: [ + { label: 'Pending', value: 'pending' }, + { label: 'Approved', value: 'approved' }, + ], + db: { type: 'enum' }, + }), + }, + }, + }, + } + + const schema = generatePrismaSchema(config) + + // Should generate separate enum blocks for each list + expect(schema).toContain('enum PostStatus {') + expect(schema).toContain('enum CommentStatus {') + expect(schema).toContain(' draft') + expect(schema).toContain(' published') + expect(schema).toContain(' pending') + expect(schema).toContain(' approved') + }) + + it('should generate separate enum blocks for multiple enum fields on same list', () => { + const config: OpenSaasConfig = { + db: { provider: 'sqlite' }, + lists: { + Post: { + fields: { + status: select({ + options: [ + { label: 'Draft', value: 'draft' }, + { label: 'Published', value: 'published' }, + ], + db: { type: 'enum' }, + }), + type: select({ + options: [ + { label: 'Article', value: 'article' }, + { label: 'Video', value: 'video' }, + ], + db: { type: 'enum' }, + }), + }, + }, + }, + } + + const schema = generatePrismaSchema(config) + + // Should generate separate enum blocks for each field + expect(schema).toContain('enum PostStatus {') + expect(schema).toContain('enum PostType {') + }) + + it('should generate string select field as String type (no enum)', () => { + const config: OpenSaasConfig = { + db: { provider: 'sqlite' }, + lists: { + Post: { + fields: { + status: select({ + options: [ + { label: 'Draft', value: 'draft' }, + { label: 'Published', value: 'published' }, + ], + }), + }, + }, + }, + } + + const schema = generatePrismaSchema(config) + + // Default (string type) should use String + expect(schema).toContain('String') + // Should NOT generate an enum block + expect(schema).not.toContain('enum PostStatus') + }) + + it('should throw for enum values that are not valid Prisma identifiers', () => { + expect(() => { + select({ + options: [ + { label: 'In Progress', value: 'in-progress' }, + { label: 'Done', value: 'done' }, + ], + db: { type: 'enum' }, + }) + }).toThrow(/valid Prisma identifiers/) + }) + + it('should throw for enum values starting with a number', () => { + expect(() => { + select({ + options: [{ label: 'First', value: '1st' }], + db: { type: 'enum' }, + }) + }).toThrow(/valid Prisma identifiers/) + }) + + it('should accept enum values with underscores', () => { + expect(() => { + select({ + options: [ + { label: 'In Progress', value: 'in_progress' }, + { label: 'Done', value: 'done' }, + ], + db: { type: 'enum' }, + }) + }).not.toThrow() + }) + + it('should generate enum field with @map modifier', () => { + const config: OpenSaasConfig = { + db: { provider: 'sqlite' }, + lists: { + Post: { + fields: { + status: select({ + options: [ + { label: 'Draft', value: 'draft' }, + { label: 'Published', value: 'published' }, + ], + db: { type: 'enum', map: 'post_status' }, + }), + }, + }, + }, + } + + const schema = generatePrismaSchema(config) + + expect(schema).toContain('@map("post_status")') + expect(schema).toContain('enum PostStatus {') + }) + + it('should match snapshot for enum select field', () => { + const config: OpenSaasConfig = { + db: { provider: 'sqlite' }, + lists: { + Post: { + fields: { + title: text({ validation: { isRequired: true } }), + status: select({ + options: [ + { label: 'Draft', value: 'draft' }, + { label: 'Published', value: 'published' }, + { label: 'Archived', value: 'archived' }, + ], + db: { type: 'enum' }, + defaultValue: 'draft', + }), + }, + }, + }, + } + + const schema = generatePrismaSchema(config) + expect(schema).toMatchSnapshot() + }) + }) }) diff --git a/packages/cli/src/generator/prisma.ts b/packages/cli/src/generator/prisma.ts index d9d90375..ff90db60 100644 --- a/packages/cli/src/generator/prisma.ts +++ b/packages/cli/src/generator/prisma.ts @@ -9,6 +9,7 @@ function mapFieldTypeToPrisma( fieldName: string, field: FieldConfig, provider?: string, + listName?: string, ): string | null { // Relationships are handled separately if (field.type === 'relationship') { @@ -17,7 +18,7 @@ function mapFieldTypeToPrisma( // Use field's own Prisma type generator if available if (field.getPrismaType) { - const result = field.getPrismaType(fieldName, provider) + const result = field.getPrismaType(fieldName, provider, listName) return result.type } @@ -28,7 +29,12 @@ function mapFieldTypeToPrisma( /** * Get field modifiers (?, @default, @unique, etc.) */ -function getFieldModifiers(fieldName: string, field: FieldConfig, provider?: string): string { +function getFieldModifiers( + fieldName: string, + field: FieldConfig, + provider?: string, + listName?: string, +): string { // Handle relationships separately if (field.type === 'relationship') { const relField = field as RelationshipField @@ -41,7 +47,7 @@ function getFieldModifiers(fieldName: string, field: FieldConfig, provider?: str // Use field's own Prisma type generator if available if (field.getPrismaType) { - const result = field.getPrismaType(fieldName, provider) + const result = field.getPrismaType(fieldName, provider, listName) return result.modifiers || '' } @@ -261,6 +267,30 @@ export function generatePrismaSchema(config: OpenSaasConfig): string { lines.push('}') lines.push('') + // Collect enum definitions from all fields (first pass) + const enumDefinitions: Map = new Map() + for (const [listName, listConfig] of Object.entries(config.lists)) { + for (const [fieldName, fieldConfig] of Object.entries(listConfig.fields)) { + if (fieldConfig.type === 'relationship' || fieldConfig.virtual) continue + if (fieldConfig.getPrismaType) { + const result = fieldConfig.getPrismaType(fieldName, config.db.provider, listName) + if (result.enumValues && result.enumValues.length > 0) { + enumDefinitions.set(result.type, result.enumValues) + } + } + } + } + + // Generate enum blocks + for (const [enumName, values] of enumDefinitions) { + lines.push(`enum ${enumName} {`) + for (const value of values) { + lines.push(` ${value}`) + } + lines.push('}') + lines.push('') + } + // Track many-to-many relationships (for Keystone naming) const manyToManyRelationships: ManyToManyRelationship[] = [] @@ -429,10 +459,10 @@ export function generatePrismaSchema(config: OpenSaasConfig): string { continue } - const prismaType = mapFieldTypeToPrisma(fieldName, fieldConfig, config.db.provider) + const prismaType = mapFieldTypeToPrisma(fieldName, fieldConfig, config.db.provider, listName) if (!prismaType) continue // Skip if no type returned - const modifiers = getFieldModifiers(fieldName, fieldConfig, config.db.provider) + const modifiers = getFieldModifiers(fieldName, fieldConfig, config.db.provider, listName) // Format with proper spacing const paddedName = fieldName.padEnd(12) diff --git a/packages/core/src/config/types.ts b/packages/core/src/config/types.ts index 0f01021f..489a8967 100644 --- a/packages/core/src/config/types.ts +++ b/packages/core/src/config/types.ts @@ -415,14 +415,21 @@ export type BaseFieldConfig = { * Get Prisma type and modifiers for schema generation * @param fieldName - The name of the field (for generating modifiers) * @param provider - Optional database provider ('sqlite', 'postgresql', 'mysql', etc.) - * @returns Prisma type string and optional modifiers + * @param listName - Optional list name (used for generating enum type names) + * @returns Prisma type string, optional modifiers, and optional enum values */ getPrismaType?: ( fieldName: string, provider?: string, + listName?: string, ) => { type: string modifiers?: string + /** + * If set, this field requires a Prisma enum definition with these values. + * The enum name is the value of `type`. + */ + enumValues?: string[] } /** * Get TypeScript type information for type generation @@ -559,6 +566,21 @@ export type PasswordField = BaseFieldConf export type SelectField = BaseFieldConfig & { type: 'select' options: Array<{ label: string; value: string }> + defaultValue?: string + db?: { + /** + * Whether to store as a native database enum type. + * - 'string' (default): stores as a plain string/varchar column + * - 'enum': stores as a Prisma enum, generating a native enum type in the schema + * + * Note: enum values must be valid Prisma identifiers (letters, numbers, underscores, + * starting with a letter) when using 'enum' type. + * + * @default 'string' + */ + type?: 'string' | 'enum' + map?: string + } validation?: { isRequired?: boolean } diff --git a/packages/core/src/fields/index.ts b/packages/core/src/fields/index.ts index cdeecd5b..fae19c80 100644 --- a/packages/core/src/fields/index.ts +++ b/packages/core/src/fields/index.ts @@ -718,6 +718,11 @@ export function password opt.value) + .filter((value) => !PRISMA_ENUM_VALUE_PATTERN.test(value)) + + if (invalidValues.length > 0) { + throw new Error( + `Enum select field values must be valid Prisma identifiers (letters, numbers, and underscores, starting with a letter). Invalid values: ${invalidValues.join(', ')}`, + ) + } + } + return { type: 'select', ...options, @@ -743,10 +762,39 @@ export function select< return schema }, - getPrismaType: (_fieldName: string) => { + getPrismaType: (fieldName: string, _provider?: string, listName?: string) => { const isRequired = options.validation?.isRequired let modifiers = '' + if (isNativeEnum) { + // Derive enum name from list name + field name in PascalCase + const capitalizedField = fieldName.charAt(0).toUpperCase() + fieldName.slice(1) + const enumName = listName ? `${listName}${capitalizedField}` : capitalizedField + + // Required fields don't get the ? modifier + if (!isRequired) { + modifiers = '?' + } + + // Add default value if provided (no quotes for enum values) + if (options.defaultValue !== undefined) { + modifiers = ` @default(${options.defaultValue})` + } + + // Map modifier + if (options.db?.map) { + modifiers += ` @map("${options.db.map}")` + } + + return { + type: enumName, + modifiers: modifiers || undefined, + enumValues: options.options.map((opt) => opt.value), + } + } + + // String type (default) + // Required fields don't get the ? modifier if (!isRequired) { modifiers = '?' @@ -768,7 +816,7 @@ export function select< } }, getTypeScriptType: () => { - // Generate union type from options + // Generate union type from options (same for both string and enum db types) const unionType = options.options.map((opt) => `'${opt.value}'`).join(' | ') return { diff --git a/packages/core/src/fields/select.test.ts b/packages/core/src/fields/select.test.ts new file mode 100644 index 00000000..faac4211 --- /dev/null +++ b/packages/core/src/fields/select.test.ts @@ -0,0 +1,237 @@ +import { describe, it, expect } from 'vitest' +import { select } from './index.js' + +describe('select field builder', () => { + describe('string type (default)', () => { + it('should throw when no options are provided', () => { + expect(() => select({ options: [] })).toThrow('Select field must have at least one option') + }) + + it('should return String prisma type for default select field', () => { + const field = select({ + options: [ + { label: 'Draft', value: 'draft' }, + { label: 'Published', value: 'published' }, + ], + }) + + const result = field.getPrismaType!('status', 'sqlite', 'Post') + expect(result.type).toBe('String') + expect(result.enumValues).toBeUndefined() + }) + + it('should add ? modifier for optional string select', () => { + const field = select({ + options: [{ label: 'Draft', value: 'draft' }], + }) + + const result = field.getPrismaType!('status', 'sqlite', 'Post') + expect(result.modifiers).toBe('?') + }) + + it('should not add ? modifier for required string select', () => { + const field = select({ + options: [{ label: 'Draft', value: 'draft' }], + validation: { isRequired: true }, + }) + + const result = field.getPrismaType!('status', 'sqlite', 'Post') + expect(result.modifiers).toBeUndefined() + }) + + it('should generate quoted default value for string select', () => { + const field = select({ + options: [ + { label: 'Draft', value: 'draft' }, + { label: 'Published', value: 'published' }, + ], + defaultValue: 'draft', + }) + + const result = field.getPrismaType!('status', 'sqlite', 'Post') + expect(result.modifiers).toBe(' @default("draft")') + }) + + it('should generate union TypeScript type from options', () => { + const field = select({ + options: [ + { label: 'Draft', value: 'draft' }, + { label: 'Published', value: 'published' }, + ], + }) + + const result = field.getTypeScriptType!() + expect(result.type).toBe("'draft' | 'published'") + expect(result.optional).toBe(true) + }) + + it('should mark TypeScript type as non-optional when required', () => { + const field = select({ + options: [ + { label: 'Draft', value: 'draft' }, + { label: 'Published', value: 'published' }, + ], + validation: { isRequired: true }, + }) + + const result = field.getTypeScriptType!() + expect(result.optional).toBe(false) + }) + }) + + describe('enum type (db.type: enum)', () => { + it('should throw for values that are not valid Prisma identifiers (hyphens)', () => { + expect(() => + select({ + options: [{ label: 'In Progress', value: 'in-progress' }], + db: { type: 'enum' }, + }), + ).toThrow(/valid Prisma identifiers/) + }) + + it('should throw for values starting with a digit', () => { + expect(() => + select({ + options: [{ label: 'First', value: '1st' }], + db: { type: 'enum' }, + }), + ).toThrow(/valid Prisma identifiers/) + }) + + it('should throw for values with spaces', () => { + expect(() => + select({ + options: [{ label: 'In Progress', value: 'in progress' }], + db: { type: 'enum' }, + }), + ).toThrow(/valid Prisma identifiers/) + }) + + it('should accept values with underscores', () => { + expect(() => + select({ + options: [ + { label: 'In Progress', value: 'in_progress' }, + { label: 'Done', value: 'done' }, + ], + db: { type: 'enum' }, + }), + ).not.toThrow() + }) + + it('should return derived enum name from listName + fieldName', () => { + const field = select({ + options: [ + { label: 'Draft', value: 'draft' }, + { label: 'Published', value: 'published' }, + ], + db: { type: 'enum' }, + }) + + const result = field.getPrismaType!('status', 'sqlite', 'Post') + expect(result.type).toBe('PostStatus') + }) + + it('should capitalize fieldName when deriving enum name', () => { + const field = select({ + options: [ + { label: 'Article', value: 'article' }, + { label: 'Video', value: 'video' }, + ], + db: { type: 'enum' }, + }) + + const result = field.getPrismaType!('contentType', 'sqlite', 'Post') + expect(result.type).toBe('PostContentType') + }) + + it('should fall back to capitalized fieldName when listName is not provided', () => { + const field = select({ + options: [{ label: 'Draft', value: 'draft' }], + db: { type: 'enum' }, + }) + + const result = field.getPrismaType!('status') + expect(result.type).toBe('Status') + }) + + it('should return enumValues in getPrismaType result', () => { + const field = select({ + options: [ + { label: 'Draft', value: 'draft' }, + { label: 'Published', value: 'published' }, + ], + db: { type: 'enum' }, + }) + + const result = field.getPrismaType!('status', 'sqlite', 'Post') + expect(result.enumValues).toEqual(['draft', 'published']) + }) + + it('should add ? modifier for optional enum field', () => { + const field = select({ + options: [{ label: 'Draft', value: 'draft' }], + db: { type: 'enum' }, + }) + + const result = field.getPrismaType!('status', 'sqlite', 'Post') + expect(result.modifiers).toBe('?') + }) + + it('should not add ? modifier for required enum field', () => { + const field = select({ + options: [{ label: 'Draft', value: 'draft' }], + db: { type: 'enum' }, + validation: { isRequired: true }, + }) + + const result = field.getPrismaType!('status', 'sqlite', 'Post') + expect(result.modifiers).toBeUndefined() + }) + + it('should generate unquoted default value for enum field', () => { + const field = select({ + options: [ + { label: 'Draft', value: 'draft' }, + { label: 'Published', value: 'published' }, + ], + db: { type: 'enum' }, + defaultValue: 'draft', + }) + + const result = field.getPrismaType!('status', 'sqlite', 'Post') + expect(result.modifiers).toBe(' @default(draft)') + // Explicitly check there are no quotes + expect(result.modifiers).not.toContain('"') + }) + + it('should include @map modifier for enum field with map option', () => { + const field = select({ + options: [{ label: 'Draft', value: 'draft' }], + db: { type: 'enum', map: 'post_status' }, + }) + + const result = field.getPrismaType!('status', 'sqlite', 'Post') + expect(result.modifiers).toContain('@map("post_status")') + }) + + it('should generate same union TypeScript type as string select', () => { + const enumField = select({ + options: [ + { label: 'Draft', value: 'draft' }, + { label: 'Published', value: 'published' }, + ], + db: { type: 'enum' }, + }) + + const stringField = select({ + options: [ + { label: 'Draft', value: 'draft' }, + { label: 'Published', value: 'published' }, + ], + }) + + expect(enumField.getTypeScriptType!()).toEqual(stringField.getTypeScriptType!()) + }) + }) +}) diff --git a/packages/core/src/validation/schema.test.ts b/packages/core/src/validation/schema.test.ts index e00e5c4c..a657ce00 100644 --- a/packages/core/src/validation/schema.test.ts +++ b/packages/core/src/validation/schema.test.ts @@ -49,6 +49,22 @@ describe('Zod Schema Generation', () => { expect(schema).toBeDefined() }) + it('should generate schema for enum select field', () => { + const fields: Record = { + status: select({ + options: [ + { label: 'Draft', value: 'draft' }, + { label: 'Published', value: 'published' }, + ], + db: { type: 'enum' }, + validation: { isRequired: true }, + }), + } + + const schema = generateZodSchema(fields, 'create') + expect(schema).toBeDefined() + }) + it('should make fields optional in update mode', () => { const fields: Record = { name: text({ validation: { isRequired: true } }), @@ -149,6 +165,41 @@ describe('Zod Schema Generation', () => { } }) + it('should pass validation for valid enum select value', () => { + const fields: Record = { + status: select({ + options: [ + { label: 'Draft', value: 'draft' }, + { label: 'Published', value: 'published' }, + ], + db: { type: 'enum' }, + validation: { isRequired: true }, + }), + } + + const result = validateWithZod({ status: 'draft' }, fields, 'create') + expect(result.success).toBe(true) + }) + + it('should fail validation for invalid enum select value', () => { + const fields: Record = { + status: select({ + options: [ + { label: 'Draft', value: 'draft' }, + { label: 'Published', value: 'published' }, + ], + db: { type: 'enum' }, + validation: { isRequired: true }, + }), + } + + const result = validateWithZod({ status: 'archived' }, fields, 'create') + expect(result.success).toBe(false) + if (!result.success) { + expect(result.errors.status).toBeDefined() + } + }) + it('should skip system fields in validation', () => { const fields: Record = { id: text(),