diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/mail/static/src/model/model_field.js | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/mail/static/src/model/model_field.js')
| -rw-r--r-- | addons/mail/static/src/model/model_field.js | 820 |
1 files changed, 820 insertions, 0 deletions
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; + +}); |
