UNPKG

@decaf-ts/decorator-validation

Version:
366 lines 17.2 kB
import { ModelErrorDefinition } from "./ModelErrorDefinition.js"; import { ModelKeys } from "./../utils/constants.js"; import { Validation } from "./../validation/Validation.js"; import { ValidationKeys } from "./../validation/Validators/constants.js"; import { PathProxyEngine } from "./../utils/PathProxy.js"; import { ASYNC_META_KEY, VALIDATION_PARENT_KEY } from "./../constants/index.js"; import { toConditionalPromise } from "./utils.js"; import { Metadata } from "@decaf-ts/decoration"; import { ModelRegistryManager } from "./ModelRegistry.js"; /** * Safely sets temporary metadata on an object */ function setTemporaryContext(target, key, value) { if (!Object.hasOwnProperty.call(target, key)) target[key] = value; } /** * Safely removes temporary metadata from an object */ function cleanupTemporaryContext(target, key) { if (Object.hasOwnProperty.call(target, key)) delete target[key]; } /** * Executes validation with temporary context and returns the validation result * * @param nestedModel - The instance to validate * @param parentModel - Reference to a parent object for nested validation * @param isAsync - Whether to perform async validation * @returns Validation result from hasErrors() */ function getNestedValidationErrors(nestedModel, parentModel, isAsync, ...propsToIgnore) { // Set temporary context for nested models if (parentModel) { setTemporaryContext(nestedModel, VALIDATION_PARENT_KEY, parentModel); } setTemporaryContext(nestedModel, ASYNC_META_KEY, !!isAsync); const errs = nestedModel.hasErrors(...propsToIgnore); cleanupTemporaryContext(nestedModel, VALIDATION_PARENT_KEY); cleanupTemporaryContext(nestedModel, ASYNC_META_KEY); return errs; } export function validateChildValue(prop, childValue, parentModel, allowedTypes, async, ...propsToIgnore) { let err = undefined; let atLeastOneMatched = false; for (const allowedType of allowedTypes) { const Constr = ModelRegistryManager.getRegistry().get(allowedType); if (!Constr) { err = new ModelErrorDefinition({ [prop]: { [ValidationKeys.TYPE]: `Unable to verify type consistency, missing model registry for ${allowedType}`, }, }); } if (childValue instanceof Constr) { atLeastOneMatched = true; err = getNestedValidationErrors(childValue, parentModel, async, ...propsToIgnore); break; } } if (atLeastOneMatched) return err; return (err || new ModelErrorDefinition({ [prop]: { [ValidationKeys.TYPE]: `Value must be an instance of one of the expected types: ${allowedTypes.join(", ")}`, }, })); } /** * @description Retrieves nested properties to ignore for child validation * @param parentProp - The property of the parent model * @param propsToIgnore - Properties to ignore from the parent model * @returns An array of properties to ignore for the child model */ export function getChildNestedPropsToIgnore(parentProp, ...propsToIgnore) { return propsToIgnore?.map((propToIgnore) => { if (typeof propToIgnore === "string" && propToIgnore?.startsWith(`${parentProp}.`)) propToIgnore = propToIgnore.replace(`${parentProp}.`, ""); return propToIgnore; }); } export function validateDecorator(model, value, decorator, async) { // throw new Error("validateDecorator is not implemented"); const validator = Validation.get(decorator.key); if (!validator) { throw new Error(`Missing validator for ${decorator.key}`); } // skip async decorators if validateDecorators is called synchronously (async = false) if (!async && decorator.async) return undefined; const decoratorProps = decorator.key === ModelKeys.TYPE ? [decorator] : decorator || {}; const context = PathProxyEngine.create(model, { ignoreUndefined: true, ignoreNull: true, }); const validatorOptions = decorator.key === ModelKeys.TYPE ? decoratorProps[0] : decoratorProps; const maybeAsyncErrors = validator.hasErrors(value, validatorOptions, context); return toConditionalPromise(maybeAsyncErrors, async); } /** * @description * Executes validation logic for a set of decorators applied to a model's property, handling both * synchronous and asynchronous validations, including support for nested validations and lists. * * @summary * Iterates over an array of decorator metadata objects and applies each validation rule to the * provided value. For list decorators (`ValidationKeys.LIST`), it performs element-wise validation, * supporting nested model validation and type checks. If the `async` flag is set, asynchronous * validation is supported using `Promise.all`. The result is a record mapping validation keys to * error messages, or `undefined` if no errors are found. * * @template M - A type parameter extending `Model`, representing the model type being validated. * @template Async - A boolean indicating whether validation should be performed asynchronously. * * @param {M} model - The model instance that the validation is associated with. * @param {string} prop - The model field name * @param {any} value - The value to be validated against the provided decorators. * @param {DecoratorMetadataAsync} decorators - An object of metadata representing validation decorators. * @param {Async} [async] - Optional flag indicating whether validation should be performed asynchronously. * * @return {ConditionalAsync<Async, Record<string, string>> | undefined} * Returns either a record of validation errors (keyed by the decorator key) or `undefined` if no errors are found. * If `async` is true, the return value is a Promise resolving to the same structure. * * @function validateDecorators */ export function validateDecorators(model, prop, value, decorators, async, ...propsToIgnore) { const result = {}; for (const decoratorKey in decorators) { const decorator = { ...decorators[decoratorKey], key: decoratorKey }; // skip async decorators if validateDecorators is called synchronously (async = false) if (!async && decorator.async) continue; let validationErrors = validateDecorator(model, value, decorator, async); /* If the decorator is a list, each element must be checked. When 'async' is true, the 'err' will always be a pending promise initially, so the '!err' check will evaluate to false (even if the promise later resolves with no errors) */ if (decoratorKey === ValidationKeys.LIST && (!validationErrors || async)) { const values = value instanceof Set ? [...value] : value; if (values && values.length > 0) { let types = (decorator.class || decorator.clazz || decorator.customTypes); types = (Array.isArray(types) ? types : [types]).map((e) => { e = typeof e === "function" && !e.name ? e() : e; return e.name ? e.name : e; }); const allowedTypes = [types].flat().map((t) => String(t).toLowerCase()); // const reserved = Object.values(ReservedModels).map((v) => v.toLowerCase()) as string[]; const errs = values.map((childValue) => { // if (Model.isModel(v) && !reserved.includes(v) { if (Metadata.isModel(childValue)) { const nestedPropsToIgnore = getChildNestedPropsToIgnore(prop, ...propsToIgnore); return validateChildValue(prop, childValue, model, types.flat(), !!async, ...nestedPropsToIgnore); // return getNestedValidationErrors(childValue, model, async); } return allowedTypes.includes(typeof childValue) ? undefined : "Value has no validatable type"; }); if (async) { validationErrors = Promise.all(errs).then((result) => { const allEmpty = result.every((r) => !r); return allEmpty ? undefined : result; }); } else { const allEmpty = errs.every((r) => !r); validationErrors = errs.length > 0 && !allEmpty ? errs : undefined; } } } const name = decoratorKey === ModelKeys.TYPE ? ValidationKeys.TYPE : decoratorKey; if (validationErrors) result[name] = validationErrors; } if (!async) return Object.keys(result).length > 0 ? result : undefined; const keys = Object.keys(result); const promises = Object.values(result); return Promise.all(promises).then((resolvedValues) => { const res = {}; for (let i = 0; i < resolvedValues.length; i++) { const val = resolvedValues[i]; if (val !== undefined) { res[keys[i]] = val; } } return Object.keys(res).length > 0 ? res : undefined; }); } /** * @function validate * @template M * @template Async * @memberOf module:decorator-validation * @category Model * * @description * Validates the properties of a {@link Model} instance using registered decorators. * Supports both synchronous and asynchronous validation flows, depending on the `async` flag. * * @summary * This function inspects a given model object, identifies decorated properties that require validation, * and applies the corresponding validation rules. It also supports nested model validation and gracefully * merges any validation errors. For collections (Array/Set), it enforces the presence of the `@list` decorator * and checks the type of elements. If a property is a nested model, it will call `hasErrors` on it and flatten * the nested error keys using dot notation. * * @param {M} model - The model instance to be validated. Must extend from {@link Model}. * @param {Async} [async] - A flag indicating whether validation should be asynchronous. * @param {...string} propsToIgnore - A variadic list of property names that should be skipped during validation. * * @returns {ConditionalAsync<Async, ModelErrorDefinition | undefined>} * Returns either a {@link ModelErrorDefinition} containing validation errors, * or `undefined` if no errors are found. When `async` is `true`, returns a Promise. * * @see {@link Model} * @see {@link ModelErrorDefinition} * @see {@link validateDecorators} * @see {@link getValidatableProperties} * * @mermaid * sequenceDiagram * participant Caller * participant validate * participant getValidatableProperties * participant validateDecorators * participant ModelInstance * Caller->>validate: call with obj, async, propsToIgnore * validate->>getValidatableProperties: retrieve decorated props * loop for each property * validate->>validateDecorators: validate using decorators * alt is nested model * validate->>ModelInstance: call hasErrors() * end * end * alt async * validate->>validate: Promise.allSettled for errors * end * validate-->>Caller: return ModelErrorDefinition | undefined */ export function validate(model, async, ...propsToIgnore) { // throw new Error("validate is not implemented"); const decoratedProperties = Metadata.validatableProperties(model.constructor, ...propsToIgnore); const result = {}; const nestedErrors = {}; for (const prop of decoratedProperties) { const propKey = String(prop); const propValue = model[prop]; const decorators = Metadata.validationFor(model.constructor, prop) || {}; const { designTypes } = Metadata.getPropDesignTypes(model.constructor, prop); if (!designTypes) continue; // Handle array or Set types and enforce the presence of @list decorator if (designTypes.some((t) => [Array.name, Set.name].includes(t.name))) { if (!decorators || !Object.keys(decorators).includes(ValidationKeys.LIST)) { result[propKey] = { [ValidationKeys.TYPE]: `Array or Set property '${propKey}' requires a @list decorator`, }; continue; } if (propValue && !(Array.isArray(propValue) || propValue instanceof Set)) { result[propKey] = { [ValidationKeys.TYPE]: `Property '${String(prop)}' must be either an Array or a Set`, }; continue; } } const propErrors = validateDecorators(model, propKey, propValue, decorators, async, ...propsToIgnore) || {}; // Check for nested properties. // To prevent unnecessary processing, "propValue" must be defined const isConstr = Metadata.isPropertyModel(model, propKey); const hasPropValue = propValue !== null && propValue !== undefined; if (isConstr && hasPropValue) { // If property comes from a relation and has populate flag set to false, this will have the value of the id of that relation, instead of a model. // We need to capture that and excempt it from throwing an error. This is being handled in the core Metadata.validationExceptions. const Constr = designTypes .map((d) => ModelRegistryManager.getRegistry().get(d.name || d)) .find((d) => !!d); const designTypeNames = designTypes.map((d) => { if (typeof d === "function") return d.name ? d.name.toLowerCase() : d()?.name.toLowerCase(); return d.toLowerCase(); }); // If instance is NOT of the expected model class. if (!Constr || !(propValue instanceof Constr)) { if (designTypeNames.includes(typeof propValue)) { // do nothing } else { // If types don't match throw an error propErrors[ValidationKeys.TYPE] = !Constr ? `Unable to verify type consistency, missing model registry for ${designTypes.toString()} on prop ${propKey}` : `Value must be an instance of ${Constr.name}`; delete propErrors[ModelKeys.TYPE]; // remove duplicate type error } } else { const nestedPropsToIgnore = getChildNestedPropsToIgnore(propKey, ...propsToIgnore); nestedErrors[propKey] = getNestedValidationErrors(propValue, model, async, ...nestedPropsToIgnore); } } // Add to the result if we have any errors // Async mode returns a Promise that resolves to undefined when no errors exist if (Object.keys(propErrors).length > 0 || async) result[propKey] = propErrors; // Then merge any nested errors if (!async) { Object.entries(nestedErrors[propKey] || {}).forEach(([key, error]) => { if (error !== undefined) { result[`${propKey}.${key}`] = error; } }); } } // Synchronous return if (!async) { return (Object.keys(result).length > 0 ? new ModelErrorDefinition(result) : undefined); } const merged = result; // TODO: apply filtering const keys = Object.keys(merged); const promises = Object.values(merged); return Promise.allSettled(promises).then(async (results) => { const result = {}; for (const [parentProp, nestedErrPromise] of Object.entries(nestedErrors)) { const nestedPropDecErrors = (await nestedErrPromise); if (nestedPropDecErrors) Object.entries(nestedPropDecErrors).forEach(([nestedProp, nestedPropDecError]) => { if (nestedPropDecError !== undefined) { const nestedKey = [parentProp, nestedProp].join("."); result[nestedKey] = nestedPropDecError; } }); } for (let i = 0; i < results.length; i++) { const key = keys[i]; const res = results[i]; if (res.status === "fulfilled" && res.value !== undefined) { result[key] = res.value; } else if (res.status === "rejected") { result[key] = res.reason instanceof Error ? res.reason.message : String(res.reason || "Validation failed"); } } return Object.keys(result).length > 0 ? new ModelErrorDefinition(result) : undefined; }); } //# sourceMappingURL=validation.js.map