Skip to content

Commit 3398c87

Browse files
authored
fix(orm): return @db.Time fields as Date instead of raw string (#2589) (#2590)
1 parent cce978f commit 3398c87

4 files changed

Lines changed: 218 additions & 17 deletions

File tree

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

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -166,15 +166,20 @@ export class MySqlCrudDialect<Schema extends SchemaDef> extends LateralJoinDiale
166166
}
167167

168168
private transformOutputDate(value: unknown) {
169-
if (typeof value === 'string') {
170-
// MySQL DateTime columns are returned as strings (non-ISO but parsable as JS Date),
171-
// convert to ISO Date by appending 'Z' if not present
172-
return new Date(!value.endsWith('Z') ? value + 'Z' : value);
173-
} else if (value instanceof Date) {
174-
return value;
175-
} else {
169+
if (typeof value !== 'string') {
176170
return value;
177171
}
172+
173+
// MySQL `TIME` columns return bare time strings ("09:30:00") that `new Date`
174+
// can't parse on their own — anchor at the Unix epoch. Detect by shape rather
175+
// than the schema attribute so the runtime stays decoupled from `@db.*`
176+
// (which is migration/db-push only): TIME starts with `HH:`, DATE/DATETIME
177+
// values always start with `YYYY-`.
178+
const anchored = /^\d{2}:/.test(value) ? `1970-01-01T${value}` : value;
179+
180+
// MySQL DateTime columns are returned as strings (non-ISO but parsable as JS Date),
181+
// convert to ISO Date by appending 'Z' if not present
182+
return new Date(!anchored.endsWith('Z') ? anchored + 'Z' : anchored);
178183
}
179184

180185
private transformOutputBytes(value: unknown) {

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

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -256,18 +256,29 @@ export class PostgresCrudDialect<Schema extends SchemaDef> extends LateralJoinDi
256256
}
257257

258258
private transformOutputDate(value: unknown) {
259-
if (typeof value === 'string') {
260-
// PostgreSQL's jsonb_build_object serializes timestamp as ISO 8601 strings,
261-
// we force interpret them as UTC dates here if the value does not carry timezone
262-
// offset (this happens with "TIMESTAMP WITHOUT TIME ZONE" field type)
263-
const normalized = this.hasTimezoneOffset(value) ? value : `${value}Z`;
264-
const parsed = new Date(normalized);
265-
return Number.isNaN(parsed.getTime())
266-
? value // fallback to original value if parsing fails
267-
: parsed;
268-
} else {
259+
if (typeof value !== 'string') {
269260
return value;
270261
}
262+
263+
// PG `time` / `timetz` values come back as bare time strings ("09:30:00" or
264+
// "09:30:00+00") that `new Date` can't parse on their own — anchor at the
265+
// Unix epoch and expand `timetz`'s minute-less offset (`+HH` -> `+HH:00`).
266+
// Detect by shape rather than the schema attribute so the runtime stays
267+
// decoupled from `@db.*` (which is migration/db-push only): time-only
268+
// values start with `HH:`, anything date-bearing starts with `YYYY-`.
269+
const isTimeOnly = /^\d{2}:/.test(value);
270+
const anchored = isTimeOnly
271+
? `1970-01-01T${value}`.replace(/([+-]\d{2})$/, '$1:00')
272+
: value;
273+
274+
// PostgreSQL's jsonb_build_object serializes timestamp as ISO 8601 strings,
275+
// we force interpret them as UTC dates here if the value does not carry timezone
276+
// offset (this happens with "TIMESTAMP WITHOUT TIME ZONE" field type)
277+
const normalized = this.hasTimezoneOffset(anchored) ? anchored : `${anchored}Z`;
278+
const parsed = new Date(normalized);
279+
return Number.isNaN(parsed.getTime())
280+
? value // fallback to original value if parsing fails
281+
: parsed;
271282
}
272283

273284
private hasTimezoneOffset(value: string) {
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { createTestClient } from '@zenstackhq/testtools';
2+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
3+
4+
describe('Timezone handling tests for mysql', () => {
5+
// Regression for https://github.com/zenstackhq/zenstack/issues/2589 —
6+
// `@db.Time` values were returned as raw strings / Invalid Date because
7+
// `new Date("09:30:00Z")` can't parse a bare time string.
8+
describe('@db.Time fields', () => {
9+
const schema = `
10+
model Exchange {
11+
id Int @id @default(autoincrement())
12+
name String
13+
tradingWindows ExchangeTradingWindow[]
14+
}
15+
16+
model ExchangeTradingWindow {
17+
id Int @id @default(autoincrement())
18+
exchangeId Int
19+
exchange Exchange @relation(fields: [exchangeId], references: [id], onDelete: Cascade)
20+
open DateTime @db.Time(6)
21+
close DateTime @db.Time(6)
22+
}
23+
`;
24+
25+
let client: any;
26+
27+
beforeEach(async () => {
28+
client = await createTestClient(schema, {
29+
usePrismaPush: true,
30+
provider: 'mysql',
31+
});
32+
});
33+
34+
afterEach(async () => {
35+
await client?.$disconnect();
36+
});
37+
38+
it('returns @db.Time fields as Date via nested include', async () => {
39+
const exchange = await client.exchange.create({ data: { name: 'NYSE' } });
40+
41+
await client.$qb
42+
.insertInto('ExchangeTradingWindow')
43+
.values({
44+
exchangeId: exchange.id,
45+
open: '09:30:00',
46+
close: '16:00:00',
47+
})
48+
.execute();
49+
50+
const result = await client.exchange.findUnique({
51+
where: { id: exchange.id },
52+
include: { tradingWindows: true },
53+
});
54+
55+
expect(result.tradingWindows).toHaveLength(1);
56+
const win = result.tradingWindows[0];
57+
58+
expect(win.open).toBeInstanceOf(Date);
59+
expect(win.open.toISOString()).toBe('1970-01-01T09:30:00.000Z');
60+
expect(win.close).toBeInstanceOf(Date);
61+
expect(win.close.toISOString()).toBe('1970-01-01T16:00:00.000Z');
62+
});
63+
64+
it('returns @db.Time fields as Date on a direct select', async () => {
65+
const exchange = await client.exchange.create({ data: { name: 'NYSE' } });
66+
67+
await client.$qb
68+
.insertInto('ExchangeTradingWindow')
69+
.values({
70+
exchangeId: exchange.id,
71+
open: '09:30:00',
72+
close: '16:00:00',
73+
})
74+
.execute();
75+
76+
const windows = await client.exchangeTradingWindow.findMany({
77+
where: { exchangeId: exchange.id },
78+
});
79+
80+
expect(windows).toHaveLength(1);
81+
expect(windows[0].open).toBeInstanceOf(Date);
82+
expect(windows[0].open.toISOString()).toBe('1970-01-01T09:30:00.000Z');
83+
expect(windows[0].close).toBeInstanceOf(Date);
84+
});
85+
});
86+
});

tests/e2e/orm/client-api/timezone/pg-timezone.test.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -543,4 +543,103 @@ model Post {
543543
}
544544
});
545545
});
546+
547+
// Regression for https://github.com/zenstackhq/zenstack/issues/2589 —
548+
// `@db.Time` values were returned as raw strings instead of Date when fetched through
549+
// a nested include (the lateral-join JSON path where pg's per-OID parsers don't fire).
550+
describe('@db.Time fields', () => {
551+
const schema = `
552+
model Exchange {
553+
id Int @id @default(autoincrement())
554+
name String
555+
tradingWindows ExchangeTradingWindow[]
556+
}
557+
558+
model ExchangeTradingWindow {
559+
id Int @id @default(autoincrement())
560+
exchangeId Int
561+
exchange Exchange @relation(fields: [exchangeId], references: [id], onDelete: Cascade)
562+
open DateTime @db.Time(6)
563+
close DateTime @db.Time(6)
564+
openTz DateTime @db.Timetz(6)
565+
effectiveOn DateTime @db.Date
566+
}
567+
`;
568+
569+
let client: any;
570+
571+
beforeEach(async () => {
572+
client = await createTestClient(schema, {
573+
usePrismaPush: true,
574+
provider: 'postgresql',
575+
});
576+
});
577+
578+
afterEach(async () => {
579+
await client?.$disconnect();
580+
});
581+
582+
it('returns @db.Time / @db.Timetz / @db.Date fields as Date via nested include', async () => {
583+
const exchange = await client.exchange.create({ data: { name: 'NYSE' } });
584+
585+
await client.$qb
586+
.insertInto('ExchangeTradingWindow')
587+
.values({
588+
exchangeId: exchange.id,
589+
open: '09:30:00',
590+
close: '16:00:00',
591+
openTz: '09:30:00+00',
592+
effectiveOn: '2024-06-15',
593+
})
594+
.execute();
595+
596+
const result = await client.exchange.findUnique({
597+
where: { id: exchange.id },
598+
include: { tradingWindows: true },
599+
});
600+
601+
expect(result.tradingWindows).toHaveLength(1);
602+
const win = result.tradingWindows[0];
603+
604+
expect(win.open).toBeInstanceOf(Date);
605+
expect(win.open.toISOString()).toBe('1970-01-01T09:30:00.000Z');
606+
expect(win.close).toBeInstanceOf(Date);
607+
expect(win.close.toISOString()).toBe('1970-01-01T16:00:00.000Z');
608+
expect(win.openTz).toBeInstanceOf(Date);
609+
expect(win.openTz.toISOString()).toBe('1970-01-01T09:30:00.000Z');
610+
// @db.Date must not be corrupted by the tz-offset expansion (guarding
611+
// against `2024-06-15` being rewritten to `2024-06-15:00`).
612+
expect(win.effectiveOn).toBeInstanceOf(Date);
613+
expect(win.effectiveOn.toISOString()).toBe('2024-06-15T00:00:00.000Z');
614+
});
615+
616+
it('returns @db.Time / @db.Date fields as Date on a direct select', async () => {
617+
const exchange = await client.exchange.create({ data: { name: 'NYSE' } });
618+
619+
await client.$qb
620+
.insertInto('ExchangeTradingWindow')
621+
.values({
622+
exchangeId: exchange.id,
623+
open: '09:30:00',
624+
close: '16:00:00',
625+
openTz: '09:30:00+00',
626+
effectiveOn: '2024-06-15',
627+
})
628+
.execute();
629+
630+
const windows = await client.exchangeTradingWindow.findMany({
631+
where: { exchangeId: exchange.id },
632+
});
633+
634+
expect(windows).toHaveLength(1);
635+
expect(windows[0].open).toBeInstanceOf(Date);
636+
expect(windows[0].open.toISOString()).toBe('1970-01-01T09:30:00.000Z');
637+
expect(windows[0].close).toBeInstanceOf(Date);
638+
expect(windows[0].openTz).toBeInstanceOf(Date);
639+
// On direct select pg's default DATE parser returns a Date anchored in local
640+
// time, so we only assert the instance type here — the include path above
641+
// exercises the string branch (which is where the offset-expansion bug lived).
642+
expect(windows[0].effectiveOn).toBeInstanceOf(Date);
643+
});
644+
});
546645
});

0 commit comments

Comments
 (0)