Initial commit: Core packages

This commit is contained in:
Ernad Husremovic 2025-08-29 15:20:45 +02:00
commit 12c29a983b
9512 changed files with 8379910 additions and 0 deletions

View file

@ -0,0 +1,486 @@
/** @odoo-module **/
import { makeDeferred } from '@mail/utils/deferred';
/**
* Module that contains registry for adding new models or patching models.
* Useful for model manager in order to generate models.
*
* 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.
*/
export const registry = new Map();
export const IS_RECORD = Symbol("Record");
const patches = [];
/**
* Concats `contextMessage` at the beginning of any error raising when calling
* `func`.
*
* @param {Function} func The (fallible) function to be called.
* @param {string} contextMessage Extra explanations to be added to the error
* message if any.
* @throws {Error} enriched with `contextMessage` if `func` raises an error.
*/
function addContextToErrors(func, contextMessage) {
try {
func();
} catch (error) {
error.message = contextMessage + error.message;
throw error;
}
}
/**
* Adds the provided fields to the model specified by the `modelName`.
*
* @param {string} modelName The name of the model to which to add the fields.
* @param {Object} fields Fields to be added. key = field name, value = field attributes
*/
function addFields(modelName, fields) {
if (!registry.has(modelName)) {
throw new Error(`Cannot add fields to model "${modelName}": model must be registered before fields can be added.`);
}
const definition = registry.get(modelName);
for (const [fieldName, field] of Object.entries(fields)) {
addContextToErrors(() => {
assertNameIsAvailableOnRecords(fieldName, definition);
}, `Cannot add field "${fieldName}" to model "${modelName}": `);
definition.get('fields').set(fieldName, field);
}
}
/**
* Adds the provided hooks to the model specified by the `modelName`.
*
* @param {string} modelName The name of the model to which to add the hooks.
* @param {Object} hooks Hooks to be added. key = name, value = handler
*/
function addLifecycleHooks(modelName, hooks) {
if (!registry.has(modelName)) {
throw new Error(`Cannot add lifecycle hooks to model "${modelName}": model must be registered before lifecycle hooks can be added.`);
}
const definition = registry.get(modelName);
for (const [name, handler] of Object.entries(hooks)) {
addContextToErrors(() => {
assertIsFunction(handler);
assertIsValidHookName(name);
assertSectionDoesNotHaveKey('lifecycleHooks', name, definition);
}, `Cannot add lifecycle hook "${name}" to model "${modelName}": `);
definition.get('lifecycleHooks').set(name, handler);
}
}
/**
* Adds the provided model getters to the model specified by the `modelName`.
*
* @deprecated Getters are only used in `Record` and are intended to
* provide fields such as `env` to all models by inheritance. The creation of
* these fields will be directly in the model manager in the future.
* Use fields instead.
* @param {string} modelName The name of the model to which to add the model getters.
* @param {Object} modelGetters Model getters to be added. key = name, value = getter
*/
function addModelGetters(modelName, modelGetters) {
if (!registry.has(modelName)) {
throw new Error(`Cannot add record getters to model "${modelName}": model must be registered before record getters can be added.`);
}
const definition = registry.get(modelName);
for (const [getterName, getter] of Object.entries(modelGetters)) {
addContextToErrors(() => {
assertIsFunction(getter);
assertNameIsAvailableOnModel(getterName, definition);
}, `Cannot add model getter "${getterName}" to model "${modelName}": `);
definition.get('modelGetters').set(getterName, getter);
}
}
/**
* Adds the provided model methods to the model specified by the `modelName`.
*
* @param {string} modelName The name of the model to which to add the model methods.
* @param {Object} modelMethods Model methods to be added. key = name, value = method
*/
function addModelMethods(modelName, modelMethods) {
if (!registry.has(modelName)) {
throw new Error(`Cannot add model methods to model "${modelName}": model must be registered before model methods can be added.`);
}
const definition = registry.get(modelName);
for (const [name, method] of Object.entries(modelMethods)) {
addContextToErrors(() => {
assertIsFunction(method);
assertNameIsAvailableOnModel(name, definition);
}, `Cannot add model method "${name}" to model "${modelName}": `);
definition.get('modelMethods').set(name, method);
}
}
/**
* Adds the provided onChanges to the model specified by the `modelName`.
*
* @param {string} modelName The name of the model to which to add onChanges.
* @param {Object[]} onChanges Array of onChange definitions.
*/
function addOnChanges(modelName, onChanges) {
addContextToErrors(() => {
if (!registry.has(modelName)) {
throw new Error(`model must be registered before onChanges can be added.`);
}
for (const onChange of onChanges) {
if (!Object.prototype.hasOwnProperty.call(onChange, 'dependencies')) {
throw new Error("at least one onChange definition lacks dependencies (the list of fields to be watched for changes).");
}
if (!Object.prototype.hasOwnProperty.call(onChange, 'methodName')) {
throw new Error("at least one onChange definition lacks a methodName (the name of the method to be called on change).");
}
if (!Array.isArray(onChange.dependencies)) {
throw new Error("onChange dependencies must be an array of strings.");
}
if (typeof onChange.methodName !== 'string') {
throw new Error("onChange methodName must be a string.");
}
const allowedKeys = ['dependencies', 'methodName'];
for (const key of Object.keys(onChange)) {
if (!allowedKeys.includes(key)) {
throw new Error(`unknown key "${key}" in onChange definition. Allowed keys: ${allowedKeys.join(", ")}.`);
}
}
// paths of dependencies are splitted now to avoid having to do it
// each time the path is followed.
const splittedDependencies = [];
for (const dependency of onChange.dependencies) {
if (typeof dependency !== 'string') {
throw new Error("onChange dependencies must be an array of strings.");
}
splittedDependencies.push(dependency.split('.'));
}
onChange.dependencies = splittedDependencies;
}
}, `Cannot add onChanges to model "${modelName}": `);
registry.get(modelName).get('onChanges').push(...onChanges);
}
/**
* Adds the provided record methods to the model specified by the `modelName`.
*
* @param {string} modelName The name of the model to which to add the methods.
* @param {Object} recordMethods Record methods to be added. key = name, value = method
*/
function addRecordMethods(modelName, recordMethods) {
if (!registry.has(modelName)) {
throw new Error(`Cannot add record methods to model "${modelName}": model must be registered before record methods can be added.`);
}
const definition = registry.get(modelName);
for (const [name, method] of Object.entries(recordMethods)) {
addContextToErrors(() => {
assertIsFunction(method);
assertNameIsAvailableOnRecords(name, definition);
}, `Cannot add record method "${name}" to model "${modelName}": `);
definition.get('recordMethods').set(name, method);
}
}
/**
* Adds the provided record getters to the model specified by the `modelName`.
*
* @deprecated Getters are only used in `Record` and are intended to
* provide fields such as `env` to all records by inheritance. The creation of
* these fields will be directly in the model manager in the future.
* Use fields instead.
* @param {string} modelName The name of the model to which to add the record getters.
* @param {Object} recordGetters Record getters to be added. key = name, value = getter
*/
function addRecordGetters(modelName, recordGetters) {
if (!registry.has(modelName)) {
throw new Error(`Cannot add record getters to model "${modelName}": model must be registered before record getters can be added.`);
}
const definition = registry.get(modelName);
for (const [getterName, getter] of Object.entries(recordGetters)) {
addContextToErrors(() => {
assertIsFunction(getter);
assertNameIsAvailableOnRecords(getterName, definition);
}, `Cannot add record getter "${getterName}" to model "${modelName}": `);
definition.get('recordGetters').set(getterName, getter);
}
}
/**
* Asserts that `toAssert` is typeof function.
*
* @param {any} toAssert
* @throws {Error} when `toAssert` is not typeof function.
*/
function assertIsFunction(toAssert) {
if (typeof toAssert !== 'function') {
throw new Error(`"${toAssert}" must be a function`);
}
}
/**
* Asserts that `name` is a valid hook name.
*
* @param {string} name The hook name to check.
* @throws {Error} if name is not an existing hook name.
*/
function assertIsValidHookName(name) {
const validHookNames = ['_created', '_willDelete'];
if (!validHookNames.includes(name)) {
throw new Error(`unsupported hook name. Possible values: ${validHookNames.join(", ")}.`);
}
}
/**
* Asserts that the provided `key` is not already defined within the section
* `sectionName` on the model `modelDefinition`.
*
* @param {string} sectionName The section of the `modelDefinition` to check into.
* @param {string} key The key to check for.
* @param {Object} modelDefinition The definition of the model to check.
*/
function assertSectionDoesNotHaveKey(sectionName, key, modelDefinition) {
if (modelDefinition.get(sectionName).has(key)) {
throw new Error(`"${key}" is already defined on "${sectionName}".`);
}
}
/**
* Asserts that `name` is not already used as a key on the model.
*
* @param {string} name The name of the key to check the availability.
* @param {Map} modelDefinition The model definition to look into.
* @throws {Error} when `name` is already used as a key on the model.
*/
function assertNameIsAvailableOnModel(name, modelDefinition) {
if (['modelGetters', 'modelMethods'].some(x => modelDefinition.get(x).has(name))) {
throw new Error(`there is already a key with this name on the model.`);
}
}
/**
* Asserts that `name` is not already used as a key on the records.
*
* @param {string} name The name of the key to check the availability.
* @param {Map} modelDefinition The model definition to look into.
* @throws {Error} when `name` is already used as a key on the records.
*/
function assertNameIsAvailableOnRecords(name, modelDefinition) {
if (['fields', 'recordGetters', 'recordMethods'].some(x => modelDefinition.get(x).has(name))) {
throw new Error(`there is already a key with this name on the records.`);
}
}
function patchFields(patch) {
const newFieldsToAdd = Object.create(null);
for (const [fieldName, fieldData] of Object.entries(patch.fields)) {
const originalFieldDefinition = registry.get(patch.name).get('fields').get(fieldName);
if (!originalFieldDefinition) {
newFieldsToAdd[fieldName] = fieldData;
} else {
for (const [attributeName, attributeData] of Object.entries(fieldData))
switch (attributeName) {
case 'compute':
if (!originalFieldDefinition.compute) {
throw new Error(`Cannot patch compute of field ${patch.name}/${fieldName}: the field is not a compute in the original definition.`);
}
if (typeof attributeData !== 'function') {
throw new Error(`Cannot patch compute of field ${patch.name}/${fieldName}: the compute must be a function (found: "${typeof attributeData}").`);
}
const computeBeforePatch = originalFieldDefinition.compute;
originalFieldDefinition.compute = function () {
this._super = computeBeforePatch;
return attributeData.call(this);
};
break;
case 'sort':
if (originalFieldDefinition.sort) {
if (typeof attributeData !== 'function') {
throw new Error(`Cannot patch sorting rules of field ${patch.name}/${fieldName}: the value of 'sort' must be a function to apply to the sorting rules array (found: "${typeof attributeData}").`);
}
originalFieldDefinition.sort = attributeData.call({ _super: originalFieldDefinition.sort });
} else {
if (!Array.isArray(attributeData)) {
throw new Error(`Cannot add sorting rules to field ${patch.name}/${fieldName}: sorting rules must be an array.`);
}
originalFieldDefinition.sort = attributeData;
}
break;
default:
throw new Error(`Cannot patch field ${patch.name}/${fieldName}: unsupported field attribute "${attributeName}".`);
}
}
}
addFields(patch.name, newFieldsToAdd);
}
function patchLifecycleHooks(patch) {
const originalLifecycleHooksDefinition = registry.get(patch.name).get('lifecycleHooks');
const newLifecycleHooksToAdd = Object.create(null);
for (const [hookName, hookHandler] of Object.entries(patch.lifecycleHooks)) {
if (!originalLifecycleHooksDefinition.has(hookName)) {
newLifecycleHooksToAdd[hookName] = hookHandler;
} else {
if (typeof hookHandler !== 'function') {
throw new Error(`Cannot patch hook "${hookName}" on model ${patch.name}: the hook handler must be a function (current type: "${typeof hookHandler}").`);
}
const hookHandlerBeforePatch = originalLifecycleHooksDefinition.get(hookName);
originalLifecycleHooksDefinition.set(hookName, function () {
this._super = hookHandlerBeforePatch;
return hookHandler.call(this);
});
}
}
addLifecycleHooks(patch.name, newLifecycleHooksToAdd);
}
function patchModelMethods(patch) {
const originalModelMethodsDefinition = registry.get(patch.name).get('modelMethods');
const newModelMethodsToAdd = Object.create(null);
for (const [methodName, method] of Object.entries(patch.modelMethods)) {
if (!originalModelMethodsDefinition.has(methodName)) {
newModelMethodsToAdd[methodName] = method;
} else {
if (typeof method !== 'function') {
throw new Error(`Cannot patch model method "${methodName}" on model ${patch.name}: the method must be a function (current type: "${typeof method}").`);
}
const methodBeforePatch = originalModelMethodsDefinition.get(methodName);
originalModelMethodsDefinition.set(methodName, function (...args) {
this._super = methodBeforePatch;
return method.call(this, ...args);
});
}
}
addModelMethods(patch.name, newModelMethodsToAdd);
}
function patchRecordMethods(patch) {
const originalRecordMethods = registry.get(patch.name).get('recordMethods');
const newRecordMethodsToAdd = Object.create(null);
for (const [methodName, method] of Object.entries(patch.recordMethods)) {
if (!originalRecordMethods.has(methodName)) {
newRecordMethodsToAdd[methodName] = method;
} else {
if (typeof method !== 'function') {
throw new Error(`Cannot patch record method "${methodName}" on model ${patch.name}: the method must be a function (current type: "${typeof method}").`);
}
const methodBeforePatch = originalRecordMethods.get(methodName);
originalRecordMethods.set(methodName, function (...args) {
this._super = methodBeforePatch;
return method.call(this, ...args);
});
}
}
addRecordMethods(patch.name, newRecordMethodsToAdd);
}
/**
* @param {Object} definition The JSON definition of the model to register.
* @param {Object} [definition.fields]
* @param {string} [definition.identifyingMode='and']
* @param {Object} [definition.lifecycleHooks]
* @param {Object} [definition.modelGetters] Deprecated; use fields instead.
* @param {Object} [definition.modelMethods]
* @param {string} definition.name
* @param {Object[]} [definition.onChanges]
* @param {Object} [definition.recordGetters] Deprecated; use fields instead.
* @param {Object} [definition.recordMethods]
*/
export function registerModel({ fields, identifyingMode = 'and', lifecycleHooks, modelGetters, modelMethods, name, onChanges, recordGetters, recordMethods }) {
if (!name) {
throw new Error("Model is lacking a name.");
}
if (registry.has(name)) {
throw new Error(`Cannot register model "${name}": model has already been registered.`);
}
const sectionNames = ['name', 'identifyingMode', 'lifecycleHooks', 'modelMethods', 'modelGetters', 'recordMethods', 'recordGetters', 'fields', 'onChanges'];
const invalidSectionNames = Object.keys(arguments[0]).filter(x => !sectionNames.includes(x));
if (invalidSectionNames.length > 0) {
throw new Error(`Cannot register model "${name}": model definition contains unknown key(s): ${invalidSectionNames.join(", ")}`);
}
registry.set(name, new Map([
['name', name],
['identifyingMode', identifyingMode],
['lifecycleHooks', new Map()],
['modelMethods', new Map()],
['modelGetters', new Map()],
['recordMethods', new Map()],
['recordGetters', new Map()],
['fields', new Map()],
['onChanges', []],
]));
if (lifecycleHooks) {
addLifecycleHooks(name, lifecycleHooks);
}
if (modelMethods) {
addModelMethods(name, modelMethods);
}
if (modelGetters) {
addModelGetters(name, modelGetters);
}
if (recordMethods) {
addRecordMethods(name, recordMethods);
}
if (recordGetters) {
addRecordGetters(name, recordGetters);
}
if (fields) {
addFields(name, fields);
}
if (onChanges) {
addOnChanges(name, onChanges);
}
}
export function registerPatch({ fields, lifecycleHooks, modelMethods, name, onChanges, recordMethods }) {
if (!name) {
throw new Error("Patch is lacking the name of the model to be patched.");
}
const allowedSectionNames = ['name', 'lifecycleHooks', 'modelMethods', 'recordMethods', 'fields', 'onChanges'];
const invalidSectionNames = Object.keys(arguments['0']).filter(x => !allowedSectionNames.includes(x));
if (invalidSectionNames.length > 0) {
throw new Error(`Error while registering patch for model "${name}": patch definition contains unsupported key(s): ${invalidSectionNames.join(", ")}`);
}
patches.push({
name,
lifecycleHooks: lifecycleHooks || {},
modelMethods: modelMethods || {},
recordMethods: recordMethods || {},
fields: fields || {},
onChanges: onChanges || [],
});
}
export const patchesAppliedPromise = makeDeferred();
(async function applyPatches() {
if (document.readyState !== 'complete') {
await new Promise(resolve => {
/**
* Called when all JS resources are loaded. This is useful to
* ensure all definitions have been parsed before applying patches.
*/
window.addEventListener('load', resolve);
});
}
/**
* All JS resources are loaded, but not necessarily processed.
* We assume no messaging-related modules return any Promise,
* therefore they should be processed *at most* asynchronously at
* "Promise time".
*/
await new Promise(resolve => setTimeout(resolve));
for (const patch of patches) {
const definition = registry.get(patch.name);
if (!definition) {
throw new Error(`Cannot patch model "${patch.name}": there is no model registered under this name.`);
}
patchLifecycleHooks(patch);
patchModelMethods(patch);
patchRecordMethods(patch);
patchFields(patch);
addOnChanges(patch.name, patch.onChanges);
}
patchesAppliedPromise.resolve();
})();

