import {
ConditionalAsync,
DecoratorMetadataAsync,
getValidationDecorators,
Model,
ModelConditionalAsync,
ModelErrorDefinition,
ModelErrors,
ModelKeys,
toConditionalPromise,
Validation,
ValidationKeys,
ValidationPropertyDecoratorDefinition,
} from "@decaf-ts/decorator-validation";
import { Reflection } from "@decaf-ts/reflection";
import { UpdateValidationKeys, UpdateValidator } from "../validation";
import { findModelId } from "../identity";
/**
* @description
* Retrieves validation decorator definitions from a model for update operations, including
* support for special handling of list decorators.
*
* @summary
* Iterates over the model's own enumerable properties and filters out those specified in the
* `propsToIgnore` array. For each remaining property, retrieves validation decorators specific
* to update operations using the `UpdateValidationKeys.REFLECT` key. Additionally, it explicitly
* checks for and appends any `LIST` type decorators to ensure proper validation of collection types.
*
* @template M - A generic parameter extending the `Model` class, representing the model type being inspected.
*
* @param {M} model - The model instance whose properties are being inspected for update-related validations.
* @param {string[]} propsToIgnore - A list of property names to exclude from the validation decorator retrieval process.
*
* @return {ValidationPropertyDecoratorDefinition[]} An array of validation decorator definitions, including both
* update-specific and list-type decorators, excluding those for ignored properties.
*
* @function getValidatableUpdateProps
*/
export function getValidatableUpdateProps<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 validationPropertyDefinition = getValidationDecorators(
model,
prop,
UpdateValidationKeys.REFLECT
);
const listDecorator = getValidationDecorators(
model,
prop
).decorators.find(({ key }) => key === ValidationKeys.LIST);
if (listDecorator)
validationPropertyDefinition.decorators.push(listDecorator);
decoratedProperties.push(validationPropertyDefinition);
}
}
return decoratedProperties;
}
export function validateDecorator<
M extends Model,
Async extends boolean = false,
>(
newModel: M,
oldModel: M,
prop: string,
decorator: DecoratorMetadataAsync,
async?: Async
): ConditionalAsync<Async, string | undefined> {
const validator: UpdateValidator = Validation.get(
decorator.key
) as UpdateValidator;
if (!validator) {
throw new Error(`Missing validator for ${decorator.key}`);
}
// Skip validators that aren't UpdateValidators
if (!validator.updateHasErrors) return toConditionalPromise(undefined, async);
// skip async decorators if validateDecorators is called synchronously (async = false)
if (!async && decorator.props.async)
return toConditionalPromise(undefined, async);
const decoratorProps = Object.values(decorator.props) || {};
// const context = PathProxyEngine.create(obj, {
// ignoreUndefined: true,
// ignoreNull: true,
// });
const maybeError = validator.updateHasErrors(
(newModel as any)[prop],
(oldModel as any)[prop],
...decoratorProps
);
return toConditionalPromise(maybeError, async);
}
export function validateDecorators<
M extends Model,
Async extends boolean = false,
>(
newModel: M,
oldModel: M,
prop: string,
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(
newModel,
oldModel,
prop,
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 newPropValue = (newModel as any)[prop];
const oldPropValue = (oldModel as any)[prop];
const newValues =
newPropValue instanceof Set ? [...newPropValue] : newPropValue;
const oldValues =
oldPropValue instanceof Set ? [...oldPropValue] : oldPropValue;
if (newValues && newValues.length > 0) {
const types =
decorator.props.class ||
decorator.props.clazz ||
decorator.props.customTypes;
const allowedTypes = [types].flat().map((t) => String(t).toLowerCase());
const errs = newValues.map((childValue: any) => {
// find by id so the list elements order doesn't matter
const id = findModelId(childValue as any, true);
if (!id) return "Failed to find model id";
const oldModel = oldValues.find(
(el: any) => id === findModelId(el, true)
);
if (Model.isModel(childValue)) {
return childValue.hasErrors(oldModel);
}
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;
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;
}
/**
* @description Validates changes between two model versions
* @summary Compares an old and new model version to validate update operations
* @template M - Type extending Model
* @param {M} oldModel - The original model version
* @param {M} newModel - The updated model version
* @param {boolean} async - A flag indicating whether validation should be asynchronous.
* @param {...string[]} exceptions - Properties to exclude from validation
* @return {ModelErrorDefinition|undefined} Error definition if validation fails, undefined otherwise
* @function validateCompare
* @memberOf module:db-decorators
* @mermaid
* sequenceDiagram
* participant Caller
* participant validateCompare
* participant Reflection
* participant Validation
*
* Caller->>validateCompare: oldModel, newModel, exceptions
* validateCompare->>Reflection: get decorated properties
* Reflection-->>validateCompare: property decorators
* loop For each decorated property
* validateCompare->>Validation: get validator
* Validation-->>validateCompare: validator
* validateCompare->>validateCompare: validate property update
* end
* loop For nested models
* validateCompare->>validateCompare: validate nested models
* end
* validateCompare-->>Caller: validation errors or undefined
*/
export function validateCompare<M extends Model<any>>(
oldModel: M,
newModel: M,
async: boolean,
...exceptions: string[]
): ModelConditionalAsync<M> {
const decoratedProperties: ValidationPropertyDecoratorDefinition[] =
getValidatableUpdateProps(newModel, exceptions);
const result: Record<string, any> = {};
const nestedErrors: Record<string, any> = {};
for (const { prop, decorators } of decoratedProperties) {
const propKey = String(prop);
let propValue = (newModel as any)[prop];
if (!decorators?.length) continue;
// Get the default type validator
const designTypeDec = decorators.find((d) =>
[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)) {
const { decorators } = Reflection.getPropertyDecorators(
ValidationKeys.REFLECT,
newModel,
propKey
) as unknown as ValidationPropertyDecoratorDefinition;
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(newModel, oldModel, propKey, decorators, async) || {};
// Check for nested properties.
// To prevent unnecessary processing, "propValue" must be defined and validatable
const isConstr = Model.isPropertyModel(newModel, propKey);
// if propValue !== undefined, null
if (propValue && isConstr) {
const instance: Model = propValue;
const isInvalidModel =
typeof instance !== "object" ||
!instance.hasErrors ||
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 {
nestedErrors[propKey] = instance.hasErrors((oldModel as any)[prop]);
}
}
// 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