odoo.define('mail/static/src/model/model_manager.js', function (require) { 'use strict'; const { registry } = require('mail/static/src/model/model_core.js'); const ModelField = require('mail/static/src/model/model_field.js'); const { patchClassMethods, patchInstanceMethods } = require('mail/static/src/utils/utils.js'); /** * Inner separator used between bits of information in string that is used to * identify a dependent of a field. Useful to determine which record and field * to register for compute during this update cycle. */ const DEPENDENT_INNER_SEPARATOR = "--//--//--"; /** * Object that manage models and records, notably their update cycle: whenever * some records are requested for update (either with model static method * `create()` or record method `update()`), this object processes them with * direct field & and computed field updates. */ class ModelManager { //-------------------------------------------------------------------------- // Public //-------------------------------------------------------------------------- constructor(env) { /** * Inner separator used inside string to represent dependents. * Set as public attribute so that it can be used by model field. */ this.DEPENDENT_INNER_SEPARATOR = DEPENDENT_INNER_SEPARATOR; /** * The messaging env. */ this.env = env; //---------------------------------------------------------------------- // Various variables that are necessary to handle an update cycle. The // goal of having an update cycle is to delay the execution of computes, // life-cycle hooks and potential UI re-renders until the last possible // moment, for performance reasons. //---------------------------------------------------------------------- /** * Set of records that have been created during the current update * cycle. Useful to trigger `_created()` hook methods. */ this._createdRecords = new Set(); /** * Tracks whether something has changed during the current update cycle. * Useful to notify components (through the store) that some records * have been changed. */ this._hasAnyChangeDuringCycle = false; /** * Set of records that have been updated during the current update * cycle. Useful to allow observers (typically components) to detect * whether specific records have been changed. */ this._updatedRecords = new Set(); /** * Fields flagged to call compute during an update cycle. * For instance, when a field with dependents got update, dependent * fields should update themselves by invoking compute at end of * update cycle. Key is of format * , and * determine record and field to be computed. Keys are strings because * it must contain only one occurrence of pair record/field, and we want * O(1) reads/writes. */ this._toComputeFields = new Map(); /** * Map of "update after" on records that have been registered. * These are processed after any explicit update and computed/related * fields. */ this._toUpdateAfters = new Map(); } /** * Called when all JS modules that register or patch models have been * done. This launches generation of models. */ start() { /** * Generate the models. */ Object.assign(this.env.models, this._generateModels()); } //-------------------------------------------------------------------------- // Public //-------------------------------------------------------------------------- /** * Returns all records of provided model that match provided criteria. * * @param {mail.model} Model class * @param {function} [filterFunc] * @returns {mail.model[]} records matching criteria. */ all(Model, filterFunc) { const allRecords = Object.values(Model.__records); if (filterFunc) { return allRecords.filter(filterFunc); } return allRecords; } /** * Register a record that has been created, and manage update of records * from this record creation. * * @param {mail.model} Model class * @param {Object|Object[]} [data={}] * If data is an iterable, multiple records will be created. * @returns {mail.model|mail.model[]} newly created record(s) */ create(Model, data = {}) { const res = this._create(Model, data); this._flushUpdateCycle(); return res; } /** * Delete the record. After this operation, it's as if this record never * existed. Note that relation are removed, which may delete more relations * if some of them are causal. * * @param {mail.model} record */ delete(record) { this._delete(record); this._flushUpdateCycle(); } /** * Delete all records. */ deleteAll() { for (const Model of Object.values(this.env.models)) { for (const record of Object.values(Model.__records)) { this._delete(record); } } this._flushUpdateCycle(); } /** * Returns whether the given record still exists. * * @param {mail.model} Model class * @param {mail.model} record * @returns {boolean} */ exists(Model, record) { return Model.__records[record.localId] ? true : false; } /** * Get the record of provided model that has provided * criteria, if it exists. * * @param {mail.model} Model class * @param {function} findFunc * @returns {mail.model|undefined} the record of model matching criteria, if * exists. */ find(Model, findFunc) { return this.all(Model).find(findFunc); } /** * Gets the unique record of provided model that matches the given * identifying data, if it exists. * @see `_createRecordLocalId` for criteria of identification. * * @param {mail.model} Model class * @param {Object} data * @returns {mail.model|undefined} */ findFromIdentifyingData(Model, data) { const localId = Model._createRecordLocalId(data); return Model.get(localId); } /** * This method returns the record of provided model that matches provided * local id. Useful to convert a local id to a record. * Note that even if there's a record in the system having provided local * id, if the resulting record is not an instance of this model, this getter * assumes the record does not exist. * * @param {mail.model} Model class * @param {string} localId * @param {Object} param2 * @param {boolean} [param2.isCheckingInheritance=false] * @returns {mail.model|undefined} record, if exists */ get(Model, localId, { isCheckingInheritance = false } = {}) { if (!localId) { return; } const record = Model.__records[localId]; if (record) { return record; } if (!isCheckingInheritance) { return; } // support for inherited models (eg. relation targeting `mail.model`) for (const SubModel of Object.values(this.env.models)) { if (!(SubModel.prototype instanceof Model)) { continue; } const record = SubModel.__records[localId]; if (record) { return record; } } return; } /** * This method creates a record or updates one of provided Model, based on * provided data. This method assumes that records are uniquely identifiable * per "unique find" criteria from data on Model. * * @param {mail.model} Model class * @param {Object|Object[]} data * If data is an iterable, multiple records will be created/updated. * @returns {mail.model|mail.model[]} created or updated record(s). */ insert(Model, data) { const res = this._insert(Model, data); this._flushUpdateCycle(); return res; } /** * Process an update on provided record with provided data. Updating * a record consists of applying direct updates first (i.e. explicit * ones from `data`) and then indirect ones (i.e. compute/related fields * and "after updates"). * * @param {mail.model} record * @param {Object} data * @returns {boolean} whether any value changed for the current record */ update(record, data) { const res = this._update(record, data); this._flushUpdateCycle(); return res; } //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- /** * @private * @param {mail.model} Model class * @param {Object} patch */ _applyModelPatchFields(Model, patch) { for (const [fieldName, field] of Object.entries(patch)) { if (!Model.fields[fieldName]) { Model.fields[fieldName] = field; } else { Object.assign(Model.fields[fieldName].dependencies, field.dependencies); } } } /** * @private * @param {Object} Models * @throws {Error} in case some declared fields are not correct. */ _checkDeclaredFieldsOnModels(Models) { for (const Model of Object.values(Models)) { for (const fieldName in Model.fields) { const field = Model.fields[fieldName]; // 0. Get parented declared fields const parentedMatchingFields = []; let TargetModel = Model.__proto__; while (Models[TargetModel.modelName]) { if (TargetModel.fields) { const matchingField = TargetModel.fields[fieldName]; if (matchingField) { parentedMatchingFields.push(matchingField); } } TargetModel = TargetModel.__proto__; } // 1. Field type is required. if (!(['attribute', 'relation'].includes(field.fieldType))) { throw new Error(`Field "${Model.modelName}/${fieldName}" has unsupported type ${field.fieldType}.`); } // 2. Invalid keys based on field type. if (field.fieldType === 'attribute') { const invalidKeys = Object.keys(field).filter(key => ![ 'compute', 'default', 'dependencies', 'fieldType', 'related', ].includes(key) ); if (invalidKeys.length > 0) { throw new Error(`Field "${Model.modelName}/${fieldName}" contains some invalid keys: "${invalidKeys.join(", ")}".`); } } if (field.fieldType === 'relation') { const invalidKeys = Object.keys(field).filter(key => ![ 'compute', 'default', 'dependencies', 'fieldType', 'inverse', 'isCausal', 'related', 'relationType', 'to', ].includes(key) ); if (invalidKeys.length > 0) { throw new Error(`Field "${Model.modelName}/${fieldName}" contains some invalid keys: "${invalidKeys.join(", ")}".`); } if (!Models[field.to]) { throw new Error(`Relational field "${Model.modelName}/${fieldName}" targets to unknown model name "${field.to}".`); } if (field.isCausal && !(['one2many', 'one2one'].includes(field.relationType))) { throw new Error(`Relational field "${Model.modelName}/${fieldName}" has "isCausal" true with a relation of type "${field.relationType}" but "isCausal" is only supported for "one2many" and "one2one".`); } } // 3. Computed field. if (field.compute && !(typeof field.compute === 'string')) { throw new Error(`Field "${Model.modelName}/${fieldName}" property "compute" must be a string (instance method name).`); } if (field.compute && !(Model.prototype[field.compute])) { throw new Error(`Field "${Model.modelName}/${fieldName}" property "compute" does not refer to an instance method of this Model.`); } if ( field.dependencies && (!field.compute && !parentedMatchingFields.some(field => field.compute)) ) { throw new Error(`Field "${Model.modelName}/${fieldName} contains dependendencies but no compute method in itself or parented matching fields (dependencies only make sense for compute fields)."`); } if ( (field.compute || parentedMatchingFields.some(field => field.compute)) && (field.dependencies || parentedMatchingFields.some(field => field.dependencies)) ) { if (!(field.dependencies instanceof Array)) { throw new Error(`Compute field "${Model.modelName}/${fieldName}" dependencies must be an array of field names.`); } const unknownDependencies = field.dependencies.every(dependency => !(Model.fields[dependency])); if (unknownDependencies.length > 0) { throw new Error(`Compute field "${Model.modelName}/${fieldName}" contains some unknown dependencies: "${unknownDependencies.join(", ")}".`); } } // 4. Related field. if (field.compute && field.related) { throw new Error(`Field "${Model.modelName}/${fieldName}" cannot be a related and compute field at the same time.`); } if (field.related) { if (!(typeof field.related === 'string')) { throw new Error(`Field "${Model.modelName}/${fieldName}" property "related" has invalid format.`); } const [relationName, relatedFieldName, other] = field.related.split('.'); if (!relationName || !relatedFieldName || other) { throw new Error(`Field "${Model.modelName}/${fieldName}" property "related" has invalid format.`); } // find relation on self or parents. let relatedRelation; let TargetModel = Model; while (Models[TargetModel.modelName] && !relatedRelation) { if (TargetModel.fields) { relatedRelation = TargetModel.fields[relationName]; } TargetModel = TargetModel.__proto__; } if (!relatedRelation) { throw new Error(`Related field "${Model.modelName}/${fieldName}" relates to unknown relation name "${relationName}".`); } if (relatedRelation.fieldType !== 'relation') { throw new Error(`Related field "${Model.modelName}/${fieldName}" relates to non-relational field "${relationName}".`); } // Assuming related relation is valid... // find field name on related model or any parents. const RelatedModel = Models[relatedRelation.to]; let relatedField; TargetModel = RelatedModel; while (Models[TargetModel.modelName] && !relatedField) { if (TargetModel.fields) { relatedField = TargetModel.fields[relatedFieldName]; } TargetModel = TargetModel.__proto__; } if (!relatedField) { throw new Error(`Related field "${Model.modelName}/${fieldName}" relates to unknown related model field "${relatedFieldName}".`); } if (relatedField.fieldType !== field.fieldType) { throw new Error(`Related field "${Model.modelName}/${fieldName}" has mismatch type with its related model field.`); } if ( relatedField.fieldType === 'relation' && relatedField.to !== field.to ) { throw new Error(`Related field "${Model.modelName}/${fieldName}" has mismatch target model name with its related model field.`); } } } } } /** * @private * @param {Object} Models * @throws {Error} in case some fields are not correct. */ _checkProcessedFieldsOnModels(Models) { for (const Model of Object.values(Models)) { for (const fieldName in Model.fields) { const field = Model.fields[fieldName]; if (!(['attribute', 'relation'].includes(field.fieldType))) { throw new Error(`Field "${Model.modelName}/${fieldName}" has unsupported type ${field.fieldType}.`); } if (field.compute && field.related) { throw new Error(`Field "${Model.modelName}/${fieldName}" cannot be a related and compute field at the same time.`); } if (field.fieldType === 'attribute') { continue; } if (!field.relationType) { throw new Error( `Field "${Model.modelName}/${fieldName}" must define a relation type in "relationType".` ); } if (!(['one2one', 'one2many', 'many2one', 'many2many'].includes(field.relationType))) { throw new Error( `Field "${Model.modelName}/${fieldName}" has invalid relation type "${field.relationType}".` ); } if (!field.inverse) { throw new Error( `Field "${ Model.modelName }/${ fieldName }" must define an inverse relation name in "inverse".` ); } if (!field.to) { throw new Error( `Relation "${ Model.modelNames }/${ fieldName }" must define a model name in "to" (1st positional parameter of relation field helpers).` ); } const RelatedModel = Models[field.to]; if (!RelatedModel) { throw new Error( `Model name of relation "${Model.modelName}/${fieldName}" does not exist.` ); } const inverseField = RelatedModel.fields[field.inverse]; if (!inverseField) { throw new Error( `Relation "${ Model.modelName }/${ fieldName }" has no inverse field "${RelatedModel.modelName}/${field.inverse}".` ); } if (inverseField.inverse !== fieldName) { throw new Error( `Inverse field name of relation "${ Model.modelName }/${ fieldName }" does not match with field name of relation "${ RelatedModel.modelName }/${ inverseField.inverse }".` ); } const allSelfAndParentNames = []; let TargetModel = Model; while (TargetModel) { allSelfAndParentNames.push(TargetModel.modelName); TargetModel = TargetModel.__proto__; } if (!allSelfAndParentNames.includes(inverseField.to)) { throw new Error( `Relation "${ Model.modelName }/${ fieldName }" has inverse relation "${ RelatedModel.modelName }/${ field.inverse }" misconfigured (currently "${ inverseField.to }", should instead refer to this model or parented models: ${ allSelfAndParentNames.map(name => `"${name}"`).join(', ') }?)` ); } if ( (field.relationType === 'many2many' && inverseField.relationType !== 'many2many') || (field.relationType === 'one2one' && inverseField.relationType !== 'one2one') || (field.relationType === 'one2many' && inverseField.relationType !== 'many2one') || (field.relationType === 'many2one' && inverseField.relationType !== 'one2many') ) { throw new Error( `Mismatch relations types "${ Model.modelName }/${ fieldName }" (${ field.relationType }) and "${ RelatedModel.modelName }/${ field.inverse }" (${ inverseField.relationType }).` ); } } } } /** * @private * @param {mail.model} Model class * @param {Object|Object[]} [data={}] * @returns {mail.model|mail.model[]} */ _create(Model, data = {}) { const isMulti = typeof data[Symbol.iterator] === 'function'; const dataList = isMulti ? data : [data]; const records = []; for (const data of dataList) { /** * 1. Ensure the record can be created: localId must be unique. */ const localId = Model._createRecordLocalId(data); if (Model.get(localId)) { throw Error(`A record already exists for model "${Model.modelName}" with localId "${localId}".`); } /** * 2. Prepare record state. Assign various keys and values that are * expected to be found on every record. */ const record = new Model({ valid: true }); Object.assign(record, { // The messaging env. env: this.env, // The unique record identifier. localId, // Field values of record. __values: {}, // revNumber of record for detecting changes in useStore. __state: 0, }); // Ensure X2many relations are Set initially (other fields can stay undefined). for (const field of Model.__fieldList) { if (field.fieldType === 'relation') { if (['one2many', 'many2many'].includes(field.relationType)) { record.__values[field.fieldName] = new Set(); } } } /** * 3. Register record and invoke the life-cycle hook `_willCreate.` * After this step the record is in a functioning state and it is * considered existing. */ Model.__records[record.localId] = record; record._willCreate(); /** * 4. Write provided data, default data, and register computes. */ const data2 = {}; for (const field of Model.__fieldList) { // `undefined` should have the same effect as not passing the field if (data[field.fieldName] !== undefined) { data2[field.fieldName] = data[field.fieldName]; } else { data2[field.fieldName] = field.default; } if (field.compute || field.related) { // new record should always invoke computed fields. this._registerToComputeField(record, field); } } this._update(record, data2); /** * 5. Register post processing operation that are to be delayed at * the end of the update cycle. */ this._createdRecords.add(record); this._hasAnyChangeDuringCycle = true; records.push(record); } return isMulti ? records : records[0]; } /** * @private * @param {mail.model} record */ _delete(record) { const Model = record.constructor; if (!record.exists()) { throw Error(`Cannot delete already deleted record ${record.localId}.`); } record._willDelete(); for (const field of Model.__fieldList) { if (field.fieldType === 'relation') { // ensure inverses are properly unlinked field.parseAndExecuteCommands(record, [['unlink-all']]); } } this._hasAnyChangeDuringCycle = true; // TODO ideally deleting the record should be done at the top of the // method, and it shouldn't be needed to manually remove // _toComputeFields and _toUpdateAfters, but it is not possible until // related are also properly unlinked during `set` this._createdRecords.delete(record); this._toComputeFields.delete(record); this._toUpdateAfters.delete(record); delete Model.__records[record.localId]; } /** * Terminates an update cycle by executing its pending operations: execute * computed fields, execute life-cycle hooks, update rev numbers. * * @private */ _flushUpdateCycle(func) { // Execution of computes while (this._toComputeFields.size > 0) { for (const [record, fields] of this._toComputeFields) { // delete at every step to avoid recursion, indeed doCompute // might trigger an update cycle itself this._toComputeFields.delete(record); if (!record.exists()) { throw Error(`Cannot execute computes for already deleted record ${record.localId}.`); } while (fields.size > 0) { for (const field of fields) { // delete at every step to avoid recursion fields.delete(field); if (field.compute) { this._update(record, { [field.fieldName]: record[field.compute]() }); continue; } if (field.related) { this._update(record, { [field.fieldName]: field.computeRelated(record) }); continue; } throw new Error("No compute method defined on this field definition"); } } } } // Execution of _updateAfter while (this._toUpdateAfters.size > 0) { for (const [record, previous] of this._toUpdateAfters) { // delete at every step to avoid recursion, indeed _updateAfter // might trigger an update cycle itself this._toUpdateAfters.delete(record); if (!record.exists()) { throw Error(`Cannot _updateAfter for already deleted record ${record.localId}.`); } record._updateAfter(previous); } } // Execution of _created while (this._createdRecords.size > 0) { for (const record of this._createdRecords) { // delete at every step to avoid recursion, indeed _created // might trigger an update cycle itself this._createdRecords.delete(record); if (!record.exists()) { throw Error(`Cannot call _created for already deleted record ${record.localId}.`); } record._created(); } } // Increment record rev number (for useStore comparison) for (const record of this._updatedRecords) { record.__state++; } this._updatedRecords.clear(); // Trigger at most one useStore call per update cycle if (this._hasAnyChangeDuringCycle) { this.env.store.state.messagingRevNumber++; this._hasAnyChangeDuringCycle = false; } } /** * @private * @returns {Object} * @throws {Error} in case it cannot generate models. */ _generateModels() { const allNames = Object.keys(registry); const Models = {}; const generatedNames = []; let toGenerateNames = [...allNames]; while (toGenerateNames.length > 0) { const generatable = toGenerateNames.map(name => registry[name]).find(entry => { let isGenerateable = true; for (const dependencyName of entry.dependencies) { if (!generatedNames.includes(dependencyName)) { isGenerateable = false; } } return isGenerateable; }); if (!generatable) { throw new Error(`Cannot generate following Model: ${toGenerateNames.join(', ')}`); } // Make environment accessible from Model. const Model = generatable.factory(Models); Model.env = this.env; /** * Contains all records. key is local id, while value is the record. */ Model.__records = {}; for (const patch of generatable.patches) { switch (patch.type) { case 'class': patchClassMethods(Model, patch.name, patch.patch); break; case 'instance': patchInstanceMethods(Model, patch.name, patch.patch); break; case 'field': this._applyModelPatchFields(Model, patch.patch); break; } } if (!Object.prototype.hasOwnProperty.call(Model, 'modelName')) { throw new Error(`Missing static property "modelName" on Model class "${Model.name}".`); } if (generatedNames.includes(Model.modelName)) { throw new Error(`Duplicate model name "${Model.modelName}" shared on 2 distinct Model classes.`); } Models[Model.modelName] = Model; generatedNames.push(Model.modelName); toGenerateNames = toGenerateNames.filter(name => name !== Model.modelName); } /** * Check that declared model fields are correct. */ this._checkDeclaredFieldsOnModels(Models); /** * Process declared model fields definitions, so that these field * definitions are much easier to use in the system. For instance, all * relational field definitions have an inverse, or fields track all their * dependents. */ this._processDeclaredFieldsOnModels(Models); /** * Check that all model fields are correct, notably one relation * should have matching reversed relation. */ this._checkProcessedFieldsOnModels(Models); return Models; } /** * @private * @param {mail.model} * @param {Object|Object[]} data * @returns {mail.model|mail.model[]} */ _insert(Model, data) { const isMulti = typeof data[Symbol.iterator] === 'function'; const dataList = isMulti ? data : [data]; const records = []; for (const data of dataList) { let record = Model.findFromIdentifyingData(data); if (!record) { record = this._create(Model, data); } else { this._update(record, data); } records.push(record); } return isMulti ? records : records[0]; } /** * @private * @param {mail.model} Model class * @param {ModelField} field * @returns {ModelField} */ _makeInverseRelationField(Model, field) { const relFunc = field.relationType === 'many2many' ? ModelField.many2many : field.relationType === 'many2one' ? ModelField.one2many : field.relationType === 'one2many' ? ModelField.many2one : field.relationType === 'one2one' ? ModelField.one2one : undefined; if (!relFunc) { throw new Error(`Cannot compute inverse Relation of "${Model.modelName}/${field.fieldName}".`); } const inverseField = new ModelField(Object.assign( {}, relFunc(Model.modelName, { inverse: field.fieldName }), { env: this.env, fieldName: `_inverse_${Model.modelName}/${field.fieldName}`, modelManager: this, } )); return inverseField; } /** * This function processes definition of declared fields in provided models. * Basically, models have fields declared in static prop `fields`, and this * function processes and modifies them in place so that they are fully * configured. For instance, model relations need bi-directional mapping, but * inverse relation may be omitted in declared field: this function auto-fill * this inverse relation. * * @private * @param {Object} Models */ _processDeclaredFieldsOnModels(Models) { /** * 1. Prepare fields. */ for (const Model of Object.values(Models)) { if (!Object.prototype.hasOwnProperty.call(Model, 'fields')) { Model.fields = {}; } Model.inverseRelations = []; // Make fields aware of their field name. for (const [fieldName, fieldData] of Object.entries(Model.fields)) { Model.fields[fieldName] = new ModelField(Object.assign({}, fieldData, { env: this.env, fieldName, modelManager: this, })); } } /** * 2. Auto-generate definitions of undeclared inverse relations. */ for (const Model of Object.values(Models)) { for (const field of Object.values(Model.fields)) { if (field.fieldType !== 'relation') { continue; } if (field.inverse) { continue; } const RelatedModel = Models[field.to]; const inverseField = this._makeInverseRelationField(Model, field); field.inverse = inverseField.fieldName; RelatedModel.fields[inverseField.fieldName] = inverseField; } } /** * 3. Generate dependents and inverse-relates on fields. * Field definitions are not yet combined, so registration of `dependents` * may have to walk structural hierarchy of models in order to find * the appropriate field. Also, while dependencies are defined just with * field names, dependents require an additional data called a "hash" * (= field id), which is a way to identify dependents in an inverse * relation. This is necessary because dependents are a subset of an inverse * relation. */ for (const Model of Object.values(Models)) { for (const field of Object.values(Model.fields)) { for (const dependencyFieldName of field.dependencies) { let TargetModel = Model; let dependencyField = TargetModel.fields[dependencyFieldName]; while (!dependencyField) { TargetModel = TargetModel.__proto__; dependencyField = TargetModel.fields[dependencyFieldName]; } const dependent = [field.id, field.fieldName].join(DEPENDENT_INNER_SEPARATOR); dependencyField.dependents = [ ...new Set(dependencyField.dependents.concat([dependent])) ]; } if (field.related) { const [relationName, relatedFieldName] = field.related.split('.'); let TargetModel = Model; let relationField = TargetModel.fields[relationName]; while (!relationField) { TargetModel = TargetModel.__proto__; relationField = TargetModel.fields[relationName]; } const relationFieldDependent = [ field.id, field.fieldName, ].join(DEPENDENT_INNER_SEPARATOR); relationField.dependents = [ ...new Set(relationField.dependents.concat([relationFieldDependent])) ]; const OtherModel = Models[relationField.to]; let OtherTargetModel = OtherModel; let relatedField = OtherTargetModel.fields[relatedFieldName]; while (!relatedField) { OtherTargetModel = OtherTargetModel.__proto__; relatedField = OtherTargetModel.fields[relatedFieldName]; } const relatedFieldDependent = [ field.id, relationField.inverse, field.fieldName, ].join(DEPENDENT_INNER_SEPARATOR); relatedField.dependents = [ ...new Set( relatedField.dependents.concat([relatedFieldDependent]) ) ]; } } } /** * 4. Extend definition of fields of a model with the definition of * fields of its parents. Field definitions on self has precedence over * parented fields. */ for (const Model of Object.values(Models)) { Model.__combinedFields = {}; for (const field of Object.values(Model.fields)) { Model.__combinedFields[field.fieldName] = field; } let TargetModel = Model.__proto__; while (TargetModel && TargetModel.fields) { for (const targetField of Object.values(TargetModel.fields)) { const field = Model.__combinedFields[targetField.fieldName]; if (field) { Model.__combinedFields[targetField.fieldName] = field.combine(targetField); } else { Model.__combinedFields[targetField.fieldName] = targetField; } } TargetModel = TargetModel.__proto__; } } /** * 5. Register final fields and make field accessors, to redirects field * access to field getter and to prevent field from being written * without calling update (which is necessary to process update cycle). */ for (const Model of Object.values(Models)) { // Object with fieldName/field as key/value pair, for quick access. Model.__fieldMap = Model.__combinedFields; // List of all fields, for iterating. Model.__fieldList = Object.values(Model.__fieldMap); // Add field accessors. for (const field of Model.__fieldList) { Object.defineProperty(Model.prototype, field.fieldName, { get() { return field.get(this); // this is bound to record }, }); } delete Model.__combinedFields; } } /** * Registers compute of dependents for the given field, if applicable. * * @private * @param {mail.model} record * @param {ModelField} field */ _registerComputeOfDependents(record, field) { const Model = record.constructor; for (const dependent of field.dependents) { const [hash, fieldName1, fieldName2] = dependent.split( this.DEPENDENT_INNER_SEPARATOR ); const field1 = Model.__fieldMap[fieldName1]; if (fieldName2) { // "fieldName1.fieldName2" -> dependent is on another record if (['one2many', 'many2many'].includes(field1.relationType)) { for (const otherRecord of record[fieldName1]) { const OtherModel = otherRecord.constructor; const field2 = OtherModel.__fieldMap[fieldName2]; if (field2 && field2.hashes.includes(hash)) { this._registerToComputeField(otherRecord, field2); } } } else { const otherRecord = record[fieldName1]; if (!otherRecord) { continue; } const OtherModel = otherRecord.constructor; const field2 = OtherModel.__fieldMap[fieldName2]; if (field2 && field2.hashes.includes(hash)) { this._registerToComputeField(otherRecord, field2); } } } else { // "fieldName1" only -> dependent is on current record if (field1 && field1.hashes.includes(hash)) { this._registerToComputeField(record, field1); } } } } /** * Register a pair record/field for the compute step of the update cycle in * progress. * * @private * @param {mail.model} record * @param {ModelField} field */ _registerToComputeField(record, field) { if (!this._toComputeFields.has(record)) { this._toComputeFields.set(record, new Set()); } this._toComputeFields.get(record).add(field); } /** * @private * @param {mail.model} record * @param {Object} data * @param {Object} [options] * @returns {boolean} whether any value changed for the current record */ _update(record, data, options) { if (!record.exists()) { throw Error(`Cannot update already deleted record ${record.localId}.`); } if (!this._toUpdateAfters.has(record)) { // queue updateAfter before calling field.set to ensure previous // contains the value at the start of update cycle this._toUpdateAfters.set(record, record._updateBefore()); } const Model = record.constructor; let hasChanged = false; for (const fieldName of Object.keys(data)) { if (data[fieldName] === undefined) { // `undefined` should have the same effect as not passing the field continue; } const field = Model.__fieldMap[fieldName]; if (!field) { throw new Error(`Cannot create/update record with data unrelated to a field. (model: "${Model.modelName}", non-field attempted update: "${fieldName}")`); } const newVal = data[fieldName]; if (!field.parseAndExecuteCommands(record, newVal, options)) { continue; } hasChanged = true; // flag all dependent fields for compute this._registerComputeOfDependents(record, field); } if (hasChanged) { this._updatedRecords.add(record); this._hasAnyChangeDuringCycle = true; } return hasChanged; } } return ModelManager; });