Skip to content

Commit 64cf273

Browse files
committed
fix(zod): address PR review comments
- Skip @@Validate rules per-rule based on which fields are present in the resulting shape, instead of skipping all rules when any omit/select is used. Rules whose referenced fields are all present still apply. - Handle widened boolean (not just literal true) in SelectEntryToZod to prevent field types resolving to never - Validate options at runtime with a recursive Zod schema, enforcing that select/include and select/omit cannot be used together - Rename 'Prisma-style' to 'ORM-style' in comments and docs"
1 parent 0ac1ed2 commit 64cf273

4 files changed

Lines changed: 156 additions & 18 deletions

File tree

packages/zod/src/factory.ts

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,37 @@ type RawOptions = {
3939
omit?: Record<string, unknown>;
4040
};
4141

42+
/**
43+
* Recursive Zod schema that validates a `RawOptions` object at runtime,
44+
* enforcing the same mutual-exclusion rules that the TypeScript union type
45+
* enforces at compile time:
46+
* - `select` and `include` cannot be used together.
47+
* - `select` and `omit` cannot be used together.
48+
* Nested relation options are validated with the same rules.
49+
*/
50+
const rawOptionsSchema: z.ZodType<RawOptions> = z.lazy(() =>
51+
z
52+
.object({
53+
select: z.record(z.string(), z.union([z.boolean(), rawOptionsSchema])).optional(),
54+
include: z.record(z.string(), z.union([z.boolean(), rawOptionsSchema])).optional(),
55+
omit: z.record(z.string(), z.boolean()).optional(),
56+
})
57+
.superRefine((val, ctx) => {
58+
if (val.select && val.include) {
59+
ctx.addIssue({
60+
code: 'custom',
61+
message: '`select` and `include` cannot be used together',
62+
});
63+
}
64+
if (val.select && val.omit) {
65+
ctx.addIssue({
66+
code: 'custom',
67+
message: '`select` and `omit` cannot be used together',
68+
});
69+
}
70+
}),
71+
);
72+
4273
class SchemaFactory<Schema extends SchemaDef> {
4374
private readonly schema: SchemaAccessor<Schema>;
4475

@@ -92,15 +123,18 @@ class SchemaFactory<Schema extends SchemaDef> {
92123
}
93124

94125
// ── Options path ─────────────────────────────────────────────────────
95-
const rawOptions = options as unknown as RawOptions;
126+
const rawOptions = rawOptionsSchema.parse(options);
96127
const fields = this.buildFieldsWithOptions(model as string, rawOptions);
97128
const shape = z.strictObject(fields);
98-
// @@validate expressions reference fields by name — when `select` is
99-
// used only a subset of fields is present, so running @@validate would
100-
// silently evaluate missing fields as null and produce false negatives.
101-
// We therefore only apply model-level custom validation on the
102-
// include/omit path where all scalar fields are still present.
103-
const withValidation = rawOptions.select ? shape : addCustomValidation(shape, modelDef.attributes);
129+
// @@validate expressions reference fields by name. When `select` or
130+
// `omit` produces a partial shape, some fields referenced by @@validate
131+
// may be absent. Applying those rules would cause false negatives (the
132+
// field evaluates to null) or make the schema impossible to satisfy
133+
// (strict parsing rejects a field the refinement needs).
134+
// We therefore apply each @@validate rule only when every field it
135+
// references is present in the resulting shape.
136+
const presentFields = new Set(Object.keys(fields));
137+
const withValidation = addCustomValidation(shape, modelDef.attributes, presentFields);
104138
return this.applyDescription(withValidation, modelDef.attributes) as unknown as z.ZodObject<
105139
GetModelSchemaShapeWithOptions<Schema, Model, Options>,
106140
z.core.$strict

packages/zod/src/types.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ type ZodNullableIf<T extends z.ZodType, Condition extends boolean> = Condition e
144144
type ZodArrayIf<T extends z.ZodType, Condition extends boolean> = Condition extends true ? z.ZodArray<T> : T;
145145

146146
// -------------------------------------------------------------------------
147-
// Query options types (Prisma-style include / select / omit)
147+
// Query options types (ORM-style include / select / omit)
148148
// -------------------------------------------------------------------------
149149

150150
/**
@@ -176,7 +176,7 @@ type RelatedModel<
176176
> = GetModelFieldType<Schema, Model, Field> extends GetModels<Schema> ? GetModelFieldType<Schema, Model, Field> : never;
177177

178178
/**
179-
* Prisma-style query options accepted by `makeModelSchema`.
179+
* ORM-style query options accepted by `makeModelSchema`.
180180
*
181181
* Exactly mirrors the `select` / `include` / `omit` vocabulary:
182182
* - `select` — pick specific fields (scalars and/or relations). Mutually
@@ -281,8 +281,11 @@ type SelectEntryToZod<
281281
Model extends GetModels<Schema>,
282282
Field extends GetModelFields<Schema, Model>,
283283
Value,
284-
> = Value extends true
285-
? // `true` — use the default shape for this field (scalar or relation)
284+
> = Value extends boolean
285+
? // `true` or widened `boolean` — use the default shape for this field.
286+
// Handling `boolean` (not just literal `true`) prevents the type from
287+
// collapsing to `never` when callers use a boolean variable instead of
288+
// a literal (e.g. `const pick: boolean = true`).
286289
GetModelFieldsShape<Schema, Model>[FieldInShape<Schema, Model, Field>]
287290
: Value extends object
288291
? // nested options — must be a relation field

packages/zod/src/utils.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,9 +261,21 @@ export function addListValidation(
261261
return result;
262262
}
263263

264+
/**
265+
* Applies `@@validate` rules from `attributes` to `schema` as Zod refinements.
266+
*
267+
* When `presentFields` is provided, only rules whose referenced fields are all
268+
* present in the set are applied. Rules that reference an absent field are
269+
* silently skipped — they cannot be evaluated correctly against a partial
270+
* payload (e.g. after `select` or `omit`), so skipping them is the safe choice.
271+
*
272+
* Omit `presentFields` (or pass `undefined`) to apply all rules unconditionally,
273+
* which is the correct behaviour for full-model schemas.
274+
*/
264275
export function addCustomValidation(
265276
schema: z.ZodSchema,
266277
attributes: readonly AttributeApplication[] | undefined,
278+
presentFields?: ReadonlySet<string>,
267279
): z.ZodSchema {
268280
const attrs = attributes?.filter((a) => a.name === '@@validate');
269281
if (!attrs || attrs.length === 0) {
@@ -276,6 +288,15 @@ export function addCustomValidation(
276288
if (!expr) {
277289
continue;
278290
}
291+
292+
// Skip rules that reference fields absent from the resulting shape.
293+
if (presentFields !== undefined) {
294+
const referencedFields = collectFieldRefs(expr);
295+
if ([...referencedFields].some((f) => !presentFields.has(f))) {
296+
continue;
297+
}
298+
}
299+
279300
const message = getArgValue<string>(attr.args?.[1]?.value);
280301
const pathExpr = attr.args?.[2]?.value;
281302
let path: string[] | undefined = undefined;
@@ -287,6 +308,40 @@ export function addCustomValidation(
287308
return result;
288309
}
289310

311+
/**
312+
* Recursively collects all field names referenced by `kind: 'field'` nodes
313+
* inside an expression tree.
314+
*/
315+
export function collectFieldRefs(expr: Expression): Set<string> {
316+
const refs = new Set<string>();
317+
function walk(e: Expression): void {
318+
switch (e.kind) {
319+
case 'field':
320+
refs.add(e.field);
321+
break;
322+
case 'unary':
323+
walk(e.operand);
324+
break;
325+
case 'binary':
326+
walk(e.left);
327+
walk(e.right);
328+
break;
329+
case 'call':
330+
e.args?.forEach(walk);
331+
break;
332+
case 'array':
333+
e.items.forEach(walk);
334+
break;
335+
case 'member':
336+
walk(e.receiver);
337+
break;
338+
// literal / null / this / binding — no field refs
339+
}
340+
}
341+
walk(expr);
342+
return refs;
343+
}
344+
290345
function applyValidation(
291346
schema: z.ZodSchema,
292347
expr: Expression,

packages/zod/test/factory.test.ts

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -107,13 +107,13 @@ describe('SchemaFactory - makeModelSchema', () => {
107107
// scalar array
108108
expectTypeOf<Post['tags']>().toEqualTypeOf<string[]>();
109109

110-
const createPostSchema = factory.makeModelCreateSchema('Post');
111-
type PostCreate = z.infer<typeof createPostSchema>;
110+
const _createPostSchema = factory.makeModelCreateSchema('Post');
111+
type PostCreate = z.infer<typeof _createPostSchema>;
112112

113113
expectTypeOf<PostCreate['tags']>().toEqualTypeOf<string[]>();
114114

115-
const updatePostSchema = factory.makeModelUpdateSchema('Post');
116-
type PostUpdate = z.infer<typeof updatePostSchema>;
115+
const _updatePostSchema = factory.makeModelUpdateSchema('Post');
116+
type PostUpdate = z.infer<typeof _updatePostSchema>;
117117

118118
expectTypeOf<PostUpdate['tags']>().toEqualTypeOf<string[] | undefined>();
119119

@@ -896,7 +896,7 @@ describe('SchemaFactory - delegate models', () => {
896896
});
897897

898898
// ---------------------------------------------------------------------------
899-
// makeModelSchema — Prisma-style options (omit / include / select)
899+
// makeModelSchema — ORM-style options (omit / include / select)
900900
// ---------------------------------------------------------------------------
901901

902902
// User without username (the omit use-case baseline)
@@ -1142,18 +1142,64 @@ describe('SchemaFactory - makeModelSchema with options', () => {
11421142
});
11431143
});
11441144

1145+
// ── invalid option combinations ───────────────────────────────────────────
1146+
describe('invalid option combinations', () => {
1147+
it('throws when select and include are used together', () => {
1148+
expect(() =>
1149+
factory.makeModelSchema('User', { select: { id: true }, include: { posts: true } } as any),
1150+
).toThrow('`select` and `include` cannot be used together');
1151+
});
1152+
1153+
it('throws when select and omit are used together', () => {
1154+
expect(() =>
1155+
factory.makeModelSchema('User', { select: { id: true }, omit: { username: true } } as any),
1156+
).toThrow('`select` and `omit` cannot be used together');
1157+
});
1158+
1159+
it('throws when select and include are used together in nested relation options', () => {
1160+
expect(() =>
1161+
factory.makeModelSchema('User', {
1162+
include: { posts: { select: { id: true }, include: {} } as any },
1163+
}),
1164+
).toThrow('`select` and `include` cannot be used together');
1165+
});
1166+
});
1167+
11451168
// ── runtime error handling ────────────────────────────────────────────────
11461169
describe('runtime validation still applies with options', () => {
1147-
it('@@validate still runs with omit options', () => {
1170+
it('@@validate still runs with omit when the referenced field is present in the shape', () => {
1171+
// omitting `username` leaves `age` in the shape, so @@validate(age >= 18) still fires
11481172
const schema = factory.makeModelSchema('User', { omit: { username: true } });
1149-
// age: 16 still fails @@validate(age >= 18)
11501173
expect(schema.safeParse({ ...validUserNoUsername, age: 16 }).success).toBe(false);
1174+
expect(schema.safeParse({ ...validUserNoUsername, age: 18 }).success).toBe(true);
1175+
});
1176+
1177+
it('@@validate is skipped when its referenced field is omitted', () => {
1178+
// omitting `age` removes the field that @@validate(age >= 18) references,
1179+
// so the rule is silently skipped — age: 16 is no longer validated
1180+
const { age: _, username: _u, ...validUserNoAgeOrUsername } = validUser;
1181+
const schema = factory.makeModelSchema('User', { omit: { age: true, username: true } });
1182+
expect(schema.safeParse(validUserNoAgeOrUsername).success).toBe(true);
11511183
});
11521184

11531185
it('field validation still runs with select options', () => {
11541186
const schema = factory.makeModelSchema('User', { select: { email: true } });
11551187
expect(schema.safeParse({ email: 'not-an-email' }).success).toBe(false);
11561188
expect(schema.safeParse({ email: 'valid@example.com' }).success).toBe(true);
11571189
});
1190+
1191+
it('@@validate is skipped with select when the referenced field is not selected', () => {
1192+
// selecting only `email` omits `age`, so @@validate(age >= 18) is skipped
1193+
const schema = factory.makeModelSchema('User', { select: { email: true } });
1194+
// would fail @@validate if age were present and < 18, but age isn't in the shape
1195+
expect(schema.safeParse({ email: 'valid@example.com' }).success).toBe(true);
1196+
});
1197+
1198+
it('@@validate still runs with select when the referenced field is selected', () => {
1199+
// selecting both `email` and `age` keeps the @@validate(age >= 18) rule active
1200+
const schema = factory.makeModelSchema('User', { select: { email: true, age: true } });
1201+
expect(schema.safeParse({ email: 'valid@example.com', age: 16 }).success).toBe(false);
1202+
expect(schema.safeParse({ email: 'valid@example.com', age: 18 }).success).toBe(true);
1203+
});
11581204
});
11591205
});

0 commit comments

Comments
 (0)