Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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) {
Comment thread
ymc9 marked this conversation as resolved.
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
59 changes: 59 additions & 0 deletions tests/regression/test/issue-2633.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
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);
});
});
});
Loading