Skip to content
Merged
1 change: 1 addition & 0 deletions BREAKINGCHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
marcsigmund marked this conversation as resolved.
47 changes: 16 additions & 31 deletions packages/zod/src/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,9 @@ export function createSchemaFactory<Schema extends SchemaDef>(schema: Schema) {

/** Internal untyped representation of the options object used at runtime. */
type RawOptions = {
select?: Record<string, unknown>;
include?: Record<string, unknown>;
omit?: Record<string, unknown>;
select?: Record<string, true | RawOptions>;
include?: Record<string, true | RawOptions>;
omit?: Record<string, true>;
};

/**
Expand All @@ -50,9 +50,9 @@ type RawOptions = {
const rawOptionsSchema: z.ZodType<RawOptions> = 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(),
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(),
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
.superRefine((val, ctx) => {
if (val.select && val.include) {
Expand Down Expand Up @@ -93,26 +93,16 @@ class SchemaFactory<Schema extends SchemaDef> {
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<string, z.ZodType> = {};

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<Schema>),
);
// 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);
Expand Down Expand Up @@ -204,10 +194,8 @@ class SchemaFactory<Schema extends SchemaDef> {

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}"`);
Expand Down Expand Up @@ -257,8 +245,6 @@ class SchemaFactory<Schema extends SchemaDef> {
// 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}"`);
Expand Down Expand Up @@ -296,9 +282,8 @@ class SchemaFactory<Schema extends SchemaDef> {
const fields = new Set<string>();

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)) {
Comment thread
marcsigmund marked this conversation as resolved.
Outdated
const fieldDef = modelDef.fields[key];
if (fieldDef && !fieldDef.relation) {
fields.add(key);
Expand Down
57 changes: 31 additions & 26 deletions packages/zod/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Schema extends SchemaDef, Model extends GetModels<Schema>> = {
// scalar fields
[Field in GetModelFields<Schema, Model> as FieldIsRelation<Schema, Model, Field> extends true
? never
: Field]: ZodOptionalAndNullableIf<
ZodArrayIf<MapModelFieldToZod<Schema, Model, Field>, FieldIsArray<Schema, Model, Field>>,
ModelFieldIsOptional<Schema, Model, Field>
>;
} & {
};

/**
* 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<Schema extends SchemaDef, Model extends GetModels<Schema>> = GetModelFieldsShape<
Schema,
Model
> & {
// relation fields, always optional
[Field in GetModelFields<Schema, Model> as FieldIsRelation<Schema, Model, Field> extends true
? Field
Expand Down Expand Up @@ -190,13 +202,14 @@ type RelatedModel<
export type ModelSchemaOptions<Schema extends SchemaDef, Model extends GetModels<Schema>> =
| {
/**
* 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<Schema, Model>]?: FieldIsRelation<Schema, Model, Field> extends true
? boolean | ModelSchemaOptions<Schema, RelatedModel<Schema, Model, Field>>
: boolean;
? true | ModelSchemaOptions<Schema, RelatedModel<Schema, Model, Field>>
: true;
};
include?: never;
omit?: never;
Expand All @@ -205,19 +218,20 @@ export type ModelSchemaOptions<Schema extends SchemaDef, Model extends GetModels
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<Schema, Model>]?: Field extends GetModelFields<Schema, Model>
? boolean | ModelSchemaOptions<Schema, RelatedModel<Schema, Model, Field>>
? true | ModelSchemaOptions<Schema, RelatedModel<Schema, Model, Field>>
: never;
};
/**
* Remove the listed scalar fields from the output.
* Only `true` is accepted — ORM convention.
*/
omit?: {
[Field in keyof ScalarModelFields<Schema, Model>]?: boolean;
[Field in keyof ScalarModelFields<Schema, Model>]?: true;
};
};

