From 402842e1f8ef6624d4f6a557e8872d107baf3475 Mon Sep 17 00:00:00 2001 From: marcsigmund <73389028+marcsigmund@users.noreply.github.com> Date: Thu, 26 Mar 2026 16:20:50 +0100 Subject: [PATCH 1/3] refactor(zod): enforce literal `true` for select/include/omit options --- packages/zod/src/factory.ts | 23 +++++++++------------ packages/zod/src/types.ts | 33 ++++++++++++------------------- packages/zod/test/factory.test.ts | 12 ----------- 3 files changed, 22 insertions(+), 46 deletions(-) diff --git a/packages/zod/src/factory.ts b/packages/zod/src/factory.ts index 62620c114..cf6ee615b 100644 --- a/packages/zod/src/factory.ts +++ b/packages/zod/src/factory.ts @@ -34,9 +34,9 @@ export function createSchemaFactory(schema: Schema) { /** Internal untyped representation of the options object used at runtime. */ type RawOptions = { - select?: Record; - include?: Record; - omit?: Record; + select?: Record; + include?: Record; + omit?: Record; }; /** @@ -50,9 +50,9 @@ type RawOptions = { const rawOptionsSchema: z.ZodType = z.lazy(() => z .object({ - select: z.record(z.string(), z.union([z.boolean(), rawOptionsSchema])).optional(), - include: z.record(z.string(), z.union([z.boolean(), rawOptionsSchema])).optional(), - omit: z.record(z.string(), z.boolean()).optional(), + select: z.record(z.string(), z.union([z.literal(true), rawOptionsSchema])).optional(), + include: z.record(z.string(), z.union([z.literal(true), rawOptionsSchema])).optional(), + omit: z.record(z.string(), z.literal(true)).optional(), }) .superRefine((val, ctx) => { if (val.select && val.include) { @@ -204,10 +204,8 @@ class SchemaFactory { if (select) { // ── select branch ──────────────────────────────────────────────── - // Only include fields that are explicitly listed with a truthy value. + // Only include fields that are explicitly listed (value is always `true` or nested options). for (const [key, value] of Object.entries(select)) { - if (!value) continue; // false → skip - const fieldDef = modelDef.fields[key]; if (!fieldDef) { throw new SchemaFactoryError(`Field "${key}" does not exist on model "${model}"`); @@ -257,8 +255,6 @@ class SchemaFactory { // Validate include keys and add relation fields. if (include) { for (const [key, value] of Object.entries(include)) { - if (!value) continue; // false → skip - const fieldDef = modelDef.fields[key]; if (!fieldDef) { throw new SchemaFactoryError(`Field "${key}" does not exist on model "${model}"`); @@ -296,9 +292,8 @@ class SchemaFactory { const fields = new Set(); if (select) { - // Only scalar fields explicitly selected with a truthy value. - for (const [key, value] of Object.entries(select)) { - if (!value) continue; + // Only scalar fields explicitly selected (value is always `true` or nested options). + for (const [key] of Object.entries(select)) { const fieldDef = modelDef.fields[key]; if (fieldDef && !fieldDef.relation) { fields.add(key); diff --git a/packages/zod/src/types.ts b/packages/zod/src/types.ts index 2a61531b0..c699713a2 100644 --- a/packages/zod/src/types.ts +++ b/packages/zod/src/types.ts @@ -190,13 +190,14 @@ type RelatedModel< export type ModelSchemaOptions> = | { /** - * Pick only the listed fields. Values can be `true` (include with + * Pick only the listed fields. Values must be `true` (include with * default shape) or a nested options object (for relation fields). + * Only `true` is accepted — ORM convention. */ select: { [Field in GetModelFields]?: FieldIsRelation extends true - ? boolean | ModelSchemaOptions> - : boolean; + ? true | ModelSchemaOptions> + : true; }; include?: never; omit?: never; @@ -205,19 +206,20 @@ export type ModelSchemaOptions]?: Field extends GetModelFields - ? boolean | ModelSchemaOptions> + ? true | ModelSchemaOptions> : never; }; /** * Remove the listed scalar fields from the output. + * Only `true` is accepted — ORM convention. */ omit?: { - [Field in keyof ScalarModelFields]?: boolean; + [Field in keyof ScalarModelFields]?: true; }; }; @@ -297,12 +299,7 @@ type SelectEntryToZod< * recursing into relations when given nested options. */ type BuildSelectShape, S extends Record> = { - [Field in keyof S & GetModelFields as S[Field] extends false ? never : Field]: SelectEntryToZod< - Schema, - Model, - Field, - S[Field] - >; + [Field in keyof S & GetModelFields]: SelectEntryToZod; }; /** @@ -316,22 +313,18 @@ type BuildIncludeOmitShape< I extends Record | undefined, O extends Record | undefined, > = - // scalar fields, omitting those explicitly excluded + // scalar fields, omitting those explicitly excluded (only `true` omits a field) { [Field in GetModelFields as FieldIsRelation extends true ? never : O extends object ? Field extends keyof O - ? O[Field] extends true - ? never - : Field + ? never : Field : Field]: GetModelFieldsShape[FieldInShape]; } & (I extends object // included relation fields ? { - [Field in keyof I & GetModelFields as I[Field] extends false - ? never - : Field]: I[Field] extends object + [Field in keyof I & GetModelFields]: I[Field] extends object ? RelationFieldZodWithOptions : RelationFieldZodDefault; } diff --git a/packages/zod/test/factory.test.ts b/packages/zod/test/factory.test.ts index d9b38ebba..e6d0c5d19 100644 --- a/packages/zod/test/factory.test.ts +++ b/packages/zod/test/factory.test.ts @@ -987,12 +987,6 @@ describe('SchemaFactory - makeModelSchema with options', () => { expectTypeOf().toEqualTypeOf(); }); - it('include: false skips the relation', () => { - const schema = factory.makeModelSchema('User', { include: { posts: false } }); - // posts field must not be in the strict schema - expect(schema.safeParse({ ...validUser, posts: [] }).success).toBe(false); - }); - it('include with nested select on relation', () => { const schema = factory.makeModelSchema('User', { include: { posts: { select: { title: true } } }, @@ -1076,12 +1070,6 @@ describe('SchemaFactory - makeModelSchema with options', () => { expectTypeOf().not.toHaveProperty('posts'); }); - it('select: false on a field excludes it', () => { - const schema = factory.makeModelSchema('User', { select: { id: true, email: false } }); - expect(schema.safeParse({ id: 'u1' }).success).toBe(true); - expect(schema.safeParse({ id: 'u1', email: 'a@b.com' }).success).toBe(false); - }); - it('select with a relation field (true) includes the relation', () => { const schema = factory.makeModelSchema('User', { select: { id: true, posts: true } }); expect(schema.safeParse({ id: 'u1', posts: [] }).success).toBe(true); From ef51472c957b8cbd303a22941953bc073d27b01f Mon Sep 17 00:00:00 2001 From: marcsigmund <73389028+marcsigmund@users.noreply.github.com> Date: Thu, 26 Mar 2026 18:33:03 +0100 Subject: [PATCH 2/3] feat(zod): exclude relation fields from makeModelSchema by default BREAKING CHANGE: `makeModelSchema()` no longer includes relation fields by default to prevent infinite nesting with circular relations and align with ORM behavior. Use `include` or `select` options to explicitly opt in to relation fields. --- BREAKINGCHANGES.md | 1 + packages/zod/src/factory.ts | 24 +++++++----------------- packages/zod/src/types.ts | 24 ++++++++++++++++++------ packages/zod/test/factory.test.ts | 23 ++++++++++++----------- 4 files changed, 38 insertions(+), 34 deletions(-) diff --git a/BREAKINGCHANGES.md b/BREAKINGCHANGES.md index f71db4ceb..96c3d629d 100644 --- a/BREAKINGCHANGES.md +++ b/BREAKINGCHANGES.md @@ -3,3 +3,4 @@ 1. non-optional to-one relation doesn't automatically filter parent read when evaluating access policies 1. `@omit` and `@password` attributes have been removed 1. SWR plugin is removed +1. `makeModelSchema()` no longer includes relation fields by default — use `include` or `select` options to opt in, mirroring ORM behaviour diff --git a/packages/zod/src/factory.ts b/packages/zod/src/factory.ts index cf6ee615b..336d00279 100644 --- a/packages/zod/src/factory.ts +++ b/packages/zod/src/factory.ts @@ -93,26 +93,16 @@ class SchemaFactory { const modelDef = this.schema.requireModel(model); if (!options) { - // ── No-options path (original behaviour) ───────────────────────── + // ── No-options path: scalar fields only (relations excluded by default) ── const fields: Record = {}; for (const [fieldName, fieldDef] of Object.entries(modelDef.fields)) { - if (fieldDef.relation) { - const relatedModelName = fieldDef.type; - const lazySchema: z.ZodType = z.lazy(() => - this.makeModelSchema(relatedModelName as GetModels), - ); - // relation fields are always optional - fields[fieldName] = this.applyDescription( - this.applyCardinality(lazySchema, fieldDef).optional(), - fieldDef.attributes, - ); - } else { - fields[fieldName] = this.applyDescription( - this.makeScalarFieldSchema(fieldDef), - fieldDef.attributes, - ); - } + // Relation fields are excluded by default — use `include` or `select` + // to opt in, mirroring ORM behaviour and avoiding infinite + // nesting for circular relations. + if (fieldDef.relation) continue; + + fields[fieldName] = this.applyDescription(this.makeScalarFieldSchema(fieldDef), fieldDef.attributes); } const shape = z.strictObject(fields); diff --git a/packages/zod/src/types.ts b/packages/zod/src/types.ts index c699713a2..9f49ac844 100644 --- a/packages/zod/src/types.ts +++ b/packages/zod/src/types.ts @@ -20,15 +20,27 @@ import type { import type Decimal from 'decimal.js'; import type z from 'zod'; +/** + * Scalar-only shape returned by the no-options `makeModelSchema` overload. + * Relation fields are excluded by default — use `include` or `select` to opt in. + */ export type GetModelFieldsShape> = { - // scalar fields [Field in GetModelFields as FieldIsRelation extends true ? never : Field]: ZodOptionalAndNullableIf< ZodArrayIf, FieldIsArray>, ModelFieldIsOptional >; -} & { +}; + +/** + * Full shape including both scalar and relation fields — used internally for + * type lookups (e.g. resolving relation field Zod types in include/select). + */ +type GetAllModelFieldsShape> = GetModelFieldsShape< + Schema, + Model +> & { // relation fields, always optional [Field in GetModelFields as FieldIsRelation extends true ? Field @@ -234,7 +246,7 @@ type FieldInShape< Schema extends SchemaDef, Model extends GetModels, Field extends GetModelFields, -> = Field & keyof GetModelFieldsShape; +> = Field & keyof GetAllModelFieldsShape; /** * Zod shape produced when a relation field is included via `include: { field: @@ -246,7 +258,7 @@ type RelationFieldZodDefault< Schema extends SchemaDef, Model extends GetModels, Field extends GetModelFields, -> = GetModelFieldsShape[FieldInShape]; +> = GetAllModelFieldsShape[FieldInShape]; /** * Zod shape for a relation field included with nested options. We recurse @@ -288,7 +300,7 @@ type SelectEntryToZod< // Handling `boolean` (not just literal `true`) prevents the type from // collapsing to `never` when callers use a boolean variable instead of // a literal (e.g. `const pick: boolean = true`). - GetModelFieldsShape[FieldInShape] + GetAllModelFieldsShape[FieldInShape] : Value extends object ? // nested options — must be a relation field RelationFieldZodWithOptions @@ -321,7 +333,7 @@ type BuildIncludeOmitShape< ? Field extends keyof O ? never : Field - : Field]: GetModelFieldsShape[FieldInShape]; + : Field]: GetAllModelFieldsShape[FieldInShape]; } & (I extends object // included relation fields ? { [Field in keyof I & GetModelFields]: I[Field] extends object diff --git a/packages/zod/test/factory.test.ts b/packages/zod/test/factory.test.ts index e6d0c5d19..6ac47e9c8 100644 --- a/packages/zod/test/factory.test.ts +++ b/packages/zod/test/factory.test.ts @@ -83,11 +83,8 @@ describe('SchemaFactory - makeModelSchema', () => { expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf
(); - // relation field present - expectTypeOf().toHaveProperty('posts'); - const _postSchema = factory.makeModelSchema('Post'); - type Post = z.infer; - expectTypeOf().toEqualTypeOf(); + // relation fields are NOT present by default — use include/select to opt in + expectTypeOf().not.toHaveProperty('posts'); }); it('infers correct field types for Post', () => { @@ -117,18 +114,22 @@ describe('SchemaFactory - makeModelSchema', () => { expectTypeOf().toEqualTypeOf(); - // optional relation field present in type - expectTypeOf().toHaveProperty('author'); - const _userSchema = factory.makeModelSchema('User'); - type User = z.infer; - expectTypeOf().toEqualTypeOf(); + // relation fields are NOT present by default — use include/select to opt in + expectTypeOf().not.toHaveProperty('author'); }); - it('accepts a fully valid User', () => { + it('accepts a fully valid User (no relation fields)', () => { const userSchema = factory.makeModelSchema('User'); expect(userSchema.safeParse(validUser).success).toBe(true); }); + it('rejects relation fields in default schema (strict object)', () => { + const userSchema = factory.makeModelSchema('User'); + // relation fields are not part of the default schema, so they are rejected + const result = userSchema.safeParse({ ...validUser, posts: [] }); + expect(result.success).toBe(false); + }); + it('accepts a fully valid Post', () => { const postSchema = factory.makeModelSchema('Post'); expect(postSchema.safeParse(validPost).success).toBe(true); From 7ff42d44c9d2abd88a275be45b911d76c99cea92 Mon Sep 17 00:00:00 2001 From: marcsigmund <73389028+marcsigmund@users.noreply.github.com> Date: Thu, 26 Mar 2026 19:04:59 +0100 Subject: [PATCH 3/3] fix(zod): address PR review comments --- packages/zod/src/factory.ts | 2 +- packages/zod/src/types.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/zod/src/factory.ts b/packages/zod/src/factory.ts index 336d00279..8bae4c4fa 100644 --- a/packages/zod/src/factory.ts +++ b/packages/zod/src/factory.ts @@ -49,7 +49,7 @@ type RawOptions = { */ const rawOptionsSchema: z.ZodType = z.lazy(() => z - .object({ + .strictObject({ select: z.record(z.string(), z.union([z.literal(true), rawOptionsSchema])).optional(), include: z.record(z.string(), z.union([z.literal(true), rawOptionsSchema])).optional(), omit: z.record(z.string(), z.literal(true)).optional(), diff --git a/packages/zod/src/types.ts b/packages/zod/src/types.ts index 9f49ac844..48d656ad8 100644 --- a/packages/zod/src/types.ts +++ b/packages/zod/src/types.ts @@ -331,7 +331,9 @@ type BuildIncludeOmitShape< ? never : O extends object ? Field extends keyof O - ? never + ? O[Field] extends true + ? never + : Field : Field : Field]: GetAllModelFieldsShape[FieldInShape]; } & (I extends object // included relation fields