diff --git a/.changeset/restore-db-field-config.md b/.changeset/restore-db-field-config.md new file mode 100644 index 00000000..e1c741fd --- /dev/null +++ b/.changeset/restore-db-field-config.md @@ -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.` 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. diff --git a/packages/cli/src/generator/prisma.ts b/packages/cli/src/generator/prisma.ts index ff90db60..01e4842d 100644 --- a/packages/cli/src/generator/prisma.ts +++ b/packages/cli/src/generator/prisma.ts @@ -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 diff --git a/packages/core/src/config/types.ts b/packages/core/src/config/types.ts index 489a8967..7b36bf35 100644 --- a/packages/core/src/config/types.ts +++ b/packages/core/src/config/types.ts @@ -374,6 +374,52 @@ export type BaseFieldConfig = { * ``` */ 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. 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?: { /** @@ -472,37 +518,6 @@ export type TextField = BaseFieldConfig = BaseFieldConfi defaultValue?: string precision?: number scale?: number - db?: { - map?: string - isNullable?: boolean - } validation?: { isRequired?: boolean min?: string @@ -546,10 +557,6 @@ export type TimestampField = BaseFieldCon export type CalendarDayField = BaseFieldConfig & { type: 'calendarDay' defaultValue?: string - db?: { - map?: string - isNullable?: boolean - } validation?: { isRequired?: boolean } diff --git a/packages/core/src/fields/index.ts b/packages/core/src/fields/index.ts index fae19c80..a0123037 100644 --- a/packages/core/src/fields/index.ts +++ b/packages/core/src/fields/index.ts @@ -97,7 +97,7 @@ export function text< return { type: 'String', - modifiers: modifiers || undefined, + modifiers: modifiers.trimStart() || undefined, } }, getTypeScriptType: () => { @@ -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: () => { @@ -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: () => { @@ -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: () => { @@ -689,22 +719,30 @@ export function password { - 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: () => { @@ -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: () => { diff --git a/packages/core/tests/field-types.test.ts b/packages/core/tests/field-types.test.ts index eef62a44..5ba7681c 100644 --- a/packages/core/tests/field-types.test.ts +++ b/packages/core/tests/field-types.test.ts @@ -110,6 +110,72 @@ describe('Field Types', () => { expect(prismaType.type).toBe('String') expect(prismaType.modifiers).toContain('@index') }) + + test('db.isNullable: true makes optional field explicitly nullable', () => { + const field = text({ db: { isNullable: true } }) + const prismaType = field.getPrismaType('description') + + expect(prismaType.type).toBe('String') + expect(prismaType.modifiers).toContain('?') + }) + + test('db.isNullable: false makes field non-nullable regardless of validation', () => { + const field = text({ db: { isNullable: false } }) + const prismaType = field.getPrismaType('phoneNumber') + + expect(prismaType.type).toBe('String') + // Non-nullable with no other modifiers → modifiers is undefined + expect(prismaType.modifiers).toBeUndefined() + }) + + test('db.isNullable: false on required field keeps it non-nullable', () => { + const field = text({ validation: { isRequired: true }, db: { isNullable: false } }) + const prismaType = field.getPrismaType('title') + + expect(prismaType.type).toBe('String') + // Non-nullable with no other modifiers → modifiers is undefined + expect(prismaType.modifiers).toBeUndefined() + }) + + test('db.isNullable: true on required field overrides to nullable', () => { + const field = text({ validation: { isRequired: true }, db: { isNullable: true } }) + const prismaType = field.getPrismaType('title') + + expect(prismaType.type).toBe('String') + expect(prismaType.modifiers).toContain('?') + }) + + test('db.nativeType generates @db. attribute', () => { + const field = text({ db: { nativeType: 'Text' } }) + const prismaType = field.getPrismaType('medical') + + expect(prismaType.type).toBe('String') + expect(prismaType.modifiers).toContain('@db.Text') + }) + + test('db.nativeType with nullable field includes both ? and @db. attribute', () => { + const field = text({ db: { isNullable: true, nativeType: 'Text' } }) + const prismaType = field.getPrismaType('bio') + + expect(prismaType.type).toBe('String') + expect(prismaType.modifiers).toBe('? @db.Text') + }) + + test('db.nativeType with non-nullable field excludes ? but includes @db. attribute', () => { + const field = text({ db: { isNullable: false, nativeType: 'Text' } }) + const prismaType = field.getPrismaType('content') + + expect(prismaType.type).toBe('String') + expect(prismaType.modifiers).toBe('@db.Text') + }) + + test('db.nativeType with required field generates non-nullable with @db. attribute', () => { + const field = text({ validation: { isRequired: true }, db: { nativeType: 'Text' } }) + const prismaType = field.getPrismaType('content') + + expect(prismaType.type).toBe('String') + expect(prismaType.modifiers).toBe('@db.Text') + }) }) describe('getTypeScriptType', () => { @@ -203,6 +269,39 @@ describe('Field Types', () => { expect(prismaType.type).toBe('Int') expect(prismaType.modifiers).toBeUndefined() }) + + test('db.isNullable: false makes field non-nullable regardless of validation', () => { + const field = integer({ db: { isNullable: false } }) + const prismaType = field.getPrismaType('count') + + expect(prismaType.type).toBe('Int') + // Non-nullable with no other modifiers → modifiers is undefined + expect(prismaType.modifiers).toBeUndefined() + }) + + test('db.isNullable: true on required field overrides to nullable', () => { + const field = integer({ validation: { isRequired: true }, db: { isNullable: true } }) + const prismaType = field.getPrismaType('count') + + expect(prismaType.type).toBe('Int') + expect(prismaType.modifiers).toContain('?') + }) + + test('db.nativeType generates @db. attribute', () => { + const field = integer({ db: { nativeType: 'SmallInt' } }) + const prismaType = field.getPrismaType('score') + + expect(prismaType.type).toBe('Int') + expect(prismaType.modifiers).toContain('@db.SmallInt') + }) + + test('db.nativeType with non-nullable field excludes ? but includes @db. attribute', () => { + const field = integer({ db: { isNullable: false, nativeType: 'BigInt' } }) + const prismaType = field.getPrismaType('largeId') + + expect(prismaType.type).toBe('Int') + expect(prismaType.modifiers).toBe('@db.BigInt') + }) }) describe('getTypeScriptType', () => { @@ -251,7 +350,7 @@ describe('Field Types', () => { const prismaType = field.getPrismaType('isActive') expect(prismaType.type).toBe('Boolean') - expect(prismaType.modifiers).toBe(' @default(true)') + expect(prismaType.modifiers).toBe('@default(true)') }) test('returns Boolean type with default false', () => { @@ -259,7 +358,24 @@ describe('Field Types', () => { const prismaType = field.getPrismaType('isActive') expect(prismaType.type).toBe('Boolean') - expect(prismaType.modifiers).toBe(' @default(false)') + expect(prismaType.modifiers).toBe('@default(false)') + }) + + test('db.isNullable: true makes Boolean field nullable', () => { + const field = checkbox({ db: { isNullable: true } }) + const prismaType = field.getPrismaType('agreed') + + expect(prismaType.type).toBe('Boolean') + expect(prismaType.modifiers).toContain('?') + }) + + test('db.isNullable: true with default value makes nullable Boolean with default', () => { + const field = checkbox({ defaultValue: false, db: { isNullable: true } }) + const prismaType = field.getPrismaType('agreed') + + expect(prismaType.type).toBe('Boolean') + expect(prismaType.modifiers).toContain('?') + expect(prismaType.modifiers).toContain('@default(false)') }) }) @@ -309,7 +425,33 @@ describe('Field Types', () => { const prismaType = field.getPrismaType('createdAt') expect(prismaType.type).toBe('DateTime') - expect(prismaType.modifiers).toBe(' @default(now())') + expect(prismaType.modifiers).toBe('@default(now())') + }) + + test('db.isNullable: false makes timestamp non-nullable without default', () => { + const field = timestamp({ db: { isNullable: false } }) + const prismaType = field.getPrismaType('publishedAt') + + expect(prismaType.type).toBe('DateTime') + // Non-nullable with no other modifiers → modifiers is undefined + expect(prismaType.modifiers).toBeUndefined() + }) + + test('db.isNullable: true on timestamp with @default(now()) overrides to nullable', () => { + const field = timestamp({ defaultValue: { kind: 'now' }, db: { isNullable: true } }) + const prismaType = field.getPrismaType('createdAt') + + expect(prismaType.type).toBe('DateTime') + expect(prismaType.modifiers).toContain('?') + expect(prismaType.modifiers).toContain('@default(now())') + }) + + test('db.nativeType generates @db. attribute', () => { + const field = timestamp({ db: { nativeType: 'Timestamptz' } }) + const prismaType = field.getPrismaType('scheduledAt') + + expect(prismaType.type).toBe('DateTime') + expect(prismaType.modifiers).toContain('@db.Timestamptz') }) }) @@ -377,6 +519,31 @@ describe('Field Types', () => { expect(prismaType.type).toBe('String') expect(prismaType.modifiers).toBeUndefined() }) + + test('db.isNullable: false makes password non-nullable regardless of validation', () => { + const field = password({ db: { isNullable: false } }) + const prismaType = field.getPrismaType('password') + + expect(prismaType.type).toBe('String') + // Non-nullable with no other modifiers → modifiers is undefined + expect(prismaType.modifiers).toBeUndefined() + }) + + test('db.isNullable: true on required password overrides to nullable', () => { + const field = password({ validation: { isRequired: true }, db: { isNullable: true } }) + const prismaType = field.getPrismaType('password') + + expect(prismaType.type).toBe('String') + expect(prismaType.modifiers).toContain('?') + }) + + test('db.nativeType generates @db. attribute', () => { + const field = password({ db: { nativeType: 'Text' } }) + const prismaType = field.getPrismaType('password') + + expect(prismaType.type).toBe('String') + expect(prismaType.modifiers).toContain('@db.Text') + }) }) describe('getTypeScriptType', () => { @@ -641,6 +808,23 @@ describe('Field Types', () => { expect(prismaType.type).toBe('Json') expect(prismaType.modifiers).toBeUndefined() }) + + test('db.isNullable: false makes Json field non-nullable regardless of validation', () => { + const field = json({ db: { isNullable: false } }) + const prismaType = field.getPrismaType('settings') + + expect(prismaType.type).toBe('Json') + // Non-nullable with no other modifiers → modifiers is undefined + expect(prismaType.modifiers).toBeUndefined() + }) + + test('db.isNullable: true on required field overrides to nullable', () => { + const field = json({ validation: { isRequired: true }, db: { isNullable: true } }) + const prismaType = field.getPrismaType('settings') + + expect(prismaType.type).toBe('Json') + expect(prismaType.modifiers).toContain('?') + }) }) describe('getTypeScriptType', () => {