UNPKG

prisma-zod-generator

Version:

Prisma 2+ generator to emit Zod schemas from your Prisma schema

646 lines 28.5 kB
"use strict"; /** * Result Schema Generator * Generates Zod schemas for Prisma operation return values to enable validation of API responses and operation results */ Object.defineProperty(exports, "__esModule", { value: true }); exports.ResultSchemaGenerator = exports.OperationType = void 0; const defaults_1 = require("../config/defaults"); /** * Prisma operation types that return results */ var OperationType; (function (OperationType) { OperationType["FIND_UNIQUE"] = "findUnique"; OperationType["FIND_FIRST"] = "findFirst"; OperationType["FIND_MANY"] = "findMany"; OperationType["CREATE"] = "create"; OperationType["CREATE_MANY"] = "createMany"; OperationType["UPDATE"] = "update"; OperationType["UPDATE_MANY"] = "updateMany"; OperationType["UPSERT"] = "upsert"; OperationType["DELETE"] = "delete"; OperationType["DELETE_MANY"] = "deleteMany"; OperationType["AGGREGATE"] = "aggregate"; OperationType["GROUP_BY"] = "groupBy"; OperationType["COUNT"] = "count"; })(OperationType || (exports.OperationType = OperationType = {})); /** * Result Schema Generator * Main class for generating Zod schemas for Prisma operation results */ class ResultSchemaGenerator { // Safe accessors for JSON Schema compatibility flags/options to avoid strict type coupling isJsonSchemaModeEnabled() { const cfg = this.config; return !!(cfg === null || cfg === void 0 ? void 0 : cfg.jsonSchemaCompatible); } /** * Zod expression for a Decimal field in a result schema, honoring decimalMode. * * Result schemas validate what Prisma *returns*, and in the default 'decimal' * mode that is a Prisma.Decimal instance — so a plain z.number() would reject * real query results. The 'decimal' expression is deliberately import-free * (structural check on the decimal.js shape) so result files stay * dependency-free and keep working in single-file bundles; it also matches * Decimal instances coming from a different runtime copy of the class. * * It remains a union with number and numeric string so that callers already * parsing serialized results (which previously validated against z.number()) * keep working — the change only adds the Decimal shape that used to be * rejected. */ decimalResultExpression() { var _a, _b; // JSON Schema compatibility mode targets plain JSON, where Decimal has no // representation beyond a number. if (this.isJsonSchemaModeEnabled()) return 'z.number()'; const mode = (_b = (_a = this.config) === null || _a === void 0 ? void 0 : _a.decimalMode) !== null && _b !== void 0 ? _b : 'decimal'; if (mode === 'number') return 'z.number()'; if (mode === 'string') return 'z.string()'; return ('z.union([z.number(), z.string().regex(/^-?\\d+(\\.\\d+)?$/), ' + "z.custom((v) => v !== null && typeof v === 'object' && 'd' in v && 'e' in v && 's' in v && typeof (v as { toFixed?: unknown }).toFixed === 'function', { message: 'Expected a Prisma.Decimal' })])"); } getJsonSchemaOptions() { var _a; const cfg = this.config; return ((_a = cfg === null || cfg === void 0 ? void 0 : cfg.jsonSchemaOptions) !== null && _a !== void 0 ? _a : {}); } constructor(config) { this.generatedSchemas = new Map(); this.baseModelSchemas = new Map(); /** * Pure model schemas that are known to be emitted and safe to reference from * result schemas, keyed by Prisma model name. Populated by the transformer via * {@link setAvailablePureModels}. When a relation's target model is present * here, the relation field references its `<Model>Schema`; otherwise it falls * back to `z.unknown()`. Empty in single-file mode (references cannot resolve * across the inlined bundle) so relations degrade to the safe fallback. */ this.availablePureModels = new Map(); this.config = config !== null && config !== void 0 ? config : (0, defaults_1.getDefaultConfiguration)(); } /** * Register the set of pure model schemas that are emitted and safe to * reference from generated result schemas. Called by the transformer, which * owns knowledge of model enablement, the pure-model emission predicate and * the pure-model naming resolver. Passing an empty map (the single-file case) * makes every relation field fall back to `z.unknown()`. */ setAvailablePureModels(models) { this.availablePureModels = models; // Cached schemas were built against the previous mapping; invalidate them. this.generatedSchemas.clear(); } /** * Generate result schema for a specific operation */ generateResultSchema(model, options) { const context = this.buildGenerationContext(model, options); const cacheKey = this.generateCacheKey(options); // Check cache first if (this.generatedSchemas.has(cacheKey)) { const cachedSchema = this.generatedSchemas.get(cacheKey); if (cachedSchema) { return cachedSchema; } } let result; switch (options.operationType) { case OperationType.FIND_UNIQUE: case OperationType.FIND_FIRST: case OperationType.CREATE: case OperationType.UPDATE: case OperationType.UPSERT: case OperationType.DELETE: result = this.generateSingleResultSchema(context); break; case OperationType.FIND_MANY: result = this.generateArrayResultSchema(context); break; case OperationType.CREATE_MANY: case OperationType.UPDATE_MANY: case OperationType.DELETE_MANY: result = this.generateBatchResultSchema(context); break; case OperationType.AGGREGATE: result = this.generateAggregateResultSchema(context); break; case OperationType.GROUP_BY: result = this.generateGroupByResultSchema(context); break; case OperationType.COUNT: result = this.generateCountResultSchema(context); break; default: throw new Error(`Unsupported operation type: ${options.operationType}`); } // Cache the result this.generatedSchemas.set(cacheKey, result); return result; } /** * Generate schemas for all operations of a model */ generateAllResultSchemas(model, operationTypes = Object.values(OperationType)) { const results = []; operationTypes.forEach((operationType) => { const options = { modelName: model.name, operationType, paginationSupport: operationType === OperationType.FIND_MANY, nullableResult: this.isNullableOperation(operationType), }; try { const result = this.generateResultSchema(model, options); results.push(result); } catch (error) { console.warn(`Failed to generate ${operationType} result schema for ${model.name}:`, error); } }); return results; } /** * Build generation context */ buildGenerationContext(model, options) { const relatedModels = new Map(); const fieldTypeMap = new Map(); // Build field type mapping model.fields.forEach((field) => { fieldTypeMap.set(field.name, this.mapPrismaTypeToZod(field)); // Collect related models for relation fields if (field.kind === 'object' && field.type !== model.name) { // In a real implementation, you'd get this from the DMMF // For now, we'll create a placeholder relatedModels.set(field.type, { name: field.type, fields: [], dbName: null, schema: null, primaryKey: null, uniqueFields: [], uniqueIndexes: [], isGenerated: false, }); } }); return { model, options, baseModelSchema: this.getBaseModelSchema(model), relatedModels, fieldTypeMap, }; } /** * Generate single result schema (for operations returning one model or null) */ generateSingleResultSchema(context) { const { options } = context; const schemaName = this.generateSchemaName(options); const modelDependencies = new Set(); const baseSchema = this.buildBaseResultSchema(context, modelDependencies); let zodSchema; let typeDefinition; if (options.nullableResult || this.isNullableOperation(options.operationType)) { zodSchema = `z.nullable(${baseSchema})`; typeDefinition = `z.infer<typeof ${schemaName}> | null`; } else { zodSchema = baseSchema; typeDefinition = `z.infer<typeof ${schemaName}>`; } const documentation = this.generateDocumentation(options, 'Single model result'); const examples = this.generateExamples(context, 'single'); return { operationType: options.operationType, schemaName, zodSchema: `export const ${schemaName} = ${zodSchema};`, typeDefinition: `export type ${schemaName}Type = ${typeDefinition};`, imports: new Set(['z']), exports: new Set([schemaName, `${schemaName}Type`]), dependencies: this.extractDependencies(context), modelDependencies: Array.from(modelDependencies), documentation, examples, }; } /** * Generate array result schema (for findMany operations) */ generateArrayResultSchema(context) { const { options } = context; const schemaName = this.generateSchemaName(options); const modelDependencies = new Set(); const baseSchema = this.buildBaseResultSchema(context, modelDependencies); let zodSchema; let typeDefinition; if (options.paginationSupport) { const paginationSchema = this.generatePaginationSchema(); zodSchema = `z.object({\n data: z.array(${baseSchema}),\n pagination: ${paginationSchema}\n})`; typeDefinition = `z.infer<typeof ${schemaName}>`; } else { zodSchema = `z.array(${baseSchema})`; typeDefinition = `z.infer<typeof ${schemaName}>`; } const documentation = this.generateDocumentation(options, 'Array of model results'); const examples = this.generateExamples(context, 'array'); return { operationType: options.operationType, schemaName, zodSchema: `export const ${schemaName} = ${zodSchema};`, typeDefinition: `export type ${schemaName}Type = ${typeDefinition};`, imports: new Set(['z']), exports: new Set([schemaName, `${schemaName}Type`]), dependencies: this.extractDependencies(context), modelDependencies: Array.from(modelDependencies), documentation, examples, }; } /** * Generate batch operation result schema */ generateBatchResultSchema(context) { const { options } = context; const schemaName = this.generateSchemaName(options); const zodSchema = `z.object({ count: z.number() })`; const documentation = this.generateDocumentation(options, 'Batch operation result'); const examples = this.generateExamples(context, 'batch'); return { operationType: options.operationType, schemaName, zodSchema: `export const ${schemaName} = ${zodSchema};`, typeDefinition: `export type ${schemaName}Type = z.infer<typeof ${schemaName}>;`, imports: new Set(['z']), exports: new Set([schemaName, `${schemaName}Type`]), dependencies: [], modelDependencies: [], documentation, examples, }; } /** * Generate aggregate result schema */ generateAggregateResultSchema(context) { const { model, options } = context; const schemaName = this.generateSchemaName(options); const aggregateFields = this.buildAggregateFields(model); const zodSchema = `z.object({${aggregateFields}})`; const documentation = this.generateDocumentation(options, 'Aggregate operation result'); const examples = this.generateExamples(context, 'aggregate'); return { operationType: options.operationType, schemaName, zodSchema: `export const ${schemaName} = ${zodSchema};`, typeDefinition: `export type ${schemaName}Type = z.infer<typeof ${schemaName}>;`, imports: new Set(['z']), exports: new Set([schemaName, `${schemaName}Type`]), dependencies: [], modelDependencies: [], documentation, examples, }; } /** * Generate groupBy result schema */ generateGroupByResultSchema(context) { const { model, options } = context; const schemaName = this.generateSchemaName(options); const groupByFields = this.buildGroupByFields(model); const aggregateFields = this.buildAggregateFields(model); const allFields = [groupByFields, aggregateFields].filter((fields) => fields.trim().length > 0); const zodSchema = `z.array(z.object({ ${allFields.join(',\n')} }))`; const documentation = this.generateDocumentation(options, 'GroupBy operation result'); const examples = this.generateExamples(context, 'groupBy'); return { operationType: options.operationType, schemaName, zodSchema: `export const ${schemaName} = ${zodSchema};`, typeDefinition: `export type ${schemaName}Type = z.infer<typeof ${schemaName}>;`, imports: new Set(['z']), exports: new Set([schemaName, `${schemaName}Type`]), dependencies: [], modelDependencies: [], documentation, examples, }; } /** * Generate count result schema */ generateCountResultSchema(context) { const { options } = context; const schemaName = this.generateSchemaName(options); // Simple count should be a number schema const zodSchema = 'z.number()'; const documentation = this.generateDocumentation(options, 'Count operation result'); const examples = this.generateExamples(context, 'count'); return { operationType: options.operationType, schemaName, zodSchema: `export const ${schemaName} = ${zodSchema};`, typeDefinition: `export type ${schemaName}Type = z.infer<typeof ${schemaName}>;`, imports: new Set(['z']), exports: new Set([schemaName, `${schemaName}Type`]), dependencies: [], modelDependencies: [], documentation, examples, }; } /** * Helper methods */ generateSchemaName(options) { const operationSuffix = this.operationTypeToSuffix(options.operationType); return `${options.modelName}${operationSuffix}ResultSchema`; } operationTypeToSuffix(operationType) { const suffixMap = { [OperationType.FIND_UNIQUE]: 'FindUnique', [OperationType.FIND_FIRST]: 'FindFirst', [OperationType.FIND_MANY]: 'FindMany', [OperationType.CREATE]: 'Create', [OperationType.CREATE_MANY]: 'CreateMany', [OperationType.UPDATE]: 'Update', [OperationType.UPDATE_MANY]: 'UpdateMany', [OperationType.UPSERT]: 'Upsert', [OperationType.DELETE]: 'Delete', [OperationType.DELETE_MANY]: 'DeleteMany', [OperationType.AGGREGATE]: 'Aggregate', [OperationType.GROUP_BY]: 'GroupBy', [OperationType.COUNT]: 'Count', }; return suffixMap[operationType]; } isNullableOperation(operationType) { return [ OperationType.FIND_UNIQUE, OperationType.FIND_FIRST, OperationType.UPDATE, OperationType.DELETE, ].includes(operationType); } buildBaseResultSchema(context, modelDependencies = new Set()) { const { model, options } = context; // Start with base model schema const fields = model.fields.filter((field) => { var _a; if ((_a = options.excludeFields) === null || _a === void 0 ? void 0 : _a.includes(field.name)) return false; return true; }); const fieldSchemas = fields.map((field) => { // Relation (object) fields cannot be expressed by the scalar type map. // When the related model's pure schema is emitted we reference it directly // (e.g. `z.array(TagSchema).optional()`); otherwise we fall back to // `z.unknown()`. Relations are always optional because they only appear in // a result when the query explicitly `include`s them. if (field.kind === 'object') { return ` ${field.name}: ${this.buildRelationFieldSchema(field, model, modelDependencies)}`; } const zodType = this.mapPrismaTypeToZod(field); const optionalMarker = !field.isRequired ? '.optional()' : ''; return ` ${field.name}: ${zodType}${optionalMarker}`; }); // Add included relations if (options.includeRelations) { options.includeRelations.forEach((relationName) => { const relationField = model.fields.find((f) => f.name === relationName); if (relationField) { const relationSchema = this.buildRelationSchema(relationField); fieldSchemas.push(` ${relationName}: ${relationSchema}`); } }); } const baseSchemaStr = `z.object({\n${fieldSchemas.join(',\n')}\n})`; return baseSchemaStr; } /** * Build the Zod expression for a relation (object) field in a record-shaped * result schema. References the related model's pure schema when it is emitted * and safe to import; otherwise falls back to `z.unknown()`. Always optional * because relations are only present when the query `include`s them. * * Self-relations (target model === current model) use the fallback so the * result file never has to import a schema for a cyclic/self reference, * mirroring the existing guard in {@link extractDependencies}. */ buildRelationFieldSchema(field, model, modelDependencies) { const isSelfRelation = field.type === model.name; const modelRef = isSelfRelation ? undefined : this.availablePureModels.get(field.type); let inner; if (modelRef) { inner = modelRef.exportName; modelDependencies.add(field.type); } else { // Fallback: related pure model schema is not generated, this is a // self-relation, or we are in single-file mode. inner = this.isJsonSchemaModeEnabled() ? 'z.any()' : 'z.unknown()'; } const base = field.isList ? `z.array(${inner})` : inner; return `${base}.optional()`; } buildRelationSchema(field) { if (field.isList) { return `z.array(z.object({ /* ${field.type} fields */ }))`; } return `z.object({ /* ${field.type} fields */ }).optional()`; } buildAggregateFields(model) { const numericFields = model.fields.filter((f) => ['Int', 'Float', 'Decimal', 'BigInt'].includes(f.type)); // _count: object with per-field counts (including booleans and relations), optional const countObjectFields = model.fields.map((field) => ` ${field.name}: z.number()`); const aggregateFields = [ ` _count: z.object({\n${countObjectFields.join(',\n')}\n }).optional()`, ]; if (numericFields.length > 0) { // Sum: numbers for numeric fields; BigInt as bigint const sumFields = numericFields.map((field) => ` ${field.name}: ${field.type === 'BigInt' ? 'z.bigint()' : 'z.number()'}.nullable()`); aggregateFields.push(` _sum: z.object({\n${sumFields.join(',\n')}\n }).nullable().optional()`); // Avg: average results are numbers const avgFields = numericFields.map((field) => ` ${field.name}: z.number().nullable()`); aggregateFields.push(` _avg: z.object({\n${avgFields.join(',\n')}\n }).nullable().optional()`); } // Min/max for comparable fields (include BigInt) const comparableFields = model.fields.filter((field) => ['Int', 'Float', 'Decimal', 'DateTime', 'String', 'BigInt'].includes(field.type)); if (comparableFields.length > 0) { const minMaxFields = comparableFields.map((field) => { const zodType = this.mapPrismaTypeToZod(field); return ` ${field.name}: ${zodType}.nullable()`; }); aggregateFields.push(` _min: z.object({\n${minMaxFields.join(',\n')}\n }).nullable().optional()`); aggregateFields.push(` _max: z.object({\n${minMaxFields.join(',\n')}\n }).nullable().optional()`); } return aggregateFields.join(',\n'); } buildGroupByFields(model) { // For groupBy, we include the actual field values that can be grouped by // Arrays can be grouped by in databases like PostgreSQL, so include them const groupableFields = model.fields.filter((f) => f.kind === 'scalar'); return groupableFields .map((field) => { const zodType = this.mapPrismaTypeToZod(field); return ` ${field.name}: ${zodType}`; }) .join(',\n'); } generatePaginationSchema() { return `z.object({ page: z.number().int().min(1), pageSize: z.number().int().min(1), total: z.number().int().min(0), totalPages: z.number().int().min(0), hasNext: z.boolean(), hasPrev: z.boolean() })`; } mapPrismaTypeToZod(field) { const isJsonSchemaCompatible = this.isJsonSchemaModeEnabled(); // Handle JSON Schema compatibility mapping if (isJsonSchemaCompatible) { switch (field.type) { case 'DateTime': const { dateTimeFormat } = this.getJsonSchemaOptions(); const dtFormat = dateTimeFormat || 'isoString'; if (dtFormat === 'isoDate') { return field.isList ? 'z.array(z.string().regex(/^\\d{4}-\\d{2}-\\d{2}$/, "Invalid ISO date"))' : 'z.string().regex(/^\\d{4}-\\d{2}-\\d{2}$/, "Invalid ISO date")'; } else { return field.isList ? 'z.array(z.string().regex(/^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$/, "Invalid ISO datetime"))' : 'z.string().regex(/^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$/, "Invalid ISO datetime")'; } case 'BigInt': const { bigIntFormat } = this.getJsonSchemaOptions(); const biFormat = bigIntFormat || 'string'; if (biFormat === 'string') { return field.isList ? 'z.array(z.string().regex(/^\\d+$/, "Invalid bigint string"))' : 'z.string().regex(/^\\d+$/, "Invalid bigint string")'; } else { return field.isList ? 'z.array(z.number().int())' : 'z.number().int()'; } case 'Bytes': const { bytesFormat } = this.getJsonSchemaOptions(); const bFormat = bytesFormat || 'base64String'; if (bFormat === 'base64String') { return field.isList ? 'z.array(z.string().regex(/^[A-Za-z0-9+/]*={0,2}$/, "Invalid base64 string"))' : 'z.string().regex(/^[A-Za-z0-9+/]*={0,2}$/, "Invalid base64 string")'; } else { return field.isList ? 'z.array(z.string().regex(/^[0-9a-fA-F]*$/, "Invalid hex string"))' : 'z.string().regex(/^[0-9a-fA-F]*$/, "Invalid hex string")'; } } } const typeMap = { String: 'z.string()', Int: 'z.number().int()', Float: 'z.number()', Boolean: 'z.boolean()', DateTime: 'z.date()', Json: isJsonSchemaCompatible ? 'z.any()' : 'z.unknown()', Bytes: 'z.instanceof(Uint8Array)', Decimal: this.decimalResultExpression(), BigInt: 'z.bigint()', }; const baseType = typeMap[field.type] || (isJsonSchemaCompatible ? 'z.any()' : 'z.unknown()'); // Handle arrays if (field.isList) { return `z.array(${baseType})`; } return baseType; } getBaseModelSchema(model) { // This would typically reference the generated model schema return `${model.name}Schema`; } extractDependencies(context) { const dependencies = []; if (context.options.includeRelations) { context.options.includeRelations.forEach((relation) => { const relationField = context.model.fields.find((f) => f.name === relation); if (relationField && relationField.type !== context.model.name) { dependencies.push(`${relationField.type}Schema`); } }); } return dependencies; } generateDocumentation(options, description) { return `/** * ${description} for ${options.modelName} ${options.operationType} operation * Generated at: ${new Date().toISOString()} */`; } generateExamples(context, type) { // Generate example usage based on the result type const examples = []; const schemaName = this.generateSchemaName(context.options); switch (type) { case 'single': examples.push(`const result = ${schemaName}.parse(apiResponse);`); break; case 'array': examples.push(`const results = ${schemaName}.parse(apiResponse);`); break; case 'batch': examples.push(`const batchResult = ${schemaName}.parse({ count: 5 });`); break; } return examples; } generateCacheKey(options) { var _a, _b; return `${options.modelName}:${options.operationType}:${JSON.stringify({ includeRelations: (_a = options.includeRelations) === null || _a === void 0 ? void 0 : _a.sort(), excludeFields: (_b = options.excludeFields) === null || _b === void 0 ? void 0 : _b.sort(), paginationSupport: options.paginationSupport, nullableResult: options.nullableResult, })}`; } /** * Public utility methods */ /** * Clear generated schema cache */ clearCache() { this.generatedSchemas.clear(); } /** * Get all generated schemas */ getAllGeneratedSchemas() { return Array.from(this.generatedSchemas.values()); } /** * Register base model schema */ registerBaseModelSchema(modelName, schema) { this.baseModelSchemas.set(modelName, schema); } } exports.ResultSchemaGenerator = ResultSchemaGenerator; exports.default = ResultSchemaGenerator; //# sourceMappingURL=results.js.map