Files
odoo_source/addons/mail/static/src/model/model_manager.js
T
Sébastien Theys aad27c38ea [FIX] mail: fix model manager isDebug
Mix of proxy and non-proxy was causing issue in set of records.

closes odoo/odoo#99388

Signed-off-by: Alexandre Kühn (aku) <aku@odoo.com>
2022-09-01 15:33:36 +02:00

1301 lines
54 KiB
JavaScript

/** @odoo-module **/
import { IS_RECORD, registry } from '@mail/model/model_core';
import { ModelField } from '@mail/model/model_field';
import { ModelIndexAnd } from '@mail/model/model_index_and';
import { ModelIndexXor } from '@mail/model/model_index_xor';
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
* `insert()` 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 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));
this._generateModels();
/**
* Create the messaging singleton record.
*/
this.models['Messaging'].insert(values);
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: this.isDebug && `all() - ${model}`,
};
if (entry.has(listener)) {
entry.get(listener).push(info);
} else {
entry.set(listener, [info]);
}
}
const allRecords = [...model.__records];
if (filterFunc) {
return allRecords.filter(filterFunc);
}
return allRecords;
}
/**
* 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();
}
/**
* Destroys this model manager, which consists of cleaning all possible
* references in order to avoid memory leaks.
*/
destroy() {
this.messaging.delete();
for (const model of Object.values(this.models)) {
delete model.__fieldList;
delete model.__fieldMap;
delete model.__identifyingFieldNames;
delete model.__records;
delete model.__requiredFieldsList;
delete model.fields;
delete model.modelManager;
}
}
/**
* Returns whether the given record still exists.
*
* @param {Object} model
* @param {Record} record
* @returns {boolean}
*/
exists(model, record) {
return model.__records.has(record);
}
/**
* 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 = {}) {
for (const fieldName of model.__identifyingFieldNames) {
const field = model.__fieldMap.get(fieldName);
if (data[field.fieldName] === undefined && field.default !== undefined) {
data[field.fieldName] = field.default;
}
}
this._preInsertIdentifyingFieldsFromData(model, data);
const record = model.__recordsIndex.findRecord(data);
if (!record) {
return;
}
for (const listener of this._listeners) {
listener.lastObservedRecords.add(record);
const entry = record.__listenersObservingRecord;
const info = {
listener,
reason: this.isDebug && `findFromIdentifyingData record - ${record}`,
};
if (entry.has(listener)) {
entry.get(listener).push(info);
} else {
entry.set(listener, [info]);
}
}
return record;
}
/**
* 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 record of listener.lastObservedRecords) {
if (!record.exists()) {
continue;
}
record.__listenersObservingRecord.delete(listener);
const listenersObservingFieldOfRecord = record.__listenersObservingFieldsOfRecord;
for (const field of listener.lastObservedFieldsByRecord.get(record) || []) {
listenersObservingFieldOfRecord.get(field).delete(listener);
}
}
for (const model of listener.lastObservedAllByModel) {
this._listenersObservingAllByModel.get(model).delete(listener);
}
listener.lastObservedRecords.clear();
listener.lastObservedFieldsByRecord.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 identifyingMode 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 = {};
model.identifyingMode = definition.get('identifyingMode');
model.__records = new Set();
model.__recordCount = 0;
model.__recordsIndex = (() => {
switch (model.identifyingMode) {
case 'and':
return new ModelIndexAnd(model);
case 'xor':
return new ModelIndexXor(model);
}
})();
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',
'identifying',
'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',
'identifying',
'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. Check for redundant attributes on identifying fields.
if (field.identifying) {
if ('readonly' in field) {
throw new Error(`Identifying field(${fieldName}) on ${model} has unnecessary "readonly" attribute (readonly is implicit for identifying fields).`);
}
if ('required' in field && model.identifyingMode === 'and') {
throw new Error(`Identifying field(${fieldName}) on ${model} has unnecessary "required" attribute (required is implicit for AND identifying fields).`);
}
}
// 4. Computed field.
if (field.compute) {
if (!(typeof field.compute === 'string')) {
throw new Error(`Property "compute" of field(${fieldName}) on ${model} must be a string (instance method name).`);
}
if (!model.prototype[field.compute]) {
throw new Error(`Property "compute" of field(${fieldName}) on ${model} does not refer to an instance method of this model.`);
}
if ('readonly' in field) {
throw new Error(`Computed field(${fieldName}) on ${model} has unnecessary "readonly" attribute (readonly is implicit for computed fields).`);
}
}
// 5. Related field.
if (field.related) {
if (field.compute) {
throw new Error(`field(${fieldName}) on ${model} cannot be a related and compute field at the same time.`);
}
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.`);
}
if ('readonly' in field) {
throw new Error(`Related field(${fieldName}) on ${model} has unnecessary "readonly" attribute (readonly is implicit for related fields).`);
}
}
}
}
}
/**
* @private
* @throws {Error} in case some fields are not correct.
*/
_checkProcessedFieldsOnModels() {
for (const model of Object.values(this.models)) {
switch (model.identifyingMode) {
case 'and':
break;
case 'xor':
if (model.__identifyingFieldNames.size === 0) {
throw new Error(`No identifying fields has been specified for 'xor' identifying mode on ${model}`);
}
break;
default:
throw new Error(`Unsupported identifying mode "${model.identifyingMode}" on ${model}. Must be one of 'and' or 'xor'.`);
}
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.get(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.__identifyingFieldNames) {
const field = model.__fieldMap.get(identifyingField);
if (!field) {
throw new Error(`Identifying field "${identifyingField}" is not a field on ${model}.`);
}
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.get(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
* @returns {Record}
*/
_create(model) {
const localId = `${model.name}_${++model.__recordCount}`;
/**
* 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: [],
/**
* Map between listeners that are observing this record and array of
* information about how the record is observed.
*/
__listenersObservingRecord: new Map(),
/**
* Map between fields and a Map between listeners that are observing
* the field and array of information about how the field is observed.
*/
__listenersObservingFieldsOfRecord: new Map(),
// Field values of record.
__values: new Map(),
[IS_RECORD]: true,
});
const record = owl.markRaw(!this.isDebug ? nonProxyRecord : new Proxy(nonProxyRecord, {
get: function getFromProxy(record, prop) {
if (
(model.__fieldMap && !model.__fieldMap.has(prop)) &&
!['_super', 'then', 'localId'].includes(prop) &&
typeof prop !== 'symbol' &&
!(prop in record)
) {
console.warn(`non-field read "${prop}" on ${record}`);
}
return record[prop];
},
}));
if (this.isDebug) {
record.__proxifiedRecord = record;
}
// Ensure X2many relations are Set initially (other fields can stay undefined).
for (const field of model.__fieldList) {
if (field.fieldType === 'relation') {
if (field.relationType === 'many') {
record.__values.set(field.fieldName, new RelationSet(record, field));
}
}
}
/**
* Register record.
*/
model.__records.add(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);
}
/**
* 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: this.isDebug && `_create: allByModel - ${record}`,
infoList,
});
}
return record;
}
/**
* @private
* @param {Record} record
*/
_delete(record) {
if (this.isDebug) {
this._ensureNoLockingListener();
}
const model = record.constructor;
if (!record.exists()) {
throw Error(`Cannot delete already deleted record ${record}.`);
}
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 });
if (!record.exists()) {
return; // current record might have been deleted from causality
}
}
}
model.__recordsIndex.removeRecord(record);
this._createdRecordsComputes.delete(record);
this._createdRecordsCreated.delete(record);
this._createdRecordsOnChange.delete(record);
this._updatedRecordsCheckRequired.delete(record);
for (const [listener, infoList] of record.__listenersObservingRecord) {
this._markListenerToNotify(listener, {
listener,
reason: this.isDebug && `_delete: record - ${record}`,
infoList,
});
}
for (const [listener, infoList] of this._listenersObservingAllByModel.get(model)) {
this._markListenerToNotify(listener, {
listener,
reason: this.isDebug && `_delete: allByModel - ${record}`,
infoList,
});
}
delete record.__values;
delete record.__listeners;
delete record.__listenersObservingRecord;
delete record.__listenersObservingFieldsOfRecord;
model.__records.delete(record);
delete 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: this.isDebug && `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: this.isDebug && `first call on ${record}`,
});
if (!record.exists()) {
break; // onChange might have deleted the record, other onChange shouldn't be executed
}
}
}
}
/**
* 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();
}
/**
* @private
* @param {Object} model
* @param {Object[]} dataList
* @param {Object} [options={}]
* @returns {Record[]}
*/
_insert(model, dataList, options = {}) {
if (this.isDebug) {
this._ensureNoLockingListener();
}
const records = [];
for (const data of dataList) {
let record = this.findFromIdentifyingData(model, data);
if (!record) {
const data2 = this._addDefaultData(model, data);
this._preInsertIdentifyingFieldsFromData(model, data2);
record = this._create(model);
model.__recordsIndex.addRecord(record, data2);
this._update(record, data2, { ...options, allowWriteReadonly: true });
} else {
this._update(record, data, options);
}
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: field.identifying,
model: this.models[field.to],
},
));
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 record.__listenersObservingFieldsOfRecord.get(field) || []) {
this._markListenerToNotify(listener, {
listener,
reason: this.isDebug && `_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();
}
}
/**
* Processes all commands given in data that concerns relation fields that
* are identifying to execute their respective "insert-and-replace" commands
* and to replace them by corresponding "replace" commands.
*
* @param {Object} model
* @param {Object} data
*/
_preInsertIdentifyingFieldsFromData(model, data) {
for (const fieldName of model.__identifyingFieldNames) {
if (data[fieldName] === undefined) {
continue;
}
const field = model.__fieldMap.get(fieldName);
if (!field.to) {
continue;
}
const commands = field.convertToFieldCommandList(data[fieldName]);
if (commands.length !== 1) {
throw new Error(`Identifying field "${model}/${fieldName}" should receive a single command.`);
}
const [command] = commands;
if (!(command instanceof FieldCommand)) {
throw new Error(`Identifying field "${model}/${fieldName}" should receive a command.`);
}
if (!['insert-and-replace', 'replace'].includes(command._name)) {
throw new Error(`Identifying field "${model}/${fieldName}" should receive a "replace" or "insert-and-replace" command.`);
}
if (command._name === 'replace') {
continue;
}
if (!command._value) {
throw new Error(`Identifying field "${model}/${fieldName}" is lacking a relation value.`);
}
if (typeof command._value[Symbol.iterator] === 'function') {
throw new Error(`Identifying field "${model}/${fieldName}" should receive a single data object.`);
}
const [record] = this._insert(this.models[field.to], [command._value]);
data[fieldName] = record;
}
}
/**
* 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,
model,
}));
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 = new Map(Object.entries(model.__combinedFields));
// List of all fields, for iterating.
model.__fieldList = [...model.__fieldMap.values()];
model.__requiredFieldsList = model.__fieldList.filter(
field => field.required
);
model.__identifyingFieldNames = new Set();
for (const [fieldName, field] of model.__fieldMap) {
if (field.identifying) {
model.__identifyingFieldNames.add(fieldName);
}
// Add field accessors.
Object.defineProperty(model.prototype, fieldName, {
get: function getFieldValue() { // this is bound to record
const record = this.modelManager.isDebug ? this.__proxifiedRecord : this;
if (this.modelManager._listeners.size) {
let entryRecord = record.__listenersObservingRecord;
const reason = record.modelManager.isDebug && `getField - ${field} of ${record}`;
let entryField = record.__listenersObservingFieldsOfRecord.get(field);
if (!entryField) {
entryField = new Map();
record.__listenersObservingFieldsOfRecord.set(field, entryField);
}
for (const listener of record.modelManager._listeners) {
listener.lastObservedRecords.add(record);
const info = { listener, reason };
if (entryRecord.has(listener)) {
entryRecord.get(listener).push(info);
} else {
entryRecord.set(listener, [info]);
}
if (!listener.lastObservedFieldsByRecord.has(record)) {
listener.lastObservedFieldsByRecord.set(record, new Set());
}
listener.lastObservedFieldsByRecord.get(record).add(field);
if (entryField.has(listener)) {
entryField.get(listener).push(info);
} else {
entryField.set(listener, [info]);
}
}
}
return field.get(record);
},
});
}
delete model.__combinedFields;
}
}
/**
* @private
* @param {Record} record
* @param {Object} data
* @param {Object} [options={}]
* @param {boolean} [options.allowWriteReadonly=false]
* @returns {boolean} whether any value changed for the current record
*/
_update(record, data, options = {}) {
if (this.isDebug) {
this._ensureNoLockingListener();
}
if (this.isDebug && !record.exists()) {
throw Error(`Cannot update already deleted record ${record}.`);
}
const { allowWriteReadonly = false } = options;
const model = record.constructor;
let hasChanged = false;
for (const fieldName of Object.keys(data)) {
if (data[fieldName] === undefined) {
// `undefined` should have the same effect as not passing the field
continue;
}
const field = model.__fieldMap.get(fieldName);
if (!field) {
console.warn(`Cannot create/update record with data unrelated to a field. (record: "${record}", non-field attempted update: "${fieldName}")`);
continue;
}
const newVal = data[fieldName];
if (!field.parseAndExecuteCommands(record, newVal, options)) {
continue;
}
if (field.readonly && !allowWriteReadonly) {
console.warn(`read-only ${field} on ${record} was updated`);
}
hasChanged = true;
this._markRecordFieldAsChanged(record, field);
}
if (hasChanged) {
this._updatedRecordsCheckRequired.add(record);
}
return hasChanged;
}
}