import { ModelErrorDefinition } from "./ModelErrorDefinition";
import { ModelKeys } from "../utils/constants";
import { Model } from "./Model";
import { Validation } from "../validation/Validation";
import { ValidationKeys } from "../validation/Validators/constants";
import {
ModelErrors,
ValidationPropertyDecoratorDefinition,
ValidatorOptions,
} from "../validation";
import { PathProxyEngine } from "../utils/PathProxy";
import { ASYNC_META_KEY, VALIDATION_PARENT_KEY } from "../constants";
import { ConditionalAsync, DecoratorMetadataAsync } from "../types";
import { Reflection } from "@decaf-ts/reflection";
import { toConditionalPromise } from "./utils";
/**
* Retrieves the validation metadata decorators associated with a specific property of a model,
* using the reflective metadata key.
*
* @param model - The model instance or class containing the decorated property.
* @param {string} prop - The name of the property whose decorators should be retrieved.
* @param {string} reflectKey - The metadata key used to retrieve the decorators.
* Defaults to `ValidationKeys.REFLECT`.
*
* @returns The validation decorators applied to the property
*/
export function getValidationDecorators(
model: Record<string, any>,
prop: string,
reflectKey: string = ValidationKeys.REFLECT
): ValidationPropertyDecoratorDefinition {
return Reflection.getPropertyDecorators(
reflectKey,
model,
prop
) as unknown as ValidationPropertyDecoratorDefinition;
}
/**
* @description
* Retrieves all validatable property decorators from a given model, excluding specified properties.
*
* @summary
* Iterates through the own enumerable properties of a model instance, filtering out any properties
* listed in the `propsToIgnore` array. For each remaining property, it checks whether validation
* decorators are present using `getValidationDecorators`, and if so, collects them in the result array.
*
* @template M - A generic parameter extending the `Model` class, representing the model type being inspected.
*
* @param {M} model - An instance of a class extending `Model` from which validatable properties will be extracted.
* @param {string[]} propsToIgnore - An array of property names that should be excluded from validation inspection.
*
* @return {ValidationPropertyDecoratorDefinition[]} An array of validation decorator definitions
* associated with the model's properties, excluding those listed in `propsToIgnore`.
*
* @function getValidatableProperties
*/
export function getValidatableProperties<M extends Model>(
model: M,
propsToIgnore: string[]
): ValidationPropertyDecoratorDefinition[] {
const decoratedProperties: ValidationPropertyDecoratorDefinition[] = [];
for (const prop in model) {
if (
Object.prototype.hasOwnProperty.call(model, prop) &&
!propsToIgnore.includes(prop)
) {
const dec = getValidationDecorators(model, prop);
if (dec) decoratedProperties.push(dec);
}
}
return decoratedProperties;
}
/**
* Safely sets temporary metadata on an object
*/
function setTemporaryContext(
target: any,
key: symbol | string,
value: unknown
): void {
if (!Object.hasOwnProperty.call(target, key)) target[key] = value;
}
/**
* Safely removes temporary metadata from an object
*/
function cleanupTemporaryContext(target: any, key: symbol | string): void {
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<
M extends Model,
Async extends boolean = false,
>(
nestedModel: M,
parentModel?: M,
isAsync?: Async
): ConditionalAsync<Async, ModelErrorDefinition | undefined> {
// Set temporary context for nested models
if (parentModel) {
setTemporaryContext(nestedModel, VALIDATION_PARENT_KEY, parentModel);
}
setTemporaryContext(nestedModel, ASYNC_META_KEY, !!isAsync);
const errs = nestedModel.hasErrors();
cleanupTemporaryContext(nestedModel, VALIDATION_PARENT_KEY);
cleanupTemporaryContext(nestedModel, ASYNC_META_KEY);
return errs as any;
}
export function validateChildValue<M extends Model>(
prop: string,
childValue: any,
parentModel: M,
allowedTypes: string[],
async: boolean
):
| string
| undefined
| ModelErrorDefinition
| Promise<string | undefined | ModelErrorDefinition> {
let err:
| ModelErrorDefinition
| string
| undefined
| Promise<string | undefined | ModelErrorDefinition> = undefined;
let atLeastOneMatched = false;
for (const allowedType of allowedTypes) {
const Constr = Model.get(allowedType) as any;
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);
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(", ")}`,
},
})
);
}
export function validateDecorator<
M extends Model,
Async extends boolean = false,
>(
model: M,
value: any,
decorator: DecoratorMetadataAsync,
async?: Async
): ConditionalAsync<Async, string | undefined> {
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.props.async) return undefined as any;
const decoratorProps =
decorator.key === ModelKeys.TYPE
? [decorator.props]
: decorator.props || {};
const context = PathProxyEngine.create(model, {
ignoreUndefined: true,
ignoreNull: true,
});
const maybeAsyncErrors = validator.hasErrors(
value,
decoratorProps as 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 array of metadata objects 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<
M extends Model,
Async extends boolean = false,
>(
model: M,
prop: string,
value: any,
decorators: DecoratorMetadataAsync[],
async?: Async
): ConditionalAsync<Async, Record<string, string> | undefined> {
const result: Record<string, string | Promise<string>> = {};
for (const decorator of decorators) {
// skip async decorators if validateDecorators is called synchronously (async = false)
if (!async && decorator.props.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 (decorator.key === ValidationKeys.LIST && (!validationErrors || async)) {
const values = value instanceof Set ? [...value] : value;
if (values && values.length > 0) {
const types = (decorator.props.class ||
decorator.props.clazz ||
decorator.props.customTypes) as string | string[];
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: any) => {
// if (Model.isModel(v) && !reserved.includes(v) {
if (Model.isModel(childValue)) {
return validateChildValue(
prop,
childValue,
model,
[types].flat(),
!!async
);
// 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;
}) as any;
} else {
const allEmpty = errs.every((r: string | undefined) => !r);
validationErrors = errs.length > 0 && !allEmpty ? errs : undefined;
}
}
}
if (validationErrors) (result as any)[decorator.key] = validationErrors;
}
if (!async)
return Object.keys(result).length > 0
? (result as any)
: (undefined as any);
const keys = Object.keys(result);
const promises = Object.values(result) as Promise<string | undefined>[];
return Promise.all(promises).then((resolvedValues) => {
const res: Record<string, string> = {};
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;
}) as any;
}
/**
* @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<
M extends Model<boolean>,
Async extends boolean = false,
>(
model: M,
async: Async,
...propsToIgnore: string[]
): ConditionalAsync<Async, ModelErrorDefinition | undefined> {
const decoratedProperties: ValidationPropertyDecoratorDefinition[] =
getValidatableProperties(model, propsToIgnore);
const result: Record<string, any> = {};
const nestedErrors: Record<string, any> = {};
for (const { prop, decorators } of decoratedProperties) {
const propKey = String(prop);
let propValue = (model as any)[prop];
if (!decorators?.length) continue;
// Get the default type validator
const designTypeDec = decorators.find((d) => {
return [ModelKeys.TYPE, ValidationKeys.TYPE].includes(d.key as any);
});
if (!designTypeDec) continue;
const designType = designTypeDec.props.name;
// Handle array or Set types and enforce the presence of @list decorator
if ([Array.name, Set.name].includes(designType)) {
if (!decorators.some((d) => d.key === 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;
}
// Remove design:type decorator, since @list decorator already ensures type
for (let i = decorators.length - 1; i >= 0; i--) {
if (decorators[i].key === ModelKeys.TYPE) {
decorators.splice(i, 1);
}
}
propValue = propValue instanceof Set ? [...propValue] : propValue;
}
const propErrors: Record<string, any> =
validateDecorators(model, propKey, propValue, decorators, async) || {};
// Check for nested properties.
// To prevent unnecessary processing, "propValue" must be defined and validatable
// let nestedErrors: Record<string, any> = {};
const isConstr = Model.isPropertyModel(model, propKey);
const hasPropValue = propValue !== null && propValue !== undefined;
if (isConstr && hasPropValue) {
const instance = propValue as Model;
const isInvalidModel =
typeof instance !== "object" ||
typeof instance.hasErrors !== "function";
if (isInvalidModel) {
// propErrors[ValidationKeys.TYPE] = "Model should be validatable but it's not.";
console.warn("Model should be validatable but it's not.");
} else {
const Constr = Model.get(designType) as any;
// Ensure instance is of the expected model class.
if (!Constr || !(instance instanceof Constr)) {
propErrors[ValidationKeys.TYPE] = !Constr
? `Unable to verify type consistency, missing model registry for ${designType} on prop ${propKey}`
: `Value must be an instance of ${Constr.name}`;
} else {
nestedErrors[propKey] = getNestedValidationErrors(
instance,
model,
async
);
}
}
}
// 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
) as any;
}
const merged: any = result; // TODO: apply filtering
const keys = Object.keys(merged);
const promises = Object.values(merged);
return Promise.allSettled(promises).then(async (results) => {
const result: ModelErrors = {};
for (const [parentProp, nestedErrPromise] of Object.entries(nestedErrors)) {
const nestedPropDecErrors = (await nestedErrPromise) as Record<
string,
any
>;
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 as any)[key] = res.value;
} else if (res.status === "rejected") {
(result as any)[key] =
res.reason instanceof Error
? res.reason.message
: String(res.reason || "Validation failed");
}
}
return Object.keys(result).length > 0
? new ModelErrorDefinition(result)
: undefined;
}) as any;
}
Source