Skip to content

Commit d1db37c

Browse files
authored
fix(orm): format Date as HH:MM:SS for @db.Time / @db.Timetz columns (#2633) (#2634)
1 parent 9bfc3fe commit d1db37c

4 files changed

Lines changed: 110 additions & 18 deletions

File tree

packages/orm/src/client/crud/dialects/base-dialect.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,13 @@ export abstract class BaseCrudDialect<Schema extends SchemaDef> {
8787

8888
/**
8989
* Transforms input value before sending to database.
90+
*
91+
* `fieldDef` is optional so existing callers that don't have it stay
92+
* source-compatible. Dialects can use it to inspect `@db.*` native-type
93+
* attributes (e.g. to format `@db.Time` values as `HH:MM:SS` rather than
94+
* full ISO timestamps).
9095
*/
91-
transformInput(value: unknown, _type: BuiltinType, _forArrayField: boolean) {
96+
transformInput(value: unknown, _type: BuiltinType, _forArrayField: boolean, _fieldDef?: FieldDef) {
9297
return value;
9398
}
9499

@@ -539,7 +544,7 @@ export abstract class BaseCrudDialect<Schema extends SchemaDef> {
539544
}
540545

541546
invariant(fieldDef.array, 'Field must be an array type to build array filter');
542-
const value = this.transformInput(_value, fieldType, true);
547+
const value = this.transformInput(_value, fieldType, true, fieldDef);
543548

544549
let receiver = fieldRef;
545550
if (isEnum(this.schema, fieldType)) {

packages/orm/src/client/crud/dialects/postgresql.ts

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,18 @@ import { isEnum, isTypeDef } from '../../query-utils';
1818
import type { FuzzyFilterOptions } from './base-dialect';
1919
import { LateralJoinDialectBase } from './lateral-join-dialect-base';
2020

21+
/**
22+
* Formats a JS `Date` as a Postgres TIME / TIMETZ literal (`HH:MM:SS.fff`,
23+
* optionally with `+ZZ:ZZ` for TIMETZ). Reads UTC components so the value
24+
* round-trips with ISO-input parsing — callers anchor time-only inputs to
25+
* the Unix epoch.
26+
*/
27+
function formatTimeOfDay(date: Date, withTimezone: boolean): string {
28+
const pad = (n: number, w = 2) => String(n).padStart(w, '0');
29+
const time = `${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())}.${pad(date.getUTCMilliseconds(), 3)}`;
30+
return withTimezone ? `${time}+00:00` : time;
31+
}
32+
2133
export class PostgresCrudDialect<Schema extends SchemaDef> extends LateralJoinDialectBase<Schema> {
2234
private static typeParserOverrideApplied = false;
2335

@@ -155,7 +167,7 @@ export class PostgresCrudDialect<Schema extends SchemaDef> extends LateralJoinDi
155167

156168
// #region value transformation
157169

158-
override transformInput(value: unknown, type: BuiltinType, forArrayField: boolean): unknown {
170+
override transformInput(value: unknown, type: BuiltinType, forArrayField: boolean, fieldDef?: FieldDef): unknown {
159171
if (value === undefined) {
160172
return value;
161173
}
@@ -187,16 +199,25 @@ export class PostgresCrudDialect<Schema extends SchemaDef> extends LateralJoinDi
187199
// scalar `Json` fields need their input stringified
188200
return JSON.stringify(value);
189201
} else {
190-
return value.map((v) => this.transformInput(v, type, false));
202+
return value.map((v) => this.transformInput(v, type, false, fieldDef));
191203
}
192204
} else {
193205
switch (type) {
194-
case 'DateTime':
195-
return value instanceof Date
196-
? value.toISOString()
197-
: typeof value === 'string'
198-
? new Date(value).toISOString()
199-
: value;
206+
case 'DateTime': {
207+
const date = value instanceof Date ? value : typeof value === 'string' ? new Date(value) : null;
208+
if (date === null || isNaN(date.getTime())) return value;
209+
// Postgres TIME / TIMETZ columns reject ISO datetime input —
210+
// they expect `HH:MM:SS[.fff][+ZZ:ZZ]`. Detect those native
211+
// types via the field's @db.* attribute and format
212+
// accordingly. All other DateTime fields keep the existing
213+
// ISO behaviour (TIMESTAMP / TIMESTAMPTZ / DATE all accept
214+
// it natively).
215+
const dbAttrName = fieldDef?.attributes?.find((a) => a.name.startsWith('@db.'))?.name;
216+
if (dbAttrName === '@db.Time' || dbAttrName === '@db.Timetz') {
217+
return formatTimeOfDay(date, dbAttrName === '@db.Timetz');
218+
}
219+
return date.toISOString();
220+
}
200221
case 'Decimal':
201222
return value !== null ? value.toString() : value;
202223
case 'Json':

packages/orm/src/client/crud/operations/base.ts

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -439,12 +439,13 @@ export abstract class BaseOperationHandler<Schema extends SchemaDef> {
439439
Array.isArray(value.set)
440440
) {
441441
// deal with nested "set" for scalar lists
442-
createFields[field] = this.dialect.transformInput(value.set, fieldDef.type as BuiltinType, true);
442+
createFields[field] = this.dialect.transformInput(value.set, fieldDef.type as BuiltinType, true, fieldDef);
443443
} else {
444444
createFields[field] = this.dialect.transformInput(
445445
value,
446446
fieldDef.type as BuiltinType,
447447
!!fieldDef.array,
448+
fieldDef,
448449
);
449450
}
450451
} else {
@@ -887,7 +888,7 @@ export abstract class BaseOperationHandler<Schema extends SchemaDef> {
887888
for (const [name, value] of Object.entries(item)) {
888889
const fieldDef = this.requireField(model, name);
889890
invariant(!fieldDef.relation, 'createMany does not support relations');
890-
newItem[name] = this.dialect.transformInput(value, fieldDef.type as BuiltinType, !!fieldDef.array);
891+
newItem[name] = this.dialect.transformInput(value, fieldDef.type as BuiltinType, !!fieldDef.array, fieldDef);
891892
}
892893
if (fromRelation) {
893894
for (const { fk, pk } of relationKeyPairs) {
@@ -925,6 +926,7 @@ export abstract class BaseOperationHandler<Schema extends SchemaDef> {
925926
fieldDef.default,
926927
fieldDef.type as BuiltinType,
927928
!!fieldDef.array,
929+
fieldDef,
928930
);
929931
}
930932
}
@@ -1057,11 +1059,12 @@ export abstract class BaseOperationHandler<Schema extends SchemaDef> {
10571059
generated,
10581060
fieldDef.type as BuiltinType,
10591061
!!fieldDef.array,
1062+
fieldDef,
10601063
);
10611064
}
10621065
} else if (fieldDef?.updatedAt) {
10631066
// TODO: should this work at kysely level instead?
1064-
values[field] = this.dialect.transformInput(new Date(), 'DateTime', false);
1067+
values[field] = this.dialect.transformInput(new Date(), 'DateTime', false, fieldDef);
10651068
} else if (fieldDef?.default !== undefined) {
10661069
let value = fieldDef.default;
10671070
if (fieldDef.type === 'Json') {
@@ -1072,7 +1075,7 @@ export abstract class BaseOperationHandler<Schema extends SchemaDef> {
10721075
value = JSON.parse(value);
10731076
}
10741077
}
1075-
values[field] = this.dialect.transformInput(value, fieldDef.type as BuiltinType, !!fieldDef.array);
1078+
values[field] = this.dialect.transformInput(value, fieldDef.type as BuiltinType, !!fieldDef.array, fieldDef);
10761079
}
10771080
}
10781081
}
@@ -1176,7 +1179,7 @@ export abstract class BaseOperationHandler<Schema extends SchemaDef> {
11761179
if (finalData === data) {
11771180
finalData = clone(data);
11781181
}
1179-
finalData[fieldName] = this.dialect.transformInput(new Date(), 'DateTime', false);
1182+
finalData[fieldName] = this.dialect.transformInput(new Date(), 'DateTime', false, fieldDef);
11801183
autoUpdatedFields.push(fieldName);
11811184
}
11821185
}
@@ -1442,7 +1445,7 @@ export abstract class BaseOperationHandler<Schema extends SchemaDef> {
14421445
return this.transformScalarListUpdate(model, field, fieldDef, data[field]);
14431446
}
14441447

1445-
return this.dialect.transformInput(data[field], fieldDef.type as BuiltinType, !!fieldDef.array);
1448+
return this.dialect.transformInput(data[field], fieldDef.type as BuiltinType, !!fieldDef.array, fieldDef);
14461449
}
14471450

14481451
private isNumericIncrementalUpdate(fieldDef: FieldDef, value: any) {
@@ -1500,7 +1503,7 @@ export abstract class BaseOperationHandler<Schema extends SchemaDef> {
15001503
);
15011504

15021505
const key = Object.keys(payload)[0];
1503-
const value = this.dialect.transformInput(payload[key!], fieldDef.type as BuiltinType, false);
1506+
const value = this.dialect.transformInput(payload[key!], fieldDef.type as BuiltinType, false, fieldDef);
15041507
const eb = expressionBuilder<any, any>();
15051508
const fieldRef = this.dialect.fieldRef(model, field);
15061509

@@ -1523,7 +1526,7 @@ export abstract class BaseOperationHandler<Schema extends SchemaDef> {
15231526
) {
15241527
invariant(Object.keys(payload).length === 1, 'Only one of "set", "push" can be provided');
15251528
const key = Object.keys(payload)[0];
1526-
const value = this.dialect.transformInput(payload[key!], fieldDef.type as BuiltinType, true);
1529+
const value = this.dialect.transformInput(payload[key!], fieldDef.type as BuiltinType, true, fieldDef);
15271530
const eb = expressionBuilder<any, any>();
15281531
const fieldRef = this.dialect.fieldRef(model, field);
15291532

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { createTestClient } from '@zenstackhq/testtools';
2+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
3+
4+
// Regression for #2633: writes to `@db.Time` / `@db.Timetz` columns failed
5+
// with PG `22007 invalid input syntax for type time` because the dialect
6+
// serialized JS Date values as ISO datetime strings. The dialect now reads
7+
// the field's `@db.*` attribute and formats `HH:MM:SS.fff[+ZZ:ZZ]` for TIME
8+
// / TIMETZ columns; other DateTime columns keep the existing ISO behaviour.
9+
describe('Issue 2633 — write to @db.Time columns', () => {
10+
describe.each([
11+
{ name: '@db.Time', dbType: '@db.Time(6)' },
12+
{ name: '@db.Timetz', dbType: '@db.Timetz(6)' },
13+
])('$name', ({ dbType }) => {
14+
const schema = `
15+
model TradingHour {
16+
id Int @id @default(autoincrement())
17+
open DateTime ${dbType}
18+
close DateTime ${dbType}
19+
}
20+
`;
21+
22+
let client: any;
23+
24+
beforeEach(async () => {
25+
client = await createTestClient(schema, {
26+
usePrismaPush: true,
27+
provider: 'postgresql',
28+
});
29+
});
30+
31+
afterEach(async () => {
32+
await client?.$disconnect();
33+
});
34+
35+
it('accepts a Date for the open / close fields', async () => {
36+
const open = new Date('1970-01-01T09:00:00.000Z');
37+
const close = new Date('1970-01-01T16:30:00.000Z');
38+
39+
const row = await client.tradingHour.create({ data: { open, close } });
40+
41+
expect(row.id).toBeDefined();
42+
});
43+
44+
it('round-trips the time-of-day via createMany', async () => {
45+
await client.tradingHour.createMany({
46+
data: [
47+
{ open: new Date('1970-01-01T09:00:00.000Z'), close: new Date('1970-01-01T16:00:00.000Z') },
48+
{ open: new Date('1970-01-01T10:30:00.000Z'), close: new Date('1970-01-01T17:30:00.000Z') },
49+
],
50+
});
51+
52+
const rows = await client.tradingHour.findMany({ orderBy: { id: 'asc' } });
53+
expect(rows).toHaveLength(2);
54+
// The application reads `tw.open` / `tw.close` as Date objects.
55+
expect(rows[0].open).toBeInstanceOf(Date);
56+
expect(rows[0].close).toBeInstanceOf(Date);
57+
expect(rows[0].open.toISOString()).toBe('1970-01-01T09:00:00.000Z');
58+
expect(rows[0].close.toISOString()).toBe('1970-01-01T16:00:00.000Z');
59+
expect(rows[1].open.toISOString()).toBe('1970-01-01T10:30:00.000Z');
60+
expect(rows[1].close.toISOString()).toBe('1970-01-01T17:30:00.000Z');
61+
});
62+
});
63+
});

0 commit comments

Comments
 (0)