Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @erwan-joly , thanks for making continuous improvements. Date time handling is nasty ...

I'm thinking the new behavior (more accommodating) is probably preferred for most. Maybe we can drop this config altogether?

Copy link
Copy Markdown
Contributor Author

@erwan-joly erwan-joly May 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure thing, didn’t want to “break” it if it was on purpose I see now it wasn’t so will drop the configuration altogether


/**
* Whether to use compact alias names (e.g., "$$t1", "$$t2") when transforming ORM queries to SQL.
* Defaults to `true`.
Expand Down
28 changes: 27 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,29 @@ export function createQuerySchemaFactory(clientOrSchema: any, options?: any) {
return new ZodSchemaFactory(clientOrSchema, options);
}

/**
* Builds a `DateTime` value schema that accepts a `Date` object or an ISO
* datetime / date / time-only string and coerces it to a `Date`. Time-only
* strings (e.g. `"09:00:00"` for `@db.Time` fields) are anchored to the Unix
* epoch. Strings that don't parse fall through and are rejected by `z.date()`
* with the standard error.
*
* Used when `ClientOptions.strictDateInput` is left at its default (`false`).
* @see https://github.com/zenstackhq/zenstack/issues/2631
*/
export function coercedDateTimeSchema(): ZodType {
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.date());
}

/**
* Options for creating Zod schemas.
*/
Expand Down Expand Up @@ -854,7 +877,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