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/7] 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/7] 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/7] 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 From 28f9696085c8abb4ce63780be755a8c87bb14ecf Mon Sep 17 00:00:00 2001 From: marcsigmund <73389028+marcsigmund@users.noreply.github.com> Date: Tue, 31 Mar 2026 00:21:41 +0200 Subject: [PATCH 4/7] feat(zod): add optionality option to control optional field generation Add new `optionality` option to schema factory that controls which fields are made optional in generated Zod schemas: - `"all"`: makes every field optional - `"defaults"`: makes only fields with @default, @updatedAt, or already optional fields optional - `undefined`: no changes (default behavior) The implementation preserves field metadata (descriptions) when wrapping schemas with `.optional()` to maintain documentation in generated schemas. This provides more flexibility when generating schemas for partial updates or forms where certain fields should be optional based on their defaults. --- packages/zod/src/factory.ts | 55 ++++++++++++++++++++++++++++++- packages/zod/src/types.ts | 66 ++++++++++++++++++++++++++++++------- packages/zod/src/utils.ts | 49 +++++++++++++++++++-------- 3 files changed, 144 insertions(+), 26 deletions(-) diff --git a/packages/zod/src/factory.ts b/packages/zod/src/factory.ts index 8bae4c4fa..d7caef6f8 100644 --- a/packages/zod/src/factory.ts +++ b/packages/zod/src/factory.ts @@ -37,6 +37,7 @@ type RawOptions = { select?: Record; include?: Record; omit?: Record; + optionality?: 'all' | 'defaults'; }; /** @@ -53,6 +54,7 @@ const rawOptionsSchema: z.ZodType = z.lazy(() => 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(), + optionality: z.enum(['all', 'defaults']).optional(), }) .superRefine((val, ctx) => { if (val.select && val.include) { @@ -115,7 +117,8 @@ class SchemaFactory { // ── Options path ───────────────────────────────────────────────────── const rawOptions = rawOptionsSchema.parse(options); const fields = this.buildFieldsWithOptions(model as string, rawOptions); - const shape = z.strictObject(fields); + const optionalizedFields = this.applyOptionality(fields, model as string, rawOptions.optionality); + const shape = z.strictObject(optionalizedFields); // @@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; @@ -183,6 +186,41 @@ class SchemaFactory { // Options-aware field building // ------------------------------------------------------------------------- + /** + * Applies the `optionality` option to a fields map. + * + * - `"all"` — wraps every field in `z.ZodOptional`. + * - `"defaults"` — only wraps fields that have a `@default` attribute or + * are `@updatedAt` in `z.ZodOptional`. Fields that are + * already optional (nullable optional) retain their shape; + * we just add the outer optional layer. + * - `undefined` — returns the fields map unchanged. + */ + private applyOptionality( + fields: Record, + model: string, + optionality: 'all' | 'defaults' | undefined, + ): Record { + if (!optionality) return fields; + + const modelDef = this.schema.requireModel(model); + const result: Record = {}; + + for (const [fieldName, fieldSchema] of Object.entries(fields)) { + if (optionality === 'all') { + result[fieldName] = this.wrapOptionalPreservingMeta(fieldSchema); + } else { + // optionality === 'defaults' + const fieldDef = modelDef.fields[fieldName]; + const hasDefault = + fieldDef && (fieldDef.default !== undefined || fieldDef.updatedAt || fieldDef.optional); + result[fieldName] = hasDefault ? this.wrapOptionalPreservingMeta(fieldSchema) : fieldSchema; + } + } + + return result; + } + /** * Internal loose options shape used at runtime (we've already validated the * type-level constraints via the overload signatures). @@ -388,6 +426,21 @@ class SchemaFactory { ]); } + /** + * Wraps a schema with `.optional()` and copies any `description` from its + * metadata onto the resulting `ZodOptional`, so that callers inspecting + * `.meta()?.description` on shape fields still find the value after + * optionality has been applied. + */ + private wrapOptionalPreservingMeta(schema: z.ZodType): z.ZodType { + const optional = schema.optional(); + const description = schema.meta()?.description as string | undefined; + if (description) { + return optional.meta({ description }); + } + return optional; + } + private applyCardinality(schema: z.ZodType, fieldDef: FieldDef): z.ZodType { let result = schema; if (fieldDef.array) { diff --git a/packages/zod/src/types.ts b/packages/zod/src/types.ts index 48d656ad8..34f536995 100644 --- a/packages/zod/src/types.ts +++ b/packages/zod/src/types.ts @@ -187,17 +187,30 @@ type RelatedModel< Field extends GetModelFields, > = GetModelFieldType extends GetModels ? GetModelFieldType : never; +/** + * Controls which fields are made optional in the generated schema. + * + * - `"all"` — every field in the schema becomes optional. + * - `"defaults"` — only fields that have a default value (`@default`) or are + * auto-managed (`@updatedAt`) are made optional; all other + * fields retain their original optionality. + */ +export type ModelSchemaOptionality = 'all' | 'defaults'; + /** * ORM-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`. + * - `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`. + * - `optionality` — when `"all"`, every field becomes optional. When + * `"defaults"`, only fields with a `@default` value or + * `@updatedAt` are made optional. */ export type ModelSchemaOptions> = | { @@ -213,6 +226,12 @@ export type ModelSchemaOptions]?: true; }; + /** + * Controls which fields are made optional. + * - `"all"` — every field becomes optional. + * - `"defaults"` — only fields with `@default` or `@updatedAt` become optional. + */ + optionality?: ModelSchemaOptionality; }; // ---- Output shape helpers ------------------------------------------------ @@ -345,22 +370,39 @@ type BuildIncludeOmitShape< : // no include — empty, so the intersection is a no-op {}); +/** + * Wraps every field in a shape with `z.ZodOptional` when `Optionality` is `"all"`. + * When `Optionality` is anything else the shape is returned as-is. + */ +type ApplyOptionality, Optionality> = Optionality extends 'all' + ? { [K in keyof Shape]: z.ZodOptional } + : Shape; + /** * The top-level conditional that maps options → Zod shape. * * - No options / undefined → existing `GetModelFieldsShape` (no change). - * - `{ select: S }` → `BuildSelectShape`. - * - `{ include?, omit? }` → `BuildIncludeOmitShape`. + * - `{ select: S }` → `BuildSelectShape` (+ optionality wrapper). + * - `{ include?, omit? }` → `BuildIncludeOmitShape` (+ optionality wrapper). + * + * Note: `optionality: "defaults"` is handled fully at runtime (it requires + * knowledge of which fields carry a `@default` / `@updatedAt` attribute that + * is only available in the schema def at runtime). The static type cannot + * distinguish "has-default" from "no-default" fields, so `"defaults"` is + * intentionally left unrepresented in the output type — callers receive the + * same shape they would without `optionality` (i.e. fields that happen to have + * defaults are already typed as optional via `ModelFieldIsOptional`). */ export type GetModelSchemaShapeWithOptions< Schema extends SchemaDef, Model extends GetModels, Options, -> = Options extends { select: infer S extends Record } - ? BuildSelectShape +> = Options extends { select: infer S extends Record; optionality?: infer Opt } + ? ApplyOptionality, Opt> : Options extends { include?: infer I extends Record | undefined; omit?: infer O extends Record | undefined; + optionality?: infer Opt; } - ? BuildIncludeOmitShape + ? ApplyOptionality, Opt> : GetModelFieldsShape; diff --git a/packages/zod/src/utils.ts b/packages/zod/src/utils.ts index d5c9cf81a..9081c1c86 100644 --- a/packages/zod/src/utils.ts +++ b/packages/zod/src/utils.ts @@ -387,8 +387,20 @@ function evalExpression(data: any, expr: Expression): unknown { } } +/** + * Sentinel value returned by `evalField` when a field key is entirely absent + * from the data object (as opposed to being present with a `null` value). + * Used by comparison operators to skip @@validate rules against missing + * optional fields (e.g. when `optionality: 'all'` produces a partial payload). + */ +const ABSENT = Symbol('absent'); + function evalField(data: any, e: FieldExpression) { - return data?.[e.field] ?? null; + if (data == null || !(e.field in data)) { + return ABSENT; + } + // Coerce undefined to null so downstream code only needs to handle null. + return data[e.field] ?? null; } function evalUnary(data: any, expr: UnaryExpression) { @@ -410,17 +422,28 @@ function evalBinary(data: any, expr: BinaryExpression) { case '||': return Boolean(left) || Boolean(right); case '==': - return left == right; + // Treat ABSENT the same as null for equality checks — an absent + // optional field and an explicit null are semantically equivalent. + return (left === ABSENT ? null : left) == (right === ABSENT ? null : right); case '!=': - return left != right; + return (left === ABSENT ? null : left) != (right === ABSENT ? null : right); case '<': - return (left as any) < (right as any); case '<=': - return (left as any) <= (right as any); case '>': - return (left as any) > (right as any); case '>=': - return (left as any) >= (right as any); + // If either operand is the ABSENT sentinel (field not present in the + // partial payload), skip the comparison by returning true so that + // @@validate rules are not incorrectly triggered against missing + // optional fields (e.g. when optionality: 'all' produces a partial + // object or a field is omitted/not-selected). + if (left === ABSENT || right === ABSENT) return true; + return expr.op === '<' + ? (left as any) < (right as any) + : expr.op === '<=' + ? (left as any) <= (right as any) + : expr.op === '>' + ? (left as any) > (right as any) + : (left as any) >= (right as any); case '?': if (!Array.isArray(left)) { return false; @@ -464,7 +487,7 @@ function evalCall(data: any, expr: CallExpression) { switch (f) { // string functions case 'length': { - if (fieldArg === undefined || fieldArg === null) { + if (fieldArg === undefined || fieldArg === null || fieldArg === ABSENT) { return false; } invariant( @@ -476,7 +499,7 @@ function evalCall(data: any, expr: CallExpression) { case 'startsWith': case 'endsWith': case 'contains': { - if (fieldArg === undefined || fieldArg === null) { + if (fieldArg === undefined || fieldArg === null || fieldArg === ABSENT) { return false; } invariant(typeof fieldArg === 'string', `"${f}" first argument must be a string`); @@ -500,7 +523,7 @@ function evalCall(data: any, expr: CallExpression) { : applyStringOp(fieldArg, search); } case 'regex': { - if (fieldArg === undefined || fieldArg === null) { + if (fieldArg === undefined || fieldArg === null || fieldArg === ABSENT) { return false; } invariant(typeof fieldArg === 'string', `"${f}" first argument must be a string`); @@ -511,7 +534,7 @@ function evalCall(data: any, expr: CallExpression) { case 'isEmail': case 'isUrl': case 'isDateTime': { - if (fieldArg === undefined || fieldArg === null) { + if (fieldArg === undefined || fieldArg === null || fieldArg === ABSENT) { return false; } invariant(typeof fieldArg === 'string', `"${f}" first argument must be a string`); @@ -523,7 +546,7 @@ function evalCall(data: any, expr: CallExpression) { case 'hasEvery': case 'hasSome': { invariant(expr.args?.[1], `${f} requires a search argument`); - if (fieldArg === undefined || fieldArg === null) { + if (fieldArg === undefined || fieldArg === null || fieldArg === ABSENT) { return false; } invariant(Array.isArray(fieldArg), `"${f}" first argument must be an array field`); @@ -540,7 +563,7 @@ function evalCall(data: any, expr: CallExpression) { } } case 'isEmpty': { - if (fieldArg === undefined || fieldArg === null) { + if (fieldArg === undefined || fieldArg === null || fieldArg === ABSENT) { return false; } invariant(Array.isArray(fieldArg), `"${f}" first argument must be an array field`); From 4cd3293248575d9fb1015cc452fa193a586b8e6e Mon Sep 17 00:00:00 2001 From: marcsigmund <73389028+marcsigmund@users.noreply.github.com> Date: Tue, 31 Mar 2026 00:22:01 +0200 Subject: [PATCH 5/7] test(zod): add comprehensive tests for optionality option in makeModelSchema Add extensive test coverage for the `optionality` option in the schema factory's `makeModelSchema` method. Tests cover three modes: - `optionality: 'all'` - makes all fields optional - `optionality: 'defaults'` - makes only fields with @default or @updatedAt optional - `optionality: 'required'` - keeps all fields required (default behavior) Tests verify: - Field optionality behavior and type inference - Validation of field constraints when values are provided - Interaction with other options (omit, select, strict) - Preservation of metadata (descriptions) - Handling of computed fields and default values - Proper validation of required vs optional fields This ensures the optionality feature works correctly across different scenarios and combinations with other schema options. --- packages/zod/test/factory.test.ts | 334 ++++++++++++++++++++++++++++++ 1 file changed, 334 insertions(+) diff --git a/packages/zod/test/factory.test.ts b/packages/zod/test/factory.test.ts index 6ac47e9c8..319ccda87 100644 --- a/packages/zod/test/factory.test.ts +++ b/packages/zod/test/factory.test.ts @@ -1190,6 +1190,340 @@ describe('SchemaFactory - makeModelSchema with options', () => { }); }); + // ── optionality ───────────────────────────────────────────────────────── + describe('optionality', () => { + // optionality: 'all' — every field becomes optional + describe("optionality: 'all'", () => { + it('accepts an empty object when optionality is all', () => { + const schema = factory.makeModelSchema('User', { optionality: 'all' }); + expect(schema.safeParse({}).success).toBe(true); + }); + + it('accepts a fully populated object when optionality is all', () => { + const schema = factory.makeModelSchema('User', { optionality: 'all' }); + expect(schema.safeParse(validUser).success).toBe(true); + }); + + it('rejects extra fields when optionality is all (still strict)', () => { + const schema = factory.makeModelSchema('User', { optionality: 'all' }); + expect(schema.safeParse({ ...validUser, unknownField: 'x' }).success).toBe(false); + }); + + it('infers all fields as optional when optionality is all', () => { + const _schema = factory.makeModelSchema('User', { optionality: 'all' }); + type Result = z.infer; + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + }); + + it('still validates field constraints when the field is provided with optionality all', () => { + const schema = factory.makeModelSchema('User', { optionality: 'all' }); + // email constraint still applies when email is provided + expect(schema.safeParse({ email: 'not-an-email' }).success).toBe(false); + expect(schema.safeParse({ email: 'valid@example.com' }).success).toBe(true); + // empty object passes (all optional, null comparisons in @@validate pass through) + expect(schema.safeParse({}).success).toBe(true); + }); + + it('combines optionality all with omit', () => { + const schema = factory.makeModelSchema('User', { + omit: { username: true }, + optionality: 'all', + }); + // empty object is fine (all optional, username omitted) + expect(schema.safeParse({}).success).toBe(true); + // username must not be present (strict + omitted) + expect(schema.safeParse({ username: 'alice' }).success).toBe(false); + // other fields are optional + expect(schema.safeParse({ email: 'a@b.com' }).success).toBe(true); + }); + + it('combines optionality all with select', () => { + const schema = factory.makeModelSchema('User', { + select: { id: true, email: true }, + optionality: 'all', + }); + // both fields optional → empty passes (no @@validate fields in shape) + expect(schema.safeParse({}).success).toBe(true); + // non-selected field rejected + expect(schema.safeParse({ id: 'u1', username: 'x' }).success).toBe(false); + // subset passes + expect(schema.safeParse({ id: 'u1' }).success).toBe(true); + }); + + it('preserves @meta description on fields wrapped by optionality all', () => { + const schema = factory.makeModelSchema('User', { optionality: 'all' }); + expect(schema.shape.email.meta()?.description).toBe("The user's email address"); + }); + }); + + // optionality: 'defaults' — only fields with @default or @updatedAt become optional + describe("optionality: 'defaults'", () => { + it('makes fields with @default optional', () => { + // Product.discount has @default(0), Product.id has @default(cuid()) + // finalPrice is computed (no @default) so it must still be provided + const schema = factory.makeModelSchema('Product', { optionality: 'defaults' }); + // omitting id and discount (both have defaults) should pass + expect(schema.safeParse({ name: 'Widget', price: 10.0, finalPrice: 8.0 }).success).toBe(true); + // omitting both id AND discount passes + expect(schema.safeParse({ name: 'Widget', price: 10.0, finalPrice: 8.0 }).success).toBe(true); + }); + + it('keeps fields without @default required with optionality defaults', () => { + const schema = factory.makeModelSchema('Product', { optionality: 'defaults' }); + // omitting name (no default) should fail + expect(schema.safeParse({ price: 10.0, finalPrice: 8.0 }).success).toBe(false); + // omitting price (no default) should fail + expect(schema.safeParse({ name: 'Widget', finalPrice: 8.0 }).success).toBe(false); + // omitting finalPrice (computed, no default) should fail + expect(schema.safeParse({ name: 'Widget', price: 10.0 }).success).toBe(false); + }); + + it('infers fields with @default as optional and others as required', () => { + const _schema = factory.makeModelSchema('Product', { optionality: 'defaults' }); + type Result = z.infer; + // Note: optionality: 'defaults' is handled purely at runtime — the static + // TypeScript type cannot distinguish which fields carry @default without + // running the schema. The type is therefore identical to the no-options + // shape (fields appear as their original types). + // id has @default at runtime but the static type still shows string + expectTypeOf().toEqualTypeOf(); + // discount already has a default so it is typed as optional in the base shape + expectTypeOf().toEqualTypeOf(); + // name has no default → required (unchanged) + expectTypeOf().toEqualTypeOf(); + // price has no default → required (unchanged) + expectTypeOf().toEqualTypeOf(); + }); + + it('also makes already-optional (nullable) fields optional with optionality defaults', () => { + // User.website is optional: true (nullable optional in the schema) + // optionality: 'defaults' should also make it optional in the output + const schema = factory.makeModelSchema('User', { optionality: 'defaults' }); + // website being absent should still pass since it is an optional field + const { website: _, ...withoutWebsite } = validUser; + expect(schema.safeParse(withoutWebsite).success).toBe(true); + }); + + it('combines optionality defaults with omit', () => { + // omit finalPrice (computed) and apply defaults optionality + const schema = factory.makeModelSchema('Product', { + omit: { finalPrice: true }, + optionality: 'defaults', + }); + // id and discount have defaults → optional; name and price required + expect(schema.safeParse({ name: 'Widget', price: 10.0 }).success).toBe(true); + // finalPrice must be absent + expect(schema.safeParse({ name: 'Widget', price: 10.0, finalPrice: 8.0 }).success).toBe(false); + }); + + it('combines optionality defaults with select (only selected fields apply defaults logic)', () => { + // select only `id` (has default) and `name` (no default) + const schema = factory.makeModelSchema('Product', { + select: { id: true, name: true }, + optionality: 'defaults', + }); + // id has default → optional; name has no default → required + expect(schema.safeParse({ name: 'Widget' }).success).toBe(true); + expect(schema.safeParse({}).success).toBe(false); + // non-selected field rejected + expect(schema.safeParse({ name: 'Widget', price: 10.0 }).success).toBe(false); + }); + + it('preserves @meta description on fields wrapped by optionality defaults', () => { + // id has @default, so it gets wrapped; email has no @default but has @meta + const schema = factory.makeModelSchema('User', { optionality: 'defaults' }); + expect(schema.shape.email.meta()?.description).toBe("The user's email address"); + }); + }); + }); + + // ── optionality ───────────────────────────────────────────────────────── + describe('optionality option', () => { + describe('optionality: "all" — every field becomes optional', () => { + it('accepts an empty object when optionality is "all"', () => { + const schema = factory.makeModelSchema('User', { optionality: 'all' }); + expect(schema.safeParse({}).success).toBe(true); + }); + + it('still accepts the full object when optionality is "all"', () => { + const schema = factory.makeModelSchema('User', { optionality: 'all' }); + expect(schema.safeParse(validUser).success).toBe(true); + }); + + it('infers all scalar fields as optional when optionality is "all"', () => { + const _schema = factory.makeModelSchema('User', { optionality: 'all' }); + type Result = z.infer; + // required fields become optional + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + // already-optional field stays valid as optional + expectTypeOf().toEqualTypeOf(); + }); + + it('still rejects extra fields (strict) with optionality "all"', () => { + const schema = factory.makeModelSchema('User', { optionality: 'all' }); + expect(schema.safeParse({ unknownField: 'x' }).success).toBe(false); + }); + + it('still validates field constraints with optionality "all"', () => { + const schema = factory.makeModelSchema('User', { optionality: 'all' }); + // email is optional, but if provided must be a valid email + expect(schema.safeParse({ email: 'not-an-email' }).success).toBe(false); + expect(schema.safeParse({ email: 'valid@example.com' }).success).toBe(true); + // empty object passes (all optional, null comparisons in @@validate pass through) + expect(schema.safeParse({}).success).toBe(true); + }); + + it('combines optionality: "all" with omit', () => { + const schema = factory.makeModelSchema('User', { + omit: { username: true }, + optionality: 'all', + }); + // empty object (all fields optional, username omitted) + expect(schema.safeParse({}).success).toBe(true); + // username still rejected (omitted) + expect(schema.safeParse({ username: 'alice' }).success).toBe(false); + // other fields accepted when provided + expect(schema.safeParse({ email: 'a@b.com' }).success).toBe(true); + }); + + it('infers omitted field absent even with optionality "all"', () => { + const _schema = factory.makeModelSchema('User', { + omit: { username: true }, + optionality: 'all', + }); + type Result = z.infer; + expectTypeOf().not.toHaveProperty('username'); + expectTypeOf().toEqualTypeOf(); + }); + + it('combines optionality: "all" with select', () => { + const schema = factory.makeModelSchema('User', { + select: { id: true, email: true }, + optionality: 'all', + }); + // all selected fields optional (no @@validate fields in shape) + expect(schema.safeParse({}).success).toBe(true); + expect(schema.safeParse({ id: 'u1' }).success).toBe(true); + expect(schema.safeParse({ id: 'u1', email: 'a@b.com' }).success).toBe(true); + // non-selected field still rejected + expect(schema.safeParse({ id: 'u1', username: 'alice' }).success).toBe(false); + }); + + it('infers selected fields as optional when optionality is "all"', () => { + const _schema = factory.makeModelSchema('User', { + select: { id: true, email: true }, + optionality: 'all', + }); + type Result = z.infer; + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().not.toHaveProperty('username'); + }); + + it('preserves @meta description on fields when optionality is "all"', () => { + const schema = factory.makeModelSchema('User', { optionality: 'all' }); + expect(schema.shape.email.meta()?.description).toBe("The user's email address"); + }); + }); + + describe('optionality: "defaults" — only fields with @default or @updatedAt become optional', () => { + it('makes @default fields optional', () => { + // User.id has @default(cuid()) + const schema = factory.makeModelSchema('User', { optionality: 'defaults' }); + // omitting id should be fine (it has a default) + const { id: _, ...withoutId } = validUser; + expect(schema.safeParse(withoutId).success).toBe(true); + }); + + it('keeps required fields required', () => { + // User.email has no @default + const schema = factory.makeModelSchema('User', { optionality: 'defaults' }); + const { email: _, ...withoutEmail } = validUser; + expect(schema.safeParse(withoutEmail).success).toBe(false); + }); + + it('still accepts the full valid object', () => { + const schema = factory.makeModelSchema('User', { optionality: 'defaults' }); + expect(schema.safeParse(validUser).success).toBe(true); + }); + + it('makes @default(0) fields on Product optional', () => { + // Product.discount has @default(0); finalPrice is computed (no @default, required) + const schema = factory.makeModelSchema('Product', { optionality: 'defaults' }); + // without discount — should pass (discount has a default) + expect(schema.safeParse({ name: 'Widget', price: 10.0, finalPrice: 8.0 }).success).toBe(true); + // without name — should fail (no default) + expect(schema.safeParse({ price: 10.0, finalPrice: 8.0 }).success).toBe(false); + }); + + it('makes @default(autoincrement) and @default(now) fields optional on Asset', () => { + // Asset.id has @default(autoincrement), Asset.createdAt has @default(now) + const schema = factory.makeModelSchema('Asset', { optionality: 'defaults' }); + // discriminator assetType has no default — must be provided + expect(schema.safeParse({ assetType: 'Video' }).success).toBe(true); + // omitting assetType fails + expect(schema.safeParse({}).success).toBe(false); + }); + + it('preserves @meta description on fields when optionality is "defaults"', () => { + const schema = factory.makeModelSchema('User', { optionality: 'defaults' }); + // email has no default so it stays required and its schema is unchanged + expect(schema.shape.email.meta()?.description).toBe("The user's email address"); + }); + + it('combines optionality: "defaults" with omit', () => { + // omit username; id has default so it becomes optional + const schema = factory.makeModelSchema('User', { + omit: { username: true }, + optionality: 'defaults', + }); + const { id: _i, username: _u, ...withoutIdAndUsername } = validUser; + expect(schema.safeParse(withoutIdAndUsername).success).toBe(true); + // username still rejected + expect(schema.safeParse({ ...withoutIdAndUsername, username: 'alice' }).success).toBe(false); + }); + }); + + describe('makeModelCreateSchema / makeModelUpdateSchema use optionality internally', () => { + it('makeModelCreateSchema makes @default fields optional', () => { + // User.id has @default, so it must be optional in create + const createSchema = factory.makeModelCreateSchema('User'); + const { id: _, ...withoutId } = validUser; + // strip relations — create schema has no relation fields + const { ...withoutRelations } = withoutId; + expect(createSchema.safeParse(withoutRelations).success).toBe(true); + }); + + it('makeModelUpdateSchema makes all fields optional', () => { + const updateSchema = factory.makeModelUpdateSchema('User'); + expect(updateSchema.safeParse({}).success).toBe(true); + expect(updateSchema.safeParse({ email: 'a@b.com' }).success).toBe(true); + }); + + it('makeModelUpdateSchema still validates constraints when field is provided', () => { + const updateSchema = factory.makeModelUpdateSchema('User'); + expect(updateSchema.safeParse({ email: 'not-an-email' }).success).toBe(false); + expect(updateSchema.safeParse({ email: 'valid@example.com' }).success).toBe(true); + }); + + it('makeModelUpdateSchema preserves @meta description on fields', () => { + const updateSchema = factory.makeModelUpdateSchema('User'); + expect(updateSchema.shape.email.meta()?.description).toBe("The user's email address"); + }); + + it('makeModelCreateSchema preserves @meta description on fields', () => { + const createSchema = factory.makeModelCreateSchema('User'); + expect(createSchema.shape.email.meta()?.description).toBe("The user's email address"); + }); + }); + }); + // ── runtime error handling ──────────────────────────────────────────────── describe('runtime validation still applies with options', () => { it('@@validate still runs with omit when the referenced field is present in the shape', () => { From 5de0984b65f86819900e76f16e62643b5eee63a5 Mon Sep 17 00:00:00 2001 From: marcsigmund <73389028+marcsigmund@users.noreply.github.com> Date: Tue, 31 Mar 2026 00:24:15 +0200 Subject: [PATCH 6/7] refactor(zod): deprecate makeModelCreateSchema and makeModelUpdateSchema methods Deprecate `makeModelCreateSchema` and `makeModelUpdateSchema` methods in favor of the more flexible `makeModelSchema` method with optionality parameter. Users should now use `makeModelSchema(model, { optionality: 'defaults' })` and `makeModelSchema(model, { optionality: 'all' })` respectively. This change consolidates the API surface and provides a more consistent interface for schema generation with different optionality requirements. --- packages/zod/src/factory.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/zod/src/factory.ts b/packages/zod/src/factory.ts index d7caef6f8..8e6ad2ded 100644 --- a/packages/zod/src/factory.ts +++ b/packages/zod/src/factory.ts @@ -132,6 +132,9 @@ class SchemaFactory { >; } + /** + * @deprecated Use `makeModelSchema(model, { optionality: 'defaults' })` instead. + */ makeModelCreateSchema>( model: Model, ): z.ZodObject, z.core.$strict> { @@ -158,6 +161,9 @@ class SchemaFactory { ) as unknown as z.ZodObject, z.core.$strict>; } + /** + * @deprecated Use `makeModelSchema(model, { optionality: 'all' })` instead. + */ makeModelUpdateSchema>( model: Model, ): z.ZodObject, z.core.$strict> { From f74bf755726e274ea87042b2a2a2dad03165762f Mon Sep 17 00:00:00 2001 From: marcsigmund <73389028+marcsigmund@users.noreply.github.com> Date: Tue, 31 Mar 2026 00:43:12 +0200 Subject: [PATCH 7/7] refactor(zod): reorganize optionality tests to focus on type inference Consolidate optionality: 'all' tests by removing redundant runtime validation tests and keeping only type-level inference assertions. The runtime behavior is already covered by other test suites, so this change reduces duplication and improves test organization by grouping type inference tests together. --- packages/zod/test/factory.test.ts | 120 ++++-------------------------- 1 file changed, 15 insertions(+), 105 deletions(-) diff --git a/packages/zod/test/factory.test.ts b/packages/zod/test/factory.test.ts index 319ccda87..c130aea65 100644 --- a/packages/zod/test/factory.test.ts +++ b/packages/zod/test/factory.test.ts @@ -1339,60 +1339,20 @@ describe('SchemaFactory - makeModelSchema with options', () => { expect(schema.shape.email.meta()?.description).toBe("The user's email address"); }); }); - }); - - // ── optionality ───────────────────────────────────────────────────────── - describe('optionality option', () => { - describe('optionality: "all" — every field becomes optional', () => { - it('accepts an empty object when optionality is "all"', () => { - const schema = factory.makeModelSchema('User', { optionality: 'all' }); - expect(schema.safeParse({}).success).toBe(true); - }); - - it('still accepts the full object when optionality is "all"', () => { - const schema = factory.makeModelSchema('User', { optionality: 'all' }); - expect(schema.safeParse(validUser).success).toBe(true); - }); - it('infers all scalar fields as optional when optionality is "all"', () => { + // Additional type-level assertions for optionality: 'all' + describe("optionality: 'all' — type inference", () => { + it('infers all scalar fields as optional (including already-optional)', () => { const _schema = factory.makeModelSchema('User', { optionality: 'all' }); type Result = z.infer; - // required fields become optional expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); - // already-optional field stays valid as optional + // already-optional nullable field expectTypeOf().toEqualTypeOf(); }); - it('still rejects extra fields (strict) with optionality "all"', () => { - const schema = factory.makeModelSchema('User', { optionality: 'all' }); - expect(schema.safeParse({ unknownField: 'x' }).success).toBe(false); - }); - - it('still validates field constraints with optionality "all"', () => { - const schema = factory.makeModelSchema('User', { optionality: 'all' }); - // email is optional, but if provided must be a valid email - expect(schema.safeParse({ email: 'not-an-email' }).success).toBe(false); - expect(schema.safeParse({ email: 'valid@example.com' }).success).toBe(true); - // empty object passes (all optional, null comparisons in @@validate pass through) - expect(schema.safeParse({}).success).toBe(true); - }); - - it('combines optionality: "all" with omit', () => { - const schema = factory.makeModelSchema('User', { - omit: { username: true }, - optionality: 'all', - }); - // empty object (all fields optional, username omitted) - expect(schema.safeParse({}).success).toBe(true); - // username still rejected (omitted) - expect(schema.safeParse({ username: 'alice' }).success).toBe(false); - // other fields accepted when provided - expect(schema.safeParse({ email: 'a@b.com' }).success).toBe(true); - }); - - it('infers omitted field absent even with optionality "all"', () => { + it('infers omitted field absent even with optionality all', () => { const _schema = factory.makeModelSchema('User', { omit: { username: true }, optionality: 'all', @@ -1402,20 +1362,7 @@ describe('SchemaFactory - makeModelSchema with options', () => { expectTypeOf().toEqualTypeOf(); }); - it('combines optionality: "all" with select', () => { - const schema = factory.makeModelSchema('User', { - select: { id: true, email: true }, - optionality: 'all', - }); - // all selected fields optional (no @@validate fields in shape) - expect(schema.safeParse({}).success).toBe(true); - expect(schema.safeParse({ id: 'u1' }).success).toBe(true); - expect(schema.safeParse({ id: 'u1', email: 'a@b.com' }).success).toBe(true); - // non-selected field still rejected - expect(schema.safeParse({ id: 'u1', username: 'alice' }).success).toBe(false); - }); - - it('infers selected fields as optional when optionality is "all"', () => { + it('infers selected fields as optional when optionality is all', () => { const _schema = factory.makeModelSchema('User', { select: { id: true, email: true }, optionality: 'all', @@ -1425,79 +1372,42 @@ describe('SchemaFactory - makeModelSchema with options', () => { expectTypeOf().toEqualTypeOf(); expectTypeOf().not.toHaveProperty('username'); }); - - it('preserves @meta description on fields when optionality is "all"', () => { - const schema = factory.makeModelSchema('User', { optionality: 'all' }); - expect(schema.shape.email.meta()?.description).toBe("The user's email address"); - }); }); - describe('optionality: "defaults" — only fields with @default or @updatedAt become optional', () => { - it('makes @default fields optional', () => { - // User.id has @default(cuid()) + // Additional cases for optionality: 'defaults' with User model + describe("optionality: 'defaults' — User model", () => { + it('makes @default(cuid) id field optional on User', () => { const schema = factory.makeModelSchema('User', { optionality: 'defaults' }); - // omitting id should be fine (it has a default) const { id: _, ...withoutId } = validUser; expect(schema.safeParse(withoutId).success).toBe(true); }); - it('keeps required fields required', () => { - // User.email has no @default + it('keeps non-default fields required on User', () => { const schema = factory.makeModelSchema('User', { optionality: 'defaults' }); const { email: _, ...withoutEmail } = validUser; expect(schema.safeParse(withoutEmail).success).toBe(false); }); - it('still accepts the full valid object', () => { + it('still accepts the full valid User object', () => { const schema = factory.makeModelSchema('User', { optionality: 'defaults' }); expect(schema.safeParse(validUser).success).toBe(true); }); - it('makes @default(0) fields on Product optional', () => { - // Product.discount has @default(0); finalPrice is computed (no @default, required) - const schema = factory.makeModelSchema('Product', { optionality: 'defaults' }); - // without discount — should pass (discount has a default) - expect(schema.safeParse({ name: 'Widget', price: 10.0, finalPrice: 8.0 }).success).toBe(true); - // without name — should fail (no default) - expect(schema.safeParse({ price: 10.0, finalPrice: 8.0 }).success).toBe(false); - }); - it('makes @default(autoincrement) and @default(now) fields optional on Asset', () => { - // Asset.id has @default(autoincrement), Asset.createdAt has @default(now) const schema = factory.makeModelSchema('Asset', { optionality: 'defaults' }); - // discriminator assetType has no default — must be provided + // assetType has no default — must be provided expect(schema.safeParse({ assetType: 'Video' }).success).toBe(true); // omitting assetType fails expect(schema.safeParse({}).success).toBe(false); }); - - it('preserves @meta description on fields when optionality is "defaults"', () => { - const schema = factory.makeModelSchema('User', { optionality: 'defaults' }); - // email has no default so it stays required and its schema is unchanged - expect(schema.shape.email.meta()?.description).toBe("The user's email address"); - }); - - it('combines optionality: "defaults" with omit', () => { - // omit username; id has default so it becomes optional - const schema = factory.makeModelSchema('User', { - omit: { username: true }, - optionality: 'defaults', - }); - const { id: _i, username: _u, ...withoutIdAndUsername } = validUser; - expect(schema.safeParse(withoutIdAndUsername).success).toBe(true); - // username still rejected - expect(schema.safeParse({ ...withoutIdAndUsername, username: 'alice' }).success).toBe(false); - }); }); - describe('makeModelCreateSchema / makeModelUpdateSchema use optionality internally', () => { + // makeModelCreateSchema / makeModelUpdateSchema + describe('makeModelCreateSchema and makeModelUpdateSchema', () => { it('makeModelCreateSchema makes @default fields optional', () => { - // User.id has @default, so it must be optional in create const createSchema = factory.makeModelCreateSchema('User'); const { id: _, ...withoutId } = validUser; - // strip relations — create schema has no relation fields - const { ...withoutRelations } = withoutId; - expect(createSchema.safeParse(withoutRelations).success).toBe(true); + expect(createSchema.safeParse(withoutId).success).toBe(true); }); it('makeModelUpdateSchema makes all fields optional', () => {