From 0ac1ed28d5f3a8441c389ace4b6794a676beae84 Mon Sep 17 00:00:00 2001 From: marcsigmund <73389028+marcsigmund@users.noreply.github.com> Date: Fri, 20 Mar 2026 21:34:29 +0100 Subject: [PATCH 1/4] feat(zod): add Prisma-style select/include/omit options to makeModelSchema Add support for field selection and relation inclusion in Zod schema generation through new options parameter. The `makeModelSchema` method now accepts optional `select`, `include`, and `omit` options to control which fields appear in the generated schema. Key changes: - Add overloaded signatures to `makeModelSchema` with type-safe options - Implement `buildFieldsWithOptions` to handle field filtering and relation inclusion - Add `GetModelSchemaShapeWithOptions` type to compute resulting shape based on options - Skip model-level @@validate on select path to avoid false negatives when referenced fields are not part of the selection - Add comprehensive type utilities for field selection and relation handling This enables more flexible schema generation for partial model validation and nested relation schemas while maintaining full type safety. --- packages/zod/src/factory.ts | 161 ++++++++++++++++-- packages/zod/src/index.ts | 1 + packages/zod/src/types.ts | 212 ++++++++++++++++++++++++ packages/zod/test/factory.test.ts | 263 ++++++++++++++++++++++++++++++ 4 files changed, 620 insertions(+), 17 deletions(-) diff --git a/packages/zod/src/factory.ts b/packages/zod/src/factory.ts index dece3e27e..a0d0ef2c0 100644 --- a/packages/zod/src/factory.ts +++ b/packages/zod/src/factory.ts @@ -15,8 +15,10 @@ import { SchemaFactoryError } from './error'; import type { GetModelCreateFieldsShape, GetModelFieldsShape, + GetModelSchemaShapeWithOptions, GetModelUpdateFieldsShape, GetTypeDefFieldsShape, + ModelSchemaOptions, } from './types'; import { addBigIntValidation, @@ -30,6 +32,13 @@ export function createSchemaFactory(schema: Schema) { return new SchemaFactory(schema); } +/** Internal untyped representation of the options object used at runtime. */ +type RawOptions = { + select?: Record; + include?: Record; + omit?: Record; +}; + class SchemaFactory { private readonly schema: SchemaAccessor; @@ -39,29 +48,63 @@ class SchemaFactory { makeModelSchema>( model: Model, - ): z.ZodObject, z.core.$strict> { + ): z.ZodObject, z.core.$strict>; + + makeModelSchema, Options extends ModelSchemaOptions>( + model: Model, + options: Options, + ): z.ZodObject, z.core.$strict>; + + makeModelSchema, Options extends ModelSchemaOptions>( + model: Model, + options?: Options, + ): z.ZodObject, z.core.$strict> { const modelDef = this.schema.requireModel(model); - 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); + if (!options) { + // ── No-options path (original behaviour) ───────────────────────── + 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, + ); + } } + + const shape = z.strictObject(fields); + return this.applyDescription( + addCustomValidation(shape, modelDef.attributes), + modelDef.attributes, + ) as unknown as z.ZodObject, z.core.$strict>; } + // ── Options path ───────────────────────────────────────────────────── + const rawOptions = options as unknown as RawOptions; + const fields = this.buildFieldsWithOptions(model as string, rawOptions); const shape = z.strictObject(fields); - return this.applyDescription( - addCustomValidation(shape, modelDef.attributes), - modelDef.attributes, - ) as unknown as z.ZodObject, z.core.$strict>; + // @@validate expressions reference fields by name — when `select` is + // used only a subset of fields is present, so running @@validate would + // silently evaluate missing fields as null and produce false negatives. + // We therefore only apply model-level custom validation on the + // include/omit path where all scalar fields are still present. + const withValidation = rawOptions.select ? shape : addCustomValidation(shape, modelDef.attributes); + return this.applyDescription(withValidation, modelDef.attributes) as unknown as z.ZodObject< + GetModelSchemaShapeWithOptions, + z.core.$strict + >; } makeModelCreateSchema>( @@ -114,6 +157,90 @@ class SchemaFactory { ) as unknown as z.ZodObject, z.core.$strict>; } + // ------------------------------------------------------------------------- + // Options-aware field building + // ------------------------------------------------------------------------- + + /** + * Internal loose options shape used at runtime (we've already validated the + * type-level constraints via the overload signatures). + */ + private buildFieldsWithOptions(model: string, options: RawOptions): Record { + const { select, include, omit } = options; + const modelDef = this.schema.requireModel(model); + const fields: Record = {}; + + if (select) { + // ── select branch ──────────────────────────────────────────────── + // Only include fields that are explicitly listed with a truthy value. + for (const [key, value] of Object.entries(select)) { + if (!value) continue; // false → skip + + const fieldDef = modelDef.fields[key]; + if (!fieldDef) continue; + + if (fieldDef.relation) { + // Relation field: recurse if value is a nested options object, + // otherwise use the default lazy schema. + const subOptions = typeof value === 'object' ? (value as RawOptions) : undefined; + const relSchema = this.makeRelationFieldSchema(fieldDef, subOptions); + fields[key] = this.applyDescription( + this.applyCardinality(relSchema, fieldDef).optional(), + fieldDef.attributes, + ); + } else { + fields[key] = this.applyDescription(this.makeScalarFieldSchema(fieldDef), fieldDef.attributes); + } + } + } else { + // ── include + omit branch ──────────────────────────────────────── + // Start with all scalar fields, applying omit exclusions. + for (const [fieldName, fieldDef] of Object.entries(modelDef.fields)) { + if (fieldDef.relation) continue; // relations handled below + + // Skip if this field is explicitly omitted. + if (omit && (omit as Record)[fieldName] === true) continue; + + fields[fieldName] = this.applyDescription(this.makeScalarFieldSchema(fieldDef), fieldDef.attributes); + } + + // Add included relation fields. + if (include) { + for (const [key, value] of Object.entries(include)) { + if (!value) continue; // false → skip + + const fieldDef = modelDef.fields[key]; + if (!fieldDef?.relation) continue; + + const subOptions = typeof value === 'object' ? (value as RawOptions) : undefined; + const relSchema = this.makeRelationFieldSchema(fieldDef, subOptions); + fields[key] = this.applyDescription( + this.applyCardinality(relSchema, fieldDef).optional(), + fieldDef.attributes, + ); + } + } + } + + return fields; + } + + /** + * Build the inner Zod schema for a relation field, optionally with nested + * query options. Does NOT apply cardinality/optional wrappers — the caller + * does that. + */ + private makeRelationFieldSchema(fieldDef: FieldDef, subOptions?: RawOptions): z.ZodType { + const relatedModelName = fieldDef.type as GetModels; + if (subOptions) { + // Recurse: build the related model's schema with its own options. + return this.makeModelSchema(relatedModelName, subOptions as ModelSchemaOptions>); + } + // No sub-options: use a lazy reference to the default schema so that + // circular models don't cause infinite recursion at build time. + return z.lazy(() => this.makeModelSchema(relatedModelName)); + } + private makeScalarFieldSchema(fieldDef: FieldDef): z.ZodType { const { type, attributes } = fieldDef; diff --git a/packages/zod/src/index.ts b/packages/zod/src/index.ts index 55f241ea1..4323894e8 100644 --- a/packages/zod/src/index.ts +++ b/packages/zod/src/index.ts @@ -1,2 +1,3 @@ export { createSchemaFactory } from './factory'; +export type { ModelSchemaOptions, GetModelSchemaShapeWithOptions } from './types'; export * as ZodUtils from './utils'; diff --git a/packages/zod/src/types.ts b/packages/zod/src/types.ts index c4db2c025..d2c678c87 100644 --- a/packages/zod/src/types.ts +++ b/packages/zod/src/types.ts @@ -142,3 +142,215 @@ type ZodOptionalAndNullableIf = type ZodOptionalIf = Condition extends true ? z.ZodOptional : T; type ZodNullableIf = Condition extends true ? z.ZodNullable : T; type ZodArrayIf = Condition extends true ? z.ZodArray : T; + +// ------------------------------------------------------------------------- +// Query options types (Prisma-style include / select / omit) +// ------------------------------------------------------------------------- + +/** + * The non-relation scalar fields of a model (excludes relation fields and + * foreign-key fields that back a relation). + */ +type ScalarModelFields> = { + [Field in GetModelFields as FieldIsRelation extends true + ? never + : Field]: Field; +}; + +/** + * The relation fields of a model. + */ +type RelationModelFields> = { + [Field in GetModelFields as FieldIsRelation extends true + ? Field + : never]: Field; +}; + +/** + * For a relation field, resolve the related model name. + */ +type RelatedModel< + Schema extends SchemaDef, + Model extends GetModels, + Field extends GetModelFields, +> = GetModelFieldType extends GetModels ? GetModelFieldType : never; + +/** + * Prisma-style query options accepted by `makeModelSchema`. + * + * Exactly mirrors the `select` / `include` / `omit` vocabulary: + * - `select` — pick specific fields (scalars and/or relations). Mutually + * exclusive with `include` and `omit`. + * - `include` — start with all scalar fields, then add the named relation + * fields. Can be combined with `omit`. + * - `omit` — remove named scalar fields from the default scalar set. + * Can be combined with `include`, mutually exclusive with + * `select`. + */ +export type ModelSchemaOptions> = + | { + /** + * Pick only the listed fields. Values can be `true` (include with + * default shape) or a nested options object (for relation fields). + */ + select: { + [Field in GetModelFields]?: FieldIsRelation extends true + ? boolean | ModelSchemaOptions> + : boolean; + }; + include?: never; + omit?: never; + } + | { + select?: never; + /** + * Add the listed relation fields on top of the scalar fields. + * Values can be `true` / `{}` (default shape) or a nested options + * object. + */ + include?: { + [Field in keyof RelationModelFields]?: Field extends GetModelFields + ? boolean | ModelSchemaOptions> + : never; + }; + /** + * Remove the listed scalar fields from the output. + */ + omit?: { + [Field in keyof ScalarModelFields]?: boolean; + }; + }; + +// ---- Output shape helpers ------------------------------------------------ + +/** + * Narrows `Field` so it can safely index `GetModelFieldsShape`. The mapped + * type uses a `as`-remapping clause, so TypeScript widens the key set and + * `Field extends GetModelFields<…>` alone is not enough for indexing. + */ +type FieldInShape< + Schema extends SchemaDef, + Model extends GetModels, + Field extends GetModelFields, +> = Field & keyof GetModelFieldsShape; + +/** + * Zod shape produced when a relation field is included via `include: { field: + * true }` or `select: { field: true }` — identical to how the existing + * `makeModelSchema` (no-options) represents relation fields: optional, carries + * array-ness and nullability from the field definition. + */ +type RelationFieldZodDefault< + Schema extends SchemaDef, + Model extends GetModels, + Field extends GetModelFields, +> = GetModelFieldsShape[FieldInShape]; + +/** + * Zod shape for a relation field included with nested options. We recurse + * into `GetModelSchemaShapeWithOptions` for the related model, then re-apply + * the same optional/array/nullable wrappers as the default relation field. + */ +type RelationFieldZodWithOptions< + Schema extends SchemaDef, + Model extends GetModels, + Field extends GetModelFields, + Options, +> = + RelatedModel extends GetModels + ? ZodNullableIf< + z.ZodOptional< + ZodArrayIf< + z.ZodObject< + GetModelSchemaShapeWithOptions, Options>, + z.core.$strict + >, + FieldIsArray + > + >, + ModelFieldIsOptional + > + : never; + +/** + * Resolve the Zod type for a single field given a select-entry value (`true` + * or a nested options object). + */ +type SelectEntryToZod< + Schema extends SchemaDef, + Model extends GetModels, + Field extends GetModelFields, + Value, +> = Value extends true + ? // `true` — use the default shape for this field (scalar or relation) + GetModelFieldsShape[FieldInShape] + : Value extends object + ? // nested options — must be a relation field + RelationFieldZodWithOptions + : never; + +/** + * Build the Zod shape for the `select` branch: only the listed fields, + * 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] + >; +}; + +/** + * Build the Zod shape for the `include` + `omit` branch: + * - All scalar fields, minus any that appear in `omit` with value `true`. + * - Plus the relation fields listed in `include`. + */ +type BuildIncludeOmitShape< + Schema extends SchemaDef, + Model extends GetModels, + I extends Record | undefined, + O extends Record | undefined, +> = + // scalar fields, omitting those explicitly excluded + { + [Field in GetModelFields as FieldIsRelation extends true + ? never + : O extends object + ? Field extends keyof O + ? O[Field] extends true + ? never + : Field + : 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 + ? RelationFieldZodWithOptions + : RelationFieldZodDefault; + } + : // no include — empty, so the intersection is a no-op + {}); + +/** + * The top-level conditional that maps options → Zod shape. + * + * - No options / undefined → existing `GetModelFieldsShape` (no change). + * - `{ select: S }` → `BuildSelectShape`. + * - `{ include?, omit? }` → `BuildIncludeOmitShape`. + */ +export type GetModelSchemaShapeWithOptions< + Schema extends SchemaDef, + Model extends GetModels, + Options, +> = Options extends { select: infer S extends Record } + ? BuildSelectShape + : Options extends { + include?: infer I extends Record | undefined; + omit?: infer O extends Record | undefined; + } + ? BuildIncludeOmitShape + : GetModelFieldsShape; diff --git a/packages/zod/test/factory.test.ts b/packages/zod/test/factory.test.ts index 40160a040..7420de9be 100644 --- a/packages/zod/test/factory.test.ts +++ b/packages/zod/test/factory.test.ts @@ -894,3 +894,266 @@ describe('SchemaFactory - delegate models', () => { }); }); }); + +// --------------------------------------------------------------------------- +// makeModelSchema — Prisma-style options (omit / include / select) +// --------------------------------------------------------------------------- + +// User without username (the omit use-case baseline) +const validUserNoUsername = (() => { + const { username: _, ...rest } = validUser; + return rest; +})(); + +describe('SchemaFactory - makeModelSchema with options', () => { + // ── omit ──────────────────────────────────────────────────────────────── + describe('omit', () => { + it('excludes the omitted scalar field at runtime', () => { + const schema = factory.makeModelSchema('User', { omit: { username: true } }); + // validUserNoUsername has no username field — should pass + expect(schema.safeParse(validUserNoUsername).success).toBe(true); + }); + + it('rejects when the omitted field is present (strict object)', () => { + const schema = factory.makeModelSchema('User', { omit: { username: true } }); + // passing the full validUser (which has username) must fail because + // the schema is strict and username is no longer a known key + expect(schema.safeParse(validUser).success).toBe(false); + }); + + it('infers omitted field is absent from the output type', () => { + const _schema = factory.makeModelSchema('User', { omit: { username: true } }); + type Result = z.infer; + expectTypeOf().not.toHaveProperty('username'); + }); + + it('keeps all other scalar fields when one is omitted', () => { + const _schema = factory.makeModelSchema('User', { omit: { username: true } }); + type Result = z.infer; + expectTypeOf().toHaveProperty('id'); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toHaveProperty('email'); + expectTypeOf().toEqualTypeOf(); + }); + + it('omit: {} (empty) keeps all scalar fields', () => { + const schema = factory.makeModelSchema('User', { omit: {} }); + expect(schema.safeParse(validUser).success).toBe(true); + }); + + it('can omit multiple fields', () => { + const schema = factory.makeModelSchema('User', { omit: { username: true, avatar: true } }); + const { username: _u, avatar: _a, ...rest } = validUser; + expect(schema.safeParse(rest).success).toBe(true); + }); + + it('infers multiple omitted fields absent', () => { + const _schema = factory.makeModelSchema('User', { omit: { username: true, avatar: true } }); + type Result = z.infer; + expectTypeOf().not.toHaveProperty('username'); + expectTypeOf().not.toHaveProperty('avatar'); + expectTypeOf().toHaveProperty('email'); + }); + }); + + // ── include ───────────────────────────────────────────────────────────── + describe('include', () => { + it('adds the relation field alongside all scalars', () => { + const schema = factory.makeModelSchema('User', { include: { posts: true } }); + // All scalar fields must still be present + expect(schema.safeParse(validUser).success).toBe(true); + }); + + it('the included relation field is optional', () => { + const schema = factory.makeModelSchema('User', { include: { posts: true } }); + // omitting posts should still pass + expect(schema.safeParse(validUser).success).toBe(true); + }); + + it('infers included relation field in output type', () => { + const _schema = factory.makeModelSchema('User', { include: { posts: true } }); + type Result = z.infer; + expectTypeOf().toHaveProperty('posts'); + const _postSchema = factory.makeModelSchema('Post'); + type Post = z.infer; + expectTypeOf().toEqualTypeOf(); + }); + + it('infers scalar fields still present when using include', () => { + const _schema = factory.makeModelSchema('User', { include: { posts: true } }); + type Result = z.infer; + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + 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 } } }, + }); + // posts with only title should pass + expect(schema.safeParse({ ...validUser, posts: [{ title: 'Hello' }] }).success).toBe(true); + // posts with extra field should fail (strict) + expect(schema.safeParse({ ...validUser, posts: [{ title: 'Hello', published: true }] }).success).toBe( + false, + ); + }); + + it('infers nested select shape on included relation', () => { + const _schema = factory.makeModelSchema('User', { + include: { posts: { select: { title: true } } }, + }); + type Result = z.infer; + type Posts = Exclude; + type Post = Posts extends Array ? P : never; + expectTypeOf().toHaveProperty('title'); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().not.toHaveProperty('id'); + }); + }); + + // ── include + omit ─────────────────────────────────────────────────────── + describe('include + omit', () => { + it('omits the scalar field and adds the relation', () => { + const schema = factory.makeModelSchema('User', { + omit: { username: true }, + include: { posts: true }, + }); + expect(schema.safeParse({ ...validUserNoUsername, posts: [] }).success).toBe(true); + }); + + it('rejects when omitted field is present', () => { + const schema = factory.makeModelSchema('User', { + omit: { username: true }, + include: { posts: true }, + }); + expect(schema.safeParse({ ...validUser, posts: [] }).success).toBe(false); + }); + + it('infers combined shape correctly', () => { + const _schema = factory.makeModelSchema('User', { + omit: { username: true }, + include: { posts: true }, + }); + type Result = z.infer; + expectTypeOf().not.toHaveProperty('username'); + expectTypeOf().toHaveProperty('email'); + expectTypeOf().toHaveProperty('posts'); + }); + }); + + // ── select ─────────────────────────────────────────────────────────────── + describe('select', () => { + it('returns only the selected scalar fields', () => { + const schema = factory.makeModelSchema('User', { select: { id: true, email: true } }); + expect(schema.safeParse({ id: 'u1', email: 'a@b.com' }).success).toBe(true); + }); + + it('rejects when a non-selected field is present (strict)', () => { + const schema = factory.makeModelSchema('User', { select: { id: true, email: true } }); + expect(schema.safeParse({ id: 'u1', email: 'a@b.com', username: 'alice' }).success).toBe(false); + }); + + it('rejects when a selected field is missing', () => { + const schema = factory.makeModelSchema('User', { select: { id: true, email: true } }); + expect(schema.safeParse({ id: 'u1' }).success).toBe(false); + }); + + it('infers only selected fields in output type', () => { + const _schema = factory.makeModelSchema('User', { select: { id: true, email: true } }); + type Result = z.infer; + expectTypeOf().toHaveProperty('id'); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toHaveProperty('email'); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().not.toHaveProperty('username'); + 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); + // email should not be present + expect(schema.safeParse({ id: 'u1', posts: [], email: 'a@b.com' }).success).toBe(false); + }); + + it('infers relation field type when selected with true', () => { + const _schema = factory.makeModelSchema('User', { select: { id: true, posts: true } }); + type Result = z.infer; + expectTypeOf().toHaveProperty('id'); + expectTypeOf().toHaveProperty('posts'); + expectTypeOf().not.toHaveProperty('email'); + }); + + it('select with nested options on a relation', () => { + const schema = factory.makeModelSchema('User', { + select: { + id: true, + posts: { select: { title: true, published: true } }, + }, + }); + expect(schema.safeParse({ id: 'u1', posts: [{ title: 'Hello', published: true }] }).success).toBe(true); + // extra field in nested post + expect(schema.safeParse({ id: 'u1', posts: [{ title: 'Hello', published: true, id: 'p1' }] }).success).toBe( + false, + ); + }); + + it('infers nested select shape on relation when selected with options', () => { + const _schema = factory.makeModelSchema('User', { + select: { + id: true, + posts: { select: { title: true } }, + }, + }); + type Result = z.infer; + type Posts = Exclude; + type Post = Posts extends Array ? P : never; + expectTypeOf().toHaveProperty('title'); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().not.toHaveProperty('id'); + expectTypeOf().not.toHaveProperty('published'); + }); + + it('select on Post with author relation (nested include)', () => { + const schema = factory.makeModelSchema('Post', { + select: { + id: true, + author: { select: { id: true, email: true } }, + }, + }); + expect(schema.safeParse({ id: 'p1', author: { id: 'u1', email: 'a@b.com' } }).success).toBe(true); + // author with extra field + expect(schema.safeParse({ id: 'p1', author: { id: 'u1', email: 'a@b.com', username: 'x' } }).success).toBe( + false, + ); + }); + }); + + // ── runtime error handling ──────────────────────────────────────────────── + describe('runtime validation still applies with options', () => { + it('@@validate still runs with omit options', () => { + const schema = factory.makeModelSchema('User', { omit: { username: true } }); + // age: 16 still fails @@validate(age >= 18) + expect(schema.safeParse({ ...validUserNoUsername, age: 16 }).success).toBe(false); + }); + + it('field validation still runs with select options', () => { + const schema = factory.makeModelSchema('User', { select: { email: true } }); + expect(schema.safeParse({ email: 'not-an-email' }).success).toBe(false); + expect(schema.safeParse({ email: 'valid@example.com' }).success).toBe(true); + }); + }); +}); From 64cf273318474bd406c9a55d5f2acbe08634a706 Mon Sep 17 00:00:00 2001 From: marcsigmund <73389028+marcsigmund@users.noreply.github.com> Date: Mon, 23 Mar 2026 10:35:51 +0100 Subject: [PATCH 2/4] fix(zod): address PR review comments - Skip @@validate rules per-rule based on which fields are present in the resulting shape, instead of skipping all rules when any omit/select is used. Rules whose referenced fields are all present still apply. - Handle widened boolean (not just literal true) in SelectEntryToZod to prevent field types resolving to never - Validate options at runtime with a recursive Zod schema, enforcing that select/include and select/omit cannot be used together - Rename 'Prisma-style' to 'ORM-style' in comments and docs" --- packages/zod/src/factory.ts | 48 +++++++++++++++++++++---- packages/zod/src/types.ts | 11 +++--- packages/zod/src/utils.ts | 55 ++++++++++++++++++++++++++++ packages/zod/test/factory.test.ts | 60 +++++++++++++++++++++++++++---- 4 files changed, 156 insertions(+), 18 deletions(-) diff --git a/packages/zod/src/factory.ts b/packages/zod/src/factory.ts index a0d0ef2c0..dfb927de7 100644 --- a/packages/zod/src/factory.ts +++ b/packages/zod/src/factory.ts @@ -39,6 +39,37 @@ type RawOptions = { omit?: Record; }; +/** + * Recursive Zod schema that validates a `RawOptions` object at runtime, + * enforcing the same mutual-exclusion rules that the TypeScript union type + * enforces at compile time: + * - `select` and `include` cannot be used together. + * - `select` and `omit` cannot be used together. + * Nested relation options are validated with the same rules. + */ +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(), + }) + .superRefine((val, ctx) => { + if (val.select && val.include) { + ctx.addIssue({ + code: 'custom', + message: '`select` and `include` cannot be used together', + }); + } + if (val.select && val.omit) { + ctx.addIssue({ + code: 'custom', + message: '`select` and `omit` cannot be used together', + }); + } + }), +); + class SchemaFactory { private readonly schema: SchemaAccessor; @@ -92,15 +123,18 @@ class SchemaFactory { } // ── Options path ───────────────────────────────────────────────────── - const rawOptions = options as unknown as RawOptions; + const rawOptions = rawOptionsSchema.parse(options); const fields = this.buildFieldsWithOptions(model as string, rawOptions); const shape = z.strictObject(fields); - // @@validate expressions reference fields by name — when `select` is - // used only a subset of fields is present, so running @@validate would - // silently evaluate missing fields as null and produce false negatives. - // We therefore only apply model-level custom validation on the - // include/omit path where all scalar fields are still present. - const withValidation = rawOptions.select ? shape : addCustomValidation(shape, modelDef.attributes); + // @@validate expressions reference fields by name. When `select` or + // `omit` produces a partial shape, some fields referenced by @@validate + // may be absent. Applying those rules would cause false negatives (the + // field evaluates to null) or make the schema impossible to satisfy + // (strict parsing rejects a field the refinement needs). + // We therefore apply each @@validate rule only when every field it + // references is present in the resulting shape. + const presentFields = new Set(Object.keys(fields)); + const withValidation = addCustomValidation(shape, modelDef.attributes, presentFields); return this.applyDescription(withValidation, modelDef.attributes) as unknown as z.ZodObject< GetModelSchemaShapeWithOptions, z.core.$strict diff --git a/packages/zod/src/types.ts b/packages/zod/src/types.ts index d2c678c87..2a61531b0 100644 --- a/packages/zod/src/types.ts +++ b/packages/zod/src/types.ts @@ -144,7 +144,7 @@ type ZodNullableIf = Condition e type ZodArrayIf = Condition extends true ? z.ZodArray : T; // ------------------------------------------------------------------------- -// Query options types (Prisma-style include / select / omit) +// Query options types (ORM-style include / select / omit) // ------------------------------------------------------------------------- /** @@ -176,7 +176,7 @@ type RelatedModel< > = GetModelFieldType extends GetModels ? GetModelFieldType : never; /** - * Prisma-style query options accepted by `makeModelSchema`. + * ORM-style query options accepted by `makeModelSchema`. * * Exactly mirrors the `select` / `include` / `omit` vocabulary: * - `select` — pick specific fields (scalars and/or relations). Mutually @@ -281,8 +281,11 @@ type SelectEntryToZod< Model extends GetModels, Field extends GetModelFields, Value, -> = Value extends true - ? // `true` — use the default shape for this field (scalar or relation) +> = Value extends boolean + ? // `true` or widened `boolean` — use the default shape for this field. + // 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] : Value extends object ? // nested options — must be a relation field diff --git a/packages/zod/src/utils.ts b/packages/zod/src/utils.ts index 5874d1664..db8a54bac 100644 --- a/packages/zod/src/utils.ts +++ b/packages/zod/src/utils.ts @@ -261,9 +261,21 @@ export function addListValidation( return result; } +/** + * Applies `@@validate` rules from `attributes` to `schema` as Zod refinements. + * + * When `presentFields` is provided, only rules whose referenced fields are all + * present in the set are applied. Rules that reference an absent field are + * silently skipped — they cannot be evaluated correctly against a partial + * payload (e.g. after `select` or `omit`), so skipping them is the safe choice. + * + * Omit `presentFields` (or pass `undefined`) to apply all rules unconditionally, + * which is the correct behaviour for full-model schemas. + */ export function addCustomValidation( schema: z.ZodSchema, attributes: readonly AttributeApplication[] | undefined, + presentFields?: ReadonlySet, ): z.ZodSchema { const attrs = attributes?.filter((a) => a.name === '@@validate'); if (!attrs || attrs.length === 0) { @@ -276,6 +288,15 @@ export function addCustomValidation( if (!expr) { continue; } + + // Skip rules that reference fields absent from the resulting shape. + if (presentFields !== undefined) { + const referencedFields = collectFieldRefs(expr); + if ([...referencedFields].some((f) => !presentFields.has(f))) { + continue; + } + } + const message = getArgValue(attr.args?.[1]?.value); const pathExpr = attr.args?.[2]?.value; let path: string[] | undefined = undefined; @@ -287,6 +308,40 @@ export function addCustomValidation( return result; } +/** + * Recursively collects all field names referenced by `kind: 'field'` nodes + * inside an expression tree. + */ +export function collectFieldRefs(expr: Expression): Set { + const refs = new Set(); + function walk(e: Expression): void { + switch (e.kind) { + case 'field': + refs.add(e.field); + break; + case 'unary': + walk(e.operand); + break; + case 'binary': + walk(e.left); + walk(e.right); + break; + case 'call': + e.args?.forEach(walk); + break; + case 'array': + e.items.forEach(walk); + break; + case 'member': + walk(e.receiver); + break; + // literal / null / this / binding — no field refs + } + } + walk(expr); + return refs; +} + function applyValidation( schema: z.ZodSchema, expr: Expression, diff --git a/packages/zod/test/factory.test.ts b/packages/zod/test/factory.test.ts index 7420de9be..5287705b6 100644 --- a/packages/zod/test/factory.test.ts +++ b/packages/zod/test/factory.test.ts @@ -107,13 +107,13 @@ describe('SchemaFactory - makeModelSchema', () => { // scalar array expectTypeOf().toEqualTypeOf(); - const createPostSchema = factory.makeModelCreateSchema('Post'); - type PostCreate = z.infer; + const _createPostSchema = factory.makeModelCreateSchema('Post'); + type PostCreate = z.infer; expectTypeOf().toEqualTypeOf(); - const updatePostSchema = factory.makeModelUpdateSchema('Post'); - type PostUpdate = z.infer; + const _updatePostSchema = factory.makeModelUpdateSchema('Post'); + type PostUpdate = z.infer; expectTypeOf().toEqualTypeOf(); @@ -896,7 +896,7 @@ describe('SchemaFactory - delegate models', () => { }); // --------------------------------------------------------------------------- -// makeModelSchema — Prisma-style options (omit / include / select) +// makeModelSchema — ORM-style options (omit / include / select) // --------------------------------------------------------------------------- // User without username (the omit use-case baseline) @@ -1142,12 +1142,44 @@ describe('SchemaFactory - makeModelSchema with options', () => { }); }); + // ── invalid option combinations ─────────────────────────────────────────── + describe('invalid option combinations', () => { + it('throws when select and include are used together', () => { + expect(() => + factory.makeModelSchema('User', { select: { id: true }, include: { posts: true } } as any), + ).toThrow('`select` and `include` cannot be used together'); + }); + + it('throws when select and omit are used together', () => { + expect(() => + factory.makeModelSchema('User', { select: { id: true }, omit: { username: true } } as any), + ).toThrow('`select` and `omit` cannot be used together'); + }); + + it('throws when select and include are used together in nested relation options', () => { + expect(() => + factory.makeModelSchema('User', { + include: { posts: { select: { id: true }, include: {} } as any }, + }), + ).toThrow('`select` and `include` cannot be used together'); + }); + }); + // ── runtime error handling ──────────────────────────────────────────────── describe('runtime validation still applies with options', () => { - it('@@validate still runs with omit options', () => { + it('@@validate still runs with omit when the referenced field is present in the shape', () => { + // omitting `username` leaves `age` in the shape, so @@validate(age >= 18) still fires const schema = factory.makeModelSchema('User', { omit: { username: true } }); - // age: 16 still fails @@validate(age >= 18) expect(schema.safeParse({ ...validUserNoUsername, age: 16 }).success).toBe(false); + expect(schema.safeParse({ ...validUserNoUsername, age: 18 }).success).toBe(true); + }); + + it('@@validate is skipped when its referenced field is omitted', () => { + // omitting `age` removes the field that @@validate(age >= 18) references, + // so the rule is silently skipped — age: 16 is no longer validated + const { age: _, username: _u, ...validUserNoAgeOrUsername } = validUser; + const schema = factory.makeModelSchema('User', { omit: { age: true, username: true } }); + expect(schema.safeParse(validUserNoAgeOrUsername).success).toBe(true); }); it('field validation still runs with select options', () => { @@ -1155,5 +1187,19 @@ describe('SchemaFactory - makeModelSchema with options', () => { expect(schema.safeParse({ email: 'not-an-email' }).success).toBe(false); expect(schema.safeParse({ email: 'valid@example.com' }).success).toBe(true); }); + + it('@@validate is skipped with select when the referenced field is not selected', () => { + // selecting only `email` omits `age`, so @@validate(age >= 18) is skipped + const schema = factory.makeModelSchema('User', { select: { email: true } }); + // would fail @@validate if age were present and < 18, but age isn't in the shape + expect(schema.safeParse({ email: 'valid@example.com' }).success).toBe(true); + }); + + it('@@validate still runs with select when the referenced field is selected', () => { + // selecting both `email` and `age` keeps the @@validate(age >= 18) rule active + const schema = factory.makeModelSchema('User', { select: { email: true, age: true } }); + expect(schema.safeParse({ email: 'valid@example.com', age: 16 }).success).toBe(false); + expect(schema.safeParse({ email: 'valid@example.com', age: 18 }).success).toBe(true); + }); }); }); From 119ac253d3da3ac327428d4858e86b0ffb5f0a96 Mon Sep 17 00:00:00 2001 From: marcsigmund <73389028+marcsigmund@users.noreply.github.com> Date: Mon, 23 Mar 2026 11:40:42 +0100 Subject: [PATCH 3/4] fix(zod): address PR review comments --- packages/zod/src/factory.ts | 91 ++++++++++++++++++++++---- packages/zod/src/utils.ts | 102 ++++++++++++++++++++++++++---- packages/zod/test/factory.test.ts | 36 +++++++++++ 3 files changed, 204 insertions(+), 25 deletions(-) diff --git a/packages/zod/src/factory.ts b/packages/zod/src/factory.ts index dfb927de7..a641e6dbc 100644 --- a/packages/zod/src/factory.ts +++ b/packages/zod/src/factory.ts @@ -26,6 +26,7 @@ import { addDecimalValidation, addNumberValidation, addStringValidation, + type PresentFieldsShape, } from './utils'; export function createSchemaFactory(schema: Schema) { @@ -133,8 +134,8 @@ class SchemaFactory { // (strict parsing rejects a field the refinement needs). // We therefore apply each @@validate rule only when every field it // references is present in the resulting shape. - const presentFields = new Set(Object.keys(fields)); - const withValidation = addCustomValidation(shape, modelDef.attributes, presentFields); + const presentShape = this.buildPresentShape(model as string, rawOptions); + const withValidation = addCustomValidation(shape, modelDef.attributes, presentShape); return this.applyDescription(withValidation, modelDef.attributes) as unknown as z.ZodObject< GetModelSchemaShapeWithOptions, z.core.$strict @@ -211,11 +212,11 @@ class SchemaFactory { if (!value) continue; // false → skip const fieldDef = modelDef.fields[key]; - if (!fieldDef) continue; + if (!fieldDef) { + throw new SchemaFactoryError(`Field "${key}" does not exist on model "${model}"`); + } if (fieldDef.relation) { - // Relation field: recurse if value is a nested options object, - // otherwise use the default lazy schema. const subOptions = typeof value === 'object' ? (value as RawOptions) : undefined; const relSchema = this.makeRelationFieldSchema(fieldDef, subOptions); fields[key] = this.applyDescription( @@ -223,28 +224,53 @@ class SchemaFactory { fieldDef.attributes, ); } else { + if (typeof value === 'object') { + throw new SchemaFactoryError( + `Field "${key}" on model "${model}" is a scalar field and cannot have nested options`, + ); + } fields[key] = this.applyDescription(this.makeScalarFieldSchema(fieldDef), fieldDef.attributes); } } } else { // ── include + omit branch ──────────────────────────────────────── + // Validate omit keys up-front. + if (omit) { + for (const key of Object.keys(omit)) { + const fieldDef = modelDef.fields[key]; + if (!fieldDef) { + throw new SchemaFactoryError(`Field "${key}" does not exist on model "${model}"`); + } + if (fieldDef.relation) { + throw new SchemaFactoryError( + `Field "${key}" on model "${model}" is a relation field and cannot be used in "omit"`, + ); + } + } + } + // Start with all scalar fields, applying omit exclusions. for (const [fieldName, fieldDef] of Object.entries(modelDef.fields)) { - if (fieldDef.relation) continue; // relations handled below - - // Skip if this field is explicitly omitted. - if (omit && (omit as Record)[fieldName] === true) continue; + if (fieldDef.relation) continue; + if (omit?.[fieldName] === true) continue; fields[fieldName] = this.applyDescription(this.makeScalarFieldSchema(fieldDef), fieldDef.attributes); } - // Add included relation fields. + // 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?.relation) continue; + if (!fieldDef) { + throw new SchemaFactoryError(`Field "${key}" does not exist on model "${model}"`); + } + if (!fieldDef.relation) { + throw new SchemaFactoryError( + `Field "${key}" on model "${model}" is not a relation field and cannot be used in "include"`, + ); + } const subOptions = typeof value === 'object' ? (value as RawOptions) : undefined; const relSchema = this.makeRelationFieldSchema(fieldDef, subOptions); @@ -259,6 +285,49 @@ class SchemaFactory { return fields; } + /** + * Builds a `PresentFieldsShape` tree from `options` that mirrors exactly + * what fields will be present in the resulting schema. Used by + * `addCustomValidation` to decide which `@@validate` rules to apply. + * + * - `true` — field is fully present (all sub-fields available). + * - `PresentFieldsShape` — field is present with a nested sub-selection. + */ + private buildPresentShape(model: string, options: RawOptions): PresentFieldsShape { + const { select, include, omit } = options; + const modelDef = this.schema.requireModel(model); + const shape: PresentFieldsShape = {}; + + if (select) { + for (const [key, value] of Object.entries(select)) { + if (!value) continue; + const fieldDef = modelDef.fields[key]; + if (!fieldDef) continue; + shape[key] = + typeof value === 'object' ? this.buildPresentShape(fieldDef.type, value as RawOptions) : true; + } + } else { + // All scalar fields minus omitted ones. + for (const [fieldName, fieldDef] of Object.entries(modelDef.fields)) { + if (fieldDef.relation) continue; + if (omit?.[fieldName] === true) continue; + shape[fieldName] = true; + } + // Included relation fields. + if (include) { + for (const [key, value] of Object.entries(include)) { + if (!value) continue; + const fieldDef = modelDef.fields[key]; + if (!fieldDef) continue; + shape[key] = + typeof value === 'object' ? this.buildPresentShape(fieldDef.type, value as RawOptions) : true; + } + } + } + + return shape; + } + /** * Build the inner Zod schema for a relation field, optionally with nested * query options. Does NOT apply cardinality/optional wrappers — the caller diff --git a/packages/zod/src/utils.ts b/packages/zod/src/utils.ts index db8a54bac..02dca9f25 100644 --- a/packages/zod/src/utils.ts +++ b/packages/zod/src/utils.ts @@ -261,21 +261,95 @@ export function addListValidation( return result; } +/** + * Represents the set of fields present in a (potentially partial) model shape. + * + * - `true` — the field is fully present (all its own fields are available). + * - `PresentFieldsShape` — the field is present but with a nested sub-selection; + * only the listed sub-fields are available. + * + * This mirrors the `select` / `include` / `omit` options tree so that + * `@@validate` rules referencing nested relation fields (e.g. `author.email`) + * can be checked precisely against what is actually projected. + */ +export type PresentFieldsShape = { [field: string]: true | PresentFieldsShape }; + +/** + * Returns `true` when every field reference in `expr` is fully satisfied by + * `shape` — meaning the field (and any nested member path) is present in the + * projected shape. + * + * Handles arbitrarily deep member expressions (`author.address.city`) by + * recursively walking the `PresentFieldsShape` tree: + * - If the receiver field maps to `true`, the full sub-tree is available → + * any member path on it is valid. + * - If the receiver field maps to a nested `PresentFieldsShape`, each member + * in the path must be present in that nested shape. + */ +function allFieldRefsPresent(expr: Expression, shape: PresentFieldsShape): boolean { + switch (expr.kind) { + case 'literal': + case 'null': + case 'this': + case 'binding': + return true; + case 'array': + return expr.items.every((item) => allFieldRefsPresent(item, shape)); + case 'field': + return expr.field in shape; + case 'member': { + // member expressions: receiver.member1.member2... + // The receiver must be a simple field reference. + if (expr.receiver.kind !== 'field') { + // Complex receiver (e.g. nested member) — would need deeper + // analysis; conservatively treat as missing. + return false; + } + const receiverField = expr.receiver.field; + if (!(receiverField in shape)) return false; + const receiverShape = shape[receiverField]; + // noUncheckedIndexedAccess: guard undefined even after `in` check. + if (receiverShape === undefined) return false; + // If the receiver is fully present, every member path is available. + if (receiverShape === true) return true; + // Navigate the nested shape one member at a time. + let current: PresentFieldsShape = receiverShape; + for (const member of expr.members) { + if (!(member in current)) return false; + const next = current[member]; + // noUncheckedIndexedAccess: guard against undefined even + // after the `in` check (TS doesn't narrow index signatures). + if (next === undefined) return false; + // Subtree is fully present — remaining members are all valid. + if (next === true) return true; + current = next; + } + return true; + } + case 'unary': + return allFieldRefsPresent(expr.operand, shape); + case 'binary': + return allFieldRefsPresent(expr.left, shape) && allFieldRefsPresent(expr.right, shape); + case 'call': + return (expr.args ?? []).every((arg) => allFieldRefsPresent(arg, shape)); + } +} + /** * Applies `@@validate` rules from `attributes` to `schema` as Zod refinements. * - * When `presentFields` is provided, only rules whose referenced fields are all - * present in the set are applied. Rules that reference an absent field are - * silently skipped — they cannot be evaluated correctly against a partial - * payload (e.g. after `select` or `omit`), so skipping them is the safe choice. + * When `presentShape` is provided, only rules whose every field reference is + * fully satisfied by the shape are applied. Rules that reference a field (or + * nested member path) absent from the shape are silently skipped — they cannot + * be evaluated correctly against a partial payload. * - * Omit `presentFields` (or pass `undefined`) to apply all rules unconditionally, + * Omit `presentShape` (or pass `undefined`) to apply all rules unconditionally, * which is the correct behaviour for full-model schemas. */ export function addCustomValidation( schema: z.ZodSchema, attributes: readonly AttributeApplication[] | undefined, - presentFields?: ReadonlySet, + presentShape?: PresentFieldsShape, ): z.ZodSchema { const attrs = attributes?.filter((a) => a.name === '@@validate'); if (!attrs || attrs.length === 0) { @@ -289,12 +363,9 @@ export function addCustomValidation( continue; } - // Skip rules that reference fields absent from the resulting shape. - if (presentFields !== undefined) { - const referencedFields = collectFieldRefs(expr); - if ([...referencedFields].some((f) => !presentFields.has(f))) { - continue; - } + // Skip rules whose field references are not fully satisfied by the shape. + if (presentShape !== undefined && !allFieldRefsPresent(expr, presentShape)) { + continue; } const message = getArgValue(attr.args?.[1]?.value); @@ -309,8 +380,11 @@ export function addCustomValidation( } /** - * Recursively collects all field names referenced by `kind: 'field'` nodes - * inside an expression tree. + * Recursively collects all top-level field names referenced by `kind: 'field'` + * nodes inside an expression tree. For member expressions (`author.email`), + * only the receiver field name (`author`) is collected. + * + * @see allFieldRefsPresent for a shape-aware check that handles nested paths. */ export function collectFieldRefs(expr: Expression): Set { const refs = new Set(); diff --git a/packages/zod/test/factory.test.ts b/packages/zod/test/factory.test.ts index 5287705b6..d9b38ebba 100644 --- a/packages/zod/test/factory.test.ts +++ b/packages/zod/test/factory.test.ts @@ -1163,6 +1163,42 @@ describe('SchemaFactory - makeModelSchema with options', () => { }), ).toThrow('`select` and `include` cannot be used together'); }); + + it('throws when select references a non-existent field', () => { + expect(() => factory.makeModelSchema('User', { select: { nonExistent: true } as any })).toThrow( + 'Field "nonExistent" does not exist on model "User"', + ); + }); + + it('throws when select provides nested options for a scalar field', () => { + expect(() => + factory.makeModelSchema('User', { select: { email: { select: { id: true } } } as any }), + ).toThrow('Field "email" on model "User" is a scalar field and cannot have nested options'); + }); + + it('throws when include references a non-existent field', () => { + expect(() => factory.makeModelSchema('User', { include: { nonExistent: true } as any })).toThrow( + 'Field "nonExistent" does not exist on model "User"', + ); + }); + + it('throws when include references a scalar field', () => { + expect(() => factory.makeModelSchema('User', { include: { email: true } as any })).toThrow( + 'Field "email" on model "User" is not a relation field and cannot be used in "include"', + ); + }); + + it('throws when omit references a non-existent field', () => { + expect(() => factory.makeModelSchema('User', { omit: { nonExistent: true } as any })).toThrow( + 'Field "nonExistent" does not exist on model "User"', + ); + }); + + it('throws when omit references a relation field', () => { + expect(() => factory.makeModelSchema('User', { omit: { posts: true } as any })).toThrow( + 'Field "posts" on model "User" is a relation field and cannot be used in "omit"', + ); + }); }); // ── runtime error handling ──────────────────────────────────────────────── From 077fa649d3b8f176eca49ba99543dc7f9db0bcc4 Mon Sep 17 00:00:00 2001 From: marcsigmund <73389028+marcsigmund@users.noreply.github.com> Date: Mon, 23 Mar 2026 12:15:53 +0100 Subject: [PATCH 4/4] fix(zod): address PR review comments --- packages/zod/src/factory.ts | 56 +++++-------- packages/zod/src/utils.ts | 160 ++++++++++-------------------------- 2 files changed, 67 insertions(+), 149 deletions(-) diff --git a/packages/zod/src/factory.ts b/packages/zod/src/factory.ts index a641e6dbc..62620c114 100644 --- a/packages/zod/src/factory.ts +++ b/packages/zod/src/factory.ts @@ -26,7 +26,6 @@ import { addDecimalValidation, addNumberValidation, addStringValidation, - type PresentFieldsShape, } from './utils'; export function createSchemaFactory(schema: Schema) { @@ -127,15 +126,13 @@ class SchemaFactory { const rawOptions = rawOptionsSchema.parse(options); const fields = this.buildFieldsWithOptions(model as string, rawOptions); const shape = z.strictObject(fields); - // @@validate expressions reference fields by name. When `select` or - // `omit` produces a partial shape, some fields referenced by @@validate - // may be absent. Applying those rules would cause false negatives (the - // field evaluates to null) or make the schema impossible to satisfy - // (strict parsing rejects a field the refinement needs). - // We therefore apply each @@validate rule only when every field it - // references is present in the resulting shape. - const presentShape = this.buildPresentShape(model as string, rawOptions); - const withValidation = addCustomValidation(shape, modelDef.attributes, presentShape); + // @@validate conditions only reference scalar fields of the same model + // (the ZModel compiler rejects relation fields). When `select` or `omit` + // produces a partial shape some of those scalar fields may be absent; + // we skip any rule that references a missing field so it can't produce + // a false negative against a partial payload. + const presentFields = this.buildPresentFields(model as string, rawOptions); + const withValidation = addCustomValidation(shape, modelDef.attributes, presentFields); return this.applyDescription(withValidation, modelDef.attributes) as unknown as z.ZodObject< GetModelSchemaShapeWithOptions, z.core.$strict @@ -286,46 +283,37 @@ class SchemaFactory { } /** - * Builds a `PresentFieldsShape` tree from `options` that mirrors exactly - * what fields will be present in the resulting schema. Used by - * `addCustomValidation` to decide which `@@validate` rules to apply. + * Returns the set of scalar field names that will be present in the + * resulting schema after applying `options`. Used by `addCustomValidation` + * to skip `@@validate` rules that reference an absent field. * - * - `true` — field is fully present (all sub-fields available). - * - `PresentFieldsShape` — field is present with a nested sub-selection. + * Only scalar fields matter here because `@@validate` conditions are + * restricted by the ZModel compiler to scalar fields of the same model. */ - private buildPresentShape(model: string, options: RawOptions): PresentFieldsShape { - const { select, include, omit } = options; + private buildPresentFields(model: string, options: RawOptions): ReadonlySet { + const { select, omit } = options; const modelDef = this.schema.requireModel(model); - const shape: PresentFieldsShape = {}; + 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; const fieldDef = modelDef.fields[key]; - if (!fieldDef) continue; - shape[key] = - typeof value === 'object' ? this.buildPresentShape(fieldDef.type, value as RawOptions) : true; + if (fieldDef && !fieldDef.relation) { + fields.add(key); + } } } else { - // All scalar fields minus omitted ones. + // All scalar fields minus explicitly omitted ones. for (const [fieldName, fieldDef] of Object.entries(modelDef.fields)) { if (fieldDef.relation) continue; if (omit?.[fieldName] === true) continue; - shape[fieldName] = true; - } - // Included relation fields. - if (include) { - for (const [key, value] of Object.entries(include)) { - if (!value) continue; - const fieldDef = modelDef.fields[key]; - if (!fieldDef) continue; - shape[key] = - typeof value === 'object' ? this.buildPresentShape(fieldDef.type, value as RawOptions) : true; - } + fields.add(fieldName); } } - return shape; + return fields; } /** diff --git a/packages/zod/src/utils.ts b/packages/zod/src/utils.ts index 02dca9f25..d5c9cf81a 100644 --- a/packages/zod/src/utils.ts +++ b/packages/zod/src/utils.ts @@ -262,94 +262,58 @@ export function addListValidation( } /** - * Represents the set of fields present in a (potentially partial) model shape. - * - * - `true` — the field is fully present (all its own fields are available). - * - `PresentFieldsShape` — the field is present but with a nested sub-selection; - * only the listed sub-fields are available. - * - * This mirrors the `select` / `include` / `omit` options tree so that - * `@@validate` rules referencing nested relation fields (e.g. `author.email`) - * can be checked precisely against what is actually projected. - */ -export type PresentFieldsShape = { [field: string]: true | PresentFieldsShape }; - -/** - * Returns `true` when every field reference in `expr` is fully satisfied by - * `shape` — meaning the field (and any nested member path) is present in the - * projected shape. - * - * Handles arbitrarily deep member expressions (`author.address.city`) by - * recursively walking the `PresentFieldsShape` tree: - * - If the receiver field maps to `true`, the full sub-tree is available → - * any member path on it is valid. - * - If the receiver field maps to a nested `PresentFieldsShape`, each member - * in the path must be present in that nested shape. + * Recursively collects all field names referenced by `kind: 'field'` nodes + * inside an expression tree. */ -function allFieldRefsPresent(expr: Expression, shape: PresentFieldsShape): boolean { - switch (expr.kind) { - case 'literal': - case 'null': - case 'this': - case 'binding': - return true; - case 'array': - return expr.items.every((item) => allFieldRefsPresent(item, shape)); - case 'field': - return expr.field in shape; - case 'member': { - // member expressions: receiver.member1.member2... - // The receiver must be a simple field reference. - if (expr.receiver.kind !== 'field') { - // Complex receiver (e.g. nested member) — would need deeper - // analysis; conservatively treat as missing. - return false; - } - const receiverField = expr.receiver.field; - if (!(receiverField in shape)) return false; - const receiverShape = shape[receiverField]; - // noUncheckedIndexedAccess: guard undefined even after `in` check. - if (receiverShape === undefined) return false; - // If the receiver is fully present, every member path is available. - if (receiverShape === true) return true; - // Navigate the nested shape one member at a time. - let current: PresentFieldsShape = receiverShape; - for (const member of expr.members) { - if (!(member in current)) return false; - const next = current[member]; - // noUncheckedIndexedAccess: guard against undefined even - // after the `in` check (TS doesn't narrow index signatures). - if (next === undefined) return false; - // Subtree is fully present — remaining members are all valid. - if (next === true) return true; - current = next; - } - return true; +export function collectFieldRefs(expr: Expression): Set { + const refs = new Set(); + function walk(e: Expression): void { + switch (e.kind) { + case 'field': + refs.add(e.field); + break; + case 'unary': + walk(e.operand); + break; + case 'binary': + walk(e.left); + walk(e.right); + break; + case 'call': + e.args?.forEach(walk); + break; + case 'array': + e.items.forEach(walk); + break; + case 'member': + walk(e.receiver); + break; + // literal / null / this / binding — no field refs } - case 'unary': - return allFieldRefsPresent(expr.operand, shape); - case 'binary': - return allFieldRefsPresent(expr.left, shape) && allFieldRefsPresent(expr.right, shape); - case 'call': - return (expr.args ?? []).every((arg) => allFieldRefsPresent(arg, shape)); } + walk(expr); + return refs; } /** * Applies `@@validate` rules from `attributes` to `schema` as Zod refinements. * - * When `presentShape` is provided, only rules whose every field reference is - * fully satisfied by the shape are applied. Rules that reference a field (or - * nested member path) absent from the shape are silently skipped — they cannot - * be evaluated correctly against a partial payload. + * When `presentFields` is provided, only rules whose every field reference is + * present in the set are applied. Rules that reference a field absent from the + * set are silently skipped — they cannot be evaluated correctly against a + * partial payload (e.g. when `select` or `omit` has been used). + * + * Omit `presentFields` (or pass `undefined`) to apply all rules + * unconditionally, which is the correct behaviour for full-model schemas. * - * Omit `presentShape` (or pass `undefined`) to apply all rules unconditionally, - * which is the correct behaviour for full-model schemas. + * Note: `@@validate` conditions are restricted by the ZModel compiler to + * scalar fields of the same model only — relation fields are a compile error. + * A flat field-name set is therefore sufficient. */ export function addCustomValidation( schema: z.ZodSchema, attributes: readonly AttributeApplication[] | undefined, - presentShape?: PresentFieldsShape, + presentFields?: ReadonlySet, ): z.ZodSchema { const attrs = attributes?.filter((a) => a.name === '@@validate'); if (!attrs || attrs.length === 0) { @@ -363,9 +327,12 @@ export function addCustomValidation( continue; } - // Skip rules whose field references are not fully satisfied by the shape. - if (presentShape !== undefined && !allFieldRefsPresent(expr, presentShape)) { - continue; + // Skip rules that reference a field absent from the partial shape. + if (presentFields !== undefined) { + const refs = collectFieldRefs(expr); + if ([...refs].some((ref) => !presentFields.has(ref))) { + continue; + } } const message = getArgValue(attr.args?.[1]?.value); @@ -379,43 +346,6 @@ export function addCustomValidation( return result; } -/** - * Recursively collects all top-level field names referenced by `kind: 'field'` - * nodes inside an expression tree. For member expressions (`author.email`), - * only the receiver field name (`author`) is collected. - * - * @see allFieldRefsPresent for a shape-aware check that handles nested paths. - */ -export function collectFieldRefs(expr: Expression): Set { - const refs = new Set(); - function walk(e: Expression): void { - switch (e.kind) { - case 'field': - refs.add(e.field); - break; - case 'unary': - walk(e.operand); - break; - case 'binary': - walk(e.left); - walk(e.right); - break; - case 'call': - e.args?.forEach(walk); - break; - case 'array': - e.items.forEach(walk); - break; - case 'member': - walk(e.receiver); - break; - // literal / null / this / binding — no field refs - } - } - walk(expr); - return refs; -} - function applyValidation( schema: z.ZodSchema, expr: Expression,