Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
49 changes: 17 additions & 32 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 @@ -49,10 +49,10 @@ 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(),
.strictObject({
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(),
})
.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)) {
const fieldDef = modelDef.fields[key];
if (fieldDef && !fieldDef.relation) {
fields.add(key);
Expand Down
53 changes: 30 additions & 23 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,7 +325,7 @@ 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
Expand All @@ -326,12 +335,10 @@ type BuildIncludeOmitShape<
? never
: Field
: 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