Files
Foundry-VTT-Docker/resources/app/common/abstract/data.mjs

615 lines
24 KiB
JavaScript
Raw Normal View History

2025-01-04 00:34:03 +01:00
import {deepClone, diffObject, expandObject, flattenObject, getType, isEmpty, mergeObject} from "../utils/helpers.mjs";
import {
DataField,
SchemaField,
EmbeddedDataField,
EmbeddedCollectionField,
ObjectField,
TypeDataField, EmbeddedDocumentField
} from "../data/fields.mjs";
import {DataModelValidationFailure} from "../data/validation-failure.mjs";
/**
* @typedef {Record<string, DataField>} DataSchema
*/
/**
* @typedef {Object} DataValidationOptions
* @property {boolean} [strict=true] Throw an error if validation fails.
* @property {boolean} [fallback=false] Attempt to replace invalid values with valid defaults?
* @property {boolean} [partial=false] Allow partial source data, ignoring absent fields?
* @property {boolean} [dropInvalidEmbedded=false] If true, invalid embedded documents will emit a warning and be
* placed in the invalidDocuments collection rather than causing the
* parent to be considered invalid.
*/
/**
* The abstract base class which defines the data schema contained within a Document.
* @param {object} [data={}] Initial data used to construct the data object. The provided object
* will be owned by the constructed model instance and may be mutated.
* @param {DataValidationOptions} [options={}] Options which affect DataModel construction
* @param {Document} [options.parent] A parent DataModel instance to which this DataModel belongs
* @abstract
*/
export default class DataModel {
constructor(data={}, {parent=null, strict=true, ...options}={}) {
// Parent model
Object.defineProperty(this, "parent", {
value: (() => {
if ( parent === null ) return null;
if ( parent instanceof DataModel ) return parent;
throw new Error("The provided parent must be a DataModel instance");
})(),
writable: false,
enumerable: false
});
// Source data
Object.defineProperty(this, "_source", {
value: this._initializeSource(data, {strict, ...options}),
writable: false,
enumerable: false
});
Object.seal(this._source);
// Additional subclass configurations
this._configure(options);
// Data validation and initialization
const fallback = options.fallback ?? !strict;
const dropInvalidEmbedded = options.dropInvalidEmbedded ?? !strict;
this.validate({strict, fallback, dropInvalidEmbedded, fields: true, joint: true});
this._initialize({strict, ...options});
}
/**
* Configure the data model instance before validation and initialization workflows are performed.
* @protected
*/
_configure(options={}) {}
/* -------------------------------------------- */
/**
* The source data object for this DataModel instance.
* Once constructed, the source object is sealed such that no keys may be added nor removed.
* @type {object}
*/
_source;
/**
* The defined and cached Data Schema for all instances of this DataModel.
* @type {SchemaField}
* @private
*/
static _schema;
/**
* An immutable reverse-reference to a parent DataModel to which this model belongs.
* @type {DataModel|null}
*/
parent;
/* ---------------------------------------- */
/* Data Schema */
/* ---------------------------------------- */
/**
* Define the data schema for documents of this type.
* The schema is populated the first time it is accessed and cached for future reuse.
* @virtual
* @returns {DataSchema}
*/
static defineSchema() {
throw new Error(`The ${this["name"]} subclass of DataModel must define its Document schema`);
}
/* ---------------------------------------- */
/**
* The Data Schema for all instances of this DataModel.
* @type {SchemaField}
*/
static get schema() {
if ( this.hasOwnProperty("_schema") ) return this._schema;
const schema = new SchemaField(Object.freeze(this.defineSchema()));
Object.defineProperty(this, "_schema", {value: schema, writable: false});
return schema;
}
/* ---------------------------------------- */
/**
* Define the data schema for this document instance.
* @type {SchemaField}
*/
get schema() {
return this.constructor.schema;
}
/* ---------------------------------------- */
/**
* Is the current state of this DataModel invalid?
* The model is invalid if there is any unresolved failure.
* @type {boolean}
*/
get invalid() {
return Object.values(this.#validationFailures).some(f => f?.unresolved);
}
/**
* An array of validation failure instances which may have occurred when this instance was last validated.
* @type {{fields: DataModelValidationFailure|null, joint: DataModelValidationFailure|null}}
*/
get validationFailures() {
return this.#validationFailures;
}
#validationFailures = Object.seal({fields: null, joint: null });
/**
* A set of localization prefix paths which are used by this DataModel.
* @type {string[]}
*/
static LOCALIZATION_PREFIXES = [];
/* ---------------------------------------- */
/* Data Cleaning Methods */
/* ---------------------------------------- */
/**
* Initialize the source data for a new DataModel instance.
* One-time migrations and initial cleaning operations are applied to the source data.
* @param {object|DataModel} data The candidate source data from which the model will be constructed
* @param {object} [options] Options provided to the model constructor
* @returns {object} Migrated and cleaned source data which will be stored to the model instance
* @protected
*/
_initializeSource(data, options={}) {
if ( data instanceof DataModel ) data = data.toObject();
const dt = getType(data);
if ( dt !== "Object" ) {
logger.error(`${this.constructor.name} was incorrectly constructed with a ${dt} instead of an object.
Attempting to fall back to default values.`)
data = {};
}
data = this.constructor.migrateDataSafe(data); // Migrate old data to the new format
data = this.constructor.cleanData(data); // Clean the data in the new format
return this.constructor.shimData(data); // Apply shims which preserve backwards compatibility
}
/* ---------------------------------------- */
/**
* Clean a data source object to conform to a specific provided schema.
* @param {object} [source] The source data object
* @param {object} [options={}] Additional options which are passed to field cleaning methods
* @returns {object} The cleaned source data
*/
static cleanData(source={}, options={}) {
return this.schema.clean(source, options);
}
/* ---------------------------------------- */
/* Data Initialization */
/* ---------------------------------------- */
/**
* A generator that orders the DataFields in the DataSchema into an expected initialization order.
* @returns {Generator<[string,DataField]>}
* @protected
*/
static *_initializationOrder() {
for ( const entry of this.schema.entries() ) yield entry;
}
/* ---------------------------------------- */
/**
* Initialize the instance by copying data from the source object to instance attributes.
* This mirrors the workflow of SchemaField#initialize but with some added functionality.
* @param {object} [options] Options provided to the model constructor
* @protected
*/
_initialize(options={}) {
for ( let [name, field] of this.constructor._initializationOrder() ) {
const sourceValue = this._source[name];
// Field initialization
const value = field.initialize(sourceValue, this, options);
// Special handling for Document IDs.
if ( (name === "_id") && (!Object.getOwnPropertyDescriptor(this, "_id") || (this._id === null)) ) {
Object.defineProperty(this, name, {value, writable: false, configurable: true});
}
// Readonly fields
else if ( field.readonly ) {
if ( this[name] !== undefined ) continue;
Object.defineProperty(this, name, {value, writable: false});
}
// Getter fields
else if ( value instanceof Function ) {
Object.defineProperty(this, name, {get: value, set() {}, configurable: true});
}
// Writable fields
else this[name] = value;
}
}
/* ---------------------------------------- */
/**
* Reset the state of this data instance back to mirror the contained source data, erasing any changes.
*/
reset() {
this._initialize();
}
/* ---------------------------------------- */
/**
* Clone a model, creating a new data model by combining current data with provided overrides.
* @param {Object} [data={}] Additional data which overrides current document data at the time of creation
* @param {object} [context={}] Context options passed to the data model constructor
* @returns {Document|Promise<Document>} The cloned Document instance
*/
clone(data={}, context={}) {
data = mergeObject(this.toObject(), data, {insertKeys: false, performDeletions: true, inplace: true});
return new this.constructor(data, {parent: this.parent, ...context});
}
/* ---------------------------------------- */
/* Data Validation Methods */
/* ---------------------------------------- */
/**
* Validate the data contained in the document to check for type and content
* This function throws an error if data within the document is not valid
*
* @param {object} options Optional parameters which customize how validation occurs.
* @param {object} [options.changes] A specific set of proposed changes to validate, rather than the full
* source data of the model.
* @param {boolean} [options.clean=false] If changes are provided, attempt to clean the changes before validating
* them?
* @param {boolean} [options.fallback=false] Allow replacement of invalid values with valid defaults?
* @param {boolean} [options.dropInvalidEmbedded=false] If true, invalid embedded documents will emit a warning and
* be placed in the invalidDocuments collection rather than
* causing the parent to be considered invalid.
* @param {boolean} [options.strict=true] Throw if an invalid value is encountered, otherwise log a warning?
* @param {boolean} [options.fields=true] Perform validation on individual fields?
* @param {boolean} [options.joint] Perform joint validation on the full data model?
* Joint validation will be performed by default if no changes are passed.
* Joint validation will be disabled by default if changes are passed.
* Joint validation can be performed on a complete set of changes (for
* example testing a complete data model) by explicitly passing true.
* @return {boolean} An indicator for whether the document contains valid data
*/
validate({changes, clean=false, fallback=false, dropInvalidEmbedded=false, strict=true, fields=true, joint}={}) {
const source = changes ?? this._source;
this.#validationFailures.fields = this.#validationFailures.joint = null; // Remove any prior failures
// Determine whether we are performing partial or joint validation
const partial = !!changes;
joint = joint ?? !changes;
if ( partial && joint ) {
throw new Error("It is not supported to perform joint data model validation with only a subset of changes");
}
// Optionally clean the data before validating
if ( partial && clean ) this.constructor.cleanData(source, {partial});
// Validate individual fields in the data or in a specific change-set, throwing errors if validation fails
if ( fields ) {
const failure = this.schema.validate(source, {partial, fallback, dropInvalidEmbedded});
if ( failure ) {
const id = this._source._id ? `[${this._source._id}] ` : "";
failure.message = `${this.constructor.name} ${id}validation errors:`;
this.#validationFailures.fields = failure;
if ( strict && failure.unresolved ) throw failure.asError();
else logger.warn(failure.asError());
}
}
// Perform joint document-level validations which consider all fields together
if ( joint ) {
try {
this.schema._validateModel(source); // Validate inner models
this.constructor.validateJoint(source); // Validate this model
} catch (err) {
const id = this._source._id ? `[${this._source._id}] ` : "";
const message = [this.constructor.name, id, `Joint Validation Error:\n${err.message}`].filterJoin(" ");
const failure = new DataModelValidationFailure({message, unresolved: true});
this.#validationFailures.joint = failure;
if ( strict ) throw failure.asError();
else logger.warn(failure.asError());
}
}
return !this.invalid;
}
/* ---------------------------------------- */
/**
* Evaluate joint validation rules which apply validation conditions across multiple fields of the model.
* Field-specific validation rules should be defined as part of the DataSchema for the model.
* This method allows for testing aggregate rules which impose requirements on the overall model.
* @param {object} data Candidate data for the model
* @throws An error if a validation failure is detected
*/
static validateJoint(data) {
/**
* @deprecated since v11
* @ignore
*/
if ( this.prototype._validateModel instanceof Function ) {
const msg = `${this.name} defines ${this.name}.prototype._validateModel instance method which should now be`
+ ` declared as ${this.name}.validateJoint static method.`
foundry.utils.logCompatibilityWarning(msg, {from: 11, until: 13});
return this.prototype._validateModel.call(this, data);
}
}
/* ---------------------------------------- */
/* Data Management */
/* ---------------------------------------- */
/**
* Update the DataModel locally by applying an object of changes to its source data.
* The provided changes are cleaned, validated, and stored to the source data object for this model.
* The source data is then re-initialized to apply those changes to the prepared data.
* The method returns an object of differential changes which modified the original data.
*
* @param {object} changes New values which should be applied to the data model
* @param {object} [options={}] Options which determine how the new data is merged
* @returns {object} An object containing the changed keys and values
*/
updateSource(changes={}, options={}) {
const schema = this.schema;
const source = this._source;
const _diff = {};
const _backup = {};
const _collections = this.collections;
const _singletons = this.singletons;
// Expand the object, if dot-notation keys are provided
if ( Object.keys(changes).some(k => /\./.test(k)) ) changes = expandObject(changes);
// Clean and validate the provided changes, throwing an error if any change is invalid
this.validate({changes, clean: true, fallback: options.fallback, strict: true, fields: true, joint: false});
// Update the source data for all fields and validate the final combined model
let error;
try {
DataModel.#updateData(schema, source, changes, {_backup, _collections, _singletons, _diff, ...options});
this.validate({fields: this.invalid, joint: true, strict: true});
} catch(err) {
error = err;
}
// Restore the backup data
if ( error || options.dryRun ) {
mergeObject(this._source, _backup, { recursive: false });
if ( error ) throw error;
}
// Initialize the updated data
if ( !options.dryRun ) this._initialize();
return _diff;
}
/* ---------------------------------------- */
/**
* Update the source data for a specific DataSchema.
* This method assumes that both source and changes are valid objects.
* @param {SchemaField} schema The data schema to update
* @param {object} source Source data to be updated
* @param {object} changes Changes to apply to the source data
* @param {object} [options={}] Options which modify the update workflow
* @returns {object} The updated source data
* @throws An error if the update operation was unsuccessful
* @private
*/
static #updateData(schema, source, changes, options) {
const {_backup, _diff} = options;
for ( let [name, value] of Object.entries(changes) ) {
const field = schema.get(name);
if ( !field ) continue;
// Skip updates where the data is unchanged
const prior = source[name];
if ( (value?.equals instanceof Function) && value.equals(prior) ) continue; // Arrays, Sets, etc...
if ( (prior === value) ) continue; // Direct comparison
_backup[name] = deepClone(prior);
_diff[name] = value;
// Field-specific updating logic
this.#updateField(name, field, source, value, options);
}
return source;
}
/* ---------------------------------------- */
/**
* Update the source data for a specific DataField.
* @param {string} name The field name being updated
* @param {DataField} field The field definition being updated
* @param {object} source The source object being updated
* @param {*} value The new value for the field
* @param {object} options Options which modify the update workflow
* @throws An error if the new candidate value is invalid
* @private
*/
static #updateField(name, field, source, value, options) {
const {dryRun, fallback, recursive, restoreDelta, _collections, _singletons, _diff, _backup} = options;
let current = source?.[name]; // The current value may be null or undefined
// Special Case: Update Embedded Collection
if ( field instanceof EmbeddedCollectionField ) {
_backup[name] = current;
if ( !dryRun ) _collections[name].update(value, {fallback, recursive, restoreDelta});
return;
}
// Special Case: Update Embedded Document
if ( (field instanceof EmbeddedDocumentField) && _singletons[name] ) {
_diff[name] = _singletons[name].updateSource(value ?? {}, {dryRun, fallback, recursive, restoreDelta});
if ( isEmpty(_diff[name]) ) delete _diff[name];
return;
}
// Special Case: Inner Data Schema
let innerSchema;
if ( (field instanceof SchemaField) || (field instanceof EmbeddedDataField) ) innerSchema = field;
else if ( field instanceof TypeDataField ) {
const cls = field.getModelForType(source.type);
if ( cls ) {
innerSchema = cls.schema;
if ( dryRun ) {
_backup[name] = current;
current = deepClone(current);
}
}
}
if ( innerSchema && current && value ) {
_diff[name] = {};
const recursiveOptions = {fallback, recursive, _backup: current, _collections, _diff: _diff[name]};
this.#updateData(innerSchema, current, value, recursiveOptions);
if ( isEmpty(_diff[name]) ) delete _diff[name];
}
// Special Case: Object Field
else if ( (field instanceof ObjectField) && current && value && (recursive !== false) ) {
_diff[name] = diffObject(current, value);
mergeObject(current, value, {insertKeys: true, insertValues: true, performDeletions: true});
if ( isEmpty(_diff[name]) ) delete _diff[name];
}
// Standard Case: Update Directly
else source[name] = value;
}
/* ---------------------------------------- */
/* Serialization and Storage */
/* ---------------------------------------- */
/**
* Copy and transform the DataModel into a plain object.
* Draw the values of the extracted object from the data source (by default) otherwise from its transformed values.
* @param {boolean} [source=true] Draw values from the underlying data source rather than transformed values
* @returns {object} The extracted primitive object
*/
toObject(source=true) {
if ( source ) return deepClone(this._source);
// We have use the schema of the class instead of the schema of the instance to prevent an infinite recursion:
// the EmbeddedDataField replaces the schema of its model instance with itself
// and EmbeddedDataField#toObject calls DataModel#toObject.
return this.constructor.schema.toObject(this);
}
/* ---------------------------------------- */
/**
* Extract the source data for the DataModel into a simple object format that can be serialized.
* @returns {object} The document source data expressed as a plain object
*/
toJSON() {
return this.toObject(true);
}
/* -------------------------------------------- */
/**
* Create a new instance of this DataModel from a source record.
* The source is presumed to be trustworthy and is not strictly validated.
* @param {object} source Initial document data which comes from a trusted source.
* @param {DocumentConstructionContext & DataValidationOptions} [context] Model construction context
* @param {boolean} [context.strict=false] Models created from trusted source data are validated non-strictly
* @returns {DataModel}
*/
static fromSource(source, {strict=false, ...context}={}) {
return new this(source, {strict, ...context});
}
/* ---------------------------------------- */
/**
* Create a DataModel instance using a provided serialized JSON string.
* @param {string} json Serialized document data in string format
* @returns {DataModel} A constructed data model instance
*/
static fromJSON(json) {
return this.fromSource(JSON.parse(json))
}
/* -------------------------------------------- */
/* Deprecations and Compatibility */
/* -------------------------------------------- */
/**
* Migrate candidate source data for this DataModel which may require initial cleaning or transformations.
* @param {object} source The candidate source data from which the model will be constructed
* @returns {object} Migrated source data, if necessary
*/
static migrateData(source) {
if ( !source ) return source;
this.schema.migrateSource(source, source);
return source;
}
/* ---------------------------------------- */
/**
* Wrap data migration in a try/catch which attempts it safely
* @param {object} source The candidate source data from which the model will be constructed
* @returns {object} Migrated source data, if necessary
*/
static migrateDataSafe(source) {
try {
this.migrateData(source);
} catch(err) {
err.message = `Failed data migration for ${this.name}: ${err.message}`;
logger.warn(err);
}
return source;
}
/* ---------------------------------------- */
/**
* Take data which conforms to the current data schema and add backwards-compatible accessors to it in order to
* support older code which uses this data.
* @param {object} data Data which matches the current schema
* @param {object} [options={}] Additional shimming options
* @param {boolean} [options.embedded=true] Apply shims to embedded models?
* @returns {object} Data with added backwards-compatible properties
*/
static shimData(data, {embedded=true}={}) {
if ( Object.isSealed(data) ) return data;
const schema = this.schema;
if ( embedded ) {
for ( const [name, value] of Object.entries(data) ) {
const field = schema.get(name);
if ( (field instanceof EmbeddedDataField) && !Object.isSealed(value) ) {
data[name] = field.model.shimData(value || {});
}
else if ( field instanceof EmbeddedCollectionField ) {
for ( const d of (value || []) ) {
if ( !Object.isSealed(d) ) field.model.shimData(d)
}
}
}
}
return data;
}
}
export {DataModel};