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.
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)) {
Comment thread
marcsigmund marked this conversation as resolved.
Outdated
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