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 62620c114..8e6ad2ded 100644 --- a/packages/zod/src/factory.ts +++ b/packages/zod/src/factory.ts @@ -34,9 +34,10 @@ 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; + optionality?: 'all' | 'defaults'; }; /** @@ -49,10 +50,11 @@ 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(), + .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(), + optionality: z.enum(['all', 'defaults']).optional(), }) .superRefine((val, ctx) => { if (val.select && val.include) { @@ -93,26 +95,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); @@ -125,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; @@ -139,6 +132,9 @@ class SchemaFactory { >; } + /** + * @deprecated Use `makeModelSchema(model, { optionality: 'defaults' })` instead. + */ makeModelCreateSchema>( model: Model, ): z.ZodObject, z.core.$strict> { @@ -165,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> { @@ -193,6 +192,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). @@ -204,10 +238,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 +289,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 +326,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); @@ -403,6 +432,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 2a61531b0..34f536995 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 @@ -175,50 +187,77 @@ 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> = | { /** - * 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; + /** + * Controls which fields are made optional. + * - `"all"` — every field becomes optional. + * - `"defaults"` — only fields with `@default` or `@updatedAt` become optional. + */ + optionality?: ModelSchemaOptionality; } | { select?: never; /** * Add the listed relation fields on top of the scalar fields. - * Values can be `true` / `{}` (default shape) or a nested options - * object. + * Values must be `true` (default shape) or a nested options object. + * Only `true` is accepted — ORM convention. */ include?: { [Field in keyof RelationModelFields]?: 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; }; + /** + * 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 ------------------------------------------------ @@ -232,7 +271,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: @@ -244,7 +283,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 @@ -286,7 +325,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 @@ -297,12 +336,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,7 +350,7 @@ 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 @@ -326,34 +360,49 @@ type BuildIncludeOmitShape< ? never : Field : Field - : Field]: GetModelFieldsShape[FieldInShape]; + : Field]: GetAllModelFieldsShape[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; } : // 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`); diff --git a/packages/zod/test/factory.test.ts b/packages/zod/test/factory.test.ts index d9b38ebba..c130aea65 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); @@ -987,12 +988,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 +1071,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); @@ -1201,6 +1190,250 @@ 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"); + }); + }); + + // 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; + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + // already-optional nullable field + expectTypeOf().toEqualTypeOf(); + }); + + 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('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'); + }); + }); + + // 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' }); + const { id: _, ...withoutId } = validUser; + expect(schema.safeParse(withoutId).success).toBe(true); + }); + + 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 User object', () => { + const schema = factory.makeModelSchema('User', { optionality: 'defaults' }); + expect(schema.safeParse(validUser).success).toBe(true); + }); + + it('makes @default(autoincrement) and @default(now) fields optional on Asset', () => { + const schema = factory.makeModelSchema('Asset', { optionality: 'defaults' }); + // assetType has no default — must be provided + expect(schema.safeParse({ assetType: 'Video' }).success).toBe(true); + // omitting assetType fails + expect(schema.safeParse({}).success).toBe(false); + }); + }); + + // makeModelCreateSchema / makeModelUpdateSchema + describe('makeModelCreateSchema and makeModelUpdateSchema', () => { + it('makeModelCreateSchema makes @default fields optional', () => { + const createSchema = factory.makeModelCreateSchema('User'); + const { id: _, ...withoutId } = validUser; + expect(createSchema.safeParse(withoutId).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', () => {