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
33 changes: 32 additions & 1 deletion packages/orm/src/client/zod/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,37 @@ export function createQuerySchemaFactory(clientOrSchema: any, options?: any) {
return new ZodSchemaFactory(clientOrSchema, options);
}

/**
* Builds a `DateTime` value schema that accepts a `Date` object or any string
* the JS `Date` constructor parses, and coerces it to a `Date`. ISO datetime,
* ISO date, and time-only strings (e.g. `"09:00:00"` for `@db.Time` fields,
* anchored to the Unix epoch) are the documented happy paths; other formats
* accepted by `new Date(...)` also pass through, mirroring Prisma's pre-3.5
* behaviour. Strings the engine can't parse fall through and are rejected by
* `z.date()` with the standard error.
*
* @see https://github.com/zenstackhq/zenstack/issues/2631
*/
export function coercedDateTimeSchema(): ZodType {
// The schema keeps the original `z.iso.datetime() | z.iso.date() | z.date()`
// union so the generated OpenAPI spec still documents the accepted ISO
// forms. Preprocess runs first and coerces strings into `Date` objects,
// so the union's `z.date()` arm catches everything that successfully
// parses — including non-ISO formats like `"2024/01/15"` for Prisma
// compatibility (rejected with the standard error if `new Date(...)`
// returns Invalid Date).
return z.preprocess((val) => {
if (typeof val !== 'string') return val;
if (/^\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d\d(?::\d\d)?)?$/.test(val)) {
const hasTz = val.endsWith('Z') || /[+-]\d\d(?::\d\d)?$/.test(val);
const d = new Date(`1970-01-01T${val}${hasTz ? '' : 'Z'}`);
return isNaN(d.getTime()) ? val : d;
}
const d = new Date(val);
return isNaN(d.getTime()) ? val : d;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}, z.union([z.iso.datetime(), z.iso.date(), z.date()]));
}

/**
* Options for creating Zod schemas.
*/
Expand Down Expand Up @@ -854,7 +885,7 @@ export class ZodSchemaFactory<

@cache()
private makeDateTimeValueSchema(): ZodType {
const schema = z.union([z.iso.datetime(), z.iso.date(), z.date()]);
const schema = coercedDateTimeSchema();
this.registerSchema('DateTime', schema);
return schema;
}
Expand Down
50 changes: 50 additions & 0 deletions tests/regression/test/issue-2631.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { createTestClient } from '@zenstackhq/testtools';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';

// Regression for #2631: ZenStack 3.5+ replaced Prisma's permissive
// datetime input coercion with a strict zod union, breaking every caller
// that passed ISO strings to `DateTime` fields. `DateTime` inputs now
// coerce strings the JS `Date` constructor parses back to `Date`,
// mirroring Prisma's pre-3.5 behaviour.
describe('Issue 2631 — DateTime input coercion', () => {
const schema = `
model Event {
id Int @id @default(autoincrement())
label String
when DateTime
}
`;

let db: any;

beforeEach(async () => {
db = await createTestClient(schema, { usePrismaPush: true, provider: 'sqlite' });
});
afterEach(async () => db?.$disconnect());

it('accepts a Date object', async () => {
const e = await db.event.create({ data: { label: 'date', when: new Date('2024-01-15T10:30:00Z') } });
expect(e.when).toBeInstanceOf(Date);
});

it('accepts an ISO datetime string and coerces to Date', async () => {
const e = await db.event.create({ data: { label: 'iso', when: '2024-01-15T10:30:00.000Z' } });
expect(e.when).toBeInstanceOf(Date);
});

it('accepts an ISO date string and coerces to Date', async () => {
const e = await db.event.create({ data: { label: 'date-only', when: '2024-01-15' } });
expect(e.when).toBeInstanceOf(Date);
});

it('accepts a bare time-only string anchored to the Unix epoch', async () => {
const e = await db.event.create({ data: { label: 'time-only', when: '09:30:00' } });
expect(e.when).toBeInstanceOf(Date);
expect((e.when as Date).getUTCHours()).toBe(9);
expect((e.when as Date).getUTCMinutes()).toBe(30);
});

it('rejects a non-parseable string', async () => {
await expect(db.event.create({ data: { label: 'junk', when: 'not-a-date' as any } })).rejects.toThrow();
});
});
Loading