Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions .changeset/silver-mice-dream.md
Original file line number Diff line number Diff line change
@@ -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 `<ListName><FieldName>` 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
26 changes: 26 additions & 0 deletions packages/cli/src/generator/__snapshots__/prisma.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
"
`;
299 changes: 298 additions & 1 deletion packages/cli/src/generator/prisma.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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()
})
})
})
Loading
Loading