/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM – Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see .
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
/** @module collection */
import Model from 'model';
import {Events, View as BullView} from 'bullbone';
import _ from 'underscore';
/**
* On sync with backend.
*
* @event Collection#sync
* @param {Collection} collection A collection.
* @param {Object} response Response from backend.
* @param {Object} o Options.
*/
/**
* Any number of models have been added, removed or changed.
*
* @event Collection#update
* @param {Collection} collection A collection.
* @param {Object} o Options.
*/
/**
* On reset.
*
* @event Collection#reset
* @param {Collection} collection A collection.
* @param {Object} o Options.
*/
/**
* On model sync.
*
* @event Collection#model-sync
* @param {Model} model A model.
* @param {Record & {action?: 'fetch'|'save'|'destroy'}} o Options.
* @since 9.1.0
*/
/**
* @typedef {Object} module:collection~Data
* @property {string|null} [primaryFilter]
* @property {string[]} [boolFilterList]
* @property {string} [textFilter]
* @property {string} [select]
* @property {string} [q]
*/
/**
* A collection.
*
* @mixes Bull.Events
* @copyright Credits to Backbone.js.
*/
class Collection {
/**
* An entity type.
*
* @type {string|null}
*/
entityType = null
/**
* A total number of records.
*
* @type {number}
*/
total = 0
/**
* A current offset (for pagination).
*
* @type {number}
*/
offset = 0
/**
* A max size (for pagination).
*
* @type {number}
*/
maxSize = 20
/**
* An order.
*
* @type {boolean|'asc'|'desc'|null}
*/
order = null
/**
* An order-by field.
*
* @type {string|null}
*/
orderBy = null
/**
* A where clause.
*
* @type {module:search-manager~whereItem[]|null}
*/
where = null
/**
* @deprecated
* @type {module:search-manager~whereItem[]|null}
*/
whereAdditional = null
/**
* A length correction.
*
* @type {number}
*/
lengthCorrection = 0
/**
* A max max-size.
*
* @type {number}
*/
maxMaxSize = 0
/**
* A where function.
*
* @type {function(): module:search-manager~whereItem[]}
*/
whereFunction
/**
* A last sync request promise.
*
* @type {module:ajax.AjaxPromise|null}
*/
lastSyncPromise = null
/**
* A parent model. To be used for own purposes. E.g. to have access to a parent from related models.
*
* @type {import('model').default}
*/
parentModel
/**
* @param {Model[]|Record[]|null} [models] Models.
* @param {{
* entityType?: string,
* model?: Model.prototype,
* defs?: module:model~defs,
* order?: 'asc'|'desc'|boolean|null,
* orderBy?: string|null,
* urlRoot?: string,
* url?: string,
* maxSize?: number,
* }} [options] Options.
*/
constructor(models, options) {
options = {...options};
if (options.model) {
this.model = options.model;
}
if (options.maxSize !== undefined) {
this.maxSize = options.maxSize;
}
this._reset();
if (options.entityType) {
this.entityType = options.entityType;
/** @deprecated */
this.name = this.entityType;
}
/**
* A root URL.
*
* @public
* @type {string|null}
*/
this.urlRoot = options.urlRoot || this.urlRoot || this.entityType;
/**
* An URL.
*
* @type {string|null}
*/
this.url = options.url || this.url || this.urlRoot;
this.orderBy = options.orderBy || this.orderBy;
this.order = options.order || this.order;
this.defaultOrder = this.order;
this.defaultOrderBy = this.orderBy;
/** @type {module:model~defs} */
this.defs = options.defs || {};
/**
* @type {module:collection~Data | Record}
*/
this.data = {};
/**
* @private
* @type {Model#}
*/
this.model = options.model || Model;
if (models) {
this.reset(models, {silent: true, ...options});
}
}
/**
* Add models or a model.
*
* @param {Model[]|Model|Record[]|Record} models Models ar a model.
* @param {{
* merge?: boolean,
* at?: number,
* silent?: boolean,
* }} [options] Options. `at` – position; `merge` – merge existing models, otherwise, they are ignored.
* @return {this}
* @fires Collection#update
*/
add(models, options) {
this.set(models, {merge: false, ...options, ...addOptions});
return this;
}
/**
* Remove models or a model.
*
* @param {Model[]|Model|string} models Models, a model or a model ID.
* @param {{
* silent?: boolean,
* } & Object.} [options] Options.
* @return {this}
* @fires Collection#update
*/
remove(models, options) {
options = {...options};
const singular = !_.isArray(models);
models = singular ? [models] : models.slice();
const removed = this._removeModels(models, options);
if (!options.silent && removed.length) {
options.changes = {
added: [],
merged: [],
removed: removed,
};
this.trigger('update', this, options);
}
return this;
}
/**
* @protected
* @param {Model[]|Model|Record[]} models Models ar a model.
* @param {{
* silent?: boolean,
* at?: number,
* prepare?: boolean,
* add?: boolean,
* merge?: boolean,
* remove?: boolean,
* index?: number,
* } & Object.} [options]
* @return {Model[]}
*/
set(models, options) {
if (models == null) {
return [];
}
options = {...setOptions, ...options};
if (options.prepare && !this._isModel(models)) {
models = this.prepareAttributes(models, options) || [];
}
const singular = !_.isArray(models);
models = singular ? [models] : models.slice();
let at = options.at;
if (at != null) {
at = +at;
}
if (at > this.length) {
at = this.length;
}
if (at < 0) {
at += this.length + 1;
}
const set = [];
const toAdd = [];
const toMerge = [];
const toRemove = [];
const modelMap = {};
const add = options.add;
const merge = options.merge;
const remove = options.remove;
let model, i;
for (i = 0; i < models.length; i++) {
model = models[i];
const existing = this._get(model);
if (existing) {
if (merge && model !== existing) {
let attributes = this._isModel(model) ?
model.attributes :
model;
if (options.prepare) {
attributes = existing.prepareAttributes(attributes, options);
}
existing.set(attributes, options);
toMerge.push(existing);
}
if (!modelMap[existing.cid]) {
modelMap[existing.cid] = true;
set.push(existing);
}
models[i] = existing;
}
else if (add) {
model = models[i] = this._prepareModel(model);
if (model) {
toAdd.push(model);
this._addReference(model, options);
modelMap[model.cid] = true;
set.push(model);
}
}
}
// Remove stale models.
if (remove) {
for (i = 0; i < this.length; i++) {
model = this.models[i];
if (!modelMap[model.cid]) {
toRemove.push(model);
}
}
if (toRemove.length) {
this._removeModels(toRemove, options);
}
}
let orderChanged = false;
const replace = add && remove;
if (set.length && replace) {
orderChanged =
this.length !== set.length ||
_.some(this.models, (m, index) => {
return m !== set[index];
});
this.models.length = 0;
splice(this.models, set, 0);
this.length = this.models.length;
}
else if (toAdd.length) {
splice(this.models, toAdd, at == null ? this.length : at);
this.length = this.models.length;
}
if (!options.silent) {
for (i = 0; i < toAdd.length; i++) {
if (at != null) {
options.index = at + i;
}
model = toAdd[i];
model.trigger('add', model, this, options);
}
if (orderChanged) {
this.trigger('sort', this, options);
}
if (toAdd.length || toRemove.length || toMerge.length) {
options.changes = {
added: toAdd,
removed: toRemove,
merged: toMerge
};
this.trigger('update', this, options);
}
}
return models;
}
/**
* Reset.
*
* @param {Model[]|null} [models] Models to replace the collection with.
* @param {{
* silent?: boolean,
* } & Object.} [options]
* @return {this}
* @fires Collection#reset
*/
reset(models, options) {
this.lengthCorrection = 0;
options = options ? _.clone(options) : {};
for (let i = 0; i < this.models.length; i++) {
this._removeReference(this.models[i], options);
}
options.previousModels = this.models;
this._reset();
if (models) {
this.add(models, {silent: true, ...options});
}
if (!options.silent) {
this.trigger('reset', this, options);
}
return this;
}
/**
* Add a model at the end.
*
* @param {Model} model A model.
* @param {{
* silent?: boolean,
* }} [options] Options
* @return {this}
*/
push(model, options) {
this.add(model, {at: this.length, ...options});
return this;
}
/**
* Remove and return the last model.
*
* @param {{
* silent?: boolean,
* }} [options] Options
* @return {Model|null}
*/
pop(options) {
const model = this.at(this.length - 1);
if (!model) {
return null;
}
this.remove(model, options);
return model;
}
/**
* Add a model to the beginning.
*
* @param {Model} model A model.
* @param {{
* silent?: boolean,
* }} [options] Options
* @return {this}
*/
unshift(model, options) {
this.add(model, {at: 0, ...options});
return this;
}
/**
* Remove and return the first model.
*
* @param {{
* silent?: boolean,
* }} [options] Options
* @return {Model|null}
*/
shift(options) {
const model = this.at(0);
if (!model) {
return null;
}
this.remove(model, options);
return model;
}
/**
* Get a model by an ID.
*
* @todo Usage to _get.
* @param {string} id An ID.
* @return {Model|undefined}
*/
get(id) {
return this._get(id);
}
/**
* Whether a model in the collection.
*
* @todo Usage to _has.
* @param {string} id An ID.
* @return {boolean}
*/
has(id) {
return this._has(id);
}
/**
* Get a model by index.
*
* @param {number} index An index. Can be negative, then counted from the end.
* @return {Model|undefined}
*/
at(index) {
if (index < 0) {
index += this.length;
}
return this.models[index];
}
/**
* Iterates through a collection.
*
* @param {function(Model)} callback A function.
* @param {Object} [context] A context.
*/
forEach(callback, context) {
return this.models.forEach(callback, context);
}
/**
* Get an index of a model. Returns -1 if not found.
*
* @param {Model} model A model
* @return {number}
*/
indexOf(model) {
return this.models.indexOf(model);
}
/**
* @private
* @param {string|Object.|Model} obj
* @return {boolean}
*/
_has(obj) {
return !!this._get(obj)
}
/**
* @private
* @param {string|Object.|Model} obj
* @return {Model|undefined}
*/
_get(obj) {
if (obj == null) {
return void 0;
}
return this._byId[obj] ||
this._byId[this.modelId(obj.attributes || obj)] ||
obj.cid && this._byId[obj.cid];
}
/**
* @protected
* @param {Object.} attributes
* @return {*}
*/
modelId(attributes) {
return attributes['id'];
}
/** @private */
_reset() {
/**
* A number of records.
*/
this.length = 0;
/**
* Models.
*
* @type {Model[]}
*/
this.models = [];
/** @private */
this._byId = {};
}
/**
* @param {string} orderBy An order field.
* @param {bool|null|'desc'|'asc'} [order] True for desc.
* @returns {Promise}
*/
sort(orderBy, order) {
this.orderBy = orderBy;
if (order === true) {
order = 'desc';
}
else if (order === false) {
order = 'asc';
}
this.order = order || 'asc';
return this.fetch();
}
/**
* Has previous page.
*
* @return {boolean}
*/
hasPreviousPage() {
return this.offset > 0;
}
/**
* Has next page.
*
* @return {boolean}
*/
hasNextPage() {
return this.total - this.offset > this.length || this.total === -1;
}
/**
* Next page.
*
* @returns {Promise}
*/
nextPage() {
return this.setOffset(this.offset + this.length);
}
/**
* Previous page.
*
* @returns {Promise}
*/
previousPage() {
return this.setOffset(Math.max(0, this.offset - this.maxSize));
}
/**
* First page.
*
* @returns {Promise}
*/
firstPage() {
return this.setOffset(0);
}
/**
* Last page.
*
* @returns {Promise}
*/
lastPage() {
let offset = this.total - this.total % this.maxSize;
if (offset === this.total) {
offset = this.total - this.maxSize;
}
return this.setOffset(offset);
}
/**
* Set an offset.
*
* @param {number} offset Offset.
* @returns {Promise}
*/
setOffset(offset) {
if (offset < 0) {
throw new RangeError('offset can not be less than 0');
}
if (
offset > this.total &&
this.total !== -1 &&
this.total !== -2 &&
offset > 0
) {
throw new RangeError('offset can not be larger than total count');
}
this.offset = offset;
return this.fetch({maxSize: this.maxSize});
}
/**
* Has more.
*
* @return {boolean}
*/
hasMore() {
return this.total > (this.length + this.offset + this.lengthCorrection) || this.total === -1;
}
/**
* Prepare attributes.
*
* @protected
* @param {Object.|Record[]} response A response from the backend.
* @param {Object.} options Options.
* @returns {Object.[]}
*/
prepareAttributes(response, options) {
this.total = response.total;
// noinspection JSUnusedGlobalSymbols
/**
* @deprecated As of v8.4. Use 'sync' event to obtain any additional data from a response.
*/
this.dataAdditional = response.additionalData || null;
return response.list;
}
/**
* Fetch from the backend.
*
* @param {{
* remove?: boolean,
* more?: boolean,
* offset?: number,
* maxSize?: number,
* orderBy?: string,
* order?: 'asc'|'desc',
* } & Object.} [options] Options.
* @returns {Promise}
* @fires Collection#sync Unless `{silent: true}`.
*/
fetch(options) {
options = {...options};
options.data = {...options.data, ...this.data};
this.offset = options.offset || this.offset;
this.orderBy = options.orderBy || this.orderBy;
this.order = options.order || this.order;
this.where = options.where || this.where;
const length = this.length + this.lengthCorrection;
if ('maxSize' in options) {
options.data.maxSize = options.maxSize;
} else {
options.data.maxSize = options.more ? this.maxSize : Math.max(length, this.maxSize);
if (this.maxMaxSize && options.data.maxSize > this.maxMaxSize) {
options.data.maxSize = this.maxMaxSize;
}
}
options.data.offset = options.more ? (this.offset + length) : this.offset;
options.data.orderBy = this.orderBy;
options.data.order = this.order;
options.data.whereGroup = this.getWhere();
if (options.data.select) {
options.data.attributeSelect = options.data.select;
delete options.data.select;
}
options = {prepare: true, ...options};
const success = options.success;
options.success = response => {
options.reset ?
this.reset(response, options) :
this.set(response, options);
if (success) {
success.call(options.context, this, response, options);
}
this.trigger('sync', this, response, options);
};
const error = options.error;
options.error = response => {
if (error) {
error.call(options.context, this, response, options);
}
this.trigger('error', this, response, options);
};
this.lastSyncPromise = Model.prototype.sync.call(this, 'read', this, options);
return this.lastSyncPromise;
}
/**
* Is being fetched.
*
* @return {boolean}
*/
isBeingFetched() {
return this.lastSyncPromise && this.lastSyncPromise.getReadyState() < 4;
}
/**
* Abort the last fetch.
*/
abortLastFetch() {
if (this.isBeingFetched()) {
this.lastSyncPromise.abort();
}
}
/**
* Get a where clause.
*
* @returns {module:search-manager~whereItem[]}
*/
getWhere() {
let where = (this.where ?? []).concat(this.whereAdditional || []);
if (this.whereFunction) {
where = where.concat(this.whereFunction() || []);
}
return where;
}
/**
* Get an entity type.
*
* @returns {string}
*/
getEntityType() {
return this.entityType || this.name;
}
/**
* Reset the order to default.
*/
resetOrderToDefault() {
this.orderBy = this.defaultOrderBy;
this.order = this.defaultOrder;
}
/**
* Set an order.
*
* @param {string|null} orderBy
* @param {boolean|'asc'|'desc'|null} [order]
* @param {boolean} [setDefault]
*/
setOrder(orderBy, order, setDefault) {
this.orderBy = orderBy;
this.order = order;
if (setDefault) {
this.defaultOrderBy = orderBy;
this.defaultOrder = order;
}
}
/**
* Clone.
*
* @param {{withModels?: boolean}} [options]
* @return {Collection}
*/
clone(options = {}) {
let models = this.models;
if (options.withModels) {
models = this.models.map(m => m.clone());
}
const collection = new this.constructor(models, {
model: this.model,
entityType: this.entityType,
defs: this.defs,
orderBy: this.orderBy,
order: this.order,
});
collection.name = this.name;
collection.urlRoot = this.urlRoot;
collection.url = this.url;
collection.defaultOrder = this.defaultOrder;
collection.defaultOrderBy = this.defaultOrderBy;
collection.data = Espo.Utils.cloneDeep(this.data);
collection.where = Espo.Utils.cloneDeep(this.where);
collection.whereAdditional = Espo.Utils.cloneDeep(this.whereAdditional);
collection.total = this.total;
collection.offset = this.offset;
collection.maxSize = this.maxSize;
collection.maxMaxSize = this.maxMaxSize;
collection.whereFunction = this.whereFunction;
collection.parentModel = this.parentModel;
return collection;
}
/**
* Prepare an empty model instance.
*
* @return {Model}
*/
prepareModel() {
return this._prepareModel({});
}
// noinspection JSUnusedGlobalSymbols
/**
* Compose a URL for syncing. Called from Model.sync.
*
* @protected
* @return {string}
*/
composeSyncUrl() {
return this.url;
}
/** @private */
_isModel(object) {
return object instanceof Model;
}
/** @private */
_removeModels(models, options) {
const removed = [];
for (let i = 0; i < models.length; i++) {
const model = this.get(models[i]);
if (!model) {
continue;
}
const index = this.models.indexOf(model);
this.models.splice(index, 1);
this.length--;
delete this._byId[model.cid];
const id = this.modelId(model.attributes);
if (id != null) {
delete this._byId[id];
}
if (!options.silent) {
options.index = index;
model.trigger('remove', model, this, options);
}
removed.push(model);
this._removeReference(model, options);
}
return removed;
}
/** @private */
_addReference(model) {
this._byId[model.cid] = model;
const id = this.modelId(model.attributes);
if (id != null) {
this._byId[id] = model;
}
model.on('all', this._onModelEvent, this);
}
/** @private */
_removeReference(model) {
delete this._byId[model.cid];
const id = this.modelId(model.attributes);
if (id != null) {
delete this._byId[id];
}
if (this === model.collection) {
delete model.collection;
}
model.off('all', this._onModelEvent, this);
}
/** @private */
_onModelEvent(event, model, collection, options) {
// @todo Revise. Never triggerred? Remove?
if (event === 'sync' && collection !== this) {
return;
}
if (!model) {
this.trigger.apply(this, arguments);
return;
}
if ((event === 'add' || event === 'remove') && collection !== this) {
return;
}
if (event === 'destroy') {
this.remove(model, options);
}
if (event === 'change') {
const prevId = this.modelId(model.previousAttributes());
const id = this.modelId(model.attributes);
if (prevId !== id) {
if (prevId != null) {
delete this._byId[prevId];
}
if (id != null) {
this._byId[id] = model;
}
}
}
this.trigger.apply(this, arguments);
}
// noinspection JSDeprecatedSymbols
/** @private*/
_prepareModel(attributes) {
if (this._isModel(attributes)) {
if (!attributes.collection) {
attributes.collection = this;
}
return attributes;
}
const ModelClass = this.model;
// noinspection JSValidateTypes
return new ModelClass(attributes, {
collection: this,
entityType: this.entityType || this.name,
defs: this.defs,
});
}
}
Object.assign(Collection.prototype, Events);
Collection.extend = BullView.extend;
const setOptions = {
add: true,
remove: true,
merge: true,
};
const addOptions = {
add: true,
remove: false,
};
const splice = (array, insert, at) => {
at = Math.min(Math.max(at, 0), array.length);
const tail = Array(array.length - at);
const length = insert.length;
let i;
for (i = 0; i < tail.length; i++) {
tail[i] = array[i + at];
}
for (i = 0; i < length; i++) {
array[i + at] = insert[i];
}
for (i = 0; i < tail.length; i++) {
array[i + length + at] = tail[i];
}
};
export default Collection;