Skip to content
Merged
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
19 changes: 12 additions & 7 deletions packages/orm/src/client/crud/dialects/mysql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,15 +166,20 @@ export class MySqlCrudDialect<Schema extends SchemaDef> 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) {
Expand Down
31 changes: 21 additions & 10 deletions packages/orm/src/client/crud/dialects/postgresql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,18 +256,29 @@ export class PostgresCrudDialect<Schema extends SchemaDef> 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) {
Expand Down
86 changes: 86 additions & 0 deletions tests/e2e/orm/client-api/timezone/mysql-timezone.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
99 changes: 99 additions & 0 deletions tests/e2e/orm/client-api/timezone/pg-timezone.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
Loading