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
110 changes: 77 additions & 33 deletions packages/zod/src/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,10 @@ 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>;
optionality?: 'all' | 'defaults';
};

/**
Expand All @@ -49,10 +50,11 @@ 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(),
optionality: z.enum(['all', 'defaults']).optional(),
})
.superRefine((val, ctx) => {
if (val.select && val.include) {
Expand Down Expand Up @@ -93,26 +95,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 All @@ -125,7 +117,8 @@ class SchemaFactory<Schema extends SchemaDef> {
// ── Options path ─────────────────────────────────────────────────────
const rawOptions = rawOptionsSchema.parse(options);
const fields = this.buildFieldsWithOptions(model as string, rawOptions);
const shape = z.strictObject(fields);
const optionalizedFields = this.applyOptionality(fields, model as string, rawOptions.optionality);
const shape = z.strictObject(optionalizedFields);
// @@validate conditions only reference scalar fields of the same model
// (the ZModel compiler rejects relation fields). When `select` or `omit`
// produces a partial shape some of those scalar fields may be absent;
Expand All @@ -139,6 +132,9 @@ class SchemaFactory<Schema extends SchemaDef> {
>;
}

/**
* @deprecated Use `makeModelSchema(model, { optionality: 'defaults' })` instead.
*/
makeModelCreateSchema<Model extends GetModels<Schema>>(
model: Model,
): z.ZodObject<GetModelCreateFieldsShape<Schema, Model>, z.core.$strict> {
Expand All @@ -165,6 +161,9 @@ class SchemaFactory<Schema extends SchemaDef> {
) as unknown as z.ZodObject<GetModelCreateFieldsShape<Schema, Model>, z.core.$strict>;
}

/**
* @deprecated Use `makeModelSchema(model, { optionality: 'all' })` instead.
*/
makeModelUpdateSchema<Model extends GetModels<Schema>>(
model: Model,
): z.ZodObject<GetModelUpdateFieldsShape<Schema, Model>, z.core.$strict> {
Expand Down Expand Up @@ -193,6 +192,41 @@ class SchemaFactory<Schema extends SchemaDef> {
// Options-aware field building
// -------------------------------------------------------------------------

/**
* Applies the `optionality` option to a fields map.
*
* - `"all"` — wraps every field in `z.ZodOptional`.
* - `"defaults"` — only wraps fields that have a `@default` attribute or
* are `@updatedAt` in `z.ZodOptional`. Fields that are
* already optional (nullable optional) retain their shape;
* we just add the outer optional layer.
* - `undefined` — returns the fields map unchanged.
*/
private applyOptionality(
fields: Record<string, z.ZodType>,
model: string,
optionality: 'all' | 'defaults' | undefined,
): Record<string, z.ZodType> {
if (!optionality) return fields;

const modelDef = this.schema.requireModel(model);
const result: Record<string, z.ZodType> = {};

for (const [fieldName, fieldSchema] of Object.entries(fields)) {
if (optionality === 'all') {
result[fieldName] = this.wrapOptionalPreservingMeta(fieldSchema);
} else {
// optionality === 'defaults'
const fieldDef = modelDef.fields[fieldName];
const hasDefault =
fieldDef && (fieldDef.default !== undefined || fieldDef.updatedAt || fieldDef.optional);
result[fieldName] = hasDefault ? this.wrapOptionalPreservingMeta(fieldSchema) : fieldSchema;
}
}

return result;
}

/**
* Internal loose options shape used at runtime (we've already validated the
* type-level constraints via the overload signatures).
Expand All @@ -204,10 +238,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 +289,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 +326,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 Expand Up @@ -403,6 +432,21 @@ class SchemaFactory<Schema extends SchemaDef> {
]);
}

/**
* Wraps a schema with `.optional()` and copies any `description` from its
* metadata onto the resulting `ZodOptional`, so that callers inspecting
* `.meta()?.description` on shape fields still find the value after
* optionality has been applied.
*/
private wrapOptionalPreservingMeta(schema: z.ZodType): z.ZodType {
const optional = schema.optional();
const description = schema.meta()?.description as string | undefined;
if (description) {
return optional.meta({ description });
}
return optional;
}

private applyCardinality(schema: z.ZodType, fieldDef: FieldDef): z.ZodType {
let result = schema;
if (fieldDef.array) {
Expand Down
Loading
Loading