299 lines
9.5 KiB
JavaScript
299 lines
9.5 KiB
JavaScript
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;
|
|
}
|
|
}
|