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(),