mirror of
https://github.com/bringout/oca-ocb-core.git
synced 2026-04-20 20:12:03 +02:00
Initial commit: Core packages
This commit is contained in:
commit
12c29a983b
9512 changed files with 8379910 additions and 0 deletions
486
odoo-bringout-oca-ocb-mail/mail/static/src/model/model_core.js
Normal file
486
odoo-bringout-oca-ocb-mail/mail/static/src/model/model_core.js
Normal 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();
|
||||
})();
|
||||
912
odoo-bringout-oca-ocb-mail/mail/static/src/model/model_field.js
Normal file
912
odoo-bringout-oca-ocb-mail/mail/static/src/model/model_field.js
Normal 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;
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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]();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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})`;
|
||||
}
|
||||
}
|
||||
1322
odoo-bringout-oca-ocb-mail/mail/static/src/model/model_manager.js
Normal file
1322
odoo-bringout-oca-ocb-mail/mail/static/src/model/model_manager.js
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue