Skip to content
Open
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
10 changes: 10 additions & 0 deletions packages/orm/src/client/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,16 @@ export type ClientOptions<Schema extends SchemaDef> = QueryOptions<Schema> & {
*/
validateInput?: boolean;

/**
* Whether to require `Date` objects (rather than ISO strings) for `DateTime` field inputs. Defaults
* to `false`, matching Prisma's longstanding behavior of coercing ISO strings — including bare
* time-only strings like `"09:00:00"` for `@db.Time` fields — to `Date`.
*
* Set to `true` to opt into strict input validation that rejects all string forms.
* @see https://github.com/zenstackhq/zenstack/issues/2631
*/
strictDateInput?: boolean;

/**
* Whether to use compact alias names (e.g., "$$t1", "$$t2") when transforming ORM queries to SQL.
* Defaults to `true`.
Expand Down
38 changes: 37 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,39 @@ 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. Callers wanting strict ISO-or-Date
* validation should set `ClientOptions.strictDateInput: true`.
*
* Used when `ClientOptions.strictDateInput` is left at its default (`false`).
* @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 +887,10 @@ export class ZodSchemaFactory<

@cache()
private makeDateTimeValueSchema(): ZodType {
const schema = z.union([z.iso.datetime(), z.iso.date(), z.date()]);
// Strict mode: require an actual `Date` instance, matching what the
// engine ultimately wants. Default mode: coerce ISO strings (datetime,
// date, time-only) to `Date` for Prisma compatibility (#2631).
const schema = (this.options as ClientOptions<Schema>)?.strictDateInput ? z.date() : coercedDateTimeSchema();
this.registerSchema('DateTime', schema);
return schema;
}
Expand Down
78 changes: 78 additions & 0 deletions tests/regression/test/issue-2631.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
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. The default behaviour now
// coerces strings; `ClientOptions.strictDateInput: true` opts back in to
// the strict semantics.
describe('Issue 2631 — strictDateInput option', () => {
const schema = `
model Event {
id Int @id @default(autoincrement())
label String
when DateTime
}
`;

describe('default (strictDateInput unset / false)', () => {
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();
});
});

describe('strictDateInput: true', () => {
let db: any;

beforeEach(async () => {
db = await createTestClient(schema, { usePrismaPush: true, provider: 'sqlite', strictDateInput: true });
});
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('rejects an ISO datetime string', async () => {
await expect(db.event.create({ data: { label: 'iso', when: '2024-01-15T10:30:00.000Z' as any } })).rejects.toThrow();
});

it('rejects an ISO date string', async () => {
await expect(db.event.create({ data: { label: 'date-only', when: '2024-01-15' as any } })).rejects.toThrow();
});

it('rejects a bare time-only string', async () => {
await expect(db.event.create({ data: { label: 'time-only', when: '09:30:00' as any } })).rejects.toThrow();
});
});
});
Loading