diff --git a/packages/orm/src/client/crud/dialects/base-dialect.ts b/packages/orm/src/client/crud/dialects/base-dialect.ts index c90f1f4d0..d374bed3f 100644 --- a/packages/orm/src/client/crud/dialects/base-dialect.ts +++ b/packages/orm/src/client/crud/dialects/base-dialect.ts @@ -87,8 +87,13 @@ export abstract class BaseCrudDialect { /** * Transforms input value before sending to database. + * + * `fieldDef` is optional so existing callers that don't have it stay + * source-compatible. Dialects can use it to inspect `@db.*` native-type + * attributes (e.g. to format `@db.Time` values as `HH:MM:SS` rather than + * full ISO timestamps). */ - transformInput(value: unknown, _type: BuiltinType, _forArrayField: boolean) { + transformInput(value: unknown, _type: BuiltinType, _forArrayField: boolean, _fieldDef?: FieldDef) { return value; } @@ -523,7 +528,7 @@ export abstract class BaseCrudDialect { } invariant(fieldDef.array, 'Field must be an array type to build array filter'); - const value = this.transformInput(_value, fieldType, true); + const value = this.transformInput(_value, fieldType, true, fieldDef); let receiver = fieldRef; if (isEnum(this.schema, fieldType)) { diff --git a/packages/orm/src/client/crud/dialects/postgresql.ts b/packages/orm/src/client/crud/dialects/postgresql.ts index c8a12de2b..30cc3909d 100644 --- a/packages/orm/src/client/crud/dialects/postgresql.ts +++ b/packages/orm/src/client/crud/dialects/postgresql.ts @@ -17,6 +17,18 @@ import type { ClientOptions } from '../../options'; import { isEnum, isTypeDef } from '../../query-utils'; import { LateralJoinDialectBase } from './lateral-join-dialect-base'; +/** + * Formats a JS `Date` as a Postgres TIME / TIMETZ literal (`HH:MM:SS.fff`, + * optionally with `+ZZ:ZZ` for TIMETZ). Reads UTC components so the value + * round-trips with ISO-input parsing — callers anchor time-only inputs to + * the Unix epoch. + */ +function formatTimeOfDay(date: Date, withTimezone: boolean): string { + const pad = (n: number, w = 2) => String(n).padStart(w, '0'); + const time = `${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())}.${pad(date.getUTCMilliseconds(), 3)}`; + return withTimezone ? `${time}+00:00` : time; +} + export class PostgresCrudDialect extends LateralJoinDialectBase { private static typeParserOverrideApplied = false; @@ -154,7 +166,7 @@ export class PostgresCrudDialect extends LateralJoinDi // #region value transformation - override transformInput(value: unknown, type: BuiltinType, forArrayField: boolean): unknown { + override transformInput(value: unknown, type: BuiltinType, forArrayField: boolean, fieldDef?: FieldDef): unknown { if (value === undefined) { return value; } @@ -186,16 +198,25 @@ export class PostgresCrudDialect extends LateralJoinDi // scalar `Json` fields need their input stringified return JSON.stringify(value); } else { - return value.map((v) => this.transformInput(v, type, false)); + return value.map((v) => this.transformInput(v, type, false, fieldDef)); } } else { switch (type) { - case 'DateTime': - return value instanceof Date - ? value.toISOString() - : typeof value === 'string' - ? new Date(value).toISOString() - : value; + case 'DateTime': { + const date = value instanceof Date ? value : typeof value === 'string' ? new Date(value) : null; + if (date === null || isNaN(date.getTime())) return value; + // Postgres TIME / TIMETZ columns reject ISO datetime input — + // they expect `HH:MM:SS[.fff][+ZZ:ZZ]`. Detect those native + // types via the field's @db.* attribute and format + // accordingly. All other DateTime fields keep the existing + // ISO behaviour (TIMESTAMP / TIMESTAMPTZ / DATE all accept + // it natively). + const dbAttrName = fieldDef?.attributes?.find((a) => a.name.startsWith('@db.'))?.name; + if (dbAttrName === '@db.Time' || dbAttrName === '@db.Timetz') { + return formatTimeOfDay(date, dbAttrName === '@db.Timetz'); + } + return date.toISOString(); + } case 'Decimal': return value !== null ? value.toString() : value; case 'Json': diff --git a/packages/orm/src/client/crud/operations/base.ts b/packages/orm/src/client/crud/operations/base.ts index 4c37e0eae..1190be056 100644 --- a/packages/orm/src/client/crud/operations/base.ts +++ b/packages/orm/src/client/crud/operations/base.ts @@ -439,12 +439,13 @@ export abstract class BaseOperationHandler { Array.isArray(value.set) ) { // deal with nested "set" for scalar lists - createFields[field] = this.dialect.transformInput(value.set, fieldDef.type as BuiltinType, true); + createFields[field] = this.dialect.transformInput(value.set, fieldDef.type as BuiltinType, true, fieldDef); } else { createFields[field] = this.dialect.transformInput( value, fieldDef.type as BuiltinType, !!fieldDef.array, + fieldDef, ); } } else { @@ -887,7 +888,7 @@ export abstract class BaseOperationHandler { for (const [name, value] of Object.entries(item)) { const fieldDef = this.requireField(model, name); invariant(!fieldDef.relation, 'createMany does not support relations'); - newItem[name] = this.dialect.transformInput(value, fieldDef.type as BuiltinType, !!fieldDef.array); + newItem[name] = this.dialect.transformInput(value, fieldDef.type as BuiltinType, !!fieldDef.array, fieldDef); } if (fromRelation) { for (const { fk, pk } of relationKeyPairs) { @@ -925,6 +926,7 @@ export abstract class BaseOperationHandler { fieldDef.default, fieldDef.type as BuiltinType, !!fieldDef.array, + fieldDef, ); } } @@ -1057,11 +1059,12 @@ export abstract class BaseOperationHandler { generated, fieldDef.type as BuiltinType, !!fieldDef.array, + fieldDef, ); } } else if (fieldDef?.updatedAt) { // TODO: should this work at kysely level instead? - values[field] = this.dialect.transformInput(new Date(), 'DateTime', false); + values[field] = this.dialect.transformInput(new Date(), 'DateTime', false, fieldDef); } else if (fieldDef?.default !== undefined) { let value = fieldDef.default; if (fieldDef.type === 'Json') { @@ -1072,7 +1075,7 @@ export abstract class BaseOperationHandler { value = JSON.parse(value); } } - values[field] = this.dialect.transformInput(value, fieldDef.type as BuiltinType, !!fieldDef.array); + values[field] = this.dialect.transformInput(value, fieldDef.type as BuiltinType, !!fieldDef.array, fieldDef); } } } @@ -1176,7 +1179,7 @@ export abstract class BaseOperationHandler { if (finalData === data) { finalData = clone(data); } - finalData[fieldName] = this.dialect.transformInput(new Date(), 'DateTime', false); + finalData[fieldName] = this.dialect.transformInput(new Date(), 'DateTime', false, fieldDef); autoUpdatedFields.push(fieldName); } } @@ -1442,7 +1445,7 @@ export abstract class BaseOperationHandler { return this.transformScalarListUpdate(model, field, fieldDef, data[field]); } - return this.dialect.transformInput(data[field], fieldDef.type as BuiltinType, !!fieldDef.array); + return this.dialect.transformInput(data[field], fieldDef.type as BuiltinType, !!fieldDef.array, fieldDef); } private isNumericIncrementalUpdate(fieldDef: FieldDef, value: any) { @@ -1500,7 +1503,7 @@ export abstract class BaseOperationHandler { ); const key = Object.keys(payload)[0]; - const value = this.dialect.transformInput(payload[key!], fieldDef.type as BuiltinType, false); + const value = this.dialect.transformInput(payload[key!], fieldDef.type as BuiltinType, false, fieldDef); const eb = expressionBuilder(); const fieldRef = this.dialect.fieldRef(model, field); @@ -1523,7 +1526,7 @@ export abstract class BaseOperationHandler { ) { invariant(Object.keys(payload).length === 1, 'Only one of "set", "push" can be provided'); const key = Object.keys(payload)[0]; - const value = this.dialect.transformInput(payload[key!], fieldDef.type as BuiltinType, true); + const value = this.dialect.transformInput(payload[key!], fieldDef.type as BuiltinType, true, fieldDef); const eb = expressionBuilder(); const fieldRef = this.dialect.fieldRef(model, field); diff --git a/tests/regression/test/issue-2633.test.ts b/tests/regression/test/issue-2633.test.ts new file mode 100644 index 000000000..d134732e2 --- /dev/null +++ b/tests/regression/test/issue-2633.test.ts @@ -0,0 +1,63 @@ +import { createTestClient } from '@zenstackhq/testtools'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +// Regression for #2633: writes to `@db.Time` / `@db.Timetz` columns failed +// with PG `22007 invalid input syntax for type time` because the dialect +// serialized JS Date values as ISO datetime strings. The dialect now reads +// the field's `@db.*` attribute and formats `HH:MM:SS.fff[+ZZ:ZZ]` for TIME +// / TIMETZ columns; other DateTime columns keep the existing ISO behaviour. +describe('Issue 2633 — write to @db.Time columns', () => { + describe.each([ + { name: '@db.Time', dbType: '@db.Time(6)' }, + { name: '@db.Timetz', dbType: '@db.Timetz(6)' }, + ])('$name', ({ dbType }) => { + const schema = ` +model TradingHour { + id Int @id @default(autoincrement()) + open DateTime ${dbType} + close DateTime ${dbType} +} + `; + + let client: any; + + beforeEach(async () => { + client = await createTestClient(schema, { + usePrismaPush: true, + provider: 'postgresql', + }); + }); + + afterEach(async () => { + await client?.$disconnect(); + }); + + it('accepts a Date for the open / close fields', async () => { + const open = new Date('1970-01-01T09:00:00.000Z'); + const close = new Date('1970-01-01T16:30:00.000Z'); + + const row = await client.tradingHour.create({ data: { open, close } }); + + expect(row.id).toBeDefined(); + }); + + it('round-trips the time-of-day via createMany', async () => { + await client.tradingHour.createMany({ + data: [ + { open: new Date('1970-01-01T09:00:00.000Z'), close: new Date('1970-01-01T16:00:00.000Z') }, + { open: new Date('1970-01-01T10:30:00.000Z'), close: new Date('1970-01-01T17:30:00.000Z') }, + ], + }); + + const rows = await client.tradingHour.findMany({ orderBy: { id: 'asc' } }); + expect(rows).toHaveLength(2); + // The application reads `tw.open` / `tw.close` as Date objects. + expect(rows[0].open).toBeInstanceOf(Date); + expect(rows[0].close).toBeInstanceOf(Date); + expect(rows[0].open.toISOString()).toBe('1970-01-01T09:00:00.000Z'); + expect(rows[0].close.toISOString()).toBe('1970-01-01T16:00:00.000Z'); + expect(rows[1].open.toISOString()).toBe('1970-01-01T10:30:00.000Z'); + expect(rows[1].close.toISOString()).toBe('1970-01-01T17:30:00.000Z'); + }); + }); +});