Skip to content

Commit ef51472

Browse files
committed
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.
1 parent 402842e commit ef51472

4 files changed

Lines changed: 38 additions & 34 deletions

File tree

BREAKINGCHANGES.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@
33
1. non-optional to-one relation doesn't automatically filter parent read when evaluating access policies
44
1. `@omit` and `@password` attributes have been removed
55
1. SWR plugin is removed
6+
1. `makeModelSchema()` no longer includes relation fields by default — use `include` or `select` options to opt in, mirroring ORM behaviour

packages/zod/src/factory.ts

Lines changed: 7 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -93,26 +93,16 @@ class SchemaFactory<Schema extends SchemaDef> {
9393
const modelDef = this.schema.requireModel(model);
9494

9595
if (!options) {
96-
// ── No-options path (original behaviour) ─────────────────────────
96+
// ── No-options path: scalar fields only (relations excluded by default) ──
9797
const fields: Record<string, z.ZodType> = {};
9898

9999
for (const [fieldName, fieldDef] of Object.entries(modelDef.fields)) {
100-
if (fieldDef.relation) {
101-
const relatedModelName = fieldDef.type;
102-
const lazySchema: z.ZodType = z.lazy(() =>
103-
this.makeModelSchema(relatedModelName as GetModels<Schema>),
104-
);
105-
// relation fields are always optional
106-
fields[fieldName] = this.applyDescription(
107-
this.applyCardinality(lazySchema, fieldDef).optional(),
108-
fieldDef.attributes,
109-
);
110-
} else {
111-
fields[fieldName] = this.applyDescription(
112-
this.makeScalarFieldSchema(fieldDef),
113-
fieldDef.attributes,
114-
);
115-
}
100+
// Relation fields are excluded by default — use `include` or `select`
101+
// to opt in, mirroring ORM behaviour and avoiding infinite
102+
// nesting for circular relations.
103+
if (fieldDef.relation) continue;
104+
105+
fields[fieldName] = this.applyDescription(this.makeScalarFieldSchema(fieldDef), fieldDef.attributes);
116106
}
117107

118108
const shape = z.strictObject(fields);

packages/zod/src/types.ts

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,15 +20,27 @@ import type {
2020
import type Decimal from 'decimal.js';
2121
import type z from 'zod';
2222

23+
/**
24+
* Scalar-only shape returned by the no-options `makeModelSchema` overload.
25+
* Relation fields are excluded by default — use `include` or `select` to opt in.
26+
*/
2327
export type GetModelFieldsShape<Schema extends SchemaDef, Model extends GetModels<Schema>> = {
24-
// scalar fields
2528
[Field in GetModelFields<Schema, Model> as FieldIsRelation<Schema, Model, Field> extends true
2629
? never
2730
: Field]: ZodOptionalAndNullableIf<
2831
ZodArrayIf<MapModelFieldToZod<Schema, Model, Field>, FieldIsArray<Schema, Model, Field>>,
2932
ModelFieldIsOptional<Schema, Model, Field>
3033
>;
31-
} & {
34+
};
35+
36+
/**
37+
* Full shape including both scalar and relation fields — used internally for
38+
* type lookups (e.g. resolving relation field Zod types in include/select).
39+
*/
40+
type GetAllModelFieldsShape<Schema extends SchemaDef, Model extends GetModels<Schema>> = GetModelFieldsShape<
41+
Schema,
42+
Model
43+
> & {
3244
// relation fields, always optional
3345
[Field in GetModelFields<Schema, Model> as FieldIsRelation<Schema, Model, Field> extends true
3446
? Field
@@ -234,7 +246,7 @@ type FieldInShape<
234246
Schema extends SchemaDef,
235247
Model extends GetModels<Schema>,
236248
Field extends GetModelFields<Schema, Model>,
237-
> = Field & keyof GetModelFieldsShape<Schema, Model>;
249+
> = Field & keyof GetAllModelFieldsShape<Schema, Model>;
238250

239251
/**
240252
* Zod shape produced when a relation field is included via `include: { field:
@@ -246,7 +258,7 @@ type RelationFieldZodDefault<
246258
Schema extends SchemaDef,
247259
Model extends GetModels<Schema>,
248260
Field extends GetModelFields<Schema, Model>,
249-
> = GetModelFieldsShape<Schema, Model>[FieldInShape<Schema, Model, Field>];
261+
> = GetAllModelFieldsShape<Schema, Model>[FieldInShape<Schema, Model, Field>];
250262

251263
/**
252264
* Zod shape for a relation field included with nested options. We recurse
@@ -288,7 +300,7 @@ type SelectEntryToZod<
288300
// Handling `boolean` (not just literal `true`) prevents the type from
289301
// collapsing to `never` when callers use a boolean variable instead of
290302
// a literal (e.g. `const pick: boolean = true`).
291-
GetModelFieldsShape<Schema, Model>[FieldInShape<Schema, Model, Field>]
303+
GetAllModelFieldsShape<Schema, Model>[FieldInShape<Schema, Model, Field>]
292304
: Value extends object
293305
? // nested options — must be a relation field
294306
RelationFieldZodWithOptions<Schema, Model, Field, Value>
@@ -321,7 +333,7 @@ type BuildIncludeOmitShape<
321333
? Field extends keyof O
322334
? never
323335
: Field
324-
: Field]: GetModelFieldsShape<Schema, Model>[FieldInShape<Schema, Model, Field>];
336+
: Field]: GetAllModelFieldsShape<Schema, Model>[FieldInShape<Schema, Model, Field>];
325337
} & (I extends object // included relation fields
326338
? {
327339
[Field in keyof I & GetModelFields<Schema, Model>]: I[Field] extends object

packages/zod/test/factory.test.ts

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -83,11 +83,8 @@ describe('SchemaFactory - makeModelSchema', () => {
8383
expectTypeOf<Address['zip']>().toEqualTypeOf<string | null | undefined>();
8484
expectTypeOf<User['address']>().toEqualTypeOf<Address | null | undefined>();
8585

86-
// relation field present
87-
expectTypeOf<User>().toHaveProperty('posts');
88-
const _postSchema = factory.makeModelSchema('Post');
89-
type Post = z.infer<typeof _postSchema>;
90-
expectTypeOf<User['posts']>().toEqualTypeOf<Post[] | undefined>();
86+
// relation fields are NOT present by default — use include/select to opt in
87+
expectTypeOf<User>().not.toHaveProperty('posts');
9188
});
9289

9390
it('infers correct field types for Post', () => {
@@ -117,18 +114,22 @@ describe('SchemaFactory - makeModelSchema', () => {
117114

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

120-
// optional relation field present in type
121-
expectTypeOf<Post>().toHaveProperty('author');
122-
const _userSchema = factory.makeModelSchema('User');
123-
type User = z.infer<typeof _userSchema>;
124-
expectTypeOf<Post['author']>().toEqualTypeOf<User | undefined | null>();
117+
// relation fields are NOT present by default — use include/select to opt in
118+
expectTypeOf<Post>().not.toHaveProperty('author');
125119
});
126120

127-
it('accepts a fully valid User', () => {
121+
it('accepts a fully valid User (no relation fields)', () => {
128122
const userSchema = factory.makeModelSchema('User');
129123
expect(userSchema.safeParse(validUser).success).toBe(true);
130124
});
131125

126+
it('rejects relation fields in default schema (strict object)', () => {
127+
const userSchema = factory.makeModelSchema('User');
128+
// relation fields are not part of the default schema, so they are rejected
129+
const result = userSchema.safeParse({ ...validUser, posts: [] });
130+
expect(result.success).toBe(false);
131+
});
132+
132133
it('accepts a fully valid Post', () => {
133134
const postSchema = factory.makeModelSchema('Post');
134135
expect(postSchema.safeParse(validPost).success).toBe(true);

0 commit comments

Comments
 (0)