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} 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} 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};