@decaf-ts/db-decorators
Version:
Agnostic database decorators and repository
268 lines • 12.7 kB
JavaScript
import { getValidationDecorators, Model, ModelErrorDefinition, ModelKeys, toConditionalPromise, Validation, ValidationKeys, } from "@decaf-ts/decorator-validation";
import { Reflection } from "@decaf-ts/reflection";
import { UpdateValidationKeys } from "./../validation/index.js";
import { findModelId } from "./../identity/index.js";
/**
* @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(model, propsToIgnore) {
const decoratedProperties = [];
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(newModel, oldModel, prop, decorator, async) {
const validator = Validation.get(decorator.key);
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[prop], oldModel[prop], ...decoratorProps);
return toConditionalPromise(maybeError, async);
}
export function validateDecorators(newModel, oldModel, prop, decorators, async) {
const result = {};
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[prop];
const oldPropValue = oldModel[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) => {
t = typeof t === "function" && !t.name ? t() : t;
t = t.name ? t.name : t;
return String(t).toLowerCase();
});
const errs = newValues.map((childValue) => {
// find by id so the list elements order doesn't matter
const id = findModelId(childValue, true);
if (!id)
return "Failed to find model id";
const oldModel = oldValues.find((el) => 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;
});
}
else {
const allEmpty = errs.every((r) => !r);
validationErrors = errs.length > 0 && !allEmpty ? errs : undefined;
}
}
}
if (validationErrors)
result[decorator.key] = 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;
});
}
/**
* @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(oldModel, newModel, async, ...exceptions) {
const decoratedProperties = getValidatableUpdateProps(newModel, exceptions);
const result = {};
const nestedErrors = {};
for (const { prop, decorators } of decoratedProperties) {
const propKey = String(prop);
let propValue = newModel[prop];
if (!decorators?.length)
continue;
// Get the default type validator
const designTypeDec = decorators.find((d) => [ModelKeys.TYPE, ValidationKeys.TYPE].includes(d.key));
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);
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 = 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 = 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[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);
}
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