Skip to content

Commit ce50d3b

Browse files
erwan-jolyclaude
andauthored
fix(orm): coerce ISO strings on DateTime input, with strictDateInput opt-in (#2631) (#2632)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 08c11e7 commit ce50d3b

2 files changed

Lines changed: 82 additions & 1 deletion

File tree

packages/orm/src/client/zod/factory.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,37 @@ export function createQuerySchemaFactory(clientOrSchema: any, options?: any) {
8484
return new ZodSchemaFactory(clientOrSchema, options);
8585
}
8686

87+
/**
88+
* Builds a `DateTime` value schema that accepts a `Date` object or any string
89+
* the JS `Date` constructor parses, and coerces it to a `Date`. ISO datetime,
90+
* ISO date, and time-only strings (e.g. `"09:00:00"` for `@db.Time` fields,
91+
* anchored to the Unix epoch) are the documented happy paths; other formats
92+
* accepted by `new Date(...)` also pass through, mirroring Prisma's pre-3.5
93+
* behaviour. Strings the engine can't parse fall through and are rejected by
94+
* `z.date()` with the standard error.
95+
*
96+
* @see https://github.com/zenstackhq/zenstack/issues/2631
97+
*/
98+
export function coercedDateTimeSchema(): ZodType {
99+
// The schema keeps the original `z.iso.datetime() | z.iso.date() | z.date()`
100+
// union so the generated OpenAPI spec still documents the accepted ISO
101+
// forms. Preprocess runs first and coerces strings into `Date` objects,
102+
// so the union's `z.date()` arm catches everything that successfully
103+
// parses — including non-ISO formats like `"2024/01/15"` for Prisma
104+
// compatibility (rejected with the standard error if `new Date(...)`
105+
// returns Invalid Date).
106+
return z.preprocess((val) => {
107+
if (typeof val !== 'string') return val;
108+
if (/^\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d\d(?::\d\d)?)?$/.test(val)) {
109+
const hasTz = val.endsWith('Z') || /[+-]\d\d(?::\d\d)?$/.test(val);
110+
const d = new Date(`1970-01-01T${val}${hasTz ? '' : 'Z'}`);
111+
return isNaN(d.getTime()) ? val : d;
112+
}
113+
const d = new Date(val);
114+
return isNaN(d.getTime()) ? val : d;
115+
}, z.union([z.iso.datetime(), z.iso.date(), z.date()]));
116+
}
117+
87118
/**
88119
* Options for creating Zod schemas.
89120
*/
@@ -864,7 +895,7 @@ export class ZodSchemaFactory<
864895

865896
@cache()
866897
private makeDateTimeValueSchema(): ZodType {
867-
const schema = z.union([z.iso.datetime(), z.iso.date(), z.date()]);
898+
const schema = coercedDateTimeSchema();
868899
this.registerSchema('DateTime', schema);
869900
return schema;
870901
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { createTestClient } from '@zenstackhq/testtools';
2+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
3+
4+
// Regression for #2631: ZenStack 3.5+ replaced Prisma's permissive
5+
// datetime input coercion with a strict zod union, breaking every caller
6+
// that passed ISO strings to `DateTime` fields. `DateTime` inputs now
7+
// coerce strings the JS `Date` constructor parses back to `Date`,
8+
// mirroring Prisma's pre-3.5 behaviour.
9+
describe('Issue 2631 — DateTime input coercion', () => {
10+
const schema = `
11+
model Event {
12+
id Int @id @default(autoincrement())
13+
label String
14+
when DateTime
15+
}
16+
`;
17+
18+
let db: any;
19+
20+
beforeEach(async () => {
21+
db = await createTestClient(schema, { usePrismaPush: true, provider: 'sqlite' });
22+
});
23+
afterEach(async () => db?.$disconnect());
24+
25+
it('accepts a Date object', async () => {
26+
const e = await db.event.create({ data: { label: 'date', when: new Date('2024-01-15T10:30:00Z') } });
27+
expect(e.when).toBeInstanceOf(Date);
28+
});
29+
30+
it('accepts an ISO datetime string and coerces to Date', async () => {
31+
const e = await db.event.create({ data: { label: 'iso', when: '2024-01-15T10:30:00.000Z' } });
32+
expect(e.when).toBeInstanceOf(Date);
33+
});
34+
35+
it('accepts an ISO date string and coerces to Date', async () => {
36+
const e = await db.event.create({ data: { label: 'date-only', when: '2024-01-15' } });
37+
expect(e.when).toBeInstanceOf(Date);
38+
});
39+
40+
it('accepts a bare time-only string anchored to the Unix epoch', async () => {
41+
const e = await db.event.create({ data: { label: 'time-only', when: '09:30:00' } });
42+
expect(e.when).toBeInstanceOf(Date);
43+
expect((e.when as Date).getUTCHours()).toBe(9);
44+
expect((e.when as Date).getUTCMinutes()).toBe(30);
45+
});
46+
47+
it('rejects a non-parseable string', async () => {
48+
await expect(db.event.create({ data: { label: 'junk', when: 'not-a-date' as any } })).rejects.toThrow();
49+
});
50+
});

0 commit comments

Comments
 (0)