import { ExpressionUtils, type AttributeApplication, type BinaryExpression, type CallExpression, type Expression, type FieldExpression, type MemberExpression, type UnaryExpression, } from '@zenstackhq/schema'; import Decimal from 'decimal.js'; import { z } from 'zod'; import { SchemaFactoryError } from './error'; // z.string()[mapped] const stringFuncZodMap = { isEmail: 'email', isUrl: 'url', isPhone: 'e164', isUuid: 'uuid', isDate: 'date', isTime: 'time', isDateTime: 'datetime', } as const; function getArgValue(expr: Expression | undefined): T | undefined { if (!expr || !ExpressionUtils.isLiteral(expr)) { return undefined; } return expr.value as T; } function getNamedAttributeArgValue( attr: AttributeApplication, name: string, ): T | undefined { const named = attr.args?.find((a) => a.name === name); if (named) { return getArgValue(named.value); } else { return undefined; } } export function addStringValidation( schema: z.ZodString, attributes: readonly AttributeApplication[] | undefined, ): z.ZodSchema { if (!attributes || attributes.length === 0) { return schema; } let result = schema; for (const attr of attributes) { switch (attr.name) { case '@length': { const min = getArgValue(attr.args?.find((a) => a.name === 'min')?.value); if (min !== undefined) { result = result.min(min); } const max = getArgValue(attr.args?.find((a) => a.name === 'max')?.value); if (max !== undefined) { result = result.max(max); } break; } case '@startsWith': { const value = getArgValue(attr.args?.[0]?.value); if (value !== undefined) { result = result.startsWith(value); } break; } case '@endsWith': { const value = getArgValue(attr.args?.[0]?.value); if (value !== undefined) { result = result.endsWith(value); } break; } case '@contains': { const value = getArgValue(attr.args?.[0]?.value); if (value !== undefined) { result = result.includes(value); } break; } case '@regex': { const pattern = getArgValue(attr.args?.[0]?.value); if (pattern !== undefined) { result = result.regex(new RegExp(pattern)); } break; } case '@uuid': { const version = getNamedAttributeArgValue(attr, 'version'); if (version === 4) { result = result.uuidv4(); } else if (version === 7) { result = result.uuidv7(); } else { result = result.uuid(); } break; } case '@email': result = result.email(); break; case '@phone': result = result.e164(); break; case '@date': result = result.date(); break; case '@time': { const precision = getNamedAttributeArgValue(attr, 'precision'); result = result.time({ precision }); break; } case '@datetime': result = result.datetime(); break; case '@url': result = result.url(); break; case '@trim': result = result.trim(); break; case '@lower': result = result.toLowerCase(); break; case '@upper': result = result.toUpperCase(); break; } } return result; } export function addNumberValidation( schema: z.ZodNumber, attributes: readonly AttributeApplication[] | undefined, ): z.ZodSchema { if (!attributes || attributes.length === 0) { return schema; } let result = schema; for (const attr of attributes) { const val = getArgValue(attr.args?.[0]?.value); if (val === undefined) { continue; } switch (attr.name) { case '@gt': result = result.gt(val); break; case '@gte': result = result.gte(val); break; case '@lt': result = result.lt(val); break; case '@lte': result = result.lte(val); break; } } return result; } export function addBigIntValidation( schema: z.ZodBigInt, attributes: readonly AttributeApplication[] | undefined, ): z.ZodSchema { if (!attributes || attributes.length === 0) { return schema; } let result = schema; for (const attr of attributes) { const val = getArgValue(attr.args?.[0]?.value); if (val === undefined) { continue; } switch (attr.name) { case '@gt': result = result.gt(BigInt(val)); break; case '@gte': result = result.gte(BigInt(val)); break; case '@lt': result = result.lt(BigInt(val)); break; case '@lte': result = result.lte(BigInt(val)); break; } } return result; } export function addDecimalValidation( schema: z.ZodType | z.ZodString, attributes: readonly AttributeApplication[] | undefined, addExtraValidation: boolean, ): z.ZodSchema { let result: z.ZodSchema = schema; // parse string to Decimal if (schema instanceof z.ZodString) { result = schema .superRefine((v, ctx) => { try { new Decimal(v); } catch (err) { ctx.addIssue({ code: 'custom', message: `Invalid decimal: ${err}`, }); } }) .transform((val) => new Decimal(val)); } // add validations function refine(schema: z.ZodSchema, op: 'gt' | 'gte' | 'lt' | 'lte', value: number) { return schema.superRefine((v, ctx) => { const base = z.number(); const { error } = base[op](value).safeParse((v as Decimal).toNumber()); error?.issues.forEach((issue) => { if (op === 'gt' || op === 'gte') { ctx.addIssue({ code: 'too_small', origin: 'number', minimum: value, type: 'decimal', inclusive: op === 'gte', message: issue.message, }); } else { ctx.addIssue({ code: 'too_big', origin: 'number', maximum: value, type: 'decimal', inclusive: op === 'lte', message: issue.message, }); } }); }); } if (attributes && addExtraValidation) { for (const attr of attributes) { const val = getArgValue(attr.args?.[0]?.value); if (val === undefined) { continue; } switch (attr.name) { case '@gt': result = refine(result, 'gt', val); break; case '@gte': result = refine(result, 'gte', val); break; case '@lt': result = refine(result, 'lt', val); break; case '@lte': result = refine(result, 'lte', val); break; } } } return result; } export function addListValidation( schema: z.ZodArray, attributes: readonly AttributeApplication[] | undefined, ): z.ZodSchema { if (!attributes || attributes.length === 0) { return schema; } let result = schema; for (const attr of attributes) { if (attr.name === '@length') { const min = getArgValue(attr.args?.find((a) => a.name === 'min')?.value); if (min !== undefined) { result = result.min(min); } const max = getArgValue(attr.args?.find((a) => a.name === 'max')?.value); if (max !== undefined) { result = result.max(max); } } } return result; } /** * Recursively collects all field names referenced by `kind: 'field'` nodes * inside an expression tree. */ export function collectFieldRefs(expr: Expression): Set { const refs = new Set(); function walk(e: Expression): void { switch (e.kind) { case 'field': refs.add(e.field); break; case 'unary': walk(e.operand); break; case 'binary': walk(e.left); walk(e.right); break; case 'call': e.args?.forEach(walk); break; case 'array': e.items.forEach(walk); break; case 'member': walk(e.receiver); break; // literal / null / this / binding — no field refs } } walk(expr); return refs; } /** * Applies `@@validate` rules from `attributes` to `schema` as Zod refinements. * * When `presentFields` is provided, only rules whose every field reference is * present in the set are applied. Rules that reference a field absent from the * set are silently skipped — they cannot be evaluated correctly against a * partial payload (e.g. when `select` or `omit` has been used). * * Omit `presentFields` (or pass `undefined`) to apply all rules * unconditionally, which is the correct behaviour for full-model schemas. * * Note: `@@validate` conditions are restricted by the ZModel compiler to * scalar fields of the same model only — relation fields are a compile error. * A flat field-name set is therefore sufficient. */ export function addCustomValidation( schema: z.ZodSchema, attributes: readonly AttributeApplication[] | undefined, presentFields?: ReadonlySet, ): z.ZodSchema { const attrs = attributes?.filter((a) => a.name === '@@validate'); if (!attrs || attrs.length === 0) { return schema; } let result = schema; for (const attr of attrs) { const expr = attr.args?.[0]?.value; if (!expr) { continue; } // Skip rules that reference a field absent from the partial shape. if (presentFields !== undefined) { const refs = collectFieldRefs(expr); if ([...refs].some((ref) => !presentFields.has(ref))) { continue; } } const message = getArgValue(attr.args?.[1]?.value); const pathExpr = attr.args?.[2]?.value; let path: string[] | undefined = undefined; if (pathExpr && ExpressionUtils.isArray(pathExpr)) { path = pathExpr.items.map((e) => ExpressionUtils.getLiteralValue(e) as string); } result = applyValidation(result, expr, message, path); } return result; } function applyValidation( schema: z.ZodSchema, expr: Expression, message: string | undefined, path: string[] | undefined, ) { const options: Parameters[1] = {}; if (message) { options.error = message; } if (path) { options.path = path; } return schema.refine((data) => Boolean(evalExpression(data, expr)), options); } function evalExpression(data: any, expr: Expression): unknown { switch (expr.kind) { case 'literal': return expr.value; case 'array': return expr.items.map((item) => evalExpression(data, item)); case 'field': return evalField(data, expr); case 'member': return evalMember(data, expr); case 'unary': return evalUnary(data, expr); case 'binary': return evalBinary(data, expr); case 'call': return evalCall(data, expr); case 'this': return data ?? null; case 'null': return null; case 'binding': throw new SchemaFactoryError('Binding expression is not supported in validation expressions'); } } /** * Sentinel value returned by `evalField` when a field key is entirely absent * from the data object (as opposed to being present with a `null` value). * Used by comparison operators to skip @@validate rules against missing * optional fields (e.g. when `optionality: 'all'` produces a partial payload). */ const ABSENT = Symbol('absent'); function evalField(data: any, e: FieldExpression) { if (data == null || !(e.field in data)) { return ABSENT; } // Coerce undefined to null so downstream code only needs to handle null. return data[e.field] ?? null; } function evalUnary(data: any, expr: UnaryExpression) { const operand = evalExpression(data, expr.operand); switch (expr.op) { case '!': return !operand; default: throw new SchemaFactoryError(`Unsupported unary operator: ${expr.op}`); } } function evalBinary(data: any, expr: BinaryExpression) { const left = evalExpression(data, expr.left); const right = evalExpression(data, expr.right); switch (expr.op) { case '&&': return Boolean(left) && Boolean(right); case '||': return Boolean(left) || Boolean(right); case '==': // Treat ABSENT the same as null for equality checks — an absent // optional field and an explicit null are semantically equivalent. return (left === ABSENT ? null : left) == (right === ABSENT ? null : right); case '!=': return (left === ABSENT ? null : left) != (right === ABSENT ? null : right); case '<': case '<=': case '>': case '>=': // If either operand is the ABSENT sentinel (field not present in the // partial payload), skip the comparison by returning true so that // @@validate rules are not incorrectly triggered against missing // optional fields (e.g. when optionality: 'all' produces a partial // object or a field is omitted/not-selected). if (left === ABSENT || right === ABSENT) return true; return expr.op === '<' ? (left as any) < (right as any) : expr.op === '<=' ? (left as any) <= (right as any) : expr.op === '>' ? (left as any) > (right as any) : (left as any) >= (right as any); case '?': if (!Array.isArray(left)) { return false; } return left.some((item) => item === right); case '!': if (!Array.isArray(left)) { return false; } return left.every((item) => item === right); case '^': if (!Array.isArray(left)) { return false; } return !left.some((item) => item === right); case 'in': if (!Array.isArray(right)) { return false; } return right.includes(left); default: throw new SchemaFactoryError(`Unsupported binary operator: ${expr.op}`); } } function evalMember(data: any, expr: MemberExpression) { let result: any = evalExpression(data, expr.receiver); for (const member of expr.members) { if (!result || typeof result !== 'object') { return undefined; } result = result[member]; } return result ?? null; } function evalCall(data: any, expr: CallExpression) { const f = expr.function; const fieldArg = expr.args?.[0] ? evalExpression(data, expr.args[0]) : undefined; switch (f) { // string functions case 'length': { if (fieldArg === undefined || fieldArg === null || fieldArg === ABSENT) { return false; } invariant( typeof fieldArg === 'string' || Array.isArray(fieldArg), `"${f}" first argument must be a string or a list`, ); return fieldArg.length; } case 'startsWith': case 'endsWith': case 'contains': { if (fieldArg === undefined || fieldArg === null || fieldArg === ABSENT) { return false; } invariant(typeof fieldArg === 'string', `"${f}" first argument must be a string`); invariant(expr.args?.[1], `"${f}" requires a search argument`); const search = getArgValue(expr.args?.[1])!; const caseInsensitive = getArgValue(expr.args?.[2]) ?? false; const applyStringOp = (x: string, y: string) => { switch (f) { case 'startsWith': return x.startsWith(y); case 'endsWith': return x.endsWith(y); case 'contains': return x.includes(y); } }; return caseInsensitive ? applyStringOp(fieldArg.toLowerCase(), search.toLowerCase()) : applyStringOp(fieldArg, search); } case 'regex': { if (fieldArg === undefined || fieldArg === null || fieldArg === ABSENT) { return false; } invariant(typeof fieldArg === 'string', `"${f}" first argument must be a string`); const pattern = getArgValue(expr.args?.[1])!; invariant(pattern !== undefined, `"${f}" requires a pattern argument`); return new RegExp(pattern).test(fieldArg); } case 'isEmail': case 'isUrl': case 'isPhone': case 'isUuid': case 'isDate': case 'isTime': case 'isDateTime': { if (fieldArg === undefined || fieldArg === null || fieldArg === ABSENT) { return false; } invariant(typeof fieldArg === 'string', `"${f}" first argument must be a string`); if (f === 'isTime') { const precision = getArgValue(expr.args?.[1]); invariant( precision === null || precision == undefined || typeof precision === 'number', `"isTime" optional second argument must be a number`, ); return z.iso.time({ precision }).safeParse(fieldArg).success; } else if (f === 'isUuid') { const version = getArgValue(expr.args?.[1]); invariant( version === null || version == undefined || version === 4 || version === 7, `"isUuid" optional second argument must 4 or 7`, ); return z.uuid({ version: version ? `v${version}` : undefined }).safeParse(fieldArg).success; } const fn = stringFuncZodMap[f]; return z.string()[fn]().safeParse(fieldArg).success; } // list functions case 'has': case 'hasEvery': case 'hasSome': { invariant(expr.args?.[1], `${f} requires a search argument`); if (fieldArg === undefined || fieldArg === null || fieldArg === ABSENT) { return false; } invariant(Array.isArray(fieldArg), `"${f}" first argument must be an array field`); const search = evalExpression(data, expr.args?.[1])!; if (f === 'has') { return fieldArg.some((item) => item === search); } else if (f === 'hasEvery') { invariant(Array.isArray(search), 'hasEvery second argument must be an array'); return search.every((v) => fieldArg.some((item) => item === v)); } else { invariant(Array.isArray(search), 'hasSome second argument must be an array'); return search.some((v) => fieldArg.some((item) => item === v)); } } case 'isEmpty': { if (fieldArg === undefined || fieldArg === null || fieldArg === ABSENT) { return false; } invariant(Array.isArray(fieldArg), `"${f}" first argument must be an array field`); return fieldArg.length === 0; } default: throw new SchemaFactoryError(`Unsupported function "${f}"`); } } function invariant(condition: unknown, message?: string): asserts condition { if (!condition) { throw new SchemaFactoryError(message ?? 'Invariant failed'); } }