[REF] mail: introduce Record.attr() on discuss JS models

In preparation to simplify mail.record/insert processing
(Message.body markup)

Part-of: odoo/odoo#139501
This commit is contained in:
Alexandre Kühn
2023-10-26 13:41:15 +00:00
parent e6f501bea5
commit 46e3594118
3 changed files with 105 additions and 65 deletions
@@ -89,8 +89,7 @@ export class Message extends Record {
attachments = Record.many("Attachment", { inverse: "message" });
author = Record.one("Persona");
/** @type {string} */
body;
body = Record.attr("");
composer = Record.one("Composer", { inverse: "message", onDelete: (r) => r.delete() });
/** @type {string} */
default_subject;
+69 -36
View File
@@ -5,12 +5,15 @@ import { registry } from "@web/core/registry";
export const modelRegistry = registry.category("discuss.model");
const ATTR_SYM = Symbol("attr");
const MANY_SYM = Symbol("many");
const ONE_SYM = Symbol("one");
const OR_SYM = Symbol("or");
const AND_SYM = Symbol("and");
const IS_RECORD_SYM = Symbol("isRecord");
/** @typedef {ATTR_SYM|MANY_SYM|ONE_SYM} FIELD_SYM */
export function AND(...args) {
return [AND_SYM, ...args];
}
@@ -101,18 +104,18 @@ export class RecordList extends Array {
if (r2 && r2.notEq(r3)) {
r2.__uses__.delete(receiver);
}
const { inverse, onDelete } = receiver.owner.Model.__rels__[receiver.name];
const { inverse, onDelete } = receiver.owner.Model._fields[receiver.name];
onDelete?.call(receiver.owner, r2);
if (inverse) {
r2.__rels__[inverse].delete(receiver);
r2._fields[inverse].delete(receiver);
}
receiver.data[index] = r3?.localId;
if (r3) {
r3.__uses__.add(receiver);
const { inverse, onAdd } = receiver.owner.Model.__rels__[receiver.name];
const { inverse, onAdd } = receiver.owner.Model._fields[receiver.name];
onAdd?.call(receiver.owner, r3);
if (inverse) {
r3.__rels__[inverse].add(receiver);
r3._fields[inverse].add(receiver);
}
}
});
@@ -143,7 +146,7 @@ export class RecordList extends Array {
* comes from deletion, we want to "DELETE".
*/
_insert(val, fn, { inv = true, mode = "ADD" } = {}) {
const { inverse } = this.owner.Model.__rels__[this.name];
const { inverse } = this.owner.Model._fields[this.name];
if (inverse && inv) {
// special command to call _addNoinv/_deleteNoInv, to prevent infinite loop
val[inverse] = [[mode === "ADD" ? "ADD.noinv" : "DELETE.noinv", this.owner]];
@@ -151,7 +154,7 @@ export class RecordList extends Array {
/** @type {R} */
let r3;
if (!Record.isRecord(val)) {
const { targetModel } = this.owner.Model.__rels__[this.name];
const { targetModel } = this.owner.Model._fields[this.name];
r3 = this.owner.Model.store[targetModel].preinsert(val);
} else {
r3 = val;
@@ -159,7 +162,7 @@ export class RecordList extends Array {
fn(r3);
if (!Record.isRecord(val)) {
// was preinserted, fully insert now
const { targetModel } = this.owner.Model.__rels__[this.name];
const { targetModel } = this.owner.Model._fields[this.name];
this.owner.Model.store[targetModel].insert(val);
}
return r3;
@@ -178,10 +181,10 @@ export class RecordList extends Array {
this.data.push(r3.localId);
r3.__uses__.add(this);
});
const { inverse, onAdd } = this.owner.Model.__rels__[this.name];
const { inverse, onAdd } = this.owner.Model._fields[this.name];
onAdd?.call(this.owner, r);
if (inverse) {
r.__rels__[inverse].add(this.owner);
r._fields[inverse].add(this.owner);
}
}
return this.data.length;
@@ -198,11 +201,11 @@ export class RecordList extends Array {
shift() {
const r2 = this.store.get(this.data.shift());
r2?.__uses__.delete(this);
const { inverse, onDelete } = this.owner.Model.__rels__[this.name];
const { inverse, onDelete } = this.owner.Model._fields[this.name];
if (r2) {
onDelete?.call(this.owner, r2);
if (inverse) {
r2.__rels__[inverse].delete(this.owner);
r2._fields[inverse].delete(this.owner);
}
}
return r2;
@@ -214,10 +217,10 @@ export class RecordList extends Array {
this.data.unshift(r3.localId);
r3.__uses__.add(this);
});
const { inverse, onAdd } = this.owner.Model.__rels__[this.name];
const { inverse, onAdd } = this.owner.Model._fields[this.name];
onAdd?.call(this.owner, r);
if (inverse) {
r.__rels__[inverse].add(this.owner);
r._fields[inverse].add(this.owner);
}
}
return this.data.length;
@@ -287,18 +290,18 @@ export class RecordList extends Array {
this.data.splice(start, deleteCount, ...newRecords.map((r) => r.localId));
for (const r of oldRecords) {
r.__uses__.delete(this);
const { inverse, onDelete } = this.owner.Model.__rels__[this.name];
const { inverse, onDelete } = this.owner.Model._fields[this.name];
onDelete?.call(this.owner, r);
if (inverse) {
r.__rels__[inverse].delete(this.owner);
r._fields[inverse].delete(this.owner);
}
}
for (const r of newRecords) {
r.__uses__.add(this);
const { inverse, onAdd } = this.owner.Model.__rels__[this.name];
const { inverse, onAdd } = this.owner.Model._fields[this.name];
onAdd?.call(this.owner, r);
if (inverse) {
r.__rels__[inverse].add(this.owner);
r._fields[inverse].add(this.owner);
}
}
}
@@ -435,12 +438,20 @@ export class RecordList extends Array {
}
/**
* @typedef {Object} RelationDefinition
* @property {string} [targetModel]
* @property {Function} [compute]
* @property {string} [inverse]
* @property {Function} [onAdd]
* @property {Function} [onDelete]
* @typedef {Object} FieldDefinition
* @property {boolean} [ATTR_SYM] true when this is an attribute, i.e. a non-relational field.
* @property {boolean} [MANY_SYM] true when this is a many relation.
* @property {boolean} [ONE_SYM] true when this is a one relation.
* @property {any} [default] the default value of this attribute.
* @property {string} [targetModel] model name of records contained in this relational field.
* @property {Function} [compute] if set the field is computed based on provided function.
* The `this` of function is the record, and the function is recalled whenever any field
* in models used by this compute function is changed.
* @property {string} [inverse] name of inverse relational field in targetModel.
* @property {Function} [onAdd] hook that is called when relation is updated
* with a record being added. Callback param is record being added into relation.
* @property {Function} [onDelete] hook that is called when relation is updated
* with a record being deleted. Callback param is record being deleted from relation.
*/
export class Record {
@@ -450,16 +461,27 @@ export class Record {
/** @type {import("@mail/core/common/store_service").Store} */
static store;
/**
* Contains tracked relational fields of the model:
* - key : (relational) field name
* - value: Value contains definition of relational field
* Contains field definitions of the model:
* - key : field name
* - value: Value contains definition of field
*
* @type {Object.<string, {RelationDefinition}>}
* @type {Object.<string, {FieldDefinition}>}
*/
static __rels__ = {};
static _fields = {};
static isRecord(record) {
return Boolean(record?.[IS_RECORD_SYM]);
}
/** @param {FIELD_SYM|RecordList} val */
static isRelation(val) {
if ([MANY_SYM, ONE_SYM].includes(val)) {
return true;
}
return RecordList.isOne(val) || RecordList.isMany(val);
}
/** @param {FIELD_SYM} SYM */
static isField(SYM) {
return [MANY_SYM, ONE_SYM, ATTR_SYM].includes(SYM);
}
static get(data) {
return this.records[this.localId(data)];
}
@@ -480,10 +502,13 @@ export class Record {
}
static _localId(expr, data, { brackets = false } = {}) {
if (!Array.isArray(expr)) {
if (expr in this.__rels__) {
if (RecordList.isMany(this.__rels__[expr])) {
if (expr in this._fields) {
if (RecordList.isMany(this._fields[expr])) {
throw new Error("Using a Record.Many() as id is not (yet) supported");
}
if (!Record.isRelation(this._fields[expr])) {
return data[expr];
}
if (this.isCommand(data[expr])) {
// Note: only Record.one() is supported
const [cmd, data2] = data[expr].at(-1);
@@ -634,6 +659,14 @@ export class Record {
static many(targetModel, { compute, inverse, onAdd, onDelete } = {}) {
return [MANY_SYM, { targetModel, compute, inverse, onAdd, onDelete }];
}
/**
* @template T
* @param {T} def;
* @returns {T}
*/
static attr(def) {
return [ATTR_SYM, { default: def }];
}
/** @returns {Record} */
static insert(data) {
const res = this.preinsert(data);
@@ -677,7 +710,7 @@ export class Record {
*
* @type {Object<string, RecordList>}
*/
__rels__ = {};
_fields = {};
__uses__ = new RecordUses();
get _store() {
return this.Model.store;
@@ -711,7 +744,7 @@ export class Record {
Object.assign(this, data);
} else {
// update on single-id data
if (this.Model.id in this.Model.__rels__) {
if (this.Model.id in this.Model._fields) {
this[this.Model.id] = data;
}
}
@@ -719,7 +752,7 @@ export class Record {
delete() {
const r1 = this;
for (const name in r1.__rels__) {
for (const name in r1._fields) {
r1[name] = undefined;
}
for (const [localId, names] of r1.__uses__.data.entries()) {
@@ -730,7 +763,7 @@ export class Record {
r1.__uses__.data.delete(localId);
continue;
}
const l2 = r2.__rels__[name2];
const l2 = r2._fields[name2];
if (RecordList.isMany(l2)) {
for (let c = 0; c < count; c++) {
r2[name2].delete(r1);
@@ -772,7 +805,7 @@ export class Record {
toData() {
const data = { ...this };
for (const [name, val] of Object.entries(this.__rels__)) {
for (const [name, val] of Object.entries(this._fields)) {
if (RecordList.isMany(val)) {
data[name] = val.map((r) => r.toIdData());
} else {
@@ -780,7 +813,7 @@ export class Record {
}
}
delete data._store;
delete data.__rels__;
delete data._fields;
delete data.__uses__;
delete data.Model;
return data;
@@ -151,8 +151,8 @@ export function makeStore(env) {
const proxy = new Proxy(this, {
/** @param {Record} receiver */
get(target, name, receiver) {
if (name !== "__rels__" && name in receiver.__rels__) {
const l1 = receiver.__rels__[name];
if (name !== "_fields" && name in receiver._fields) {
const l1 = receiver._fields[name];
if (RecordList.isMany(l1)) {
return l1;
}
@@ -161,9 +161,9 @@ export function makeStore(env) {
return Reflect.get(target, name, receiver);
},
deleteProperty(target, name) {
if (name !== "__rels__" && name in target.__rels__) {
if (name !== "_fields" && name in target._fields) {
const r1 = target;
const l1 = r1.__rels__[name];
const l1 = r1._fields[name];
l1.clear();
return true;
}
@@ -172,12 +172,12 @@ export function makeStore(env) {
},
/** @param {Record} receiver */
set(target, name, val, receiver) {
if (!(name in receiver.__rels__)) {
if (!(name in receiver._fields)) {
Reflect.set(target, name, val, receiver);
return true;
}
/** @type {RecordList<Record>} */
const l1 = receiver.__rels__[name];
const l1 = receiver._fields[name];
if (RecordList.isMany(l1)) {
// [Record.many] =
if (Record.isCommand(val)) {
@@ -253,23 +253,27 @@ export function makeStore(env) {
if (this instanceof BaseStore) {
res.store = proxy;
}
for (const name in Model.__rels__) {
// Relational fields contain symbols for detection in original class.
// This constructor is called on genuine records:
// - 'one' fields => undefined
// - 'many' fields => RecordList
// this[name]?.[0] is ONE_SYM or MANY_SYM
const newVal = new RecordList(this[name]?.[0]);
if (this instanceof BaseStore) {
newVal.store = proxy;
for (const name in Model._fields) {
if (Record.isRelation(this[name]?.[0])) {
// Relational fields contain symbols for detection in original class.
// This constructor is called on genuine records:
// - 'one' fields => undefined
// - 'many' fields => RecordList
// this[name]?.[0] is ONE_SYM or MANY_SYM
const newVal = new RecordList(this[name]?.[0]);
if (this instanceof BaseStore) {
newVal.store = proxy;
} else {
newVal.store = res.store;
}
newVal.name = name;
newVal.owner = proxy;
this._fields[name] = newVal;
this.__uses__ = new RecordUses();
this[name] = newVal;
} else {
newVal.store = res.store;
this[name] = Model._fields[name].default;
}
newVal.name = name;
newVal.owner = proxy;
this.__rels__[name] = newVal;
this.__uses__ = new RecordUses();
this[name] = newVal;
}
for (const [name, fn] of Object.entries(Model.__computes__)) {
let boundFn;
@@ -284,19 +288,19 @@ export function makeStore(env) {
Object.assign(Model, {
Class,
records: JSON.parse(JSON.stringify(OgClass.records)),
__rels__: {},
_fields: {},
__computes__: {},
});
Models[name] = Model;
res.store[name] = Model;
// Detect relational fields with a dummy record and setup getter/setters on them
// Detect fields with a dummy record and setup getter/setters on them
const obj = new OgClass();
for (const [name, val] of Object.entries(obj)) {
const SYM = val?.[0];
if (![Record.one()[0], Record.many()[0]].includes(SYM)) {
if (!Record.isField(SYM)) {
continue;
}
Model.__rels__[name] = { [SYM]: true, ...val[1] };
Model._fields[name] = { [SYM]: true, ...val[1] };
if (val[1].compute) {
Model.__computes__[name] = val[1].compute;
}
@@ -304,12 +308,16 @@ export function makeStore(env) {
}
// Sync inverse fields
for (const Model of Object.values(Models)) {
for (const [name, { targetModel, inverse }] of Object.entries(Model.__rels__)) {
for (const [name, definition] of Object.entries(Model._fields)) {
if (!Record.isRelation(definition)) {
continue;
}
const { targetModel, inverse } = definition;
if (targetModel && !Models[targetModel]) {
throw new Error(`No target model ${targetModel} exists`);
}
if (inverse) {
const rel2 = Models[targetModel].__rels__[inverse];
const rel2 = Models[targetModel]._fields[inverse];
if (rel2.targetModel && rel2.targetModel !== Model.name) {
throw new Error(
`Fields ${Models[targetModel].name}.${inverse} has wrong targetModel. Expected: "${Model.name}" Actual: "${rel2.targetModel}"`