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
252 changes: 235 additions & 17 deletions packages/zod/src/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@ import { SchemaFactoryError } from './error';
import type {
GetModelCreateFieldsShape,
GetModelFieldsShape,
GetModelSchemaShapeWithOptions,
GetModelUpdateFieldsShape,
GetTypeDefFieldsShape,
ModelSchemaOptions,
} from './types';
import {
addBigIntValidation,
Expand All @@ -30,6 +32,44 @@ export function createSchemaFactory<Schema extends SchemaDef>(schema: Schema) {
return new SchemaFactory(schema);
}

/** Internal untyped representation of the options object used at runtime. */
type RawOptions = {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently RawOptions is not parameterized with schema and model type, so select, include, and omit are of generic record types. It's better to turn it into: RawOptions<Schema extends SchemaDef, Model extends GetModels<Schema>>, so that when writing the options, the user gets perfect auto completion for fields.

The same applies to the zod schemas that validate the options. With schema and model context, the zod schemas can be more precise and only allow valid fields.

@marcsigmund , we can either continue evolving this PR to address it, or we can merge it first and I can help make this revision if needed. Please let me know how you want to proceed.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ymc9
Thanks for the feedback. I'm not fully confident I understand the revision well enough to implement it correctly. If you're open to it, I'd love for you to take that part over.

select?: Record<string, unknown>;
include?: Record<string, unknown>;
omit?: Record<string, unknown>;
};

/**
* Recursive Zod schema that validates a `RawOptions` object at runtime,
* enforcing the same mutual-exclusion rules that the TypeScript union type
* enforces at compile time:
* - `select` and `include` cannot be used together.
* - `select` and `omit` cannot be used together.
* Nested relation options are validated with the same rules.
*/
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(),
})
.superRefine((val, ctx) => {
if (val.select && val.include) {
ctx.addIssue({
code: 'custom',
message: '`select` and `include` cannot be used together',
});
}
if (val.select && val.omit) {
ctx.addIssue({
code: 'custom',
message: '`select` and `omit` cannot be used together',
});
}
}),
);

class SchemaFactory<Schema extends SchemaDef> {
private readonly schema: SchemaAccessor<Schema>;

Expand All @@ -39,29 +79,64 @@ class SchemaFactory<Schema extends SchemaDef> {

makeModelSchema<Model extends GetModels<Schema>>(
model: Model,
): z.ZodObject<GetModelFieldsShape<Schema, Model>, z.core.$strict> {
): z.ZodObject<GetModelFieldsShape<Schema, Model>, z.core.$strict>;

makeModelSchema<Model extends GetModels<Schema>, Options extends ModelSchemaOptions<Schema, Model>>(
model: Model,
options: Options,
): z.ZodObject<GetModelSchemaShapeWithOptions<Schema, Model, Options>, z.core.$strict>;

makeModelSchema<Model extends GetModels<Schema>, Options extends ModelSchemaOptions<Schema, Model>>(
model: Model,
options?: Options,
): z.ZodObject<Record<string, z.ZodType>, z.core.$strict> {
const modelDef = this.schema.requireModel(model);
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);
if (!options) {
// ── No-options path (original behaviour) ─────────────────────────
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,
);
}
}

const shape = z.strictObject(fields);
return this.applyDescription(
addCustomValidation(shape, modelDef.attributes),
modelDef.attributes,
) as unknown as z.ZodObject<GetModelFieldsShape<Schema, Model>, z.core.$strict>;
}

// ── Options path ─────────────────────────────────────────────────────
const rawOptions = rawOptionsSchema.parse(options);
const fields = this.buildFieldsWithOptions(model as string, rawOptions);
const shape = z.strictObject(fields);
return this.applyDescription(
addCustomValidation(shape, modelDef.attributes),
modelDef.attributes,
) as unknown as z.ZodObject<GetModelFieldsShape<Schema, Model>, z.core.$strict>;
// @@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;
// we skip any rule that references a missing field so it can't produce
// a false negative against a partial payload.
const presentFields = this.buildPresentFields(model as string, rawOptions);
const withValidation = addCustomValidation(shape, modelDef.attributes, presentFields);
return this.applyDescription(withValidation, modelDef.attributes) as unknown as z.ZodObject<
GetModelSchemaShapeWithOptions<Schema, Model, Options>,
z.core.$strict
>;
}

makeModelCreateSchema<Model extends GetModels<Schema>>(
Expand Down Expand Up @@ -114,6 +189,149 @@ class SchemaFactory<Schema extends SchemaDef> {
) as unknown as z.ZodObject<GetModelUpdateFieldsShape<Schema, Model>, z.core.$strict>;
}

