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
64 changes: 64 additions & 0 deletions .changeset/restore-db-field-config.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
---
'@opensaas/stack-core': minor
---

Add `db.isNullable` and `db.nativeType` support to all field types

All field types now support two new `db` configuration options that were previously only available in Keystone 6:

### `db.isNullable`

Controls DB-level nullability independently of `validation.isRequired`. This allows you to:

- Make a field non-nullable at the DB level without making it API-required
- Explicitly mark a field as nullable regardless of other settings

```typescript
fields: {
// DB non-nullable, but API optional (relies on a default value or hook)
phoneNumber: text({
db: { isNullable: false }
// Generates: phoneNumber String (non-nullable)
}),

// DB nullable, explicitly set
lastMessagePreview: text({
db: { isNullable: true }
// Generates: lastMessagePreview String? (nullable)
}),

// DB non-nullable without API validation (field must always be set via hooks or defaults)
internalCode: integer({
db: { isNullable: false }
// Generates: internalCode Int (non-nullable)
})
}
```

### `db.nativeType`

Overrides the native database column type. Generates a `@db.<nativeType>` attribute in the Prisma schema. Available types depend on your database provider.

```typescript
fields: {
// PostgreSQL: use TEXT instead of VARCHAR for long content
medical: text({
db: { isNullable: true, nativeType: 'Text' }
// Generates: medical String? @db.Text
}),

// PostgreSQL: use SMALLINT for small numbers
score: integer({
db: { nativeType: 'SmallInt' }
// Generates: score Int? @db.SmallInt
}),

// PostgreSQL: use TIMESTAMPTZ for timezone-aware timestamps
scheduledAt: timestamp({
db: { nativeType: 'Timestamptz' }
// Generates: scheduledAt DateTime? @db.Timestamptz
})
}
```

Both options are supported on `text`, `integer`, `password`, `json`, `timestamp`, `checkbox` (isNullable only), `decimal`, and `calendarDay` fields.
7 changes: 5 additions & 2 deletions packages/cli/src/generator/prisma.ts
Original file line number Diff line number Diff line change
Expand Up @@ -464,9 +464,12 @@ export function generatePrismaSchema(config: OpenSaasConfig): string {

const modifiers = getFieldModifiers(fieldName, fieldConfig, config.db.provider, listName)

// Format with proper spacing
// Format with proper spacing: '?' attaches to type directly, other modifiers get a space
const paddedName = fieldName.padEnd(12)
lines.push(` ${paddedName} ${prismaType}${modifiers}`)
const modStr = modifiers.trimStart()
const nullPart = modStr.startsWith('?') ? '?' : ''
const attrPart = modStr.startsWith('?') ? modStr.slice(1).trimStart() : modStr
lines.push(` ${paddedName} ${prismaType}${nullPart}${attrPart ? ' ' + attrPart : ''}`)
}