View file

@ -0,0 +1,912 @@
/** @odoo-module **/
import { clear, FieldCommand, link, unlink, unlinkAll } from '@mail/model/model_field_command';
import { IS_RECORD } from '@mail/model/model_core';
/**
* Class whose instances represent field on a model.
* These field definitions are generated from declared fields in static prop
* `fields` on the model.
*/
export class ModelField {
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
constructor({
compute,
default: def,
fieldName,
fieldType,
identifying = false,
inverse,
isCausal = false,
model,
readonly = false,
related,
relationType,
required = false,
sort,
to,
} = {}) {
/**
* If set, this field acts as a computed field, and this prop
* contains the function that computes the value
* for this field. This compute method is called on creation of record
* and whenever some of its dependencies change.
*/
this.compute = compute;
/**
* Default value for this field. Used on creation of this field, to
* set a value by default.
*/
this.default = def;
/**
* 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;
/**
* Determines whether this field is an identifying field for its model.
*/
this.identifying = identifying;
/**
* 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;
this.model = model;
/**
* Determines whether the field is read only. Read only field
* can't be updated once the record is created.
* An exception is made for computed fields (updated when the
* dependencies are updated) and related fields (updated when the
* inverse relation changes).
*/
this.readonly = readonly;
/**
* 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.
*/
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.
* 2 types of relation are supported: 'many', 'one'.
*/
this.relationType = relationType;
/**
* Determine whether the field is required or not.
*
* Empty value is systematically undefined.
* null or empty string are NOT considered empty value, meaning these values meet the requirement.
*/
this.required = required;
/**
* Determines the name of the function on record that returns the
* definition on how this field is sorted (only makes sense for
* relational x2many).
*
* It must contain a function returning the definition instead of the
* definition directly (to allow the definition to depend on the value
* of another field).
*
* The definition itself should be a list of operations, and each
* operation itself should be a list of 2 elements: the first is the
* name of a supported compare method, and the second is a dot separated
* relation path, starting from the current record.
*
* When determining the order of one record compared to another, each
* compare operation will be applied in the order provided, stopping at
* the first operation that is able to determine an order for the two
* records.
*/
this.sort;
/**
* Sorted fields define sorting rules that can depend on related fields.
* The path to a related field is defined by a period-separated sequence
* of the relations to follow. To avoid wasting performances splitting
* the path each time a relation must be followed, it is spllited at
* field instantiation and stored here.
*/
this.sortedFieldSplittedPaths;
/**
* Determines whether and how elements of this field should be summed
* into a counter field (only makes sense for relational x2many).
*
* It must contain an array of object with 2 keys: `from` giving the
* name of a field on the relation record that holds the contribution of
* that record to the sum, and `to` giving the name of a field on the
* current record which contains the sum.
*/
this.sumContributions = [];
/**
* This prop only makes sense in a relational field. Determine which
* model name this relation refers to.
*/
this.to = to;
/**
* Automatically make identifying fields readonly (and required for AND
* identifying mode).
*/
if (this.identifying) {
this.readonly = true;
if (this.model.identifyingMode === 'and') {
this.required = true;
}
}
if (this.related) {
// Automatically make relateds readonly.
this.readonly = true;
}
if (this.compute) {
// Automatically make computes readonly.
this.readonly = true;
}
if (sort) {
this.sort = [];
// Only keep unique paths, as they will be used to create listeners,
// and we do not want to create useless duplicate listeners.
const relatedPathSet = new Set();
for (const [compareRule, pathAsString] of sort) {
relatedPathSet.add(pathAsString);
this.sort.push([compareRule, pathAsString.split('.')]);
}
this.sortedFieldSplittedPaths = [...relatedPathSet].map(pathAsString => pathAsString.split('.'));
}
}
/**
* Define an attribute field.
*
* @param {Object} [options]
* @returns {Object}
*/
static attr(options) {
return Object.assign({ fieldType: 'attribute' }, options);
}
/**
* Define a many field.
*
* @param {string} modelName
* @param {Object} [options]
* @returns {Object}
*/
static many(modelName, options) {
return ModelField._relation(modelName, Object.assign({}, options, { relationType: 'many' }));
}
/**
* Define a one field.
*
* @param {string} modelName
* @param {Object} [options]
* @returns {Object}
*/
static one(modelName, options) {
return ModelField._relation(modelName, Object.assign({}, options, { relationType: 'one' }));
}
/**
* 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 {Record} 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, unlinkAll(), options)) {
hasChanged = true;
}
if (!this.default) {
return hasChanged;
}
}
if (this.parseAndExecuteCommands(record, this.default, options)) {
hasChanged = true;
}
return hasChanged;
}
/**
* Compute method when this field is related.
*
* @private
* @param {Record} record
*/
computeRelated(record) {
const [relationName, relatedFieldName] = this.related.split('.');
const relationField = this.model.__fieldMap.get(relationName);
if (relationField.relationType === 'many') {
const newVal = [];
for (const otherRecord of record[relationName]) {
const otherValue = otherRecord[relatedFieldName];
if (otherValue) {
if (otherValue instanceof Array) {
for (const v of otherValue) {
newVal.push(v);
}
} else {
newVal.push(otherValue);
}
}
}
if (this.fieldType === 'relation') {
return newVal;
}
return newVal;
}
const otherRecord = record[relationName];
if (otherRecord) {
const newVal = otherRecord[relatedFieldName];
if (newVal === undefined) {
return clear();
}
if (this.fieldType === 'relation') {
return newVal;
}
return newVal;
}
return clear();
}
/**
* Converts the given value to a list of FieldCommands
*
* @param {*} newVal
* @returns {FieldCommand[]}
*/
convertToFieldCommandList(newVal) {
if (newVal instanceof FieldCommand) {
return [newVal];
} else if (newVal instanceof Array && newVal[0] instanceof FieldCommand) {
return newVal;
} else if (this.fieldType === 'relation') {
if (newVal instanceof Array && newVal[0] instanceof Array && ['clear', 'insert', 'insert-and-replace', 'insert-and-unlink', 'link', 'replace', 'unlink', 'unlink-all'].includes(newVal[0][0])) {
// newVal: [['insert', ...], ...]
return newVal.map(([name, value]) => new FieldCommand(name, value));
} else if (newVal instanceof Array && newVal[0] && !newVal[0][IS_RECORD]) {
// newVal: [data, ...]
return [new FieldCommand('insert-and-replace', newVal)];
} else if (newVal instanceof Array && newVal[0]) {
// newVal: [record, ...]
return [new FieldCommand('replace', newVal)];
} else if (!newVal[IS_RECORD]) {
// newVal: data
return [new FieldCommand('insert-and-replace', newVal)];
} else {
// newVal: record
return [new FieldCommand('replace', newVal)];
}
} else {
return [new FieldCommand('set', newVal)];
}
}
/**
* Decreases the field value by `amount`
* for an attribute field holding number value,
*
* @param {Record} record
* @param {number} amount
*/
decrement(record, amount) {
const currentValue = this.read(record);
return this._setAttribute(record, currentValue - amount);
}
/**
* Get the value associated to this field. Relations must convert record
* local ids to records.
*
* @param {Record} record
* @returns {any}
*/
get(record) {
if (this.fieldType === 'attribute') {
return this.read(record);
}
if (this.fieldType === 'relation') {
if (this.relationType === 'one') {
return this.read(record);
}
return [...this.read(record)];
}
throw new Error(`cannot get field with unsupported type ${this.fieldType}.`);
}
/**
* Increases the field value by `amount`
* for an attribute field holding number value.
*
* @param {Record} record
* @param {number} amount
*/
increment(record, amount) {
const currentValue = this.read(record);
return this._setAttribute(record, currentValue + amount);
}
/**
* Parses newVal for command(s) and executes them.
*
* @param {Record} 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
*/
parseAndExecuteCommands(record, newVal, options) {
const commandList = this.convertToFieldCommandList(newVal);
let hasChanged = false;
for (const command of commandList) {
const commandName = command.name;
const newVal = command.value;
if (this.fieldType === 'attribute') {
switch (commandName) {
case 'clear':
if (this.clear(record, options)) {
hasChanged = true;
}
break;
case 'decrement':
if (this.decrement(record, newVal)) {
hasChanged = true;
}
break;
case 'increment':
if (this.increment(record, newVal)) {
hasChanged = true;
}
break;
case 'set':
if (this._setAttribute(record, newVal)) {
hasChanged = true;
}
break;
default:
throw new Error(`Field "${this}" (${this.fieldType} type) does not support command "${commandName}"`);
}
} else if (this.fieldType === 'relation') {
switch (commandName) {
case 'clear':
if (this.clear(record, options)) {
hasChanged = true;
}
break;
case 'insert':
if (this._setRelationInsert(record, newVal, options)) {
hasChanged = true;
}
break;
case 'insert-and-replace':
if (this._setRelationInsertAndReplace(record, newVal, options)) {
hasChanged = true;
}
break;
case 'insert-and-unlink':
if (this._setRelationInsertAndUnlink(record, newVal, options)) {
hasChanged = true;
}
break;
case 'link':
if (this._setRelationLink(record, newVal, options)) {
hasChanged = true;
}
break;
case 'replace':
if (this._setRelationReplace(record, newVal, options)) {
hasChanged = true;
}
break;
case 'unlink':
if (this._setRelationUnlink(record, newVal, options)) {
hasChanged = true;
}
break;
case 'unlink-all':
if (this._setRelationUnlink(record, this.get(record), options)) {
hasChanged = true;
}
break;
default:
throw new Error(`Field "${this}" (${this.fieldType} type) does not support command "${commandName}"`);
}
}
}
return hasChanged;
}
/**
* 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 {Record} record
* @returns {any}
*/
read(record) {
return record.__values.get(this.fieldName);
}
/**
* @returns {string}
*/
toString() {
return `${this.model}/${this.fieldName}`;
}
//--------------------------------------------------------------------------
// 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 {Record|Record[]} newValue
* @param {Object} [param1={}]
* @param {boolean} [param1.hasToVerify=true] whether the value has to be
* verified @see `_verifyRelationalValue`
* @returns {Record[]}
*/
_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];
}
/**
* Creates or updates and then returns the other record(s) of a relational
* field based on the given data and the inverse relation value.
*
* @private
* @param {Record} record
* @param {Object|Object[]} data
* @param {Object} [options]
* @returns {Record|Record[]}
*/
_insertOtherRecord(record, data, options) {
const otherModel = record.models[this.to];
const otherField = otherModel.__fieldMap.get(this.inverse);
const isMulti = typeof data[Symbol.iterator] === 'function';
const dataList = isMulti ? data : [data];
for (const recordData of dataList) {
if (otherField.relationType === 'one') {
recordData[this.inverse] = record;
} else {
recordData[this.inverse] = link(record);
}
}
const records = record.modelManager._insert(otherModel, dataList, options);
return isMulti ? records : records[0];
}
/**
* Set a value for this attribute field
*
* @private
* @param {Record} record
* @param {any} newVal value to be written on the field value.
* @returns {boolean} whether the value changed for the current field
*/
_setAttribute(record, newVal) {
const currentValue = this.read(record);
if (currentValue === newVal) {
return false;
}
record.__values.set(this.fieldName, newVal);
return true;
}
/**
* 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 {Record} record
* @param {Object|Object[]} data
* @param {Object} [options]
* @returns {boolean} whether the value changed for the current field
*/
_setRelationInsert(record, data, options) {
const newValue = this._insertOtherRecord(record, data, options);
return this._setRelationLink(record, newValue, 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 {Record} record
* @param {Object|Object[]} data
* @param {Object} [options]
* @returns {boolean} whether the value changed for the current field
*/
_setRelationInsertAndReplace(record, data, options) {
const newValue = this._insertOtherRecord(record, data, options);
return this._setRelationReplace(record, newValue, options);
}
/**
* Set on this relational field in 'insert-and-unlink' mode. Basically
* data provided during set on this relational field contain data to insert
* records, which themselves must be unlinked from this field.
*
* @private
* @param {Record} record
* @param {Object|Object[]} data
* @param {Object} [options]
* @returns {boolean} whether the value changed for the current field
*/
_setRelationInsertAndUnlink(record, data, options) {
const newValue = this._insertOtherRecord(record, data, options);
return this._setRelationUnlink(record, newValue, options);
}
/**
* Set a 'link' operation on this relational field.
*
* @private
* @param {Record|Record[]} newValue
* @param {Object} [options]
* @returns {boolean} whether the value changed for the current field
*/
_setRelationLink(record, newValue, options) {
switch (this.relationType) {
case 'many':
return this._setRelationLinkX2Many(record, newValue, options);
case 'one':
return this._setRelationLinkX2One(record, newValue, options);
}
}
/**
* Handling of a `set` 'link' of a x2many relational field.
*
* @private
* @param {Record} record
* @param {Record|Record[]} 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 hasToVerify = record.modelManager.isDebug;
const recordsToLink = this._convertX2ManyValue(newValue, { hasToVerify });
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) {
record.modelManager._update(
recordToLink,
{ [this.inverse]: link(record) },
{ allowWriteReadonly: true, hasToUpdateInverse: false }
);
}
}
return hasChanged;
}
/**
* Handling of a `set` 'link' of an x2one relational field.
*
* @private
* @param {Record} record
* @param {Record} 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 } = {}) {
if (record.modelManager.isDebug) {
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: true });
// link other record to current record
record.__values.set(this.fieldName, recordToLink);
// link current record to other record
if (hasToUpdateInverse) {
record.modelManager._update(
recordToLink,
{ [this.inverse]: link(record) },
{ allowWriteReadonly: true, hasToUpdateInverse: false }
);
}
return true;
}
/**
* Set a 'replace' operation on this relational field.
*
* @private
* @param {Record} record
* @param {Record|Record[]} newValue
* @param {Object} [options]
* @returns {boolean} whether the value changed for the current field
*/
_setRelationReplace(record, newValue, options) {
if (this.relationType === 'one') {
// 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 hasToVerify = record.modelManager.isDebug;
const recordsToReplaceList = [...this._convertX2ManyValue(newValue, { hasToVerify })];
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 {Record} record
* @param {Record|Record[]} newValue
* @param {Object} [options]
* @returns {boolean} whether the value changed for the current field
*/
_setRelationUnlink(record, newValue, options) {
switch (this.relationType) {
case 'many':
return this._setRelationUnlinkX2Many(record, newValue, options);
case 'one':
return this._setRelationUnlinkX2One(record, options);
}
}
/**
* Handling of a `set` 'unlink' of a x2many relational field.
*
* @private
* @param {Record} record
* @param {Record|Record[]} 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) {
record.modelManager._delete(recordToUnlink);
} else {
record.modelManager._update(
recordToUnlink,
{ [this.inverse]: unlink(record) },
{ allowWriteReadonly: true, hasToUpdateInverse: false }
);
}
}
}
return hasChanged;
}
/**
* Handling of a `set` 'unlink' of a x2one relational field.
*
* @private
* @param {Record} 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.set(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) {
record.modelManager._delete(otherRecord);
} else {
record.modelManager._update(
otherRecord,
{ [this.inverse]: unlink(record) },
{ allowWriteReadonly: true, 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 {Record} record
* @throws {Error} if record does not satisfy related model
*/
_verifyRelationalValue(record) {
if (!record) {
throw Error(`record is undefined. Did you try to link() or insert() empty value? Considering clear() instead.`);
}
if (!record[IS_RECORD]) {
throw Error(`${record} is not a record. Did you try to use link() instead of insert() with data?`);
}
const otherModel = record.modelManager.models[this.to];
if (otherModel.__records.has(record)) {
return;
}
// support for inherited models (eg. relation targeting `Record`)
for (const subModel of Object.values(record.models)) {
if (!(subModel.prototype instanceof otherModel)) {
continue;
}
if (subModel.__records.has(record)) {
return;
}
}
throw Error(`Record ${record} is not valid for relational field ${this.fieldName}.`);
}
}
export const attr = ModelField.attr;
export const many = ModelField.many;
export const one = ModelField.one;

View file

@ -0,0 +1,172 @@
/** @odoo-module **/
/**
* 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 {string} name - command name.
* @param {any} [value] - value(s) used for the command.
*/
constructor(name, value) {
this._name = name;
this._value = value;
}
/**
* @returns {string} name/behavior of the command.
*/
get name() {
return this._name;
}
/**
* @returns {any} value used for the command to update the field
*/
get value() {
return this._value;
}
}
/**
* Returns a clear command to give to the model manager at create/update.
* `clear` command can be used for attribute fields or relation fields.
* - Set an attribute field its default value (or undefined if no default value is given);
* - or unlink the current record(s) and then set the default value for a relation field;
*
* @returns {FieldCommand}
*/
function clear() {
return new FieldCommand('clear');
}
/**
* Returns a create command to give to the model manager at create/update.
* `create` command can be used for relation fields.
* - Create new record(s) from data and then link it for a relation field;
*
* @param {Object|Object[]} data - data object or data objects array to create record(s).
* @returns {FieldCommand}
*/
function create(data) {
return new FieldCommand('create', data);
}
/**
* Returns a decrement command to give to the model manager at create/update.
* `decrement` command can be used for attribute fields (number typed).
* The field value will be decreased by `amount`.
*
* @param {number} [amount=1]
* @returns {FieldCommand}
*/
function decrement(amount = 1) {
return new FieldCommand('decrement', amount);
}
/**
* Returns a increment command to give to the model manager at create/update.
* `increment` command can be used for attribute fields (number typed).
* The field value will be increased by `amount`.
*
* @param {number} [amount=1]
* @returns {FieldCommand}
*/
function increment(amount = 1) {
return new FieldCommand('increment', amount);
}
/**
* Returns a insert command to give to the model manager at create/update.
* `insert` command can be used for relation fields.
* - Create new record(s) from data if the record(s) do not exist;
* - or update the record(s) if they can be found from identifying data;
* - and then link record(s) to a relation field.
*
* @param {Object|Object[]} data - data object or data objects array to insert record(s).
* @returns {FieldCommand}
*/
function insert(data) {
return new FieldCommand('insert', data);
}
/**
* Returns a insert-and-unlink command to give to the model manager at create/update.
* `insertAndUnlink` command can be used for relation fields.
* - Create new record(s) from data if the record(s) do not exist;
* - or update the record(s) if they can be found from identifying data;
* - and then unlink the record(s) from the relation field (if they were present).
*
* @param {Object|Object[]} [data={}] - data object or data objects array to insert and unlink record(s).
* @returns {FieldCommand}
*/
export function insertAndUnlink(data = {}) {
return new FieldCommand('insert-and-unlink', data);
}
/**
* Returns a link command to give to the model manager at create/update.
* `link` command can be used for relation fields.
* - Set the field value `newValue` if current field value differs from `newValue` for an x2one field;
* - Or add the record(s) given by `newValue` which are not in the currecnt field value
* to the field value for an x2many field.
*
* @param {Record|Record[]} newValue - record or records array to be linked.
* @returns {FieldCommand}
*/
function link(newValue) {
return new FieldCommand('link', newValue);
}
/**
* Returns a set command to give to the model manager at create/update.
* `set` command can be used for attribute fields.
* - Write the `newValue` on the field value.
*
* @param {any} newValue - value to be written on the field value.
*/
function set(newValue) {
return new FieldCommand('set', newValue);
}
/**
* Returns a unlink command to give to the model manager at create/update.
* `unlink` command can be used for relation fields.
* - Remove the current value for a x2one field
* - or remove the record(s) given by `data` which are in the current field value
* for a x2many field.
*
* @param {Record|Record[]} [data] - record or records array to be unlinked.
* `data` will be ignored if the field is x2one type.
* @returns {FieldCommand}
*/
function unlink(data) {
return new FieldCommand('unlink', data);
}
/**
* Returns a unlink-all command to give to the model manager at create/update.
* `unlinkAll` command can be used for relation fields.
* - remove all record(s) for a relation field
*
* @returns {FieldCommand}
*/
function unlinkAll() {
return new FieldCommand('unlink-all');
}
export {
FieldCommand,
clear,
create,
decrement,
increment,
insert,
link,
set,
unlink,
unlinkAll,
};

View file

@ -0,0 +1,252 @@
/** @odoo-module */
import { decrement, increment } from '@mail/model/model_field_command';
import { Listener } from '@mail/model/model_listener';
import { followRelations } from '@mail/model/model_utils';
import { cleanSearchTerm } from '@mail/utils/utils';
/**
* Defines a set containing the relation records of the given field on the given
* record. The behavior of this set depends on the field properties.
*/
export class RelationSet {
/**
* @param {Record} record
* @param {ModelField} field
*/
constructor(record, field) {
this.record = record;
this.field = field;
this.set = new Set();
if (this.field.sort) {
this.sortArray = new Array();
this.sortListenerByValue = new Map();
}
if (this.field.sumContributions.length > 0) {
this.sumByValueByField = new Map();
this.sumListenerByValueByField = new Map();
for (const { to: sumFieldName } of this.field.sumContributions) {
this.sumByValueByField.set(sumFieldName, new Map());
this.sumListenerByValueByField.set(sumFieldName, new Map());
}
}
}
/**
* @returns {integer}
*/
get size() {
return this.set.size;
}
/**
* @param {*} value
*/
add(value) {
if (this.set.has(value)) {
return;
}
this.set.add(value);
if (this.field.sort) {
const compareDefinition = this.field.sort;
const compareFunction = (a, b) => {
for (const [compareMethod, relatedPath] of compareDefinition) {
const valA = followRelations(a, relatedPath);
const valB = followRelations(b, relatedPath);
switch (compareMethod) {
case 'truthy-first': {
if (valA === valB) {
break;
}
if (!valA) {
return 1;
}
if (!valB) {
return -1;
}
break;
}
case 'falsy-first': {
if (valA === valB) {
break;
}
if (!valA) {
return -1;
}
if (!valB) {
return 1;
}
break;
}
case 'case-insensitive-asc': {
if (typeof valA !== 'string' || typeof valB !== 'string') {
break;
}
const cleanedValA = cleanSearchTerm(valA);
const cleanedValB = cleanSearchTerm(valB);
if (cleanedValA === cleanedValB) {
break;
}
return cleanedValA < cleanedValB ? -1 : 1;
}
case 'smaller-first':
if (typeof valA !== 'number' || typeof valB !== 'number') {
break;
}
if (valA === valB) {
break;
}
return valA - valB;
case 'greater-first':
if (typeof valA !== 'number' || typeof valB !== 'number') {
break;
}
if (valA === valB) {
break;
}
return valB - valA;
case 'most-recent-first':
if (!(valA instanceof Date) || !(valB instanceof Date)) {
break;
}
if (valA === valB) {
break;
}
return valB - valA;
default:
throw Error(`sort compare method "${compareMethod}" is not supported.`);
}
}
return 0;
};
const search = (from, to) => {
if (from === to) {
return to;
}
const m = Math.floor((from + to) / 2);
const compare = compareFunction(this.sortArray[m], value);
if (compare > 0) {
return search(from, m);
}
if (compare < 0) {
return search(m + 1, to);
}
return m;
};
// insert correct position
this.sortArray.splice(search(0, this.sortArray.length), 0, value);
const listener = new Listener({
isPartOfUpdateCycle: true,
name: `sort of ${value} in ${this.field} of ${this.record}`,
onChange: info => {
// access all useful values of current record (and relations) to mark them as dependencies
this.record.modelManager.startListening(listener);
for (const relatedPath of this.field.sortedFieldSplittedPaths) {
followRelations(value, relatedPath);
}
this.record.modelManager.stopListening(listener);
// sort outside of listening to avoid registering listeners for all other items (they already added their own listeners)
if (info.reason !== 'initial call') {
// Naive method: re-sort the complete array every time. Ideally each item should
// be moved at its correct place immediately, but this can be optimized
// eventually if necessary.
this.sortArray.sort(compareFunction);
}
// Similarly naive approach: the field is marked as changed even if sort didn't
// actually move any record.
this.record.modelManager._markRecordFieldAsChanged(this.record, this.field);
},
});
this.sortListenerByValue.set(value, listener);
listener.onChange({ reason: 'initial call' });
}
for (const { from: contributionFieldName, to: sumFieldName } of this.field.sumContributions) {
this.sumByValueByField.get(sumFieldName).set(value, 0);
const listener = new Listener({
isPartOfUpdateCycle: true,
name: `sum of field(${sumFieldName}) of ${this.record} from field(${contributionFieldName}) of ${value} through relation ${this.field}`,
onChange: info => {
// listen to the contribution to mark it as dependency
this.record.modelManager.startListening(listener);
const contribution = value[contributionFieldName];
this.record.modelManager.stopListening(listener);
// retrieve the previous contribution and update it
const previousContribution = this.sumByValueByField.get(sumFieldName).get(value);
this.sumByValueByField.get(sumFieldName).set(value, contribution);
this.record.modelManager._update(
this.record,
{
[sumFieldName]: [
decrement(previousContribution),
increment(contribution),
],
},
{ allowWriteReadonly: true },
);
},
});
this.sumListenerByValueByField.get(sumFieldName).set(value, listener);
listener.onChange({ reason: 'initial call' });
}
}
/**
* Removes all elements.
*/
clear() {
for (const record of this.set) {
this.delete(record);
}
}
/**
* @param {*} value
* @returns {boolean} whether the value was present
*/
delete(value) {
const wasPresent = this.set.delete(value);
if (!wasPresent) {
return false;
}
if (this.field.sort) {
// remove sort of current value
const index = this.sortArray.indexOf(value);
this.sortArray.splice(index, 1);
// remove listener on current value
const listener = this.sortListenerByValue.get(value);
this.sortListenerByValue.delete(listener);
this.record.modelManager.removeListener(listener);
}
for (const { to: sumFieldName } of this.field.sumContributions) {
// remove contribution of current value
const contribution = this.sumByValueByField.get(sumFieldName).get(value);
this.sumByValueByField.get(sumFieldName).delete(value);
this.record.modelManager._update(this.record, { [sumFieldName]: decrement(contribution) }, { allowWriteReadonly: true });
// remove listener on current value
const listener = this.sumListenerByValueByField.get(sumFieldName).get(value);
this.sumListenerByValueByField.get(sumFieldName).delete(value);
this.record.modelManager.removeListener(listener);
}
return true;
}
/**
* @param {*} value
* @returns {boolean} whether the value is present
*/
has(value) {
return this.set.has(value);
}
/**
* @returns {iterator}
*/
[Symbol.iterator]() {
if (this.field.sort) {
return this.sortArray[Symbol.iterator]();
}
return this.set[Symbol.iterator]();
}
}

View file

@ -0,0 +1,70 @@
/** @odoo-module **/
export class ModelIndexAnd {
constructor(model) {
this.model = model;
this.recordsByValuesTree = new Map();
this.valuesByRecords = new Map();
this.singleton = undefined;
}
addRecord(record, data) {
if (this.model.__identifyingFieldNames.size === 0) {
this.singleton = record;
return;
}
const valuesOfRecord = [];
let res = this.recordsByValuesTree;
const { length, [length - 1]: lastFieldName } = [...this.model.__identifyingFieldNames];
for (const fieldName of this.model.__identifyingFieldNames) {
const fieldValue = data[fieldName];
if (fieldValue === undefined) {
throw new Error(`Identifying field "${fieldName}" is lacking a value on ${this.model} with 'and' identifying mode`);
}
valuesOfRecord.push(fieldValue);
if (!res.has(fieldValue)) {
res.set(fieldValue, fieldName === lastFieldName ? record : new Map());
}
res = res.get(fieldValue);
}
this.valuesByRecords.set(record, valuesOfRecord);
}
findRecord(data) {
if (this.model.__identifyingFieldNames.size === 0) {
return this.singleton;
}
let res = this.recordsByValuesTree;
for (const fieldName of this.model.__identifyingFieldNames) {
const fieldValue = data[fieldName];
if (fieldValue === undefined) {
throw new Error(`Identifying field "${fieldName}" is lacking a value on ${this.model} with 'and' identifying mode`);
}
res = res.get(fieldValue);
if (!res) {
return;
}
}
return res;
}
removeRecord(record) {
if (this.model.__identifyingFieldNames.size === 0) {
this.singleton = undefined;
return;
}
const values = this.valuesByRecords.get(record);
let res = this.recordsByValuesTree;
for (let i = 0; i < values.length; i++) {
const index = values[i];
if (i === values.length - 1) {
res.delete(index);
break;
}
res = res.get(index);
}
this.valuesByRecords.delete(record);
}
}

View file

@ -0,0 +1,52 @@
/** @odoo-module **/
export class ModelIndexXor {
constructor(model) {
this.model = model;
this.recordsByValuesByFields = new Map();
this.fieldNameAndValueByRecords = new Map();
}
addRecord(record, data) {
const [fieldName, fieldValue] = [...this.model.__identifyingFieldNames].reduce(([fieldName, fieldValue], currentFieldName) => {
const currentFieldValue = data[currentFieldName];
if (currentFieldValue === undefined) {
return [fieldName, fieldValue];
}
if (fieldName) {
throw new Error(`Identifying field on ${this.model} with 'xor' identifying mode should have only one of the conditional values given in data. Currently have both "${fieldName}" and "${currentFieldName}".`);
}
return [currentFieldName, currentFieldValue];
}, [undefined, undefined]);
if (!this.recordsByValuesByFields.has(fieldName)) {
this.recordsByValuesByFields.set(fieldName, new Map());
}
this.recordsByValuesByFields.get(fieldName).set(fieldValue, record);
this.fieldNameAndValueByRecords.set(record, [fieldName, fieldValue]);
}
findRecord(data) {
const [fieldName, fieldValue] = [...this.model.__identifyingFieldNames].reduce(([fieldName, fieldValue], currentFieldName) => {
const currentFieldValue = data[currentFieldName];
if (currentFieldValue === undefined) {
return [fieldName, fieldValue];
}
if (fieldName) {
throw new Error(`Identifying field on ${this.model} with 'xor' identifying mode should have only one of the conditional values given in data. Currently have both "${fieldName}" and "${currentFieldName}".`);
}
return [currentFieldName, currentFieldValue];
}, [undefined, undefined]);
if (!this.recordsByValuesByFields.has(fieldName)) {
this.recordsByValuesByFields.set(fieldName, new Map());
}
return this.recordsByValuesByFields.get(fieldName).get(fieldValue);
}
removeRecord(record) {
const [fieldName, fieldValue] = this.fieldNameAndValueByRecords.get(record);
this.recordsByValuesByFields.get(fieldName).delete(fieldValue);
this.fieldNameAndValueByRecords.delete(record);
}
}

View file

@ -0,0 +1,77 @@
/** @odoo-module **/
export class Listener {
/**
* Creates a new listener for handling changes in models. This listener
* should be provided to the listening methods of the model manager.
*
* @constructor
* @param {Object} param
* @param {string} param.name name of this listener, useful for debugging
* @param {function} param.onChange function that will be called when this
* listener is notified of change, which is when records or fields that are
* listened to are created/updated/deleted. This function is called with
* 1 param that contains info
* @param {boolean} [param.isLocking=true] whether the model manager should
* be locked while this listener is observing, which means no change of
* state in any model is allowed (preventing to call insert/update/delete).
* @param {boolean} [param.isPartOfUpdateCycle=false] determines at which
* point during the update cycle of the models this `onChange` function
* will be called.
* Note: a function called as part of the update cycle cannot have any side
* effect (such as updating a record), so it is usually necessary to keep
* this value to false. Keeping it to false also improves performance by
* making sure all side effects of update cycle (such as the update of
* computed fields) have been processed before `onChange` is called (it
* could otherwise be called multiple times in quick succession).
*/
constructor({ name, onChange, isLocking = true, isPartOfUpdateCycle = false }) {
this.isLocking = isLocking;
this.isPartOfUpdateCycle = isPartOfUpdateCycle;
this.name = name;
this.onChange = onChange;
/**
* Set of records that have been accessed on model manager between the
* last call to `startListening` and `stopListening` with this listener
* as parameter.
* Each record has its own way to know the listeners that are observing
* it (to be able to notify them if it changes). This set holds the
* inverse of that information, which is useful to be able to remove
* this listener (when the need arises) from those records without
* having to verify the presence of this listener on each possible
* record one by one.
*/
this.lastObservedRecords = new Set();
/**
* Map between records and a set of fields on those records that have
* been accessed on model manager between the last call to
* `startListening` and `stopListening` with this listener as parameter.
* Each field of each record has its own way to know the listeners that
* are observing it (to be able to notify them if it changes). This map
* holds the inverse of that information, which is useful to be able to
* remove this listener (when the need arises) from those fields without
* having to verify the presence of this listener on each possible field
* one by one.
*/
this.lastObservedFieldsByRecord = new Map();
/**
* Set of model that have been accessed with `all()` on model manager
* between the last call to `startListening` and `stopListening` with
* this listener as parameter.
* Each model has its own way to know the listeners that are observing
* it (to be able to notify them if it changes). This set holds the
* inverse of that information, which is useful to be able to remove
* this listener (when the need arises) from those model without having
* to verify the presence of this listener on each possible model one by
* one.
*/
this.lastObservedAllByModel = new Set();
}
/**
* @returns {string}
*/
toString() {
return `listener(${this.name})`;
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,21 @@
/** @odoo-module **/
/**
* Follows the given related path starting from the given record, and returns
* the resulting value, or undefined if a relation can't be followed because it
* is undefined.
*
* @param {Record} record
* @param {string[]} relatedPath Array of field names.
* @returns {any}
*/
export function followRelations(record, relatedPath) {
let target = record;
for (const field of relatedPath) {
target = target[field];
if (!target) {
break;
}
}
return target;
}