diff --git a/packages/zod/src/factory.ts b/packages/zod/src/factory.ts index dece3e27e..62620c114 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,44 @@ 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; +}; + +/** + * 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; @@ -39,29 +79,64 @@ 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 = rawOptionsSchema.parse(options); + 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 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 + >; } makeModelCreateSchema>( @@ -114,6 +189,149 @@ 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) { + throw new SchemaFactoryError(`Field "${key}" does not exist on model "${model}"`); + } + + if (fieldDef.relation) { + 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 { + 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; + + if (omit?.[fieldName] === true) continue; + fields[fieldName] = this.applyDescription(this.makeScalarFieldSchema(fieldDef), fieldDef.attributes); + } + + // 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}"`); + } + 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); + fields[key] = this.applyDescription( + this.applyCardinality(relSchema, fieldDef).optional(), + fieldDef.attributes, + ); + } + } + } + + return fields; + } + + /** + * 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. + * + * Only scalar fields matter here because `@@validate` conditions are + * restricted by the ZModel compiler to scalar fields of the same model. + */ + private buildPresentFields(model: string, options: RawOptions): ReadonlySet { + const { select, omit } = options; + const modelDef = this.schema.requireModel(model); + 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 && !fieldDef.relation) { + fields.add(key); + } + } + } else { + // 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; + fields.add(fieldName); + } + } + + 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..2a61531b0 100644 --- a/packages/zod/src/types.ts +++ b/packages/zod/src/types.ts @@ -142,3 +142,218 @@ 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 (ORM-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; + +/** + * 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`. + */ +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 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 + 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/src/utils.ts b/packages/zod/src/utils.ts index 5874d1664..d5c9cf81a 100644 --- a/packages/zod/src/utils.ts +++ b/packages/zod/src/utils.ts @@ -261,9 +261,59 @@ export function addListValidation( 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; +} + +/** + * Applies `@@validate` rules from `attributes` to `schema` as Zod refinements. + * + * 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. + * + * 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, + presentFields?: ReadonlySet, ): z.ZodSchema { const attrs = attributes?.filter((a) => a.name === '@@validate'); if (!attrs || attrs.length === 0) { @@ -276,6 +326,15 @@ export function addCustomValidation( if (!expr) { 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); const pathExpr = attr.args?.[2]?.value; let path: string[] | undefined = undefined; diff --git a/packages/zod/test/factory.test.ts b/packages/zod/test/factory.test.ts index 40160a040..d9b38ebba 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(); @@ -894,3 +894,348 @@ describe('SchemaFactory - delegate models', () => { }); }); }); + +// --------------------------------------------------------------------------- +// makeModelSchema — ORM-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, + ); + }); + }); + + // ── 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'); + }); + + 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 ──────────────────────────────────────────────── + describe('runtime validation still applies with 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 } }); + 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', () => { + 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); + }); + + 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); + }); + }); +});