diff --git a/packages/orm/src/client/crud/dialects/mysql.ts b/packages/orm/src/client/crud/dialects/mysql.ts index 536ed36f4..012e755e9 100644 --- a/packages/orm/src/client/crud/dialects/mysql.ts +++ b/packages/orm/src/client/crud/dialects/mysql.ts @@ -166,15 +166,20 @@ export class MySqlCrudDialect extends LateralJoinDiale } private transformOutputDate(value: unknown) { - if (typeof value === 'string') { - // MySQL DateTime columns are returned as strings (non-ISO but parsable as JS Date), - // convert to ISO Date by appending 'Z' if not present - return new Date(!value.endsWith('Z') ? value + 'Z' : value); - } else if (value instanceof Date) { - return value; - } else { + if (typeof value !== 'string') { return value; } + + // MySQL `TIME` columns return bare time strings ("09:30:00") that `new Date` + // can't parse on their own — anchor at the Unix epoch. Detect by shape rather + // than the schema attribute so the runtime stays decoupled from `@db.*` + // (which is migration/db-push only): TIME starts with `HH:`, DATE/DATETIME + // values always start with `YYYY-`. + const anchored = /^\d{2}:/.test(value) ? `1970-01-01T${value}` : value; + + // MySQL DateTime columns are returned as strings (non-ISO but parsable as JS Date), + // convert to ISO Date by appending 'Z' if not present + return new Date(!anchored.endsWith('Z') ? anchored + 'Z' : anchored); } private transformOutputBytes(value: unknown) { diff --git a/packages/orm/src/client/crud/dialects/postgresql.ts b/packages/orm/src/client/crud/dialects/postgresql.ts index efa7eb176..1faace74c 100644 --- a/packages/orm/src/client/crud/dialects/postgresql.ts +++ b/packages/orm/src/client/crud/dialects/postgresql.ts @@ -256,18 +256,29 @@ export class PostgresCrudDialect extends LateralJoinDi } private transformOutputDate(value: unknown) { - if (typeof value === 'string') { - // PostgreSQL's jsonb_build_object serializes timestamp as ISO 8601 strings, - // we force interpret them as UTC dates here if the value does not carry timezone - // offset (this happens with "TIMESTAMP WITHOUT TIME ZONE" field type) - const normalized = this.hasTimezoneOffset(value) ? value : `${value}Z`; - const parsed = new Date(normalized); - return Number.isNaN(parsed.getTime()) - ? value // fallback to original value if parsing fails - : parsed; - } else { + if (typeof value !== 'string') { return value; } + + // PG `time` / `timetz` values come back as bare time strings ("09:30:00" or + // "09:30:00+00") that `new Date` can't parse on their own — anchor at the + // Unix epoch and expand `timetz`'s minute-less offset (`+HH` -> `+HH:00`). + // Detect by shape rather than the schema attribute so the runtime stays + // decoupled from `@db.*` (which is migration/db-push only): time-only + // values start with `HH:`, anything date-bearing starts with `YYYY-`. + const isTimeOnly = /^\d{2}:/.test(value); + const anchored = isTimeOnly + ? `1970-01-01T${value}`.replace(/([+-]\d{2})$/, '$1:00') + : value; + + // PostgreSQL's jsonb_build_object serializes timestamp as ISO 8601 strings, + // we force interpret them as UTC dates here if the value does not carry timezone + // offset (this happens with "TIMESTAMP WITHOUT TIME ZONE" field type) + const normalized = this.hasTimezoneOffset(anchored) ? anchored : `${anchored}Z`; + const parsed = new Date(normalized); + return Number.isNaN(parsed.getTime()) + ? value // fallback to original value if parsing fails + : parsed; } private hasTimezoneOffset(value: string) { diff --git a/tests/e2e/orm/client-api/timezone/mysql-timezone.test.ts b/tests/e2e/orm/client-api/timezone/mysql-timezone.test.ts new file mode 100644 index 000000000..5f99a525b --- /dev/null +++ b/tests/e2e/orm/client-api/timezone/mysql-timezone.test.ts @@ -0,0 +1,86 @@ +import { createTestClient } from '@zenstackhq/testtools'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +describe('Timezone handling tests for mysql', () => { + // Regression for https://github.com/zenstackhq/zenstack/issues/2589 — + // `@db.Time` values were returned as raw strings / Invalid Date because + // `new Date("09:30:00Z")` can't parse a bare time string. + describe('@db.Time fields', () => { + const schema = ` +model Exchange { + id Int @id @default(autoincrement()) + name String + tradingWindows ExchangeTradingWindow[] +} + +model ExchangeTradingWindow { + id Int @id @default(autoincrement()) + exchangeId Int + exchange Exchange @relation(fields: [exchangeId], references: [id], onDelete: Cascade) + open DateTime @db.Time(6) + close DateTime @db.Time(6) +} + `; + + let client: any; + + beforeEach(async () => { + client = await createTestClient(schema, { + usePrismaPush: true, + provider: 'mysql', + }); + }); + + afterEach(async () => { + await client?.$disconnect(); + }); + + it('returns @db.Time fields as Date via nested include', async () => { + const exchange = await client.exchange.create({ data: { name: 'NYSE' } }); + + await client.$qb + .insertInto('ExchangeTradingWindow') + .values({ + exchangeId: exchange.id, + open: '09:30:00', + close: '16:00:00', + }) + .execute(); + + const result = await client.exchange.findUnique({ + where: { id: exchange.id }, + include: { tradingWindows: true }, + }); + + expect(result.tradingWindows).toHaveLength(1); + const win = result.tradingWindows[0]; + + expect(win.open).toBeInstanceOf(Date); + expect(win.open.toISOString()).toBe('1970-01-01T09:30:00.000Z'); + expect(win.close).toBeInstanceOf(Date); + expect(win.close.toISOString()).toBe('1970-01-01T16:00:00.000Z'); + }); + + it('returns @db.Time fields as Date on a direct select', async () => { + const exchange = await client.exchange.create({ data: { name: 'NYSE' } }); + + await client.$qb + .insertInto('ExchangeTradingWindow') + .values({ + exchangeId: exchange.id, + open: '09:30:00', + close: '16:00:00', + }) + .execute(); + + const windows = await client.exchangeTradingWindow.findMany({ + where: { exchangeId: exchange.id }, + }); + + expect(windows).toHaveLength(1); + expect(windows[0].open).toBeInstanceOf(Date); + expect(windows[0].open.toISOString()).toBe('1970-01-01T09:30:00.000Z'); + expect(windows[0].close).toBeInstanceOf(Date); + }); + }); +}); diff --git a/tests/e2e/orm/client-api/timezone/pg-timezone.test.ts b/tests/e2e/orm/client-api/timezone/pg-timezone.test.ts index 935ca818d..915f44f1c 100644 --- a/tests/e2e/orm/client-api/timezone/pg-timezone.test.ts +++ b/tests/e2e/orm/client-api/timezone/pg-timezone.test.ts @@ -543,4 +543,103 @@ model Post { } }); }); + + // Regression for https://github.com/zenstackhq/zenstack/issues/2589 — + // `@db.Time` values were returned as raw strings instead of Date when fetched through + // a nested include (the lateral-join JSON path where pg's per-OID parsers don't fire). + describe('@db.Time fields', () => { + const schema = ` +model Exchange { + id Int @id @default(autoincrement()) + name String + tradingWindows ExchangeTradingWindow[] +} + +model ExchangeTradingWindow { + id Int @id @default(autoincrement()) + exchangeId Int + exchange Exchange @relation(fields: [exchangeId], references: [id], onDelete: Cascade) + open DateTime @db.Time(6) + close DateTime @db.Time(6) + openTz DateTime @db.Timetz(6) + effectiveOn DateTime @db.Date +} + `; + + let client: any; + + beforeEach(async () => { + client = await createTestClient(schema, { + usePrismaPush: true, + provider: 'postgresql', + }); + }); + + afterEach(async () => { + await client?.$disconnect(); + }); + + it('returns @db.Time / @db.Timetz / @db.Date fields as Date via nested include', async () => { + const exchange = await client.exchange.create({ data: { name: 'NYSE' } }); + + await client.$qb + .insertInto('ExchangeTradingWindow') + .values({ + exchangeId: exchange.id, + open: '09:30:00', + close: '16:00:00', + openTz: '09:30:00+00', + effectiveOn: '2024-06-15', + }) + .execute(); + + const result = await client.exchange.findUnique({ + where: { id: exchange.id }, + include: { tradingWindows: true }, + }); + + expect(result.tradingWindows).toHaveLength(1); + const win = result.tradingWindows[0]; + + expect(win.open).toBeInstanceOf(Date); + expect(win.open.toISOString()).toBe('1970-01-01T09:30:00.000Z'); + expect(win.close).toBeInstanceOf(Date); + expect(win.close.toISOString()).toBe('1970-01-01T16:00:00.000Z'); + expect(win.openTz).toBeInstanceOf(Date); + expect(win.openTz.toISOString()).toBe('1970-01-01T09:30:00.000Z'); + // @db.Date must not be corrupted by the tz-offset expansion (guarding + // against `2024-06-15` being rewritten to `2024-06-15:00`). + expect(win.effectiveOn).toBeInstanceOf(Date); + expect(win.effectiveOn.toISOString()).toBe('2024-06-15T00:00:00.000Z'); + }); + + it('returns @db.Time / @db.Date fields as Date on a direct select', async () => { + const exchange = await client.exchange.create({ data: { name: 'NYSE' } }); + + await client.$qb + .insertInto('ExchangeTradingWindow') + .values({ + exchangeId: exchange.id, + open: '09:30:00', + close: '16:00:00', + openTz: '09:30:00+00', + effectiveOn: '2024-06-15', + }) + .execute(); + + const windows = await client.exchangeTradingWindow.findMany({ + where: { exchangeId: exchange.id }, + }); + + expect(windows).toHaveLength(1); + expect(windows[0].open).toBeInstanceOf(Date); + expect(windows[0].open.toISOString()).toBe('1970-01-01T09:30:00.000Z'); + expect(windows[0].close).toBeInstanceOf(Date); + expect(windows[0].openTz).toBeInstanceOf(Date); + // On direct select pg's default DATE parser returns a Date anchored in local + // time, so we only assert the instance type here — the include path above + // exercises the string branch (which is where the offset-expansion bug lived). + expect(windows[0].effectiveOn).toBeInstanceOf(Date); + }); + }); });