Skip to content

Commit ba735ba

Browse files
committed
fix(zod): a couple of improvements
- Add "create" and "update" variants to model schemas - Simplify dependencies - Translate ZModel meta description into zod meta
1 parent 7a98d41 commit ba735ba

10 files changed

Lines changed: 548 additions & 335 deletions

File tree

packages/zod/package.json

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,8 @@
3333
}
3434
},
3535
"dependencies": {
36-
"@zenstackhq/common-helpers": "workspace:*",
3736
"@zenstackhq/schema": "workspace:*",
38-
"decimal.js": "catalog:",
39-
"json-stable-stringify": "^1.3.0",
40-
"ts-pattern": "catalog:"
37+
"decimal.js": "catalog:"
4138
},
4239
"devDependencies": {
4340
"@zenstackhq/eslint-config": "workspace:*",

packages/zod/src/error.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/**
22
* Error representing failures in Zod schema building.
33
*/
4-
export class ZodSchemaError extends Error {
4+
export class SchemaFactoryError extends Error {
55
constructor(message: string) {
66
super(message);
77
}

packages/zod/src/factory.ts

Lines changed: 137 additions & 132 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,23 @@
11
import {
2+
ExpressionUtils,
23
SchemaAccessor,
3-
type BuiltinType,
4+
type AttributeApplication,
45
type FieldDef,
5-
type FieldIsArray,
6-
type FieldIsRelation,
76
type GetEnum,
87
type GetEnums,
9-
type GetModelFields,
10-
type GetModelFieldType,
118
type GetModels,
12-
type GetTypeDefFields,
13-
type GetTypeDefFieldType,
149
type GetTypeDefs,
15-
type ModelFieldIsOptional,
1610
type SchemaDef,
17-
type TypeDefFieldIsOptional,
1811
} from '@zenstackhq/schema';
1912
import Decimal from 'decimal.js';
20-
import { match } from 'ts-pattern';
2113
import z from 'zod';
14+
import { SchemaFactoryError } from './error';
15+
import type {
16+
GetModelCreateFieldsShape,
17+
GetModelFieldsShape,
18+
GetModelUpdateFieldsShape,
19+
GetTypeDefFieldsShape,
20+
} from './types';
2221
import {
2322
addBigIntValidation,
2423
addCustomValidation,
@@ -41,28 +40,76 @@ class SchemaFactory<Schema extends SchemaDef> {
4140
makeModelSchema<Model extends GetModels<Schema>>(
4241
model: Model,
4342
): z.ZodObject<GetModelFieldsShape<Schema, Model>, z.core.$strict> {
44-
const modelDef = this.schema.models[model];
45-
if (!modelDef) {
46-
throw new Error(`Model "${model}" not found in schema`);
47-
}
43+
const modelDef = this.schema.requireModel(model);
4844
const fields: Record<string, z.ZodType> = {};
4945

5046
for (const [fieldName, fieldDef] of Object.entries(modelDef.fields)) {
5147
if (fieldDef.relation) {
5248
const relatedModelName = fieldDef.type;
5349
const lazySchema: z.ZodType = z.lazy(() => this.makeModelSchema(relatedModelName as GetModels<Schema>));
5450
// relation fields are always optional
55-
fields[fieldName] = this.applyCardinality(lazySchema, fieldDef).optional();
51+
fields[fieldName] = this.applyDescription(
52+
this.applyCardinality(lazySchema, fieldDef).optional(),
53+
fieldDef.attributes,
54+
);
5655
} else {
57-
fields[fieldName] = this.makeScalarFieldSchema(fieldDef);
56+
fields[fieldName] = this.applyDescription(this.makeScalarFieldSchema(fieldDef), fieldDef.attributes);
57+
}
58+
}
59+
60+
const shape = z.strictObject(fields);
61+
return this.applyDescription(
62+
addCustomValidation(shape, modelDef.attributes),
63+
modelDef.attributes,
64+
) as unknown as z.ZodObject<GetModelFieldsShape<Schema, Model>, z.core.$strict>;
65+
}
66+
67+
makeModelCreateSchema<Model extends GetModels<Schema>>(
68+
model: Model,
69+
): z.ZodObject<GetModelCreateFieldsShape<Schema, Model>, z.core.$strict> {
70+
const modelDef = this.schema.requireModel(model);
71+
const fields: Record<string, z.ZodType> = {};
72+
73+
for (const [fieldName, fieldDef] of Object.entries(modelDef.fields)) {
74+
if (fieldDef.relation) {
75+
continue;
76+
}
77+
78+
let fieldSchema = this.makeScalarFieldSchema(fieldDef);
79+
if (fieldDef.optional || fieldDef.default !== undefined || fieldDef.updatedAt) {
80+
fieldSchema = fieldSchema.optional();
81+
}
82+
fields[fieldName] = this.applyDescription(fieldSchema, fieldDef.attributes);
83+
}
84+
85+
const shape = z.strictObject(fields);
86+
return this.applyDescription(
87+
addCustomValidation(shape, modelDef.attributes),
88+
modelDef.attributes,
89+
) as unknown as z.ZodObject<GetModelCreateFieldsShape<Schema, Model>, z.core.$strict>;
90+
}
91+
92+
makeModelUpdateSchema<Model extends GetModels<Schema>>(
93+
model: Model,
94+
): z.ZodObject<GetModelUpdateFieldsShape<Schema, Model>, z.core.$strict> {
95+
const modelDef = this.schema.requireModel(model);
96+
const fields: Record<string, z.ZodType> = {};
97+
98+
for (const [fieldName, fieldDef] of Object.entries(modelDef.fields)) {
99+
if (fieldDef.relation) {
100+
continue;
58101
}
102+
103+
let fieldSchema = this.makeScalarFieldSchema(fieldDef);
104+
fieldSchema = fieldSchema.optional();
105+
fields[fieldName] = this.applyDescription(fieldSchema, fieldDef.attributes);
59106
}
60107

61108
const shape = z.strictObject(fields);
62-
return addCustomValidation(shape, modelDef.attributes) as unknown as z.ZodObject<
63-
GetModelFieldsShape<Schema, Model>,
64-
z.core.$strict
65-
>;
109+
return this.applyDescription(
110+
addCustomValidation(shape, modelDef.attributes),
111+
modelDef.attributes,
112+
) as unknown as z.ZodObject<GetModelUpdateFieldsShape<Schema, Model>, z.core.$strict>;
66113
}
67114

68115
private makeScalarFieldSchema(fieldDef: FieldDef): z.ZodType {
@@ -80,24 +127,47 @@ class SchemaFactory<Schema extends SchemaDef> {
80127
return this.applyCardinality(this.makeTypeSchema(type as GetTypeDefs<Schema>), fieldDef);
81128
}
82129

83-
const base = match<BuiltinType>(type as BuiltinType)
84-
.with('String', () => addStringValidation(z.string(), attributes))
85-
.with('Int', () => addNumberValidation(z.number().int(), attributes))
86-
.with('Float', () => addNumberValidation(z.number(), attributes))
87-
.with('Boolean', () => z.boolean())
88-
.with('BigInt', () => addBigIntValidation(z.bigint(), attributes))
89-
.with('Decimal', () =>
90-
z.union([
130+
let base: z.ZodType;
131+
switch (type) {
132+
case 'String':
133+
base = addStringValidation(z.string(), attributes);
134+
break;
135+
case 'Int':
136+
base = addNumberValidation(z.number().int(), attributes);
137+
break;
138+
case 'Float':
139+
base = addNumberValidation(z.number(), attributes);
140+
break;
141+
case 'Boolean':
142+
base = z.boolean();
143+
break;
144+
case 'BigInt':
145+
base = addBigIntValidation(z.bigint(), attributes);
146+
break;
147+
case 'Decimal':
148+
base = z.union([
91149
addNumberValidation(z.number(), attributes) as z.ZodNumber,
92150
addDecimalValidation(z.string(), attributes, true) as z.ZodString,
93151
addDecimalValidation(z.instanceof(Decimal), attributes, true),
94-
]),
95-
)
96-
.with('DateTime', () => z.union([z.date(), z.iso.datetime()]))
97-
.with('Bytes', () => z.instanceof(Uint8Array))
98-
.with('Json', () => this.makeJsonSchema())
99-
.with('Unsupported', () => z.unknown())
100-
.exhaustive();
152+
]);
153+
break;
154+
case 'DateTime':
155+
base = z.union([z.date(), z.iso.datetime()]);
156+
break;
157+
case 'Bytes':
158+
base = z.instanceof(Uint8Array);
159+
break;
160+
case 'Json':
161+
base = this.makeJsonSchema();
162+
break;
163+
case 'Unsupported':
164+
base = z.unknown();
165+
break;
166+
default: {
167+
const _exhaustive: never = type as never;
168+
throw new SchemaFactoryError(`Unsupported field type: ${_exhaustive}`);
169+
}
170+
}
101171

102172
return this.applyCardinality(base, fieldDef);
103173
}
@@ -131,113 +201,48 @@ class SchemaFactory<Schema extends SchemaDef> {
131201
const fields: Record<string, z.ZodType> = {};
132202

133203
for (const [fieldName, fieldDef] of Object.entries(typeDef.fields)) {
134-
fields[fieldName] = this.makeScalarFieldSchema(fieldDef);
204+
fields[fieldName] = this.applyDescription(this.makeScalarFieldSchema(fieldDef), fieldDef.attributes);
135205
}
136206

137207
const shape = z.strictObject(fields);
138-
return addCustomValidation(shape, typeDef.attributes) as unknown as z.ZodObject<
139-
GetTypeDefFieldsShape<Schema, Type>,
140-
z.core.$strict
141-
>;
208+
return this.applyDescription(
209+
addCustomValidation(shape, typeDef.attributes),
210+
typeDef.attributes,
211+
) as unknown as z.ZodObject<GetTypeDefFieldsShape<Schema, Type>, z.core.$strict>;
142212
}
143213

144214
makeEnumSchema<Enum extends GetEnums<Schema>>(
145215
_enum: Enum,
146216
): z.ZodEnum<{ [Key in keyof GetEnum<Schema, Enum>]: GetEnum<Schema, Enum>[Key] }> {
147217
const enumDef = this.schema.requireEnum(_enum);
148-
return z.enum(Object.keys(enumDef.values) as [string, ...string[]]) as unknown as z.ZodEnum<{
218+
const schema = z.enum(Object.keys(enumDef.values) as [string, ...string[]]);
219+
return this.applyDescription(schema, enumDef.attributes) as unknown as z.ZodEnum<{
149220
[Key in keyof GetEnum<Schema, Enum>]: GetEnum<Schema, Enum>[Key];
150221
}>;
151222
}
152-
}
153223

154-
type GetModelFieldsShape<Schema extends SchemaDef, Model extends GetModels<Schema>> = {
155-
// scalar fields
156-
[Field in GetModelFields<Schema, Model> as FieldIsRelation<Schema, Model, Field> extends true
157-
? never
158-
: Field]: ZodOptionalAndNullableIf<
159-
MapModelFieldToZod<Schema, Model, Field>,
160-
ModelFieldIsOptional<Schema, Model, Field>
161-
>;
162-
} & {
163-
// relation fields, always optional
164-
[Field in GetModelFields<Schema, Model> as FieldIsRelation<Schema, Model, Field> extends true
165-
? Field
166-
: never]: ZodNullableIf<
167-
z.ZodOptional<
168-
ZodArrayIf<
169-
z.ZodObject<
170-
GetModelFieldsShape<
171-
Schema,
172-
GetModelFieldType<Schema, Model, Field> extends GetModels<Schema>
173-
? GetModelFieldType<Schema, Model, Field>
174-
: never
175-
>,
176-
z.core.$strict
177-
>,
178-
FieldIsArray<Schema, Model, Field>
179-
>
180-
>,
181-
ModelFieldIsOptional<Schema, Model, Field>
182-
>;
183-
};
184-
185-
type GetTypeDefFieldsShape<Schema extends SchemaDef, Type extends GetTypeDefs<Schema>> = {
186-
[Field in GetTypeDefFields<Schema, Type>]: ZodOptionalAndNullableIf<
187-
MapTypeDefFieldToZod<Schema, Type, Field>,
188-
TypeDefFieldIsOptional<Schema, Type, Field>
189-
>;
190-
};
191-
192-
type FieldTypeZodMap = {
193-
String: z.ZodString;
194-
Int: z.ZodNumber;
195-
BigInt: z.ZodBigInt;
196-
Float: z.ZodNumber;
197-
Decimal: z.ZodType<Decimal>;
198-
Boolean: z.ZodBoolean;
199-
DateTime: z.ZodType<Date>;
200-
Bytes: z.ZodType<Uint8Array>;
201-
Json: JsonZodType;
202-
};
203-
204-
type MapModelFieldToZod<
205-
Schema extends SchemaDef,
206-
Model extends GetModels<Schema>,
207-
Field extends GetModelFields<Schema, Model>,
208-
FieldType = GetModelFieldType<Schema, Model, Field>,
209-
> = MapFieldTypeToZod<Schema, FieldType>;
210-
211-
type MapTypeDefFieldToZod<
212-
Schema extends SchemaDef,
213-
Type extends GetTypeDefs<Schema>,
214-
Field extends GetTypeDefFields<Schema, Type>,
215-
FieldType = GetTypeDefFieldType<Schema, Type, Field>,
216-
> = MapFieldTypeToZod<Schema, FieldType>;
217-
218-
type MapFieldTypeToZod<Schema extends SchemaDef, FieldType> = FieldType extends keyof FieldTypeZodMap
219-
? FieldTypeZodMap[FieldType]
220-
: FieldType extends GetEnums<Schema>
221-
? EnumZodType<Schema, FieldType>
222-
: FieldType extends GetTypeDefs<Schema>
223-
? z.ZodObject<GetTypeDefFieldsShape<Schema, FieldType>, z.core.$strict>
224-
: z.ZodUnknown;
225-
226-
type JsonZodType =
227-
| z.ZodObject<Record<string, z.ZodType>, z.core.$loose>
228-
| z.ZodArray<z.ZodType>
229-
| z.ZodString
230-
| z.ZodNumber
231-
| z.ZodBoolean
232-
| z.ZodNull;
233-
234-
type EnumZodType<Schema extends SchemaDef, EnumName extends GetEnums<Schema>> = z.ZodEnum<{
235-
[Key in keyof GetEnum<Schema, EnumName>]: GetEnum<Schema, EnumName>[Key];
236-
}>;
237-
238-
type ZodOptionalAndNullableIf<T extends z.ZodType, Condition extends boolean> = Condition extends true
239-
? z.ZodOptional<z.ZodNullable<T>>
240-
: T;
241-
242-
type ZodNullableIf<T extends z.ZodType, Condition extends boolean> = Condition extends true ? z.ZodNullable<T> : T;
243-
type ZodArrayIf<T extends z.ZodType, Condition extends boolean> = Condition extends true ? z.ZodArray<T> : T;
224+
private getMetaDescription(attributes: readonly AttributeApplication[] | undefined): string | undefined {
225+
if (!attributes) return undefined;
226+
for (const attr of attributes) {
227+
if (attr.name !== '@meta' && attr.name !== '@@meta') continue;
228+
const nameExpr = attr.args?.[0]?.value;
229+
if (!nameExpr || ExpressionUtils.getLiteralValue(nameExpr) !== 'description') continue;
230+
const valueExpr = attr.args?.[1]?.value;
231+
if (valueExpr) {
232+
return ExpressionUtils.getLiteralValue(valueExpr) as string | undefined;
233+
}
234+
}
235+
return undefined;
236+
}
237+
238+
private applyDescription<T extends z.ZodType>(
239+
schema: T,
240+
attributes: readonly AttributeApplication[] | undefined,
241+
): T {
242+
const description = this.getMetaDescription(attributes);
243+
if (description) {
244+
return schema.meta({ description }) as T;
245+
}
246+
return schema;
247+
}
248+
}

packages/zod/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export { createSchemaFactory as createModelSchemaFactory } from './factory';
2+
export type * from './types';
23
export * as ZodUtils from './utils';

0 commit comments

Comments
 (0)