Files
odoo_source/addons/mail/static/src/model/model_manager.js
T
Louis Wicket (wil) ee9759a144 [MOV] mail, *: move utils files up
* = calendar, hr, hr_holidays, im_livechat, note, sms, snailmail,
test_mail, website_livechat, website_slides
Task-2759586

closes odoo/odoo#86791

Related: odoo/enterprise#25491
Signed-off-by: Alexandre Kühn (aku) <aku@odoo.com>
2022-03-23 13:39:56 +01:00

1333 lines
55 KiB
JavaScript

/** @odoo-module **/
import { registry } from '@mail/model/model_core';
import { ModelField } from '@mail/model/model_field';
import { FieldCommand, unlinkAll } from '@mail/model/model_field_command';
import { RelationSet } from '@mail/model/model_field_relation_set';
import { Listener } from '@mail/model/model_listener';
import { followRelations } from '@mail/model/model_utils';
import { makeDeferred } from '@mail/utils/deferred';
/**
* Object that manage models and records, notably their update cycle: whenever
* some records are requested for update (either with model static method
* `create()` or record method `update()`), this object processes them with
* direct field & and computed field updates.
*/
export class ModelManager {
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
constructor(env) {
/**
* The messaging env.
*/
this.env = env;
/**
* Promise which becomes resolved when messaging is created. Useful for
* waiting before accessing `this.messaging`.
*/
this.messagingCreatedPromise = makeDeferred();
/**
* Promise which becomes resolved when messaging is initialized. Useful
* for waiting before accessing `this.messaging`.
*/
this.messagingInitializedPromise = makeDeferred();
//----------------------------------------------------------------------
// Various variables that are necessary to handle an update cycle. The
// goal of having an update cycle is to delay the execution of computes,
// life-cycle hooks and potential UI re-renders until the last possible
// moment, for performance reasons.
//----------------------------------------------------------------------
/**
* Set of records that have been created during the current update
* cycle and for which the compute/related methods still have to be
* executed a first time.
*/
this._createdRecordsComputes = new Set();
/**
* Set of records that have been created during the current update
* cycle and for which the _created method still has to be executed.
*/
this._createdRecordsCreated = new Set();
/**
* Set of records that have been created during the current update
* cycle and for which the onChange methods still have to be executed
* a first time.
*/
this._createdRecordsOnChange = new Set();
/**
* Set of active listeners. Useful to be able to register which records
* or fields they accessed to be able to notify them when those change.
*/
this._listeners = new Set();
/**
* Map between model and a set of listeners that are using all() on that
* model.
*/
this._listenersObservingAllByModel = new Map();
/**
* Map between localId and a set of listeners that are using it. The
* following fields use localId instead of record reference because the
* listener might try to use a record that doesn't exist yet.
*/
this._listenersObservingLocalId = new Map();
/**
* Map between fields of localId and a set of listeners that are
* using it.
*/
this._listenersObservingFieldOfLocalId = new Map();
/**
* Map of listeners that should be notified at the end of the current
* update cycle. Value contains list of info to help for debug.
*/
this._listenersToNotifyAfterUpdateCycle = new Map();
/**
* Map of listeners that should be notified as part of the current
* update cycle. Value contains list of info to help for debug.
*/
this._listenersToNotifyInUpdateCycle = new Map();
/**
* All generated models. Keys are model name, values are model class.
*/
this.models = {};
/**
* Set of records that have been updated during the current update cycle
* and for which required fields check still has to be executed.
*/
this._updatedRecordsCheckRequired = new Set();
/**
* Determines whether this model manager should run in debug mode. Debug
* mode adds more integrity checks and more verbose error messages, at
* the cost of performance.
*/
this.isDebug = false;
}
/**
* Starts the generation of models as soon as all files have been loaded and
* starts messaging itself afterwards.
*
* @param {Object} values field name/value pairs to give at messaging create
*/
async start(values) {
if (document.readyState === 'loading') {
await new Promise(resolve => {
/**
* Called when all JS resources are loaded. This is useful in order
* to do some processing after other JS files have been parsed, for
* example new models or patched models that are coming from
* other modules, because some of those patches might need to be
* applied before messaging initialization.
*/
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));
/**
* Some models require session data, like locale text direction (depends on
* fully loaded translation).
*/
await this.env.session.is_bound;
this._generateModels();
/**
* Create the messaging singleton record.
*/
const messaging = this.models['Messaging'].insert(values);
Object.assign(odoo.__DEBUG__, { messaging });
this.messagingCreatedPromise.resolve();
await this.messaging.start();
this.messagingInitializedPromise.resolve();
}
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* Returns all records of provided model that match provided criteria.
*
* @param {Object} model
* @param {function} [filterFunc]
* @returns {Record[]} records matching criteria.
*/
all(model, filterFunc) {
for (const listener of this._listeners) {
listener.lastObservedAllByModel.add(model);
const entry = this._listenersObservingAllByModel.get(model);
const info = {
listener,
reason: `all() - ${model}`,
};
if (entry.has(listener)) {
entry.get(listener).push(info);
} else {
entry.set(listener, [info]);
}
}
const allRecords = Object.values(model.__records);
if (filterFunc) {
return allRecords.filter(filterFunc);
}
return allRecords;
}
/**
* @deprecated use insert instead
*/
create(model, data = {}) {
return this.insert(model, data);
}
/**
* Delete the record. After this operation, it's as if this record never
* existed. Note that relation are removed, which may delete more relations
* if some of them are causal.
*
* @param {Record} record
*/
delete(record) {
this._delete(record);
this._flushUpdateCycle();
}
/**
* Returns whether the given record still exists.
*
* @param {Object} model
* @param {Record} record
* @returns {boolean}
*/
exists(model, record) {
const existingRecord = model.__records[record.localId];
return Boolean(existingRecord && existingRecord === record);
}
/**
* Get the record of provided model that has provided
* criteria, if it exists.
*
* @param {Object} model
* @param {function} findFunc
* @returns {Record|undefined} the record of model matching criteria, if
* exists.
*/
find(model, findFunc) {
return this.all(model).find(findFunc);
}
/**
* Gets the unique record of provided model that matches the given
* identifying data, if it exists.
*
* @param {Object} model
* @param {Object} data
* @returns {Record|undefined}
*/
findFromIdentifyingData(model, data) {
return this.get(model, this._getRecordIndex(model, data));
}
/**
* This method returns the record of provided model that matches provided
* local id. Useful to convert a local id to a record.
* Note that even if there's a record in the system having provided local
* id, if the resulting record is not an instance of this model, this getter
* assumes the record does not exist.
*
* @param {Object} model
* @param {string} localId
* @param {Object} param2
* @param {boolean} [param2.isCheckingInheritance=false]
* @returns {Record|undefined} record, if exists
*/
get(model, localId, { isCheckingInheritance = false } = {}) {
if (!localId) {
return;
}
if (!isCheckingInheritance && this.isDebug) {
const modelName = localId.split('(')[0];
if (modelName !== model.name) {
throw Error(`wrong format of localId ${localId} for ${model}.`);
}
}
for (const listener of this._listeners) {
listener.lastObservedLocalIds.add(localId);
if (!this._listenersObservingLocalId.has(localId)) {
this._listenersObservingLocalId.set(localId, new Map());
}
const entry = this._listenersObservingLocalId.get(localId);
const info = {
listener,
reason: `get record - ${localId}`,
};
if (entry.has(listener)) {
entry.get(listener).push(info);
} else {
entry.set(listener, [info]);
}
}
const record = model.__records[localId];
if (record) {
return record;
}
if (!isCheckingInheritance) {
return;
}
// support for inherited models (eg. relation targeting `Record`)
for (const subModel of Object.values(this.models)) {
if (!(subModel.prototype instanceof model)) {
continue;
}
const record = subModel.__records[localId];
if (record) {
return record;
}
}
return;
}
/**
* Returns the messaging record once it is initialized. This method should
* be considered the main entry point to the messaging system for outside
* code.
*
* @returns {Messaging}
**/
async getMessaging() {
await this.messagingCreatedPromise;
await this.messagingInitializedPromise;
return this.messaging;
}
/**
* This method creates a record or updates one of provided model, based on
* provided data. This method assumes that records are uniquely identifiable
* per "unique find" criteria from data on model.
*
* @param {Object} model
* @param {Object|Object[]} data
* If data is an iterable, multiple records will be created/updated.
* @returns {Record|Record[]} created or updated record(s).
*/
insert(model, data) {
const isMulti = typeof data[Symbol.iterator] === 'function';
const records = this._insert(model, isMulti ? data : [data]);
this._flushUpdateCycle();
return isMulti ? records : records[0];
}
/**
* Returns the messaging singleton associated to this model manager.
*
* @returns {Messaging|undefined}
*/
get messaging() {
if (!this.models['Messaging']) {
return undefined;
}
// Use "findFromIdentifyingData" specifically to ensure the record still
// exists and to ensure listeners are properly notified of this access.
return this.models['Messaging'].findFromIdentifyingData({});
}
/**
* Removes a listener, with the same object reference as given to `startListening`.
* Removing the listener effectively makes its `onChange` function no longer
* called.
*
* @param {Listener} listener
*/
removeListener(listener) {
this._listeners.delete(listener);
this._listenersToNotifyInUpdateCycle.delete(listener);
this._listenersToNotifyAfterUpdateCycle.delete(listener);
for (const localId of listener.lastObservedLocalIds) {
this._listenersObservingLocalId.get(localId).delete(listener);
const listenersObservingFieldOfLocalId = this._listenersObservingFieldOfLocalId.get(localId);
for (const field of listener.lastObservedFieldsByLocalId.get(localId) || []) {
listenersObservingFieldOfLocalId.get(field).delete(listener);
}
}
for (const model of listener.lastObservedAllByModel) {
this._listenersObservingAllByModel.get(model).delete(listener);
}
listener.lastObservedLocalIds.clear();
listener.lastObservedFieldsByLocalId.clear();
listener.lastObservedAllByModel.clear();
}
/**
* Starts a listener. All records or fields accessed between `startListening`
* and `stopListening` will be saved. When their value later changes,
* `listener.onChange` will be called.
*
* @param {Listener} listener
*/
startListening(listener) {
this.removeListener(listener);
this._listeners.add(listener);
}
/**
* Stops a listener, with the same object reference as given to `startListening`.
*
* @param {Listener} listener
*/
stopListening(listener) {
this._listeners.delete(listener);
}
/**
* Process an update on provided record with provided data. Updating
* a record consists of applying direct updates first (i.e. explicit
* ones from `data`) and then indirect ones (i.e. compute/related fields
* and "after updates").
*
* @param {Record} record
* @param {Object} data
* @returns {boolean} whether any value changed for the current record
*/
update(record, data) {
const res = this._update(record, data);
this._flushUpdateCycle();
return res;
}
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* Returns the given data object completed with the default values of the
* given model.
*
* @private
* @param {Object} model
* @param {Object} [data={}]
*/
_addDefaultData(model, data = {}) {
const data2 = { ...data };
for (const field of model.__fieldList) {
if (data2[field.fieldName] === undefined && field.default !== undefined) {
data2[field.fieldName] = field.default;
}
}
return data2;
}
/**
* Adds fields, methods, getters, and identifyingFields from the model
* definition to the model, then registers it in `this.models`.
*
* @private
* @param {Object} model
*/
_applyModelDefinition(model) {
const definition = registry.get(model.name);
Object.assign(model, Object.fromEntries(definition.get('modelMethods')));
Object.assign(model.prototype, Object.fromEntries(definition.get('recordMethods')));
for (const [getterName, getter] of definition.get('modelGetters')) {
Object.defineProperty(model, getterName, { get: getter });
}
for (const [getterName, getter] of definition.get('recordGetters')) {
Object.defineProperty(model.prototype, getterName, { get: getter });
}
// Make model manager accessible from model.
model.modelManager = this;
model.fields = {};
// Contains all records. key is local id, while value is the record.
model.__records = {};
model.__identifyingFields = definition.get('identifyingFields');
const identifyingFieldsFlattened = new Set();
for (const identifyingElement of model.__identifyingFields) {
const identifyingFields = typeof identifyingElement === 'string'
? [identifyingElement]
: identifyingElement;
for (const identifyingField of identifyingFields) {
identifyingFieldsFlattened.add(identifyingField);
}
}
model.__identifyingFieldsFlattened = identifyingFieldsFlattened;
this._listenersObservingAllByModel.set(model, new Map());
this.models[model.name] = model;
}
/**
* @private
* @throws {Error} in case some declared fields are not correct.
*/
_checkDeclaredFieldsOnModels() {
for (const model of Object.values(this.models)) {
for (const [fieldName, field] of registry.get(model.name).get('fields')) {
// 0. Forbidden name.
if (fieldName in model.prototype) {
throw new Error(`field(${fieldName}) on ${model} has a forbidden name.`);
}
// 1. Field type is required.
if (!(['attribute', 'relation'].includes(field.fieldType))) {
throw new Error(`field(${fieldName}) on ${model} has unsupported type ${field.fieldType}.`);
}
// 2. Invalid keys based on field type.
if (field.fieldType === 'attribute') {
const invalidKeys = Object.keys(field).filter(key =>
![
'compute',
'default',
'fieldType',
'readonly',
'related',
'required',
'sum',
].includes(key)
);
if (invalidKeys.length > 0) {
throw new Error(`field(${fieldName}) on ${model} contains some invalid keys: "${invalidKeys.join(", ")}".`);
}
}
if (field.fieldType === 'relation') {
const invalidKeys = Object.keys(field).filter(key =>
![
'compute',
'default',
'fieldType',
'inverse',
'isCausal',
'readonly',
'related',
'relationType',
'required',
'sort',
'to',
].includes(key)
);
if (invalidKeys.length > 0) {
throw new Error(`field(${fieldName}) on ${model} contains some invalid keys: "${invalidKeys.join(", ")}".`);
}
if (!this.models[field.to]) {
throw new Error(`Relational field(${fieldName}) on ${model} targets to unknown model name "${field.to}".`);
}
if (field.required && field.relationType !== 'one') {
throw new Error(`Relational field(${fieldName}) on ${model} has "required" true with a relation of type "${field.relationType}" but "required" is only supported for "one".`);
}
if (field.sort && field.relationType !== 'many') {
throw new Error(`Relational field "${model}/${fieldName}" has "sort" with a relation of type "${field.relationType}" but "sort" is only supported for "many".`);
}
}
// 3. Computed field.
if (field.compute && !(typeof field.compute === 'string')) {
throw new Error(`Property "compute" of field(${fieldName}) on ${model} must be a string (instance method name).`);
}
if (field.compute && !(model.prototype[field.compute])) {
throw new Error(`Property "compute" of field(${fieldName}) on ${model} does not refer to an instance method of this model.`);
}
// 4. Related field.
if (field.compute && field.related) {
throw new Error(`field(${fieldName}) on ${model} cannot be a related and compute field at the same time.`);
}
if (field.related) {
if (!(typeof field.related === 'string')) {
throw new Error(`Property "related" of field(${fieldName}) on ${model} has invalid format.`);
}
const [relationName, relatedFieldName, other] = field.related.split('.');
if (!relationName || !relatedFieldName || other) {
throw new Error(`Property "related" of field(${fieldName}) on ${model} has invalid format.`);
}
// find relation on self or parents.
let relatedRelation;
let targetModel = model;
while (this.models[targetModel.name] && !relatedRelation) {
relatedRelation = registry.get(targetModel.name).get('fields').get(relationName);
targetModel = targetModel.__proto__;
}
if (!relatedRelation) {
throw new Error(`Related field(${fieldName}) on ${model} relates to unknown relation name "${relationName}".`);
}
if (relatedRelation.fieldType !== 'relation') {
throw new Error(`Related field(${fieldName}) on ${model} relates to non-relational field "${relationName}".`);
}
// Assuming related relation is valid...
// find field name on related model or any parents.
const relatedModel = this.models[relatedRelation.to];
let relatedField;
targetModel = relatedModel;
while (this.models[targetModel.name] && !relatedField) {
relatedField = registry.get(targetModel.name).get('fields').get(relatedFieldName);
targetModel = targetModel.__proto__;
}
if (!relatedField) {
throw new Error(`Related field(${fieldName}) on ${model} relates to unknown related model field "${relatedFieldName}".`);
}
if (relatedField.fieldType !== field.fieldType) {
throw new Error(`Related field(${fieldName}) on ${model} has mismatched type with its related model field.`);
}
if (
relatedField.fieldType === 'relation' &&
relatedField.to !== field.to
) {
throw new Error(`Related field(${fieldName}) on ${model} has mismatched target model name with its related model field.`);
}
}
}
}
}
/**
* @private
* @throws {Error} in case some fields are not correct.
*/
_checkProcessedFieldsOnModels() {
for (const model of Object.values(this.models)) {
for (const field of model.__fieldList) {
const fieldName = field.fieldName;
if (!(['attribute', 'relation'].includes(field.fieldType))) {
throw new Error(`${field} on ${model} has unsupported type ${field.fieldType}.`);
}
if (field.compute && field.related) {
throw new Error(`${field} on ${model} cannot be a related and compute field at the same time.`);
}
if (field.fieldType === 'attribute') {
continue;
}
if (!field.relationType) {
throw new Error(`${field} on ${model} must define a relation type in "relationType".`);
}
if (!(['many', 'one'].includes(field.relationType))) {
throw new Error(`${field} on ${model} has invalid relation type "${field.relationType}".`);
}
if (!field.inverse) {
throw new Error(`${field} on ${model} must define an inverse relation name in "inverse".`);
}
if (!field.to) {
throw new Error(`${field} on ${model} must define a model name in "to" (1st positional parameter of relation field helpers).`);
}
const relatedModel = this.models[field.to];
if (!relatedModel) {
throw new Error(`${field} on ${model} defines a relation to model(${field.to}), but there is no model registered with this name.`);
}
const inverseField = relatedModel.__fieldMap[field.inverse];
if (!inverseField) {
throw new Error(`${field} on ${model} defines its inverse as field(${field.inverse}) on ${relatedModel}, but it does not exist.`);
}
if (inverseField.inverse !== fieldName) {
throw new Error(`The name of ${field} on ${model} does not match with the name defined in its inverse ${inverseField} on ${relatedModel}.`);
}
if (![model.name, 'Record'].includes(inverseField.to)) {
throw new Error(`${field} on ${model} has its inverse ${inverseField} on ${relatedModel} referring to an invalid model (model(${inverseField.to})).`);
}
}
for (const identifyingField of model.__identifyingFieldsFlattened) {
const field = model.__fieldMap[identifyingField];
if (!field) {
throw new Error(`Identifying field "${identifyingField}" is not a field on ${model}.`);
}
if (!field.readonly) {
throw new Error(`Identifying field "${identifyingField}" on ${model} is lacking readonly.`);
}
if (field.to) {
if (field.relationType !== 'one') {
throw new Error(`Identifying field "${identifyingField}" on ${model} has a relation of type "${field.relationType}" but identifying field is only supported for "one".`);
}
const relatedModel = this.models[field.to];
const inverseField = relatedModel.__fieldMap[field.inverse];
if (!inverseField.isCausal) {
throw new Error(`Identifying field "${identifyingField}" on ${model} has an inverse "${field.inverse}" not declared as "isCausal" on ${relatedModel}.`);
}
}
}
}
}
/**
* @private
* @param {Object} model
* @param {string} localId
* @returns {Record}
*/
_create(model, localId) {
/**
* Prepare record state. Assign various keys and values that are
* expected to be found on every record.
*/
const nonProxyRecord = new model();
Object.assign(nonProxyRecord, {
// The unique record identifier.
localId,
// Listeners that are bound to this record, to be notified of
// change in dependencies of compute, related and "on change".
__listeners: [],
// Field values of record.
__values: {},
});
const record = !this.isDebug ? nonProxyRecord : new Proxy(nonProxyRecord, {
get: function getFromProxy(record, prop) {
if (
!model.__fieldMap[prop] &&
!['_super', 'then'].includes(prop) &&
typeof prop !== 'symbol' &&
!(prop in record)
) {
console.warn(`non-field read "${prop}" on ${record}`);
}
return record[prop];
},
});
// Ensure X2many relations are Set initially (other fields can stay undefined).
for (const field of model.__fieldList) {
record.__values[field.fieldName] = undefined;
if (field.fieldType === 'relation') {
if (field.relationType === 'many') {
record.__values[field.fieldName] = new RelationSet(record, field);
}
}
}
if (!this._listenersObservingLocalId.has(localId)) {
this._listenersObservingLocalId.set(localId, new Map());
}
this._listenersObservingFieldOfLocalId.set(localId, new Map());
for (const field of model.__fieldList) {
this._listenersObservingFieldOfLocalId.get(localId).set(field, new Map());
}
/**
* Register record.
*/
model.__records[record.localId] = record;
/**
* Auto-bind record methods so that `this` always refer to the record.
*/
const recordMethods = registry.get(model.name).get('recordMethods');
for (const methodName of recordMethods.keys()) {
record[methodName] = record[methodName].bind(record);
}
/**
* Invoke the life-cycle hook `_willCreate.`
* After this step the record is in a functioning state and it is
* considered existing.
*/
const lifecycleHooks = registry.get(model.name).get('lifecycleHooks');
if (lifecycleHooks.has('_willCreate')) {
lifecycleHooks.get('_willCreate').call(record);
}
/**
* Register post processing operation that are to be delayed at
* the end of the update cycle.
*/
this._createdRecordsComputes.add(record);
this._createdRecordsCreated.add(record);
this._createdRecordsOnChange.add(record);
for (const [listener, infoList] of this._listenersObservingAllByModel.get(model)) {
this._markListenerToNotify(listener, {
listener,
reason: `_create: allByModel - ${record}`,
infoList,
});
}
for (const [listener, infoList] of this._listenersObservingLocalId.get(localId)) {
this._markListenerToNotify(listener, {
listener,
reason: `_create: localId - ${record}`,
infoList,
});
}
return record;
}
/**
* @private
* @param {Record} record
*/
_delete(record) {
this._ensureNoLockingListener();
const model = record.constructor;
if (!record.exists()) {
throw Error(`Cannot delete already deleted record ${record.localId}.`);
}
const lifecycleHooks = registry.get(model.name).get('lifecycleHooks');
if (lifecycleHooks.has('_willDelete')) {
lifecycleHooks.get('_willDelete').call(record);
}
for (const listener of record.__listeners) {
this.removeListener(listener);
}
for (const field of model.__fieldList) {
if (field.fieldType === 'relation') {
// ensure inverses are properly unlinked
field.parseAndExecuteCommands(record, unlinkAll(), { allowWriteReadonly: true });
}
}
this._createdRecordsComputes.delete(record);
this._createdRecordsCreated.delete(record);
this._createdRecordsOnChange.delete(record);
this._updatedRecordsCheckRequired.delete(record);
for (const [listener, infoList] of this._listenersObservingLocalId.get(record.localId)) {
this._markListenerToNotify(listener, {
listener,
reason: `_delete: localId - ${record}`,
infoList,
});
}
for (const [listener, infoList] of this._listenersObservingAllByModel.get(model)) {
this._markListenerToNotify(listener, {
listener,
reason: `_delete: allByModel - ${record}`,
infoList,
});
}
delete model.__records[record.localId];
}
/**
* Ensures there is currently no locking listener on this model manager, and
* throws if there is one.
*
* @throws {Error}
*/
_ensureNoLockingListener() {
for (const listener of this._listeners) {
if (listener.isLocking) {
throw Error(`Model manager locked by ${listener}. It is not allowed to insert/update/delete from inside a lock.`);
}
}
}
/**
* Executes the compute methods of the created records.
*/
_executeCreatedRecordsComputes() {
const hasChanged = this._createdRecordsComputes.size > 0;
for (const record of this._createdRecordsComputes) {
// Delete at every step to avoid recursion, indeed compute/related
// method might trigger an update cycle itself.
this._createdRecordsComputes.delete(record);
if (!record.exists()) {
throw Error(`Cannot start compute/related for already deleted ${record}.`);
}
const listeners = [];
for (const field of record.constructor.__fieldList) {
if (field.compute) {
const listener = new Listener({
isPartOfUpdateCycle: true,
name: `compute ${field} of ${record}`,
onChange: (info) => {
this.startListening(listener);
const res = record[field.compute]();
this.stopListening(listener);
this._update(record, { [field.fieldName]: res }, { allowWriteReadonly: true });
},
});
listeners.push(listener);
}
if (field.related) {
const listener = new Listener({
isPartOfUpdateCycle: true,
name: `related ${field} of ${record}`,
onChange: (info) => {
this.startListening(listener);
const res = field.computeRelated(record);
this.stopListening(listener);
this._update(record, { [field.fieldName]: res }, { allowWriteReadonly: true });
},
});
listeners.push(listener);
}
}
record.__listeners.push(...listeners);
for (const listener of listeners) {
listener.onChange({
listener,
reason: `first call on ${record}`,
});
}
}
if (hasChanged) {
this._flushUpdateCycle();
}
}
/**
* Executes the _created method of the created records.
*/
_executeCreatedRecordsCreated() {
for (const record of this._createdRecordsCreated) {
// Delete at every step to avoid recursion, indeed _created might
// trigger an update cycle itself.
this._createdRecordsCreated.delete(record);
if (!record.exists()) {
throw Error(`Cannot call _created for already deleted ${record}.`);
}
const lifecycleHooks = registry.get(record.constructor.name).get('lifecycleHooks');
if (lifecycleHooks.has('_created')) {
lifecycleHooks.get('_created').call(record);
}
}
}
/**
* Executes the onChange method of the created records.
*/
_executeCreatedRecordsOnChange() {
for (const record of this._createdRecordsOnChange) {
// Delete at every step to avoid recursion, indeed _created
// might trigger an update cycle itself.
this._createdRecordsOnChange.delete(record);
if (!record.exists()) {
throw Error(`Cannot call onChange for already deleted ${record}.`);
}
for (const onChange of registry.get(record.constructor.name).get('onChanges')) {
const listener = new Listener({
name: `${onChange} of ${record}`,
onChange: (info) => {
this.startListening(listener);
for (const dependency of onChange.dependencies) {
followRelations(record, dependency);
}
this.stopListening(listener);
record[onChange.methodName]();
},
});
record.__listeners.push(listener);
listener.onChange({
listener,
reason: `first call on ${record}`,
});
}
}
}
/**
* Executes the check of the required field of updated records.
*/
_executeUpdatedRecordsCheckRequired() {
for (const record of this._updatedRecordsCheckRequired) {
for (const required of record.constructor.__requiredFieldsList) {
if (record[required.fieldName] === undefined) {
throw Error(`required ${required} of ${record} is missing`);
}
}
}
this._updatedRecordsCheckRequired.clear();
}
/**
* Terminates an update cycle by executing its pending operations: execute
* computed fields, execute life-cycle hooks, update rev numbers.
*
* @private
*/
_flushUpdateCycle() {
this._executeCreatedRecordsComputes();
this._notifyListenersInUpdateCycle();
this._executeUpdatedRecordsCheckRequired();
this._executeCreatedRecordsCreated();
this._executeCreatedRecordsOnChange();
this._notifyListenersAfterUpdateCycle();
}
/**
* @private
* @returns {Object}
*/
_generateModels() {
// Create the model through a class to give it a meaningful name to be
// displayed in stack traces and stuff.
const model = { 'Record': class {} }['Record'];
this._applyModelDefinition(model);
// Record is generated separately and before the other models since
// it is the dependency of all of them.
const allModelNamesButRecord = [...registry.keys()].filter(name => name !== 'Record');
for (const modelName of allModelNamesButRecord) {
const model = { [modelName]: class extends this.models['Record'] {} }[modelName];
this._applyModelDefinition(model);
}
/**
* Check that fields on the generated models are correct.
*/
this._checkDeclaredFieldsOnModels();
/**
* Process declared model fields definitions, so that these field
* definitions are much easier to use in the system. For instance, all
* relational field definitions have an inverse.
*/
this._processDeclaredFieldsOnModels();
/**
* Check that all model fields are correct, notably one relation
* should have matching reversed relation.
*/
this._checkProcessedFieldsOnModels();
}
/**
* Returns an index on the given model for the given data.
*
* @param {Object} model
* @param {Object|Record} data insert data or record
* @returns {string}
*/
_getRecordIndex(model, data) {
return `${model.name}(${model.__identifyingFields.map(identifyingElement => {
const fieldName = typeof identifyingElement === 'string'
? identifyingElement
: identifyingElement.reduce((fieldName, currentFieldName) => {
const fieldValue = data[currentFieldName] !== undefined
? data[currentFieldName]
: model.__fieldMap[currentFieldName].default;
if (fieldValue === undefined) {
return fieldName;
}
if (fieldName) {
throw new Error(`Identifying element on ${model} with "or" value should have only one of the conditional values given in data. Currently have both ${fieldName} and ${currentFieldName}.`);
}
return currentFieldName;
}, undefined);
if (!fieldName) {
throw new Error(`Identifying element "${identifyingElement}" on ${model} is lacking a value.`);
}
const fieldValue = data[fieldName] !== undefined
? data[fieldName]
: model.__fieldMap[fieldName].default;
if (fieldValue === undefined) {
throw new Error(`Identifying field "${fieldName}" on ${model} is lacking a value.`);
}
const relationTo = model.__fieldMap[fieldName].to;
if (!relationTo) {
return `${fieldName}: ${fieldValue}`;
}
const OtherModel = this.models[relationTo];
if (fieldValue instanceof OtherModel) {
return `${fieldName}: ${this._getRecordIndex(OtherModel, fieldValue)}`;
}
if (!(fieldValue instanceof FieldCommand)) {
throw new Error(`Identifying element "${model}/${fieldName}" is expecting a command for relational field.`);
}
if (!['link', 'insert', 'replace', 'insert-and-replace'].includes(fieldValue._name)) {
throw new Error(`Identifying element "${model}/${fieldName}" is expecting a command of type "insert-and-replace", "replace", "insert" or "link". "${fieldValue._name}" was given instead.`);
}
const relationValue = fieldValue._value;
if (!relationValue) {
throw new Error(`Identifying element "${model}/${fieldName}" is lacking a relation value.`);
}
return `${fieldName}: ${this._getRecordIndex(OtherModel, relationValue)}`;
}).join(', ')})`;
}
/**
* @private
* @param {Object} model
* @param {Object[]} dataList
* @returns {Record[]}
*/
_insert(model, dataList) {
this._ensureNoLockingListener();
const records = [];
for (const data of dataList) {
const localId = this._getRecordIndex(model, data);
let record = model.get(localId);
if (!record) {
record = this._create(model, localId);
this._update(record, this._addDefaultData(model, data), { allowWriteReadonly: true });
} else {
this._update(record, data);
}
records.push(record);
}
return records;
}
/**
* @private
* @param {Object} model
* @param {ModelField} field
* @returns {ModelField}
*/
_makeInverseRelationField(model, field) {
const inverseField = new ModelField(Object.assign(
{},
ModelField.many(model.name, { inverse: field.fieldName }),
{
fieldName: `_inverse_${model}/${field.fieldName}`,
// Allows the inverse of an identifying field to be
// automatically generated.
isCausal: model.__identifyingFieldsFlattened.has(field.fieldName),
},
));
return inverseField;
}
/**
* Marks the given listener to be notified. This function should be called
* when the dependencies of a listener have changed, in order to inform the
* listener of the change at the proper time during the update cycle
* (according to its configuration).
*
* @private
* @param {Object} listener
*/
_markListenerToNotify(listener, info) {
if (!(listener instanceof Listener)) {
throw new Error(`Listener is not a listener ${listener}`);
}
if (listener.isPartOfUpdateCycle) {
const entry = this._listenersToNotifyInUpdateCycle.get(listener);
if (entry) {
entry.push(info);
} else {
this._listenersToNotifyInUpdateCycle.set(listener, [info]);
}
}
if (!listener.isPartOfUpdateCycle) {
const entry = this._listenersToNotifyAfterUpdateCycle.get(listener);
if (entry) {
entry.push(info);
} else {
this._listenersToNotifyAfterUpdateCycle.set(listener, [info]);
}
}
}
/**
* Marks the given field of the given record as changed.
*
* @param {Record} record
* @param {ModelField} field
*/
_markRecordFieldAsChanged(record, field) {
for (const [listener, infoList] of this._listenersObservingFieldOfLocalId.get(record.localId).get(field)) {
this._markListenerToNotify(listener, {
listener,
reason: `_update: ${field} of ${record}`,
infoList,
});
}
}
/**
* Notifies the listeners that have been flagged to be notified and for
* which the `onChange` function was defined to be called after the update
* cycle.
*
* In particular this is the case of components using models that need to
* re-render and for records with "on change".
*
* @returns {boolean} whether any change happened
*/
_notifyListenersAfterUpdateCycle() {
for (const [listener, infoList] of this._listenersToNotifyAfterUpdateCycle) {
this._listenersToNotifyAfterUpdateCycle.delete(listener);
listener.onChange(infoList);
}
}
/**
* Notifies the listeners that have been flagged to be notified and for
* which the `onChange` function was defined to be called while still in the
* update cycle.
*
* In particular this is the case of records with compute or related fields.
*
* Note: A double loop is used because calling the `onChange` function might
* lead to more listeners being flagged to be notified.
*
* @returns {boolean} whether any change happened
*/
_notifyListenersInUpdateCycle() {
const hasChanged = this._listenersToNotifyInUpdateCycle.size > 0;
for (const [listener, infoList] of this._listenersToNotifyInUpdateCycle) {
this._listenersToNotifyInUpdateCycle.delete(listener);
listener.onChange(infoList);
}
if (hasChanged) {
this._flushUpdateCycle();
}
}
/**
* This function processes definition of declared fields in provided models.
* Basically, models have fields declared in static prop `fields`, and this
* function processes and modifies them in place so that they are fully
* configured. For instance, model relations need bi-directional mapping, but
* inverse relation may be omitted in declared field: this function auto-fill
* this inverse relation.
*
* @private
*/
_processDeclaredFieldsOnModels() {
/**
* 1. Prepare fields.
*/
for (const model of Object.values(this.models)) {
const sumContributionsByFieldName = new Map();
// Make fields aware of their field name.
for (const [fieldName, fieldData] of registry.get(model.name).get('fields')) {
model.fields[fieldName] = new ModelField(Object.assign({}, fieldData, {
fieldName,
}));
if (fieldData.sum) {
const [relationFieldName, contributionFieldName] = fieldData.sum.split('.');
if (!sumContributionsByFieldName.has(relationFieldName)) {
sumContributionsByFieldName.set(relationFieldName, []);
}
sumContributionsByFieldName.get(relationFieldName).push({
from: contributionFieldName,
to: fieldName,
});
}
}
for (const [fieldName, sumContributions] of sumContributionsByFieldName) {
model.fields[fieldName].sumContributions = sumContributions;
}
}
/**
* 2. Auto-generate definitions of undeclared inverse relations.
*/
for (const model of Object.values(this.models)) {
for (const field of Object.values(model.fields)) {
if (field.fieldType !== 'relation') {
continue;
}
if (field.inverse) {
continue;
}
const relatedModel = this.models[field.to];
const inverseField = this._makeInverseRelationField(model, field);
field.inverse = inverseField.fieldName;
relatedModel.fields[inverseField.fieldName] = inverseField;
}
}
/**
* 3. Extend definition of fields of a model with the definition of
* fields of its parents.
*/
for (const model of Object.values(this.models)) {
model.__combinedFields = {};
for (const field of Object.values(model.fields)) {
model.__combinedFields[field.fieldName] = field;
}
let TargetModel = model.__proto__;
while (TargetModel && TargetModel.fields) {
for (const targetField of Object.values(TargetModel.fields)) {
const field = model.__combinedFields[targetField.fieldName];
if (!field) {
model.__combinedFields[targetField.fieldName] = targetField;
}
}
TargetModel = TargetModel.__proto__;
}
}
/**
* 4. Register final fields and make field accessors, to redirects field
* access to field getter and to prevent field from being written
* without calling update (which is necessary to process update cycle).
*/
for (const model of Object.values(this.models)) {
// Object with fieldName/field as key/value pair, for quick access.
model.__fieldMap = model.__combinedFields;
// List of all fields, for iterating.
model.__fieldList = Object.values(model.__fieldMap);
model.__requiredFieldsList = model.__fieldList.filter(
field => field.required
);
// Add field accessors.
for (const field of model.__fieldList) {
Object.defineProperty(model.prototype, field.fieldName, {
get: function getFieldValue() { // this is bound to record
if (this.modelManager._listeners.size) {
if (!this.modelManager._listenersObservingLocalId.has(this.localId)) {
this.modelManager._listenersObservingLocalId.set(this.localId, new Map());
}
const entryLocalId = this.modelManager._listenersObservingLocalId.get(this.localId);
const reason = `getField - ${field} of ${this}`;
const entryField = this.modelManager._listenersObservingFieldOfLocalId.get(this.localId).get(field);
for (const listener of this.modelManager._listeners) {
listener.lastObservedLocalIds.add(this.localId);
const info = { listener, reason };
if (entryLocalId.has(listener)) {
entryLocalId.get(listener).push(info);
} else {
entryLocalId.set(listener, [info]);
}
if (!listener.lastObservedFieldsByLocalId.has(this.localId)) {
listener.lastObservedFieldsByLocalId.set(this.localId, new Set());
}
listener.lastObservedFieldsByLocalId.get(this.localId).add(field);
if (entryField.has(listener)) {
entryField.get(listener).push(info);
} else {
entryField.set(listener, [info]);
}
}
}
return field.get(this);
},
});
}
delete model.__combinedFields;
}
}
/**
* @private
* @param {Record} record
* @param {Object} data
* @param {Object} [options]
* @param [options.allowWriteReadonly=false]
* @returns {boolean} whether any value changed for the current record
*/
_update(record, data, options = {}) {
this._ensureNoLockingListener();
if (!record.exists()) {
throw Error(`Cannot update already deleted record ${record.localId}.`);
}
const { allowWriteReadonly = false } = options;
const model = record.constructor;
let hasChanged = false;
const sortedFieldNames = Object.keys(data);
sortedFieldNames.sort((a, b) => {
// Always update identifying fields first because updating other relational field will
// trigger update of inverse field, which will require this identifying fields to be set
// beforehand to properly detect whether this is already linked or not on the inverse.
if (model.__identifyingFieldsFlattened.has(a) && !model.__identifyingFieldsFlattened.has(b)) {
return -1;
}
if (!model.__identifyingFieldsFlattened.has(a) && model.__identifyingFieldsFlattened.has(b)) {
return 1;
}
return 0;
});
for (const fieldName of sortedFieldNames) {
if (data[fieldName] === undefined) {
// `undefined` should have the same effect as not passing the field
continue;
}
const field = model.__fieldMap[fieldName];
if (!field) {
throw new Error(`Cannot create/update record with data unrelated to a field. (record: "${record.localId}", non-field attempted update: "${fieldName}")`);
}
const newVal = data[fieldName];
if (!field.parseAndExecuteCommands(record, newVal, options)) {
continue;
}
if (field.readonly && !allowWriteReadonly) {
throw new Error(`read-only ${field} on ${record} was updated`);
}
hasChanged = true;
this._markRecordFieldAsChanged(record, field);
}
if (hasChanged) {
this._updatedRecordsCheckRequired.add(record);
}
return hasChanged;
}
}