// Add relationship fields
Expand Down
85 changes: 46 additions & 39 deletions packages/core/src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,52 @@ export type BaseFieldConfig<TTypeInfo extends TypeInfo> = {
* ```
*/
map?: string
/**
* Controls DB-level nullability independently of validation.isRequired.
* When specified, overrides the default behavior where nullability is inferred
* from validation.isRequired (required = non-nullable, optional = nullable).
*
* This allows you to:
* - Make a field non-nullable at the DB level without making it API-required
* - Explicitly mark a field as nullable even when it has isRequired validation
*
* @example
* ```typescript
* // DB non-nullable, but API optional (relies on a default value or hook)
* fields: {
* phoneNumber: text({
* db: { isNullable: false }
* })
* // Generates: phoneNumber String (non-nullable)
*
* // DB nullable (explicit), regardless of validation
* lastMessagePreview: text({
* db: { isNullable: true }
* })
* // Generates: lastMessagePreview String? (nullable)
* }
* ```
*/
isNullable?: boolean
/**
* Override the native database type for the column.
* Generates a @db.<nativeType> attribute in the Prisma schema.
* The available types depend on your database provider.
*
* @example
* ```typescript
* // PostgreSQL: use TEXT instead of VARCHAR
* fields: {
* description: text({ db: { nativeType: 'Text' } })
* // Generates: description String? @db.Text
*
* // PostgreSQL: use SMALLINT instead of INT
* count: integer({ db: { nativeType: 'SmallInt' } })
* // Generates: count Int? @db.SmallInt
* }
* ```
*/
nativeType?: string
}
ui?: {
/**
Expand Down Expand Up @@ -472,37 +518,6 @@ export type TextField<TTypeInfo extends TypeInfo = TypeInfo> = BaseFieldConfig<T
}
}
isIndexed?: boolean | 'unique'
db?: {
map?: string
/**
* Prisma native database type attribute
* Allows overriding the default String type for the database provider
* @example
* ```typescript
* // PostgreSQL/MySQL
* fields: {
* description: text({ db: { nativeType: 'Text' } })
* // Generates: description String @db.Text
* }
* ```
*/
nativeType?: string
/**
* Controls nullability in the database schema
* When specified, overrides the default behavior (isRequired determines nullability)
* @example
* ```typescript
* fields: {
* description: text({
* validation: { isRequired: true },
* db: { isNullable: false }
* })
* // Generates: description String (non-nullable)
* }
* ```
*/
isNullable?: boolean
}
ui?: {
displayMode?: 'input' | 'textarea'
}
Expand All @@ -522,10 +537,6 @@ export type DecimalField<TTypeInfo extends TypeInfo = TypeInfo> = BaseFieldConfi
defaultValue?: string
precision?: number
scale?: number
db?: {
map?: string
isNullable?: boolean
}
validation?: {
isRequired?: boolean
min?: string
Expand All @@ -546,10 +557,6 @@ export type TimestampField<TTypeInfo extends TypeInfo = TypeInfo> = BaseFieldCon
export type CalendarDayField<TTypeInfo extends TypeInfo = TypeInfo> = BaseFieldConfig<TTypeInfo> & {
type: 'calendarDay'
defaultValue?: string
db?: {
map?: string
isNullable?: boolean
}
validation?: {
isRequired?: boolean
}
Expand Down
104 changes: 75 additions & 29 deletions packages/core/src/fields/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ export function text<

return {
type: 'String',
modifiers: modifiers || undefined,
modifiers: modifiers.trimStart() || undefined,
}
},
getTypeScriptType: () => {
Expand Down Expand Up @@ -145,22 +145,30 @@ export function integer<
: withMax
},
getPrismaType: (_fieldName: string) => {
const isRequired = options?.validation?.isRequired
const validation = options?.validation
const db = options?.db
const isRequired = validation?.isRequired
const isNullable = db?.isNullable ?? !isRequired
let modifiers = ''

// Optional modifier
if (!isRequired) {
if (isNullable) {
modifiers += '?'
}

// Native type modifier (e.g., @db.SmallInt, @db.BigInt)
if (db?.nativeType) {
modifiers += ` @db.${db.nativeType}`
}

// Map modifier
if (options?.db?.map) {
modifiers += ` @map("${options.db.map}")`
if (db?.map) {
modifiers += ` @map("${db.map}")`
}

return {
type: 'Int',
modifiers: modifiers || undefined,
modifiers: modifiers.trimStart() || undefined,
}
},
getTypeScriptType: () => {
Expand Down Expand Up @@ -353,21 +361,28 @@ export function checkbox<
return z.boolean().optional().nullable()
},
getPrismaType: (_fieldName: string) => {
const db = options?.db
const hasDefault = options?.defaultValue !== undefined
let modifiers = ''

// Nullable modifier - checkbox fields are non-nullable by default (must be true or false)
// Use db.isNullable: true to allow NULL values in the database
if (db?.isNullable === true) {
modifiers += '?'
}

if (hasDefault) {
modifiers = ` @default(${options.defaultValue})`
modifiers += ` @default(${options.defaultValue})`
}

// Map modifier
if (options?.db?.map) {
modifiers += ` @map("${options.db.map}")`
if (db?.map) {
modifiers += ` @map("${db.map}")`
}

return {
type: 'Boolean',
modifiers: modifiers || undefined,
modifiers: modifiers.trimStart() || undefined,
}
},
getTypeScriptType: () => {
Expand All @@ -392,26 +407,41 @@ export function timestamp<
return z.union([z.date(), z.iso.datetime()]).optional().nullable()
},
getPrismaType: (_fieldName: string) => {
let modifiers = '?'

// Check for default value
if (
const db = options?.db
const hasDefaultNow =
options?.defaultValue &&
typeof options.defaultValue === 'object' &&
'kind' in options.defaultValue &&
options.defaultValue.kind === 'now'
) {
modifiers = ' @default(now())'

// Nullability: explicit db.isNullable overrides the default (nullable unless @default(now()))
const isNullable = db?.isNullable ?? !hasDefaultNow

let modifiers = ''

// Optional modifier
if (isNullable) {
modifiers += '?'
}

// Default value
if (hasDefaultNow) {
modifiers += ' @default(now())'
}

// Native type modifier (e.g., @db.Timestamptz for PostgreSQL)
if (db?.nativeType) {
modifiers += ` @db.${db.nativeType}`
}

// Map modifier
if (options?.db?.map) {
modifiers += ` @map("${options.db.map}")`
if (db?.map) {
modifiers += ` @map("${db.map}")`
}

return {
type: 'DateTime',
modifiers,
modifiers: modifiers.trimStart() || undefined,
}
},
getTypeScriptType: () => {
Expand Down Expand Up @@ -689,22 +719,30 @@ export function password<TTypeInfo extends import('../config/types.js').TypeInfo
}
},
getPrismaType: (_fieldName: string) => {
const isRequired = options?.validation?.isRequired
const validation = options?.validation
const db = options?.db
const isRequired = validation?.isRequired
const isNullable = db?.isNullable ?? !isRequired
let modifiers = ''

// Optional modifier
if (!isRequired) {
if (isNullable) {
modifiers += '?'
}

// Native type modifier (e.g., @db.Text)
if (db?.nativeType) {
modifiers += ` @db.${db.nativeType}`
}

// Map modifier
if (options?.db?.map) {
modifiers += ` @map("${options.db.map}")`
if (db?.map) {
modifiers += ` @map("${db.map}")`
}

return {
type: 'String',
modifiers: modifiers || undefined,
modifiers: modifiers.trimStart() || undefined,
}
},
getTypeScriptType: () => {
Expand Down Expand Up @@ -938,22 +976,30 @@ export function json<
}
},
getPrismaType: (_fieldName: string) => {
const isRequired = options?.validation?.isRequired
const validation = options?.validation
const db = options?.db
const isRequired = validation?.isRequired
const isNullable = db?.isNullable ?? !isRequired
let modifiers = ''

// Optional modifier
if (!isRequired) {
if (isNullable) {
modifiers += '?'
}

// Native type modifier
if (db?.nativeType) {
modifiers += ` @db.${db.nativeType}`
}

// Map modifier
if (options?.db?.map) {
modifiers += ` @map("${options.db.map}")`
if (db?.map) {
modifiers += ` @map("${db.map}")`
}

return {
type: 'Json',
modifiers: modifiers || undefined,
modifiers: modifiers.trimStart() || undefined,
}
},
getTypeScriptType: () => {
Expand Down
Loading
Loading