Skip to content

Commit 27e4592

Browse files
committed
feat(zod): add Prisma-style select/include/omit options to makeModelSchema
Add support for field selection and relation inclusion in Zod schema generation through new options parameter. The `makeModelSchema` method now accepts optional `select`, `include`, and `omit` options to control which fields appear in the generated schema. Key changes: - Add overloaded signatures to `makeModelSchema` with type-safe options - Implement `buildFieldsWithOptions` to handle field filtering and relation inclusion - Add `GetModelSchemaShapeWithOptions` type to compute resulting shape based on options - Skip model-level @@Validate on select path to avoid false negatives when referenced fields are not part of the selection - Add comprehensive type utilities for field selection and relation handling This enables more flexible schema generation for partial model validation and nested relation schemas while maintaining full type safety.
1 parent ece062f commit 27e4592

4 files changed

Lines changed: 620 additions & 17 deletions

File tree

packages/zod/src/factory.ts

Lines changed: 144 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,10 @@ import { SchemaFactoryError } from './error';
1515
import type {
1616
GetModelCreateFieldsShape,
1717
GetModelFieldsShape,
18+
GetModelSchemaShapeWithOptions,
1819
GetModelUpdateFieldsShape,
1920
GetTypeDefFieldsShape,
21+
ModelSchemaOptions,
2022
} from './types';
2123
import {
2224
addBigIntValidation,
@@ -30,6 +32,13 @@ export function createSchemaFactory<Schema extends SchemaDef>(schema: Schema) {
3032
return new SchemaFactory(schema);
3133
}
3234

35+
/** Internal untyped representation of the options object used at runtime. */
36+
type RawOptions = {
37+
select?: Record<string, unknown>;
38+
include?: Record<string, unknown>;
39+
omit?: Record<string, unknown>;
40+
};
41+
3342
class SchemaFactory<Schema extends SchemaDef> {
3443
private readonly schema: SchemaAccessor<Schema>;
3544

@@ -39,29 +48,63 @@ class SchemaFactory<Schema extends SchemaDef> {
3948

4049
makeModelSchema<Model extends GetModels<Schema>>(
4150
model: Model,
42-
): z.ZodObject<GetModelFieldsShape<Schema, Model>, z.core.$strict> {
51+
): z.ZodObject<GetModelFieldsShape<Schema, Model>, z.core.$strict>;
52+
53+
makeModelSchema<Model extends GetModels<Schema>, Options extends ModelSchemaOptions<Schema, Model>>(
54+
model: Model,
55+
options: Options,
56+
): z.ZodObject<GetModelSchemaShapeWithOptions<Schema, Model, Options>, z.core.$strict>;
57+
58+
makeModelSchema<Model extends GetModels<Schema>, Options extends ModelSchemaOptions<Schema, Model>>(
59+
model: Model,
60+
options?: Options,
61+
): z.ZodObject<Record<string, z.ZodType>, z.core.$strict> {
4362
const modelDef = this.schema.requireModel(model);
44-
const fields: Record<string, z.ZodType> = {};
4563

46-
for (const [fieldName, fieldDef] of Object.entries(modelDef.fields)) {
47-
if (fieldDef.relation) {
48-
const relatedModelName = fieldDef.type;
49-
const lazySchema: z.ZodType = z.lazy(() => this.makeModelSchema(relatedModelName as GetModels<Schema>));
50-
// relation fields are always optional
51-
fields[fieldName] = this.applyDescription(
52-
this.applyCardinality(lazySchema, fieldDef).optional(),
53-
fieldDef.attributes,
54-
);
55-
} else {
56-
fields[fieldName] = this.applyDescription(this.makeScalarFieldSchema(fieldDef), fieldDef.attributes);
64+
if (!options) {
65+
// ── No-options path (original behaviour) ─────────────────────────
66+
const fields: Record<string, z.ZodType> = {};
67+
68+
for (const [fieldName, fieldDef] of Object.entries(modelDef.fields)) {
69+
if (fieldDef.relation) {
70+
const relatedModelName = fieldDef.type;
71+
const lazySchema: z.ZodType = z.lazy(() =>
72+
this.makeModelSchema(relatedModelName as GetModels<Schema>),
73+
);
74+
// relation fields are always optional
75+
fields[fieldName] = this.applyDescription(
76+
this.applyCardinality(lazySchema, fieldDef).optional(),
77+
fieldDef.attributes,
78+
);
79+
} else {
80+
fields[fieldName] = this.applyDescription(
81+
this.makeScalarFieldSchema(fieldDef),
82+
fieldDef.attributes,
83+
);
84+
}
5785
}
86+
87+
const shape = z.strictObject(fields);
88+
return this.applyDescription(
89+
addCustomValidation(shape, modelDef.attributes),
90+
modelDef.attributes,
91+
) as unknown as z.ZodObject<GetModelFieldsShape<Schema, Model>, z.core.$strict>;
5892
}
5993

94+
// ── Options path ─────────────────────────────────────────────────────
95+
const rawOptions = options as unknown as RawOptions;
96+
const fields = this.buildFieldsWithOptions(model as string, rawOptions);
6097
const shape = z.strictObject(fields);
61-
return this.applyDescription(
62-
addCustomValidation(shape, modelDef.attributes),
63-
modelDef.attributes,
64-
) as unknown as z.ZodObject<GetModelFieldsShape<Schema, Model>, z.core.$strict>;
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);
104+
return this.applyDescription(withValidation, modelDef.attributes) as unknown as z.ZodObject<
105+
GetModelSchemaShapeWithOptions<Schema, Model, Options>,
106+
z.core.$strict
107+
>;
65108
}
66109

67110
makeModelCreateSchema<Model extends GetModels<Schema>>(
@@ -114,6 +157,90 @@ class SchemaFactory<Schema extends SchemaDef> {
114157
) as unknown as z.ZodObject<GetModelUpdateFieldsShape<Schema, Model>, z.core.$strict>;
115158
}
116159

160+
// -------------------------------------------------------------------------
161+
// Options-aware field building
162+
// -------------------------------------------------------------------------
163+
164+
/**
165+
* Internal loose options shape used at runtime (we've already validated the
166+
* type-level constraints via the overload signatures).
167+
*/
168+
private buildFieldsWithOptions(model: string, options: RawOptions): Record<string, z.ZodType> {
169+
const { select, include, omit } = options;
170+
const modelDef = this.schema.requireModel(model);
171+
const fields: Record<string, z.ZodType> = {};
172+
173+
if (select) {
174+
// ── select branch ────────────────────────────────────────────────
175+
// Only include fields that are explicitly listed with a truthy value.
176+
for (const [key, value] of Object.entries(select)) {
177+
if (!value) continue; // false → skip
178+
179+
const fieldDef = modelDef.fields[key];
180+
if (!fieldDef) continue;
181+
182+
if (fieldDef.relation) {
183+
// Relation field: recurse if value is a nested options object,
184+
// otherwise use the default lazy schema.
185+
const subOptions = typeof value === 'object' ? (value as RawOptions) : undefined;
186+
const relSchema = this.makeRelationFieldSchema(fieldDef, subOptions);
187+
fields[key] = this.applyDescription(
188+
this.applyCardinality(relSchema, fieldDef).optional(),
189+
fieldDef.attributes,
190+
);
191+
} else {
192+
fields[key] = this.applyDescription(this.makeScalarFieldSchema(fieldDef), fieldDef.attributes);
193+
}
194+
}
195+
} else {
196+
// ── include + omit branch ────────────────────────────────────────
197+
// Start with all scalar fields, applying omit exclusions.
198+
for (const [fieldName, fieldDef] of Object.entries(modelDef.fields)) {
199+
if (fieldDef.relation) continue; // relations handled below
200+
201+
// Skip if this field is explicitly omitted.
202+
if (omit && (omit as Record<string, unknown>)[fieldName] === true) continue;
203+
204+
fields[fieldName] = this.applyDescription(this.makeScalarFieldSchema(fieldDef), fieldDef.attributes);
205+
}
206+
207+
// Add included relation fields.
208+
if (include) {
209+
for (const [key, value] of Object.entries(include)) {
210+
if (!value) continue; // false → skip
211+
212+
const fieldDef = modelDef.fields[key];
213+
if (!fieldDef?.relation) continue;
214+
215+
const subOptions = typeof value === 'object' ? (value as RawOptions) : undefined;
216+
const relSchema = this.makeRelationFieldSchema(fieldDef, subOptions);
217+
fields[key] = this.applyDescription(
218+
this.applyCardinality(relSchema, fieldDef).optional(),
219+
fieldDef.attributes,
220+
);
221+
}
222+
}
223+
}
224+
225+
return fields;
226+
}
227+
228+
/**
229+
* Build the inner Zod schema for a relation field, optionally with nested
230+
* query options. Does NOT apply cardinality/optional wrappers — the caller
231+
* does that.
232+
*/
233+
private makeRelationFieldSchema(fieldDef: FieldDef, subOptions?: RawOptions): z.ZodType {
234+
const relatedModelName = fieldDef.type as GetModels<Schema>;
235+
if (subOptions) {
236+
// Recurse: build the related model's schema with its own options.
237+
return this.makeModelSchema(relatedModelName, subOptions as ModelSchemaOptions<Schema, GetModels<Schema>>);
238+
}
239+
// No sub-options: use a lazy reference to the default schema so that
240+
// circular models don't cause infinite recursion at build time.
241+
return z.lazy(() => this.makeModelSchema(relatedModelName));
242+
}
243+
117244
private makeScalarFieldSchema(fieldDef: FieldDef): z.ZodType {
118245
const { type, attributes } = fieldDef;
119246

packages/zod/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export { createSchemaFactory } from './factory';
2+
export type { ModelSchemaOptions, GetModelSchemaShapeWithOptions } from './types';
23
export * as ZodUtils from './utils';

0 commit comments

Comments
 (0)