Initial
This commit is contained in:
298
resources/app/common/data/validation-failure.mjs
Normal file
298
resources/app/common/data/validation-failure.mjs
Normal file
@@ -0,0 +1,298 @@
|
||||
import {isEmpty} from "../utils/helpers.mjs";
|
||||
|
||||
/**
|
||||
* A class responsible for recording information about a validation failure.
|
||||
*/
|
||||
export class DataModelValidationFailure {
|
||||
/**
|
||||
* @param {any} [invalidValue] The value that failed validation for this field.
|
||||
* @param {any} [fallback] The value it was replaced by, if any.
|
||||
* @param {boolean} [dropped=false] Whether the value was dropped from some parent collection.
|
||||
* @param {string} [message] The validation error message.
|
||||
* @param {boolean} [unresolved=false] Whether this failure was unresolved
|
||||
*/
|
||||
constructor({invalidValue, fallback, dropped=false, message, unresolved=false}={}) {
|
||||
this.invalidValue = invalidValue;
|
||||
this.fallback = fallback;
|
||||
this.dropped = dropped;
|
||||
this.message = message;
|
||||
this.unresolved = unresolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* The value that failed validation for this field.
|
||||
* @type {any}
|
||||
*/
|
||||
invalidValue;
|
||||
|
||||
/**
|
||||
* The value it was replaced by, if any.
|
||||
* @type {any}
|
||||
*/
|
||||
fallback;
|
||||
|
||||
/**
|
||||
* Whether the value was dropped from some parent collection.
|
||||
* @type {boolean}
|
||||
*/
|
||||
dropped;
|
||||
|
||||
/**
|
||||
* The validation error message.
|
||||
* @type {string}
|
||||
*/
|
||||
message;
|
||||
|
||||
/**
|
||||
* If this field contains other fields that are validated as part of its validation, their results are recorded here.
|
||||
* @type {Record<string, DataModelValidationFailure>}
|
||||
*/
|
||||
fields = {};
|
||||
|
||||
/**
|
||||
* @typedef {object} ElementValidationFailure
|
||||
* @property {string|number} id Either the element's index or some other identifier for it.
|
||||
* @property {string} [name] Optionally a user-friendly name for the element.
|
||||
* @property {DataModelValidationFailure} failure The element's validation failure.
|
||||
*/
|
||||
|
||||
/**
|
||||
* If this field contains a list of elements that are validated as part of its validation, their results are recorded
|
||||
* here.
|
||||
* @type {ElementValidationFailure[]}
|
||||
*/
|
||||
elements = [];
|
||||
|
||||
/**
|
||||
* Record whether a validation failure is unresolved.
|
||||
* This reports as true if validation for this field or any hierarchically contained field is unresolved.
|
||||
* A failure is unresolved if the value was invalid and there was no valid fallback value available.
|
||||
* @type {boolean}
|
||||
*/
|
||||
unresolved;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Return this validation failure as an Error object.
|
||||
* @returns {DataModelValidationError}
|
||||
*/
|
||||
asError() {
|
||||
return new DataModelValidationError(this);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Whether this failure contains other sub-failures.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isEmpty() {
|
||||
return isEmpty(this.fields) && isEmpty(this.elements);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Return the base properties of this failure, omitting any nested failures.
|
||||
* @returns {{invalidValue: any, fallback: any, dropped: boolean, message: string}}
|
||||
*/
|
||||
toObject() {
|
||||
const {invalidValue, fallback, dropped, message} = this;
|
||||
return {invalidValue, fallback, dropped, message};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Represent the DataModelValidationFailure as a string.
|
||||
* @returns {string}
|
||||
*/
|
||||
toString() {
|
||||
return DataModelValidationFailure.#formatString(this);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Format a DataModelValidationFailure instance as a string message.
|
||||
* @param {DataModelValidationFailure} failure The failure instance
|
||||
* @param {number} _d An internal depth tracker
|
||||
* @returns {string} The formatted failure string
|
||||
*/
|
||||
static #formatString(failure, _d=0) {
|
||||
let message = failure.message ?? "";
|
||||
_d++;
|
||||
if ( !isEmpty(failure.fields) ) {
|
||||
message += "\n";
|
||||
const messages = [];
|
||||
for ( const [name, subFailure] of Object.entries(failure.fields) ) {
|
||||
const subMessage = DataModelValidationFailure.#formatString(subFailure, _d);
|
||||
messages.push(`${" ".repeat(2 * _d)}${name}: ${subMessage}`);
|
||||
}
|
||||
message += messages.join("\n");
|
||||
}
|
||||
if ( !isEmpty(failure.elements) ) {
|
||||
message += "\n";
|
||||
const messages = [];
|
||||
for ( const element of failure.elements ) {
|
||||
const subMessage = DataModelValidationFailure.#formatString(element.failure, _d);
|
||||
messages.push(`${" ".repeat(2 * _d)}${element.id}: ${subMessage}`);
|
||||
}
|
||||
message += messages.join("\n");
|
||||
}
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A specialised Error to indicate a model validation failure.
|
||||
* @extends {Error}
|
||||
*/
|
||||
export class DataModelValidationError extends Error {
|
||||
/**
|
||||
* @param {DataModelValidationFailure|string} failure The failure that triggered this error or an error message
|
||||
* @param {...any} [params] Additional Error constructor parameters
|
||||
*/
|
||||
constructor(failure, ...params) {
|
||||
super(failure.toString(), ...params);
|
||||
if ( failure instanceof DataModelValidationFailure ) this.#failure = failure;
|
||||
}
|
||||
|
||||
/**
|
||||
* The root validation failure that triggered this error.
|
||||
* @type {DataModelValidationFailure}
|
||||
*/
|
||||
#failure;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Retrieve the root failure that caused this error, or a specific sub-failure via a path.
|
||||
* @param {string} [path] The property path to the failure.
|
||||
* @returns {DataModelValidationFailure}
|
||||
*
|
||||
* @example Retrieving a failure.
|
||||
* ```js
|
||||
* const changes = {
|
||||
* "foo.bar": "validValue",
|
||||
* "foo.baz": "invalidValue"
|
||||
* };
|
||||
* try {
|
||||
* doc.validate(expandObject(changes));
|
||||
* } catch ( err ) {
|
||||
* const failure = err.getFailure("foo.baz");
|
||||
* console.log(failure.invalidValue); // "invalidValue"
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
getFailure(path) {
|
||||
if ( !this.#failure ) return;
|
||||
if ( !path ) return this.#failure;
|
||||
let failure = this.#failure;
|
||||
for ( const p of path.split(".") ) {
|
||||
if ( !failure ) return;
|
||||
if ( !isEmpty(failure.fields) ) failure = failure.fields[p];
|
||||
else if ( !isEmpty(failure.elements) ) failure = failure.elements.find(e => e.id?.toString() === p);
|
||||
}
|
||||
return failure;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Retrieve a flattened object of all the properties that failed validation as part of this error.
|
||||
* @returns {Record<string, DataModelValidationFailure>}
|
||||
*
|
||||
* @example Removing invalid changes from an update delta.
|
||||
* ```js
|
||||
* const changes = {
|
||||
* "foo.bar": "validValue",
|
||||
* "foo.baz": "invalidValue"
|
||||
* };
|
||||
* try {
|
||||
* doc.validate(expandObject(changes));
|
||||
* } catch ( err ) {
|
||||
* const failures = err.getAllFailures();
|
||||
* if ( failures ) {
|
||||
* for ( const prop in failures ) delete changes[prop];
|
||||
* doc.validate(expandObject(changes));
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
getAllFailures() {
|
||||
if ( !this.#failure || this.#failure.isEmpty() ) return;
|
||||
return DataModelValidationError.#aggregateFailures(this.#failure);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Log the validation error as a table.
|
||||
*/
|
||||
logAsTable() {
|
||||
const failures = this.getAllFailures();
|
||||
if ( isEmpty(failures) ) return;
|
||||
console.table(Object.entries(failures).reduce((table, [p, failure]) => {
|
||||
table[p] = failure.toObject();
|
||||
return table;
|
||||
}, {}));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Generate a nested tree view of the error as an HTML string.
|
||||
* @returns {string}
|
||||
*/
|
||||
asHTML() {
|
||||
const renderFailureNode = failure => {
|
||||
if ( failure.isEmpty() ) return `<li>${failure.message || ""}</li>`;
|
||||
const nodes = [];
|
||||
for ( const [field, subFailure] of Object.entries(failure.fields) ) {
|
||||
nodes.push(`<li><details><summary>${field}</summary><ul>${renderFailureNode(subFailure)}</ul></details></li>`);
|
||||
}
|
||||
for ( const element of failure.elements ) {
|
||||
const name = element.name || element.id;
|
||||
const html = `
|
||||
<li><details><summary>${name}</summary><ul>${renderFailureNode(element.failure)}</ul></details></li>
|
||||
`;
|
||||
nodes.push(html);
|
||||
}
|
||||
return nodes.join("");
|
||||
};
|
||||
return `<ul class="summary-tree">${renderFailureNode(this.#failure)}</ul>`;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Collect nested failures into an aggregate object.
|
||||
* @param {DataModelValidationFailure} failure The failure.
|
||||
* @returns {DataModelValidationFailure|Record<string, DataModelValidationFailure>} Returns the failure at the leaf of the
|
||||
* tree, otherwise an object of
|
||||
* sub-failures.
|
||||
*/
|
||||
static #aggregateFailures(failure) {
|
||||
if ( failure.isEmpty() ) return failure;
|
||||
const failures = {};
|
||||
const recordSubFailures = (field, subFailures) => {
|
||||
if ( subFailures instanceof DataModelValidationFailure ) failures[field] = subFailures;
|
||||
else {
|
||||
for ( const [k, v] of Object.entries(subFailures) ) {
|
||||
failures[`${field}.${k}`] = v;
|
||||
}
|
||||
}
|
||||
};
|
||||
for ( const [field, subFailure] of Object.entries(failure.fields) ) {
|
||||
recordSubFailures(field, DataModelValidationError.#aggregateFailures(subFailure));
|
||||
}
|
||||
for ( const element of failure.elements ) {
|
||||
recordSubFailures(element.id, DataModelValidationError.#aggregateFailures(element.failure));
|
||||
}
|
||||
return failures;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user