Skip to content
Open
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
9 changes: 7 additions & 2 deletions packages/orm/src/client/crud/dialects/base-dialect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,13 @@ export abstract class BaseCrudDialect<Schema extends SchemaDef> {

/**
* 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) {
Copy link
Copy Markdown
Contributor Author

@erwan-joly erwan-joly Apr 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very close to what we initially did and reverted on a previous PR — but I don't think there's a better way to solve this here. The shape-detect trick from #2590 worked for the read side because PG emits time-only values (09:30:00) and datetimes (2026-04-29 ...) with distinguishable prefixes. On the write side the input is always a Date object, which carries no shape clue about whether the destination column is time or timestamp, so we genuinely need the field metadata to format correctly. Open to alternatives if you see one.

return value;
}

Expand Down Expand Up @@ -523,7 +528,7 @@ export abstract class BaseCrudDialect<Schema extends SchemaDef> {
}

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)) {
Expand Down
37 changes: 29 additions & 8 deletions packages/orm/src/client/crud/dialects/postgresql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Schema extends SchemaDef> extends LateralJoinDialectBase<Schema> {
private static typeParserOverrideApplied = false;

Expand Down Expand Up @@ -154,7 +166,7 @@ export class PostgresCrudDialect<Schema extends SchemaDef> 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;
}
Expand Down Expand Up @@ -186,16 +198,25 @@ export class PostgresCrudDialect<Schema extends SchemaDef> 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':
Expand Down
19 changes: 11 additions & 8 deletions packages/orm/src/client/crud/operations/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -439,12 +439,13 @@ export abstract class BaseOperationHandler<Schema extends SchemaDef> {
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 {
Expand Down Expand Up @@ -887,7 +888,7 @@ export abstract class BaseOperationHandler<Schema extends SchemaDef> {
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) {
Expand Down Expand Up @@ -925,6 +926,7 @@ export abstract class BaseOperationHandler<Schema extends SchemaDef> {
fieldDef.default,
fieldDef.type as BuiltinType,
!!fieldDef.array,
fieldDef,
);
}
}
Expand Down Expand Up @@ -1057,11 +1059,12 @@ export abstract class BaseOperationHandler<Schema extends SchemaDef> {
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') {
Expand All @@ -1072,7 +1075,7 @@ export abstract class BaseOperationHandler<Schema extends SchemaDef> {
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);
}
}
}
Expand Down Expand Up @@ -1176,7 +1179,7 @@ export abstract class BaseOperationHandler<Schema extends SchemaDef> {
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);
}
}
Expand Down Expand Up @@ -1442,7 +1445,7 @@ export abstract class BaseOperationHandler<Schema extends SchemaDef> {
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) {
Expand Down Expand Up @@ -1500,7 +1503,7 @@ export abstract class BaseOperationHandler<Schema extends SchemaDef> {
);

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<any, any>();
const fieldRef = this.dialect.fieldRef(model, field);

Expand All @@ -1523,7 +1526,7 @@ export abstract class BaseOperationHandler<Schema extends SchemaDef> {
) {
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<any, any>();
const fieldRef = this.dialect.fieldRef(model, field);

Expand Down
63 changes: 63 additions & 0 deletions tests/regression/test/issue-2633.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
});
Loading