summaryrefslogtreecommitdiff
path: root/addons/mail/static/src/model
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/mail/static/src/model
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/mail/static/src/model')
-rw-r--r--addons/mail/static/src/model/model_core.js125
-rw-r--r--addons/mail/static/src/model/model_errors.js22
-rw-r--r--addons/mail/static/src/model/model_field.js820
-rw-r--r--addons/mail/static/src/model/model_field_command.js73
-rw-r--r--addons/mail/static/src/model/model_manager.js1098
5 files changed, 2138 insertions, 0 deletions
diff --git a/addons/mail/static/src/model/model_core.js b/addons/mail/static/src/model/model_core.js
new file mode 100644
index 00000000..0198e47e
--- /dev/null
+++ b/addons/mail/static/src/model/model_core.js
@@ -0,0 +1,125 @@
+odoo.define('mail/static/src/model/model_core.js', function (require) {
+'use strict';
+
+/**
+ * Module that contains registry for adding new models or patching models.
+ * Useful for model manager in order to generate model classes.
+ *
+ * This code is not in model manager because other JS modules should populate
+ * a registry, and it's difficult to ensure availability of the model manager
+ * when these JS modules are deployed.
+ */
+
+const registry = {};
+
+//------------------------------------------------------------------------------
+// Private
+//------------------------------------------------------------------------------
+
+/**
+ * @private
+ * @param {string} modelName
+ * @returns {Object}
+ */
+function _getEntryFromModelName(modelName) {
+ if (!registry[modelName]) {
+ registry[modelName] = {
+ dependencies: [],
+ factory: undefined,
+ name: modelName,
+ patches: [],
+ };
+ }
+ return registry[modelName];
+}
+
+/**
+ * @private
+ * @param {string} modelName
+ * @param {string} patchName
+ * @param {Object} patch
+ * @param {Object} [param3={}]
+ * @param {string} [param3.type='instance'] 'instance', 'class' or 'field'
+ */
+function _registerPatchModel(modelName, patchName, patch, { type = 'instance' } = {}) {
+ const entry = _getEntryFromModelName(modelName);
+ Object.assign(entry, {
+ patches: (entry.patches || []).concat([{
+ name: patchName,
+ patch,
+ type,
+ }]),
+ });
+}
+
+//------------------------------------------------------------------------------
+// Public
+//------------------------------------------------------------------------------
+
+/**
+ * Register a patch for static methods in model.
+ *
+ * @param {string} modelName
+ * @param {string} patchName
+ * @param {Object} patch
+ */
+function registerClassPatchModel(modelName, patchName, patch) {
+ _registerPatchModel(modelName, patchName, patch, { type: 'class' });
+}
+
+/**
+ * Register a patch for fields in model.
+ *
+ * @param {string} modelName
+ * @param {string} patchName
+ * @param {Object} patch
+ */
+function registerFieldPatchModel(modelName, patchName, patch) {
+ _registerPatchModel(modelName, patchName, patch, { type: 'field' });
+}
+
+/**
+ * Register a patch for instance methods in model.
+ *
+ * @param {string} modelName
+ * @param {string} patchName
+ * @param {Object} patch
+ */
+function registerInstancePatchModel(modelName, patchName, patch) {
+ _registerPatchModel(modelName, patchName, patch, { type: 'instance' });
+}
+
+/**
+ * @param {string} name
+ * @param {function} factory
+ * @param {string[]} [dependencies=[]]
+ */
+function registerNewModel(name, factory, dependencies = []) {
+ const entry = _getEntryFromModelName(name);
+ let entryDependencies = [...dependencies];
+ if (name !== 'mail.model') {
+ entryDependencies = [...new Set(entryDependencies.concat(['mail.model']))];
+ }
+ if (entry.factory) {
+ throw new Error(`Model "${name}" has already been registered!`);
+ }
+ Object.assign(entry, {
+ dependencies: entryDependencies,
+ factory,
+ name,
+ });
+}
+
+//------------------------------------------------------------------------------
+// Export
+//------------------------------------------------------------------------------
+
+return {
+ registerClassPatchModel,
+ registerFieldPatchModel,
+ registerInstancePatchModel,
+ registerNewModel,
+ registry,
+};
+
+});
diff --git a/addons/mail/static/src/model/model_errors.js b/addons/mail/static/src/model/model_errors.js
new file mode 100644
index 00000000..17ecc960
--- /dev/null
+++ b/addons/mail/static/src/model/model_errors.js
@@ -0,0 +1,22 @@
+odoo.define('mail/static/src/model/model_errors.js', function (require) {
+'use strict';
+
+class RecordDeletedError extends Error {
+
+ /**
+ * @override
+ * @param {string} recordLocalId local id of record that has been deleted
+ * @param {...any} args
+ */
+ constructor(recordLocalId, ...args) {
+ super(...args);
+ this.recordLocalId = recordLocalId;
+ this.name = 'RecordDeletedError';
+ }
+}
+
+return {
+ RecordDeletedError,
+};
+
+});
diff --git a/addons/mail/static/src/model/model_field.js b/addons/mail/static/src/model/model_field.js
new file mode 100644
index 00000000..d0c461ea
--- /dev/null
+++ b/addons/mail/static/src/model/model_field.js
@@ -0,0 +1,820 @@
+odoo.define('mail/static/src/model/model_field.js', function (require) {
+'use strict';
+
+const { clear, FieldCommand } = require('mail/static/src/model/model_field_command.js');
+
+/**
+ * Class whose instances represent field on a model.
+ * These field definitions are generated from declared fields in static prop
+ * `fields` on the model.
+ */
+class ModelField {
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ constructor({
+ compute,
+ default: def,
+ dependencies = [],
+ dependents = [],
+ env,
+ fieldName,
+ fieldType,
+ hashes: extraHashes = [],
+ inverse,
+ isCausal = false,
+ related,
+ relationType,
+ to,
+ } = {}) {
+ const id = _.uniqueId('field_');
+ /**
+ * If set, this field acts as a computed field, and this prop
+ * contains the name of the instance method that computes the value
+ * for this field. This compute method is called on creation of record
+ * and whenever some of its dependencies change. @see dependencies
+ */
+ this.compute = compute;
+ /**
+ * Default value for this field. Used on creation of this field, to
+ * set a value by default.
+ */
+ this.default = def;
+ /**
+ * List of field on current record that this field depends on for its
+ * `compute` method. Useful to determine whether this field should be
+ * registered for recomputation when some record fields have changed.
+ * This list must be declared in model definition, or compute method
+ * is only computed once.
+ */
+ this.dependencies = dependencies;
+ /**
+ * List of fields that are dependent of this field. They should never
+ * be declared, and are automatically generated while processing
+ * declared fields. This is populated by compute `dependencies` and
+ * `related`.
+ */
+ this.dependents = dependents;
+ /**
+ * The messaging env.
+ */
+ this.env = env;
+ /**
+ * Name of the field in the definition of fields on model.
+ */
+ this.fieldName = fieldName;
+ /**
+ * Type of this field. 2 types of fields are currently supported:
+ *
+ * 1. 'attribute': fields that store primitive values like integers,
+ * booleans, strings, objects, array, etc.
+ *
+ * 2. 'relation': fields that relate to some other records.
+ */
+ this.fieldType = fieldType;
+ /**
+ * List of hashes registered on this field definition. Technical
+ * prop that is specifically used in processing of dependent
+ * fields, useful to clearly identify which fields of a relation are
+ * dependents and must be registered for computed. Indeed, not all
+ * related records may have a field that depends on changed field,
+ * especially when dependency is defined on sub-model on a relation in
+ * a super-model.
+ *
+ * To illustrate the purpose of this hash, suppose following definition
+ * of models and fields:
+ *
+ * - 3 models (A, B, C) and 3 fields (x, y, z)
+ * - A.fields: { x: one2one(C, inverse: x') }
+ * - B extends A
+ * - B.fields: { z: related(x.y) }
+ * - C.fields: { y: attribute }
+ *
+ * Visually:
+ * x'
+ * <-----------
+ * A -----------> C { y }
+ * ^ x
+ * |
+ * | (extends)
+ * |
+ * B { z = x.y }
+ *
+ * If z has a dependency on x.y, it means y has a dependent on x'.z.
+ * Note that field z exists on B but not on all A. To determine which
+ * kinds of records in relation x' are dependent on y, y is aware of an
+ * hash on this dependent, and any dependents who has this hash in list
+ * of hashes are actual dependents.
+ */
+ this.hashes = extraHashes.concat([id]);
+ /**
+ * Identification for this field definition. Useful to map a dependent
+ * from a dependency. Indeed, declared field definitions use
+ * 'dependencies' but technical process need inverse as 'dependents'.
+ * Dependencies just need name of fields, but dependents cannot just
+ * rely on inverse field names because these dependents are a subset.
+ */
+ this.id = id;
+ /**
+ * This prop only makes sense in a relational field. This contains
+ * the name of the field name in the inverse relation. This may not
+ * be defined in declared field definitions, but processed relational
+ * field definitions always have inverses.
+ */
+ this.inverse = inverse;
+ /**
+ * This prop only makes sense in a relational field. If set, when this
+ * relation is removed, the related record is automatically deleted.
+ */
+ this.isCausal = isCausal;
+ /**
+ * If set, this field acts as a related field, and this prop contains
+ * a string that references the related field. It should have the
+ * following format: '<relationName>.<relatedFieldName>', where
+ * <relationName> is a relational field name on this model or a parent
+ * model (note: could itself be computed or related), and
+ * <relatedFieldName> is the name of field on the records that are
+ * related to current record from this relation. When there are more
+ * than one record in the relation, it maps all related fields per
+ * record in relation.
+ *
+ * FIXME: currently flatten map due to bug, improvement is planned
+ * see Task-id 2261221
+ */
+ this.related = related;
+ /**
+ * This prop only makes sense in a relational field. Determine which
+ * type of relation there is between current record and other records.
+ * 4 types of relation are supported: 'one2one', 'one2many', 'many2one'
+ * and 'many2many'.
+ */
+ this.relationType = relationType;
+ /**
+ * This prop only makes sense in a relational field. Determine which
+ * model name this relation refers to.
+ */
+ this.to = to;
+
+ if (!this.default && this.fieldType === 'relation') {
+ // default value for relational fields is the empty command
+ this.default = [];
+ }
+ }
+
+ /**
+ * Define an attribute field.
+ *
+ * @param {Object} [options]
+ * @returns {Object}
+ */
+ static attr(options) {
+ return Object.assign({ fieldType: 'attribute' }, options);
+ }
+
+ /**
+ * Define a many2many field.
+ *
+ * @param {string} modelName
+ * @param {Object} [options]
+ * @returns {Object}
+ */
+ static many2many(modelName, options) {
+ return ModelField._relation(modelName, Object.assign({}, options, { relationType: 'many2many' }));
+ }
+
+ /**
+ * Define a many2one field.
+ *
+ * @param {string} modelName
+ * @param {Object} [options]
+ * @returns {Object}
+ */
+ static many2one(modelName, options) {
+ return ModelField._relation(modelName, Object.assign({}, options, { relationType: 'many2one' }));
+ }
+
+ /**
+ * Define a one2many field.
+ *
+ * @param {string} modelName
+ * @param {Object} [options]
+ * @returns {Object}
+ */
+ static one2many(modelName, options) {
+ return ModelField._relation(modelName, Object.assign({}, options, { relationType: 'one2many' }));
+ }
+
+ /**
+ * Define a one2one field.
+ *
+ * @param {string} modelName
+ * @param {Object} [options]
+ * @returns {Object}
+ */
+ static one2one(modelName, options) {
+ return ModelField._relation(modelName, Object.assign({}, options, { relationType: 'one2one' }));
+ }
+
+ /**
+ * Clears the value of this field on the given record. It consists of
+ * setting this to its default value. In particular, using `clear` is the
+ * only way to write `undefined` on a field, as long as `undefined` is its
+ * default value. Relational fields are always unlinked before the default
+ * is applied.
+ *
+ * @param {mail.model} record
+ * @param {options} [options]
+ * @returns {boolean} whether the value changed for the current field
+ */
+ clear(record, options) {
+ let hasChanged = false;
+ if (this.fieldType === 'relation') {
+ if (this.parseAndExecuteCommands(record, [['unlink-all']], options)) {
+ hasChanged = true;
+ }
+ }
+ if (this.parseAndExecuteCommands(record, this.default, options)) {
+ hasChanged = true;
+ }
+ return hasChanged;
+ }
+
+ /**
+ * Combine current field definition with provided field definition and
+ * return the combined field definition. Useful to track list of hashes of
+ * a given field, which is necessary for the working of dependent fields
+ * (computed and related fields).
+ *
+ * @param {ModelField} field
+ * @returns {ModelField}
+ */
+ combine(field) {
+ return new ModelField(Object.assign({}, this, {
+ dependencies: this.dependencies.concat(field.dependencies),
+ hashes: this.hashes.concat(field.hashes),
+ }));
+ }
+
+ /**
+ * Compute method when this field is related.
+ *
+ * @private
+ * @param {mail.model} record
+ */
+ computeRelated(record) {
+ const [relationName, relatedFieldName] = this.related.split('.');
+ const Model = record.constructor;
+ const relationField = Model.__fieldMap[relationName];
+ if (['one2many', 'many2many'].includes(relationField.relationType)) {
+ const newVal = [];
+ for (const otherRecord of record[relationName]) {
+ const OtherModel = otherRecord.constructor;
+ const otherField = OtherModel.__fieldMap[relatedFieldName];
+ const otherValue = otherField.get(otherRecord);
+ if (otherValue) {
+ if (otherValue instanceof Array) {
+ // avoid nested array if otherField is x2many too
+ // TODO IMP task-2261221
+ for (const v of otherValue) {
+ newVal.push(v);
+ }
+ } else {
+ newVal.push(otherValue);
+ }
+ }
+ }
+ if (this.fieldType === 'relation') {
+ return [['replace', newVal]];
+ }
+ return newVal;
+ }
+ const otherRecord = record[relationName];
+ if (otherRecord) {
+ const OtherModel = otherRecord.constructor;
+ const otherField = OtherModel.__fieldMap[relatedFieldName];
+ const newVal = otherField.get(otherRecord);
+ if (newVal === undefined) {
+ return clear();
+ }
+ if (this.fieldType === 'relation') {
+ return [['replace', newVal]];
+ }
+ return newVal;
+ }
+ return clear();
+ }
+
+ /**
+ * Get the value associated to this field. Relations must convert record
+ * local ids to records.
+ *
+ * @param {mail.model} record
+ * @returns {any}
+ */
+ get(record) {
+ if (this.fieldType === 'attribute') {
+ return this.read(record);
+ }
+ if (this.fieldType === 'relation') {
+ if (['one2one', 'many2one'].includes(this.relationType)) {
+ return this.read(record);
+ }
+ return [...this.read(record)];
+ }
+ throw new Error(`cannot get field with unsupported type ${this.fieldType}.`);
+ }
+
+ /**
+ * Parses newVal for command(s) and executes them.
+ *
+ * @param {mail.model} record
+ * @param {any} newVal
+ * @param {Object} [options]
+ * @returns {boolean} whether the value changed for the current field
+ */
+ parseAndExecuteCommands(record, newVal, options) {
+ if (newVal instanceof FieldCommand) {
+ // single command given
+ return newVal.execute(this, record, options);
+ }
+ if (newVal instanceof Array && newVal[0] instanceof FieldCommand) {
+ // multi command given
+ let hasChanged = false;
+ for (const command of newVal) {
+ if (command.execute(this, record, options)) {
+ hasChanged = true;
+ }
+ }
+ return hasChanged;
+ }
+ // not a command
+ return this.set(record, newVal, options);
+ }
+
+ /**
+ * Get the raw value associated to this field. For relations, this means
+ * the local id or list of local ids of records in this relational field.
+ *
+ * @param {mail.model} record
+ * @returns {any}
+ */
+ read(record) {
+ return record.__values[this.fieldName];
+ }
+
+ /**
+ * Set a value on this field. The format of the value comes from business
+ * code.
+ *
+ * @param {mail.model} record
+ * @param {any} newVal
+ * @param {Object} [options]
+ * @param {boolean} [options.hasToUpdateInverse] whether updating the
+ * current field should also update its inverse field. Only applies to
+ * relational fields. Typically set to false only during the process of
+ * updating the inverse field itself, to avoid unnecessary recursion.
+ * @returns {boolean} whether the value changed for the current field
+ */
+ set(record, newVal, options) {
+ const currentValue = this.read(record);
+ if (this.fieldType === 'attribute') {
+ if (currentValue === newVal) {
+ return false;
+ }
+ record.__values[this.fieldName] = newVal;
+ return true;
+ }
+ if (this.fieldType === 'relation') {
+ let hasChanged = false;
+ for (const val of newVal) {
+ switch (val[0]) {
+ case 'create':
+ if (this._setRelationCreate(record, val[1], options)) {
+ hasChanged = true;
+ }
+ break;
+ case 'insert':
+ if (this._setRelationInsert(record, val[1], options)) {
+ hasChanged = true;
+ }
+ break;
+ case 'insert-and-replace':
+ if (this._setRelationInsertAndReplace(record, val[1], options)) {
+ hasChanged = true;
+ }
+ break;
+ case 'link':
+ if (this._setRelationLink(record, val[1], options)) {
+ hasChanged = true;
+ }
+ break;
+ case 'replace':
+ if (this._setRelationReplace(record, val[1], options)) {
+ hasChanged = true;
+ }
+ break;
+ case 'unlink':
+ if (this._setRelationUnlink(record, val[1], options)) {
+ hasChanged = true;
+ }
+ break;
+ case 'unlink-all':
+ if (this._setRelationUnlink(record, currentValue, options)) {
+ hasChanged = true;
+ }
+ break;
+ }
+ }
+ return hasChanged;
+ }
+ }
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {string} modelName
+ * @param {Object} [options]
+ */
+ static _relation(modelName, options) {
+ return Object.assign({
+ fieldType: 'relation',
+ to: modelName,
+ }, options);
+ }
+
+ /**
+ * Converts given value to expected format for x2many processing, which is
+ * an iterable of records.
+ *
+ * @private
+ * @param {mail.model|mail.model[]} newValue
+ * @param {Object} [param1={}]
+ * @param {boolean} [param1.hasToVerify=true] whether the value has to be
+ * verified @see `_verifyRelationalValue`
+ * @returns {mail.model[]}
+ */
+ _convertX2ManyValue(newValue, { hasToVerify = true } = {}) {
+ if (typeof newValue[Symbol.iterator] === 'function') {
+ if (hasToVerify) {
+ for (const value of newValue) {
+ this._verifyRelationalValue(value);
+ }
+ }
+ return newValue;
+ }
+ if (hasToVerify) {
+ this._verifyRelationalValue(newValue);
+ }
+ return [newValue];
+ }
+
+ /**
+ * Set on this relational field in 'create' mode. Basically data provided
+ * during set on this relational field contain data to create new records,
+ * which themselves must be linked to record of this field by means of
+ * this field.
+ *
+ * @private
+ * @param {mail.model} record
+ * @param {Object|Object[]} data
+ * @param {Object} [options]
+ * @returns {boolean} whether the value changed for the current field
+ */
+ _setRelationCreate(record, data, options) {
+ const OtherModel = this.env.models[this.to];
+ const other = this.env.modelManager._create(OtherModel, data);
+ return this._setRelationLink(record, other, options);
+ }
+
+ /**
+ * Set on this relational field in 'insert' mode. Basically data provided
+ * during set on this relational field contain data to insert records,
+ * which themselves must be linked to record of this field by means of
+ * this field.
+ *
+ * @private
+ * @param {mail.model} record
+ * @param {Object|Object[]} data
+ * @param {Object} [options]
+ * @returns {boolean} whether the value changed for the current field
+ */
+ _setRelationInsert(record, data, options) {
+ const OtherModel = this.env.models[this.to];
+ const other = this.env.modelManager._insert(OtherModel, data);
+ return this._setRelationLink(record, other, options);
+ }
+
+ /**
+ * Set on this relational field in 'insert-and-repalce' mode. Basically
+ * data provided during set on this relational field contain data to insert
+ * records, which themselves must replace value on this field.
+ *
+ * @private
+ * @param {mail.model} record
+ * @param {Object|Object[]} data
+ * @param {Object} [options]
+ * @returns {boolean} whether the value changed for the current field
+ */
+ _setRelationInsertAndReplace(record, data, options) {
+ const OtherModel = this.env.models[this.to];
+ const newValue = this.env.modelManager._insert(OtherModel, data);
+ return this._setRelationReplace(record, newValue, options);
+ }
+
+ /**
+ * Set a 'link' operation on this relational field.
+ *
+ * @private
+ * @param {mail.model|mail.model[]} newValue
+ * @param {Object} [options]
+ * @returns {boolean} whether the value changed for the current field
+ */
+ _setRelationLink(record, newValue, options) {
+ switch (this.relationType) {
+ case 'many2many':
+ case 'one2many':
+ return this._setRelationLinkX2Many(record, newValue, options);
+ case 'many2one':
+ case 'one2one':
+ return this._setRelationLinkX2One(record, newValue, options);
+ }
+ }
+
+ /**
+ * Handling of a `set` 'link' of a x2many relational field.
+ *
+ * @private
+ * @param {mail.model} record
+ * @param {mail.model|mail.model[]} newValue
+ * @param {Object} [param2={}]
+ * @param {boolean} [param2.hasToUpdateInverse=true] whether updating the
+ * current field should also update its inverse field. Typically set to
+ * false only during the process of updating the inverse field itself, to
+ * avoid unnecessary recursion.
+ * @returns {boolean} whether the value changed for the current field
+ */
+ _setRelationLinkX2Many(record, newValue, { hasToUpdateInverse = true } = {}) {
+ const recordsToLink = this._convertX2ManyValue(newValue);
+ const otherRecords = this.read(record);
+
+ let hasChanged = false;
+ for (const recordToLink of recordsToLink) {
+ // other record already linked, avoid linking twice
+ if (otherRecords.has(recordToLink)) {
+ continue;
+ }
+ hasChanged = true;
+ // link other records to current record
+ otherRecords.add(recordToLink);
+ // link current record to other records
+ if (hasToUpdateInverse) {
+ this.env.modelManager._update(
+ recordToLink,
+ { [this.inverse]: [['link', record]] },
+ { hasToUpdateInverse: false }
+ );
+ }
+ }
+ return hasChanged;
+ }
+
+ /**
+ * Handling of a `set` 'link' of an x2one relational field.
+ *
+ * @private
+ * @param {mail.model} record
+ * @param {mail.model} recordToLink
+ * @param {Object} [param2={}]
+ * @param {boolean} [param2.hasToUpdateInverse=true] whether updating the
+ * current field should also update its inverse field. Typically set to
+ * false only during the process of updating the inverse field itself, to
+ * avoid unnecessary recursion.
+ * @returns {boolean} whether the value changed for the current field
+ */
+ _setRelationLinkX2One(record, recordToLink, { hasToUpdateInverse = true } = {}) {
+ this._verifyRelationalValue(recordToLink);
+ const prevOtherRecord = this.read(record);
+ // other record already linked, avoid linking twice
+ if (prevOtherRecord === recordToLink) {
+ return false;
+ }
+ // unlink to properly update previous inverse before linking new value
+ this._setRelationUnlinkX2One(record, { hasToUpdateInverse });
+ // link other record to current record
+ record.__values[this.fieldName] = recordToLink;
+ // link current record to other record
+ if (hasToUpdateInverse) {
+ this.env.modelManager._update(
+ recordToLink,
+ { [this.inverse]: [['link', record]] },
+ { hasToUpdateInverse: false }
+ );
+ }
+ return true;
+ }
+
+ /**
+ * Set a 'replace' operation on this relational field.
+ *
+ * @private
+ * @param {mail.model} record
+ * @param {mail.model|mail.model[]} newValue
+ * @param {Object} [options]
+ * @returns {boolean} whether the value changed for the current field
+ */
+ _setRelationReplace(record, newValue, options) {
+ if (['one2one', 'many2one'].includes(this.relationType)) {
+ // for x2one replace is just link
+ return this._setRelationLinkX2One(record, newValue, options);
+ }
+
+ // for x2many: smart process to avoid unnecessary unlink/link
+ let hasChanged = false;
+ let hasToReorder = false;
+ const otherRecordsSet = this.read(record);
+ const otherRecordsList = [...otherRecordsSet];
+ const recordsToReplaceList = [...this._convertX2ManyValue(newValue)];
+ const recordsToReplaceSet = new Set(recordsToReplaceList);
+
+ // records to link
+ const recordsToLink = [];
+ for (let i = 0; i < recordsToReplaceList.length; i++) {
+ const recordToReplace = recordsToReplaceList[i];
+ if (!otherRecordsSet.has(recordToReplace)) {
+ recordsToLink.push(recordToReplace);
+ }
+ if (otherRecordsList[i] !== recordToReplace) {
+ hasToReorder = true;
+ }
+ }
+ if (this._setRelationLinkX2Many(record, recordsToLink, options)) {
+ hasChanged = true;
+ }
+
+ // records to unlink
+ const recordsToUnlink = [];
+ for (let i = 0; i < otherRecordsList.length; i++) {
+ const otherRecord = otherRecordsList[i];
+ if (!recordsToReplaceSet.has(otherRecord)) {
+ recordsToUnlink.push(otherRecord);
+ }
+ if (recordsToReplaceList[i] !== otherRecord) {
+ hasToReorder = true;
+ }
+ }
+ if (this._setRelationUnlinkX2Many(record, recordsToUnlink, options)) {
+ hasChanged = true;
+ }
+
+ // reorder result
+ if (hasToReorder) {
+ otherRecordsSet.clear();
+ for (const record of recordsToReplaceList) {
+ otherRecordsSet.add(record);
+ }
+ hasChanged = true;
+ }
+ return hasChanged;
+ }
+
+ /**
+ * Set an 'unlink' operation on this relational field.
+ *
+ * @private
+ * @param {mail.model} record
+ * @param {mail.model|mail.model[]} newValue
+ * @param {Object} [options]
+ * @returns {boolean} whether the value changed for the current field
+ */
+ _setRelationUnlink(record, newValue, options) {
+ switch (this.relationType) {
+ case 'many2many':
+ case 'one2many':
+ return this._setRelationUnlinkX2Many(record, newValue, options);
+ case 'many2one':
+ case 'one2one':
+ return this._setRelationUnlinkX2One(record, options);
+ }
+ }
+
+ /**
+ * Handling of a `set` 'unlink' of a x2many relational field.
+ *
+ * @private
+ * @param {mail.model} record
+ * @param {mail.model|mail.model[]} newValue
+ * @param {Object} [param2={}]
+ * @param {boolean} [param2.hasToUpdateInverse=true] whether updating the
+ * current field should also update its inverse field. Typically set to
+ * false only during the process of updating the inverse field itself, to
+ * avoid unnecessary recursion.
+ * @returns {boolean} whether the value changed for the current field
+ */
+ _setRelationUnlinkX2Many(record, newValue, { hasToUpdateInverse = true } = {}) {
+ const recordsToUnlink = this._convertX2ManyValue(
+ newValue,
+ { hasToVerify: false }
+ );
+ const otherRecords = this.read(record);
+
+ let hasChanged = false;
+ for (const recordToUnlink of recordsToUnlink) {
+ // unlink other record from current record
+ const wasLinked = otherRecords.delete(recordToUnlink);
+ if (!wasLinked) {
+ continue;
+ }
+ hasChanged = true;
+ // unlink current record from other records
+ if (hasToUpdateInverse) {
+ if (!recordToUnlink.exists()) {
+ // This case should never happen ideally, but the current
+ // way of handling related relational fields make it so that
+ // deleted records are not always reflected immediately in
+ // these related fields.
+ continue;
+ }
+ // apply causality
+ if (this.isCausal) {
+ this.env.modelManager._delete(recordToUnlink);
+ } else {
+ this.env.modelManager._update(
+ recordToUnlink,
+ { [this.inverse]: [['unlink', record]] },
+ { hasToUpdateInverse: false }
+ );
+ }
+ }
+ }
+ return hasChanged;
+ }
+
+ /**
+ * Handling of a `set` 'unlink' of a x2one relational field.
+ *
+ * @private
+ * @param {mail.model} record
+ * @param {Object} [param1={}]
+ * @param {boolean} [param1.hasToUpdateInverse=true] whether updating the
+ * current field should also update its inverse field. Typically set to
+ * false only during the process of updating the inverse field itself, to
+ * avoid unnecessary recursion.
+ * @returns {boolean} whether the value changed for the current field
+ */
+ _setRelationUnlinkX2One(record, { hasToUpdateInverse = true } = {}) {
+ const otherRecord = this.read(record);
+ // other record already unlinked, avoid useless processing
+ if (!otherRecord) {
+ return false;
+ }
+ // unlink other record from current record
+ record.__values[this.fieldName] = undefined;
+ // unlink current record from other record
+ if (hasToUpdateInverse) {
+ if (!otherRecord.exists()) {
+ // This case should never happen ideally, but the current
+ // way of handling related relational fields make it so that
+ // deleted records are not always reflected immediately in
+ // these related fields.
+ return;
+ }
+ // apply causality
+ if (this.isCausal) {
+ this.env.modelManager._delete(otherRecord);
+ } else {
+ this.env.modelManager._update(
+ otherRecord,
+ { [this.inverse]: [['unlink', record]] },
+ { hasToUpdateInverse: false }
+ );
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Verifies the given relational value makes sense for the current field.
+ * In particular the given value must be a record, it must be non-deleted,
+ * and it must originates from relational `to` model (or its subclasses).
+ *
+ * @private
+ * @param {mail.model} record
+ * @throws {Error} if record does not satisfy related model
+ */
+ _verifyRelationalValue(record) {
+ const OtherModel = this.env.models[this.to];
+ if (!OtherModel.get(record.localId, { isCheckingInheritance: true })) {
+ throw Error(`Record ${record.localId} is not valid for relational field ${this.fieldName}.`);
+ }
+ }
+
+}
+
+return ModelField;
+
+});
diff --git a/addons/mail/static/src/model/model_field_command.js b/addons/mail/static/src/model/model_field_command.js
new file mode 100644
index 00000000..f4e59a95
--- /dev/null
+++ b/addons/mail/static/src/model/model_field_command.js
@@ -0,0 +1,73 @@
+odoo.define('mail/static/src/model/model_field_command.js', function (require) {
+'use strict';
+
+/**
+ * Allows field update to detect if the value it received is a command to
+ * execute (in which was it will be an instance of this class) or an actual
+ * value to set (in all other cases).
+ */
+class FieldCommand {
+ /**
+ * @constructor
+ * @param {function} func function to call when executing this command.
+ * The function should ALWAYS return a boolean value
+ * to indicate whether the value changed.
+ */
+ constructor(func) {
+ this.func = func;
+ }
+
+ /**
+ * @param {ModelField} field
+ * @param {mail.model} record
+ * @param {options} [options]
+ * @returns {boolean} whether the value changed for the current field
+ */
+ execute(field, record, options) {
+ return this.func(field, record, options);
+ }
+}
+
+/**
+ * Returns a clear command to give to the model manager at create/update.
+ */
+function clear() {
+ return new FieldCommand((field, record, options) =>
+ field.clear(record, options)
+ );
+}
+
+/**
+ * Returns a decrement command to give to the model manager at create/update.
+ *
+ * @param {number} [amount=1]
+ */
+function decrement(amount = 1) {
+ return new FieldCommand((field, record, options) => {
+ const oldValue = field.get(record);
+ return field.set(record, oldValue - amount, options);
+ });
+}
+
+/**
+ * Returns a increment command to give to the model manager at create/update.
+ *
+ * @param {number} [amount=1]
+ */
+function increment(amount = 1) {
+ return new FieldCommand((field, record, options) => {
+ const oldValue = field.get(record);
+ return field.set(record, oldValue + amount, options);
+ });
+}
+
+return {
+ // class
+ FieldCommand,
+ // shortcuts
+ clear,
+ decrement,
+ increment,
+};
+
+});
diff --git a/addons/mail/static/src/model/model_manager.js b/addons/mail/static/src/model/model_manager.js
new file mode 100644
index 00000000..6ffcd8d1
--- /dev/null
+++ b/addons/mail/static/src/model/model_manager.js
@@ -0,0 +1,1098 @@
+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
+ * <record-local-id><DEPENDENT_INNER_SEPARATOR><fieldName>, 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;
+
+});