// -------------------------------------------------------------------------
// Options-aware field building
// -------------------------------------------------------------------------

/**
* Internal loose options shape used at runtime (we've already validated the
* type-level constraints via the overload signatures).
*/
private buildFieldsWithOptions(model: string, options: RawOptions): Record<string, z.ZodType> {
const { select, include, omit } = options;
const modelDef = this.schema.requireModel(model);
const fields: Record<string, z.ZodType> = {};

if (select) {
// ── select branch ────────────────────────────────────────────────
// Only include fields that are explicitly listed with a truthy value.
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}"`);
}

if (fieldDef.relation) {
const subOptions = typeof value === 'object' ? (value as RawOptions) : undefined;
const relSchema = this.makeRelationFieldSchema(fieldDef, subOptions);
fields[key] = this.applyDescription(
this.applyCardinality(relSchema, fieldDef).optional(),
fieldDef.attributes,
);
} else {
if (typeof value === 'object') {
throw new SchemaFactoryError(
`Field "${key}" on model "${model}" is a scalar field and cannot have nested options`,
);
}
fields[key] = this.applyDescription(this.makeScalarFieldSchema(fieldDef), fieldDef.attributes);
}
}
} else {
// ── include + omit branch ────────────────────────────────────────
// Validate omit keys up-front.
if (omit) {
for (const key of Object.keys(omit)) {
const fieldDef = modelDef.fields[key];
if (!fieldDef) {
throw new SchemaFactoryError(`Field "${key}" does not exist on model "${model}"`);
}
if (fieldDef.relation) {
throw new SchemaFactoryError(
`Field "${key}" on model "${model}" is a relation field and cannot be used in "omit"`,
);
}
}
}

// Start with all scalar fields, applying omit exclusions.
for (const [fieldName, fieldDef] of Object.entries(modelDef.fields)) {
if (fieldDef.relation) continue;

if (omit?.[fieldName] === true) continue;
fields[fieldName] = this.applyDescription(this.makeScalarFieldSchema(fieldDef), fieldDef.attributes);
}

// 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}"`);
}
if (!fieldDef.relation) {
throw new SchemaFactoryError(
`Field "${key}" on model "${model}" is not a relation field and cannot be used in "include"`,
);
}

const subOptions = typeof value === 'object' ? (value as RawOptions) : undefined;
const relSchema = this.makeRelationFieldSchema(fieldDef, subOptions);
fields[key] = this.applyDescription(
this.applyCardinality(relSchema, fieldDef).optional(),
fieldDef.attributes,
);
}
}
}

return fields;
}

/**
* Returns the set of scalar field names that will be present in the
* resulting schema after applying `options`. Used by `addCustomValidation`
* to skip `@@validate` rules that reference an absent field.
*
* Only scalar fields matter here because `@@validate` conditions are
* restricted by the ZModel compiler to scalar fields of the same model.
*/
private buildPresentFields(model: string, options: RawOptions): ReadonlySet<string> {
const { select, omit } = options;
const modelDef = this.schema.requireModel(model);
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;
const fieldDef = modelDef.fields[key];
if (fieldDef && !fieldDef.relation) {
fields.add(key);
}
}
} else {
// All scalar fields minus explicitly omitted ones.
for (const [fieldName, fieldDef] of Object.entries(modelDef.fields)) {
if (fieldDef.relation) continue;
if (omit?.[fieldName] === true) continue;
fields.add(fieldName);
}
}

return fields;
}

/**
* Build the inner Zod schema for a relation field, optionally with nested
* query options. Does NOT apply cardinality/optional wrappers — the caller
* does that.
*/
private makeRelationFieldSchema(fieldDef: FieldDef, subOptions?: RawOptions): z.ZodType {
const relatedModelName = fieldDef.type as GetModels<Schema>;
if (subOptions) {
// Recurse: build the related model's schema with its own options.
return this.makeModelSchema(relatedModelName, subOptions as ModelSchemaOptions<Schema, GetModels<Schema>>);
}
// No sub-options: use a lazy reference to the default schema so that
// circular models don't cause infinite recursion at build time.
return z.lazy(() => this.makeModelSchema(relatedModelName));
}

private makeScalarFieldSchema(fieldDef: FieldDef): z.ZodType {
const { type, attributes } = fieldDef;

Expand Down
1 change: 1 addition & 0 deletions packages/zod/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export { createSchemaFactory } from './factory';
export type { ModelSchemaOptions, GetModelSchemaShapeWithOptions } from './types';
export * as ZodUtils from './utils';
Loading
Loading