Expand All @@ -232,7 +246,7 @@ type FieldInShape<
Schema extends SchemaDef,
Model extends GetModels<Schema>,
Field extends GetModelFields<Schema, Model>,
> = Field & keyof GetModelFieldsShape<Schema, Model>;
> = Field & keyof GetAllModelFieldsShape<Schema, Model>;

/**
* Zod shape produced when a relation field is included via `include: { field:
Expand All @@ -244,7 +258,7 @@ type RelationFieldZodDefault<
Schema extends SchemaDef,
Model extends GetModels<Schema>,
Field extends GetModelFields<Schema, Model>,
> = GetModelFieldsShape<Schema, Model>[FieldInShape<Schema, Model, Field>];
> = GetAllModelFieldsShape<Schema, Model>[FieldInShape<Schema, Model, Field>];

/**
* Zod shape for a relation field included with nested options. We recurse
Expand Down Expand Up @@ -286,7 +300,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<Schema, Model>[FieldInShape<Schema, Model, Field>]
GetAllModelFieldsShape<Schema, Model>[FieldInShape<Schema, Model, Field>]
: Value extends object
? // nested options — must be a relation field
RelationFieldZodWithOptions<Schema, Model, Field, Value>
Expand All @@ -297,12 +311,7 @@ type SelectEntryToZod<
* recursing into relations when given nested options.
*/
type BuildSelectShape<Schema extends SchemaDef, Model extends GetModels<Schema>, S extends Record<string, unknown>> = {
[Field in keyof S & GetModelFields<Schema, Model> as S[Field] extends false ? never : Field]: SelectEntryToZod<
Schema,
Model,
Field,
S[Field]
>;
[Field in keyof S & GetModelFields<Schema, Model>]: SelectEntryToZod<Schema, Model, Field, S[Field]>;
};

/**
Expand All @@ -316,22 +325,18 @@ type BuildIncludeOmitShape<
I extends Record<string, unknown> | undefined,
O extends Record<string, unknown> | undefined,
> =
// scalar fields, omitting those explicitly excluded
// scalar fields, omitting those explicitly excluded (only `true` omits a field)
{
[Field in GetModelFields<Schema, Model> as FieldIsRelation<Schema, Model, Field> extends true
? never
: O extends object
? Field extends keyof O
? O[Field] extends true
? never
: Field
? never
: Field
: Field]: GetModelFieldsShape<Schema, Model>[FieldInShape<Schema, Model, Field>];
: Field]: GetAllModelFieldsShape<Schema, Model>[FieldInShape<Schema, Model, Field>];
} & (I extends object // included relation fields
? {
[Field in keyof I & GetModelFields<Schema, Model> as I[Field] extends false
? never
: Field]: I[Field] extends object
[Field in keyof I & GetModelFields<Schema, Model>]: I[Field] extends object
? RelationFieldZodWithOptions<Schema, Model, Field, I[Field]>
: RelationFieldZodDefault<Schema, Model, Field>;
}
Expand Down
35 changes: 12 additions & 23 deletions packages/zod/test/factory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,11 +83,8 @@ describe('SchemaFactory - makeModelSchema', () => {
expectTypeOf<Address['zip']>().toEqualTypeOf<string | null | undefined>();
expectTypeOf<User['address']>().toEqualTypeOf<Address | null | undefined>();

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

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

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

// optional relation field present in type
expectTypeOf<Post>().toHaveProperty('author');
const _userSchema = factory.makeModelSchema('User');
type User = z.infer<typeof _userSchema>;
expectTypeOf<Post['author']>().toEqualTypeOf<User | undefined | null>();
// relation fields are NOT present by default — use include/select to opt in
expectTypeOf<Post>().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);
Expand Down Expand Up @@ -987,12 +988,6 @@ describe('SchemaFactory - makeModelSchema with options', () => {
expectTypeOf<Result['username']>().toEqualTypeOf<string>();
});

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 } } },
Expand Down Expand Up @@ -1076,12 +1071,6 @@ describe('SchemaFactory - makeModelSchema with options', () => {
expectTypeOf<Result>().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);
Expand Down
Loading