/**
*
* Represents a **Collection** of records.
*
* A **Collection** is an interface to manipulate and cache a collection of records.
* To see how a **Collection** relates to models, fields and records, please refer to the [Model documentation](kiss.data.Model.html).
*
* Each **Model** has an associated default Collection to hold its records:
* - this default Collection is instantiated (but not loaded) at the same time as the Model.
* - it means that you can always access the records of a Model, even if you didn't explicity created a Collection for it.
* - this default collection is accessible with: **kiss.app.collections[modelId]**, or simply **app.collections.modelId**
*
* ```
* let myCarCollection = kiss.app.collections["car"]
* ```
*
* Below is a table that shows the global flow between KissJS client and KissJS server, and the chain of methods used.
*
* Here is the flow:
* - **kiss.data.Collection** or [**kiss.data.Record**](kiss.data.RecordFactory-Record.html) calls [**kiss.db**](kiss.db.html)
* - kiss.db points to the right db api: [**kiss.db.memory**](kiss.db.memory.html) or [**kiss.db.offline**](kiss.db.offline.html) or [**kiss.db.online**](kiss.db.online.html)
* - if online, kiss.db perform an HTTP request using [**kiss.ajax.request**](kiss.ajax.html)
* - the KissJS server receives the request
* - the request is processed by the server Controller
* - the Controller calls MongoDb api with the native MongoDb driver
* - the Controller pass the response back to kiss.ajax.request
* - if a database mutation has occured (CUD operations), the server Controller sends a WebSocket message to the connected clients
* - each Collection intercepts the WebSocket message and update its records accordingly
* - each data Component (field, datatable...) intercepts the message and updates its UI accordingly
*
* Currently, database mutations must be performed at the Record level.
*
* kiss.data.Collection | kiss.db | HTTP | KissJS server - Node.controller | MongoDb database
* --- | --- | --- | --- | ---
* find() | find | GET /modelId | find | find({})
* find(query) | find(modelId, query) | POST /modelId :body=query | findAndSort(query) | find(query.filter).sort(query.sort)
* findOne(recordId) | findOne(modelId, recordId) | GET /modelId/recordId | findOne | collection.findOne
* <none> | insertOne(modelId, record) | POST /modelId :body=record | insertOne | insertOne
* <none> | insertMany(modelId, records) | POST /modelId :body=records | insertMany | insertMany
* <none> | updateOne(modelId, recordId, update) | PATCH /modelId/recordId :body=update | updateOne | updateOne
* <none> | updateMany(modelId, query, updates) | PATCH /modelId/ :body=query+updates | updateMany | updateMany
* <none> | updateBulk(modelId, updates) | PATCH /modelId/ :body=updates | updateBulk | updateBulk
* <none> | deleteOne(modelId, recordId) | DELETE /modelId/recordId | delete | delete (performs a soft delete)
*
* Technical notes about performances:
* - KissJS collections don't contain raw data, but actual record's instances
* - this allows to use virtual (computed) fields as normal fields, and even perform aggregations on those fields
* - the process of instanciating a record takes a linear time which is about 0.0004ms per field for an Intel core i7-4790K
* - for example: it take 24ms to load 5000 records with 12 fields, or 480ms to load 50000 records with 24 fields
*
* @param {object} config - Collection configuration
* @param {string} [config.mode] - "memory" | "offline" | "online"
* @param {string} [config.id]
* @param {object} config.model - The Model used to build the collection
* @param {object[]} [config.records] - Records to init the collection: [{...}, {...}, ...]
* @param {object} [config.sort] - default sort
* @param {object} [config.filter] - default filter
* @param {object} [config.projection] - default projection
* @param {object} [config.group] - default grouping
* @param {boolean} [config.groupUnwind] - Unwind allow records belonging to multiple groups to appear as multiple entries
* @param {boolean} [config.showLoadingSpinner] - if false, doesn't show the loading spinner while retrieving data (default = true)
*
* @example
* // Register a new collection
* let userCollection = new kiss.data.Collection({model: modelUser})
*
* // Get collection records
* let users = await userCollection.find()
*
* // Create a new model and use its auto-generated collection
* let taskModel = new kiss.data.Model({
* id: "YOUR_MODEL_ID",
* name: "task",
* namePlural: "tasks",
* items: [
* {id: "name", label: "Name", type: "text"},
* {id: "duedate", label: "Due date", type: "date"},
* {id: "done", label: "Done?", type: "checkbox"} // = Checkbox implicit type is "boolean"
* ]
* })
*
* // Create a new Record
* let newTask = taskModel.create({name: "Task 1", duedate: "2021-03-30", done: false})
* await newTask.save()
*
* // Get the default collection for this model, then get the records
* let tasksCollection = kiss.app.collections["YOUR_MODEL_ID"]
* let tasks = await tasksCollection.find()
* console.log(tasks)
*/
kiss.data.Collection = class {
constructor(config) {
this.id = config.id || uid()
// Define collection's database (memory, offline, online)
this.mode = config.mode || kiss.db.mode
this.db = kiss.db[this.mode]
// Define collection's model
this.model = config.model
this.modelId = this.model.id
this.modelName = this.model.name
log(`kiss.data.Collection - Defining collection ${this.id} for <${this.modelName}> in mode <${this.mode}>`)
// The model's master collection is the default model's collection and is a proxy to access *all* its records (no filter)
// TODO: for the "in-memory" strategy, we can cache the master collection in db.memory then use it as a local proxy source for all data components
this.isMaster = config.isMaster || false
// Keep a pointer to the master collection in any case
this.masterCollection = (this.isMaster) ? this : kiss.app.collections[this.modelId]
// Init records
this.records = config.records || []
// By default, records are not loaded automatically into the collection
this.isLoaded = false
this.showLoadingSpinner = (config.showLoadingSpinner === false) ? false : true
// Filter sort, project, group, group unwind
this.filter = config.filter || {}
this.filterSyntax = config.filterSyntax || "normalized"
this.sort = config.sort || []
this.sortSyntax = config.sortSyntax || "normalized"
this.projection = {}
this.group = config.group || []
this.groupUnwind = config.groupUnwind || false
// 1 - Listen to database mutations (broadcasted via PubSub)
// 2 - Update collection's records accordingly
this.subscriptions = [
subscribe("EVT_DB_INSERT:" + this.modelId.toUpperCase(), (msgData) => {
if (msgData.dbMode != this.mode) return
this._insertOne(msgData.data)
}),
subscribe("EVT_DB_UPDATE:" + this.modelId.toUpperCase(), (msgData) => {
if (msgData.dbMode != this.mode) return
this._updateOne(msgData.id, msgData.data)
}),
subscribe("EVT_DB_DELETE:" + this.modelId.toUpperCase(), (msgData) => {
if (msgData.dbMode != this.mode) return
this._deleteOne(msgData.id)
}),
subscribe("EVT_DB_UPDATE_BULK", (msgData) => {
if (msgData.dbMode != this.mode) return
msgData.data.forEach(operation => {
if (operation.modelId == this.modelId) {
this._updateOne(operation.recordId, operation.updates)
}
})
}),
subscribe("EVT_DB_INSERT_MANY:" + this.modelId.toUpperCase(), (msgData) => {
if (msgData.dbMode != this.mode) return
this.find({}, true)
}, `Collection.insertMany / Model: ${this.model.name}`),
subscribe("EVT_DB_DELETE_MANY:" + this.modelId.toUpperCase(), (msgData) => {
if (msgData.dbMode != this.mode) return
this.find({}, true)
}, `Collection.deleteMany / Model: ${this.model.name}`)
]
// Hooks
this.hooks = {
beforeInsert: [],
afterInsert: [],
beforeUpdate: [],
afterUpdate: [],
beforeDelete: [],
afterDelete: []
}
// Self-register in the kiss.app object
kiss.app.collections[this.id] = this
return this
}
/**
* Destroy the collection.
*
* It deletes the collection and also unsubscribes all its events from kiss.pubsub
*
* @param {boolean} deleteInMemoryDb - If true, force to destroy the in-memory database
*
*/
destroy(deleteInMemoryDb) {
// Unsubscribe all the collection events from the PubSub
this.subscriptions.forEach(subscriptionId => kiss.pubsub.unsubscribe(subscriptionId))
// Delete NeDb collection if we're working with a temporary in-memory collection,
// except if the general application mode is in-memory (it would destroy the master collection)
if (this.mode == "memory" && kiss.db.mode != "memory" || deleteInMemoryDb) {
this.db.deleteCollection(this.modelId)
}
// Unregister the collection from kiss.app
delete kiss.app.collections[this.id]
// Delete the Collection object
delete this
}
/**
* Set the database mode
*
* @param {string} mode - memory | offline | online
*/
setMode(mode) {
this.mode = mode
this.db = kiss.db[mode]
}
/**
* Add a hook to perform an action before or after a mutation occurs (insert, update, delete)
*
* @param {string} hookType - "beforeInsert" | "beforeUpdate" | "beforeDelete" | "afterInsert" | "afterUpdate" | "afterDelete"
* @param {function} callback - Function to execute. It receives the following parameters: *insert(record), *update(recordId, update), *delete(recordId)
* @returns this
*
* @example
*
* // It's possible to add a hook to observe a mutation
* tasksCollection.addHook("beforeInsert", function(record) {
* console.log("The following record will be inserted:")
* console.log(record)
* })
*
* // It's possible to add multiple hooks to the same mutation
* tasksCollection.addHook("beforeInsert", function(record) {
* console.log("Another function executed for the same mutation!")
* })
*
* // Input parameters of the callback depend on the mutation type
* tasksCollection.addHook("afterUpdate", function(recordId, update) {
* console.log("The record has been udpated: " + recordId)
* console.log(update)
* })
*
* tasksCollection.addHook("afterDelete", function(recordId) {
* console.log("The following record has been udpated: " + recordId)
* })
*/
addHook(hookType, callback) {
if (["beforeInsert", "beforeUpdate", "beforeDelete", "afterInsert", "afterUpdate", "afterDelete"].includes(hookType)) this.hooks[hookType].push(callback)
return this
}
/**
* Hooks
*
* @private
* @ignore
*/
_hookInsert(type, record) {
let event = type + "Insert"
if (this.hooks[event].length != 0) {
this.hooks[event].forEach(hook => {
hook(record)
})
}
}
_hookUpdate(type, recordId, update) {
let event = type + "Update"
if (this.hooks[event].length != 0) {
this.hooks[event].forEach(hook => {
hook(recordId, update)
})
}
}
_hookDelete(type, recordId) {
let event = type + "Delete"
if (this.hooks[event].length != 0) {
this.hooks[event].forEach(hook => {
hook(recordId)
})
}
}
/**
* Insert one record into the collection.
*
* @private
* @ignore
* @param {object} record
*/
_insertOne(record) {
log("kiss.data.Collection - _insertOne in collection " + this.id, 0, record)
const existingRecord = this.records.get(record.id)
if (existingRecord) {
log("kiss.data.Collection - _insertOne rejected because it violates the unique constraint", 4)
return
}
// Hook before
this._hookInsert("before", record)
const newRecord = this.model.create(record)
this.records.push(newRecord)
this.hasChanged = true
// Hook after
this._hookInsert("after", record)
}
/**
* Update all the records that have a given id.
*
* @private
* @ignore
* @param {string} recordId
* @param {object} update - The update to apply to a record. Example: {firstName: "Bob"}
*/
_updateOne(recordId, update) {
// log(`kiss.data.Collection - _updateOne in collection ${this.id} / Record: ${recordId}`, 0, update)
// Hook before
this._hookUpdate("before", recordId, update)
// There are 2 scenarios:
// 1. the collection is not grouped and unwound: there is only one occurence of each record
// 2. the collection is grouped and unwound: the same records can appear multiple times in different groups
// Case 1: ungrouped, or grouped but not unwound
let groupId
if (this.groupUnwind !== true) {
// Update the visible records
let record = this.records.get(recordId)
if (record) {
Object.assign(record, update)
groupId = record.$groupId
}
// Update the collapsed records (they are not visible and stored in cache)
if (this.group.length > 0) {
Object.values(this.cachedRecords).forEach(collapsedGroupRecords => {
let record = collapsedGroupRecords.get(recordId)
if (record) {
Object.assign(record, update)
groupId = record.$groupId
}
})
}
}
// Case 2: grouped and unwound
else {
// Update the visible records
this.records.forEach(record => {
if (record.id == recordId) Object.assign(record, update)
})
// Update the collapsed records (they are not visible and stored in cache)
if (this.group.length > 0) {
Object.values(this.cachedRecords).forEach(collapsedGroupRecords => {
collapsedGroupRecords.forEach(record => {
if (record.id == recordId) Object.assign(record, update)
})
})
}
}
// Update aggregation, if needed
if (groupId) {
this._groupUpdateAggregations(groupId)
}
this.hasChanged = true
// Hook after
this._hookUpdate("after", recordId, update)
}
/**
* Delete all the records that have a specific id.
* There can be multiple records with the same id if the view is grouped and unwound.
* TODO: optimize process for ungrouped collections, like for _updateOne
*
* @private
* @ignore
* @param {string} recordId
*/
_deleteOne(recordId) {
log("kiss.data.Collection - _deleteOne in collection " + this.id, 2)
// Hook before
this._hookDelete("before", recordId)
// Delete the visible records
this.records = this.records.filter(record => record.id != recordId)
// Delete the collapsed records (they are not visible and stored in cache)
if (this.group.length > 0) {
Object.values(this.cachedRecords).forEach(collapsedGroupRecords => {
collapsedGroupRecords = collapsedGroupRecords.filter(record => record.id != recordId)
})
}
this.hasChanged = true
// Hook after
this._hookDelete("after", recordId)
}
/**
* Insert many records in the collection
*
* @async
* @param {object[]} records - An array of records [{...}, {...}] for bulk insert
* @returns {object[]} The array of inserted records data
*/
async insertMany(records) {
return await this.db.insertMany(this.modelId, records)
}
/**
* Insert one record in the collection
*
* @async
* @param {object} record - A single record
* @returns {object} The inserted record data
*/
async insertOne(record) {
return await this.db.insertOne(this.modelId, record)
}
/**
* Update a single record in the collection
*
* @async
* @param {string} recordId
* @param {object} update
* @returns {object} The request's result
*/
async updateOne(recordId, update) {
return await this.db.updateOne(this.modelId, recordId, update)
}
/**
* Delete a record from the collection
*
* @async
* @param {string} recordId
* @param {boolean} [sendToTrash] - If true, keeps the original record in a "trash" collection
* @returns The request's result
*/
async deleteOne(recordId, sendToTrash) {
return await this.db.deleteOne(this.modelId, recordId, sendToTrash)
}
/**
* Update many records in a single collection
*
* @async
* @param {object} query
* @param {object} update
* @returns The request's result
*/
async updateMany(query, update) {
return await this.db.updateMany(this.modelId, query, update)
}
/**
* Delete many records from a collection
*
* @async
* @param {object} query
* @param {boolean} [sendToTrash] - If true, keeps the original record in a "trash" collection
* @returns The request's result
*/
async deleteMany(query, sendToTrash) {
await this.db.deleteMany(this.modelId, query, sendToTrash)
}
/**
* Insert some fake records in the collection, for testing purpose.
*
* It automatically uses the model's fields to generate fake data.
*
* @async
* @param {integer} numberOfRecords - Number of fake records to insert
* @returns {object[]} The array of inserted records data
*
* @example
* await myCollection.insertFakeRecords(100)
*/
async insertFakeRecords(numberOfRecords) {
return await kiss.db.insertFakeRecords(this.modelId, this.model.getFields(), numberOfRecords)
}
/**
* Delete the all fake records created with the method *createFakeRecords*
*
* @async
*
* @example
* await myCollection.deleteFakeRecords()
*/
async deleteFakeRecords() {
await kiss.db.deleteFakeRecords(this.modelId)
}
/**
* Get the records matching a query.
*
* Remember:
* - without a query parameter, it returns all the records of the collection.
* - the filter can be given as a normalized object which is easy to serialize / deserialize, or as a MongoDb query
* - the sort can be given as a normalized object, or as a MongoDb sort
* - in future releases of kissjs, the query syntax could be extended to "sql"
*
* For more details about the query object, check the example in the [db.find()](kiss.db.html#.find) api.
*
* Tech note:
* This method is the one generating the most http traffic, because it returns a collection of records.
* Due to the extremely loose coupling system of KissJS components, it can happen that many components
* are requesting the same collection at the same time, without knowing it.
*
* To solve this, the method is optimized to request the database only once using a combination of pubsub and Promise:
* - the 1st call is changing the collection state "isLoading" to true
* - because isLoading is now true, subsequent calls wait for the response of the 1st call inside a promise, which is waiting for the pubsub event "EVT_COLLECTION_LOADED"
* - when the 1st call has a result, it broadcasts the result in the "EVT_COLLECTION_LOADED" channel, then turns the collection state "isLoading" to false
* - when the subsequent calls receive the result in the pubsub, the promise resolves
*
* @async
* @param {object} [query] - Query object
* @param {*} [query.filter] - The query
* @param {string} [query.filterSyntax] - The query syntax. By default, passed as a normalized object
* @param {*} [query.sort] - Sort fields
* @param {string} [query.sortSyntax] - The sort syntax. By default, passed as a normalized array
* @param {string[]} [query.group] - Array of fields to group by: ["country", "city"]
* @param {boolean} [query.groupUnwind] - true to unwind the fields for records that belongs to multiple groups
* @param {object} [query.projection] - {firstName: 1, lastName: 1, password: 0}
* @param {object} [query.skip] - Number of records to skip
* @param {object} [query.limit] - Number of records to return
* @param {boolean} [nocache] - Force the collection to request the database instead of returning the cache
* @param {boolean} [nospinner] - Hide the loading spinner if true
* @returns {array} Array of records
*
* @example
* // Retrieves the records using the default or last used query parameters
* let myRecords = await myCollection.find()
*
* // Retrieves the records matching a MongoDb query
* let myRecords = await myCollection.find({
* filterSyntax: "mongo", // Means we use a standard MongoDb query syntax
* filter: {
* $and: [
* {yearOfBirth: 1980},
* {country: "USA"}
* ]
* },
* sortSyntax: "mongo",
* sort: {
* birthDate: 1,
* lastName: -1
* },
* group: ["state", "city"]
* skip: 200,
* limit: 100,
* )
*
* // Retrieves the records using a normalized query
* let myRecords = await myCollection.find({
* filterSyntax: "normalized",
* filter: {
* type: "group",
* operator: "and",
* filters: [
* {
* type: "filter",
* fieldId: "firstName",
* operator: "contains",
* value: "wilson"
* },
* {
* type: "filter",
* fieldId: "birthDate",
* operator: ">",
* value: "2020-01-01"
* }
* ]
* },
* sortSyntax: "normalized",
* sort: [
* {birthDate: "desc"},
* {lastName: "asc"}
* ],
* group: ["state", "city"],
* skip: 200,
* limit: 100,
* })
*/
async find(query = {}, nocache, nospinner) {
let loadingId
try {
// If the collection records haven't changed and cache is allowed, we return its current records
// TODO: test if the query is the same
if (this.isLoaded && this.hasChanged == false && nocache != true) {
log("kiss.data.Collection - find - " + this.id + " - Got data from CACHE", 2)
return this.records
}
// If the collection is already loading, we wait for the loading process to finish so that we can capture its result
// TODO: test if the query is the same
if (this.isLoading && !this.hasChanged) {
this.records = await new Promise((resolve, reject) => {
const subscriptionId = subscribe("EVT_COLLECTION_LOADED:" + this.id, (msgData) => {
kiss.pubsub.unsubscribe(subscriptionId)
resolve(msgData)
})
})
log("kiss.data.Collection - find - " + this.id + " - Got data from PUBSUB", 2)
return this.records
}
log("kiss.data.Collection - find - " + this.id)
if (this.showLoadingSpinner && nospinner != true) loadingId = kiss.loadingSpinner.show()
this.isLoading = true
this.isLoaded = false
this.hasChanged = false
this.cachedRecords = []
// Update filter, projection, sort, group, skip, limit, normalization
if (query.filter) this.filter = query.filter
if (query.filterSyntax) this.filterSyntax = query.filterSyntax
if (query.sort) this.sort = query.sort
if (query.sortSyntax) this.sortSyntax = query.sortSyntax
if (query.group) this.group = query.group
if (query.groupUnwind) this.groupUnwind = query.groupUnwind
if (query.projection) this.projection = query.projection
if (query.skip) this.skip = query.skip
if (query.limit) this.limit = query.limit
let search = {
operation: "search",
filter: this.filter,
filterSyntax: this.filterSyntax || "normalized",
sort: this.sort,
sortSyntax: this.sortSyntax || "normalized",
group: this.group,
groupUnwind: this.groupUnwind,
projection: this.projection,
skip: this.skip,
limit: this.limit
}
if (this.group.length != 0) {
// Case 1. Records are grouped by a field
this.collapsedGroups = []
this.groupedRecords = await this.db.find(this.modelId, search, this.mode)
this.groupedRecords = this.groupedRecords.map(record => this.model.create(record))
this.groupedRecords = this._groupBy(this.groupedRecords, this.group, this.groupUnwind)
// Convert the hierarchical structure of this.groupedRecords (a Map of Maps of Maps...)
// into a flat array where each group / sub-group is represented by a "group" row in the datatable
this._groupBuildHierarchy()
} else {
// Case 2. Records are not grouped
this.records = await this.db.find(this.modelId, search, this.mode)
this.records = this.records.map(record => this.model.create(record))
this.count = this.records.length
}
this.hasChanged = false
this.isLoading = false
this.isLoaded = true
// Broadcast result to parallel queries
publish("EVT_COLLECTION_LOADED:" + this.id, this.records)
log("kiss.data.Collection - find - " + this.id + " - Got data from DATABASE", 2)
if (this.showLoadingSpinner && nospinner != true) kiss.loadingSpinner.hide(loadingId)
return this.records
} catch (err) {
//if (!this.records) this.records = []
this.isLoaded = false
this.isLoading = false
this.hasChanged = false
if (this.showLoadingSpinner && nospinner != true) kiss.loadingSpinner.hide(loadingId)
return this.records
}
}
/**
* Get a single record of the collection ASYNCHRONOUSLY
*
* @async
* @param {string} recordId
* @param {boolean} nocache - If true, doesn't use cache
* @returns {object} The record, or false is not found
*
* @example
* const myRecord = await myCollection.findOne("Xzyww90sqxnllM38")
*/
async findOne(recordId, nocache) {
log(`kiss.data.Collection - findOne ${this.model.name} - ${this.id} - Record: ${recordId}`)
let record
if (this.isLoaded && !this.hasChanged && !nocache) {
log(`kiss.data.Collection - returning cached record`)
record = this.records.get(recordId)
}
if (!record) {
log(`kiss.data.Collection - retrieving record from db`)
let recordData = await this.db.findOne(this.modelId, recordId)
if (!recordData) return false
record = this.model.create(recordData)
}
return record
}
/**
* Get multiple records of the collection, found by id
*
* @async
* @param {string} recordIds - ids of the records to retrieve
* @param {object[]|object} [sort] - Sort options, as a normalized array or a Mongo object. Normalized example: [{fieldA: "asc"}, {fieldB: "desc"}]. Mongo example: {fieldA: 1, fieldB: -1}
* @param {string} [sortSyntax] - Sort syntax: "nomalized" | "mongo". Default is normalized
* @param {boolean} [nocache] - If true, doesn't use cache. Default is false
* @returns {object[]} The list of records, or false is not found
*
* @example
* const myRecord = await myCollection.findOne("Xzyww90sqxnllM38")
*/
async findById(recordIds, sort = [], sortSyntax = "normalized", nocache) {
log(`kiss.data.Collection - findById ${this.model.name} - ${this.id} - Records: ${recordIds}`)
let records = []
let missingRecordIds = [...recordIds]
if (this.isLoaded && !this.hasChanged && !nocache) {
log(`kiss.data.Collection - returning cached records`)
while (recordIds.length > 0) {
let recordId = recordIds.pop()
let record = this.records.get(recordId)
if (record) {
records.push(record)
missingRecordIds.pop()
}
}
if (missingRecordIds.length == 0) return records
}
if (missingRecordIds.length > 0) {
log(`kiss.data.Collection - retrieving missing records from db`)
let missingRecords = await this.db.findById(this.modelId, missingRecordIds, sort, sortSyntax)
if (!missingRecords) return false
records = records.concat(missingRecords)
records = records.map(record => this.model.create(record))
return records
}
}
/**
* Get a single record of the collection SYNCHRONOUSLY
*
* Important: the collection must be loaded before using this method, or it will return undefined
*
* @param {string} recordId
* @returns {object} The record
*
* @example
* const myRecord = myCollection.getRecord("Xzyww90sqxnllM38")
*/
getRecord(recordId) {
return this.records.get(recordId)
}
/**
* Filter the collection
*
* @async
* @param {object} filterConfig - Use MongoDb syntax
* @returns {object[]} Array of records
*
* @example
* await myCollection.filterBy({
* $and: [
* {yearOfBirth: 1980},
* {country: "USA"}
* ]
* })
*/
async filterBy(filterConfig) {
this.filter = filterConfig
this.hasChanged = true
await this.find()
return this.records
}
/**
* Sort the collection
*
* @async
* @param {object[]} sortConfig - Array of fields to sort by.
*
* @example
* await myCollection.sortBy(
* [
* {firstName: "asc"},
* {birthDate: "desc"}
* ]
* )
*/
async sortBy(sortConfig) {
this.sort = sortConfig
this.hasChanged = true
await this.find()
}
/**
* Set a groupBy field and reload the records
*
* @async
* @param {string[]} groupFields - Array of fields to group by.
*
* @example
* await myCollection.groupBy(["country", "city", "age"])
*/
async groupBy(groupFields) {
this.group = (groupFields.length != 0) ? groupFields : []
this.hasChanged = true
await this.find()
}
/**
* Expand all groups
*/
groupExpandAll() {
this.collapsedGroups = []
this.cachedRecords = {}
this._groupBuildHierarchy()
}
/**
* Collapse all groups
*/
groupCollapseAll() {
this.collapsedGroups = []
this.cachedRecords = {}
this._groupBuildHierarchy(true)
}
/**
* Group the records by 1 or multiple fields
*
* @private
* @ignore
* @param {object[]} records - Records to group
* @param {string[]} fieldIds - Array of field ids by which records should be grouped
* @param {boolean} groupUnwind - true to unwind fields with multiple values over multiple groups
* @returns {Map} - Where the Map key is the field value (= group) and the Map values are the records belonging to this group
*
* @note
* We've built different functions for performance reasons.
* Having a single function would have required to perform a test within huge loops, which costs extra processing
*/
_groupBy(records, fieldIds, groupUnwind) {
switch (fieldIds.length) {
case 1:
return this._groupBy1Field(records, fieldIds, groupUnwind)
break
case 2:
return this._groupBy2Fields(records, fieldIds, groupUnwind)
break
case 3:
return this._groupBy3Fields(records, fieldIds, groupUnwind)
break
case 4:
return this._groupBy4Fields(records, fieldIds, groupUnwind)
break
case 5:
return this._groupBy5Fields(records, fieldIds, groupUnwind)
break
case 6:
return this._groupBy6Fields(records, fieldIds, groupUnwind)
break
}
}
/**
* Group the records by 1 field.
* TODO: USE DIFFERENT GROUPERS DEPENDING ON FIELD TYPE TO AVOID TESTING INSIDE LOOPS!!!
*
* @private
* @ignore
* @param {object[]} records - Records to group
* @param {string[]} fieldIds - Array of field ids by which records should be grouped
* @param {boolean} groupUnwind - true to unwind fields with multiple values over multiple groups
* @returns {Map} Where the Map key is the field value (= group) and the Map values are the records belonging to this group
*/
_groupBy1Field(records, fieldIds, groupUnwind) {
const fieldId = fieldIds[0]
// Unwind group if the field is a multi-valued field:
// - select field with multiple = true
// - collaborator field with multiple = true
// if (groupUnwind) return this._groupBy1UnwoundField(records, fieldIds)
const groupField = this.model.getField(fieldId)
if (groupField.multiple) {
return this._groupBy1UnwoundField(records, fieldIds)
}
return records.reduce((map, record) => {
let value = record[fieldId]
if (Array.isArray(value)) value = value[0]
return map.set(value, [...map.get(value) || [], record])
}, new Map())
}
/**
* Virtualize a record to be able to assign it multiple $groupId values.
* This is used only for the purpose of aggregations on multi-values fields
*
* @private
* @ignore
* @param {object} record
* @returns The proxified record
*/
_proxifier(record) {
let groupId = null
return new Proxy(record, {
set(target, prop, value) {
if (prop == "$groupId") {
groupId = value
return true
}
target[prop] = value
return true
},
get(target, prop) {
if (prop == "$groupId") return groupId
else return target[prop]
}
})
}
/**
* Group the records by 1 field
* + unwind fields with multiple values
*
* @private
* @ignore
* @param {object[]} records - Records to group
* @param {string[]} fieldIds - Array of field ids by which records should be grouped
*/
_groupBy1UnwoundField(records, fieldIds) {
const fieldId = fieldIds[0]
return records.reduce((map, record) => {
[].concat(record[fieldId])
.forEach(value => {
map.set(value, [...map.get(value) || [], this._proxifier(record)]) // Build & feed a new category per unwound value
})
return map
}, new Map())
}
/**
* Group the records by 2 fields.
*
* @private
* @ignore
* @param {*} records - Records to group
* @param {string[]} fieldIds - Array of field ids by which records should be grouped
* @returns {Map} Where the Map key is the field value (= group) and the Map values are the records belonging to this group
*/
_groupBy2Fields(records, fieldIds) {
let map = new Map()
records.forEach(record => {
let groupLevel1 = record[fieldIds[0]]
if (Array.isArray(groupLevel1)) groupLevel1 = groupLevel1[0]
let groupLevel2 = record[fieldIds[1]]
if (Array.isArray(groupLevel2)) groupLevel2 = groupLevel2[0]
if (!map.get(groupLevel1)) map.set(groupLevel1, new Map())
map.get(groupLevel1).set(groupLevel2, [...map.get(groupLevel1).get(groupLevel2) || [], record])
})
return map
}
/**
* Group the records by 3 fields.
*
* @private
* @ignore
* @param {*} records - Records to group
* @param {string[]} fieldIds - Array of field ids by which records should be grouped
* @returns {Map} Where the Map key is the field value (= group) and the Map values are the records belonging to this group
*/
_groupBy3Fields(records, fieldIds) {
let map = new Map()
records.forEach(record => {
let groupLevel1 = record[fieldIds[0]]
if (Array.isArray(groupLevel1)) groupLevel1 = groupLevel1[0]
let groupLevel2 = record[fieldIds[1]]
if (Array.isArray(groupLevel2)) groupLevel2 = groupLevel2[0]
let groupLevel3 = record[fieldIds[2]]
if (Array.isArray(groupLevel3)) groupLevel3 = groupLevel3[0]
if (!map.get(groupLevel1)) map.set(groupLevel1, new Map())
if (!map.get(groupLevel1).get(groupLevel2)) map.get(groupLevel1).set(groupLevel2, new Map())
map.get(groupLevel1).get(groupLevel2).set(groupLevel3, [...map.get(groupLevel1).get(groupLevel2).get(groupLevel3) || [], record])
})
return map
}
/**
* Group the records by 4 fields.
*
* @private
* @ignore
* @param {*} records - Records to group
* @param {string[]} fieldIds - Array of field ids by which records should be grouped
* @returns {Map} Where the Map key is the field value (= group) and the Map values are the records belonging to this group
*/
_groupBy4Fields(records, fieldIds) {
let map = new Map()
records.forEach(record => {
let groupLevel1 = record[fieldIds[0]]
if (Array.isArray(groupLevel1)) groupLevel1 = groupLevel1[0]
let groupLevel2 = record[fieldIds[1]]
if (Array.isArray(groupLevel2)) groupLevel2 = groupLevel2[0]
let groupLevel3 = record[fieldIds[2]]
if (Array.isArray(groupLevel3)) groupLevel3 = groupLevel3[0]
let groupLevel4 = record[fieldIds[3]]
if (Array.isArray(groupLevel4)) groupLevel4 = groupLevel4[0]
if (!map.get(groupLevel1)) map.set(groupLevel1, new Map())
if (!map.get(groupLevel1).get(groupLevel2)) map.get(groupLevel1).set(groupLevel2, new Map())
if (!map.get(groupLevel1).get(groupLevel2).get(groupLevel3)) map.get(groupLevel1).get(groupLevel2).set(groupLevel3, new Map())
map.get(groupLevel1).get(groupLevel2).get(groupLevel3).set(groupLevel4, [...map.get(groupLevel1).get(groupLevel2).get(groupLevel3).get(groupLevel4) || [], record])
})
return map
}
/**
* Group the records by 5 fields.
*
* @private
* @ignore
* @param {*} records - Records to group
* @param {string[]} fieldIds - Array of field ids by which records should be grouped
* @returns {Map} Where the Map key is the field value (= group) and the Map values are the records belonging to this group
*/
_groupBy5Fields(records, fieldIds) {
let map = new Map()
records.forEach(record => {
let groupLevel1 = record[fieldIds[0]]
if (Array.isArray(groupLevel1)) groupLevel1 = groupLevel1[0]
let groupLevel2 = record[fieldIds[1]]
if (Array.isArray(groupLevel2)) groupLevel2 = groupLevel2[0]
let groupLevel3 = record[fieldIds[2]]
if (Array.isArray(groupLevel3)) groupLevel3 = groupLevel3[0]
let groupLevel4 = record[fieldIds[3]]
if (Array.isArray(groupLevel4)) groupLevel4 = groupLevel4[0]
let groupLevel5 = record[fieldIds[4]]
if (Array.isArray(groupLevel5)) groupLevel5 = groupLevel5[0]
if (!map.get(groupLevel1)) map.set(groupLevel1, new Map())
if (!map.get(groupLevel1).get(groupLevel2)) map.get(groupLevel1).set(groupLevel2, new Map())
if (!map.get(groupLevel1).get(groupLevel2).get(groupLevel3)) map.get(groupLevel1).get(groupLevel2).set(groupLevel3, new Map())
if (!map.get(groupLevel1).get(groupLevel2).get(groupLevel3).get(groupLevel4)) map.get(groupLevel1).get(groupLevel2).get(groupLevel3).set(groupLevel4, new Map())
map.get(groupLevel1).get(groupLevel2).get(groupLevel3).get(groupLevel4).set(groupLevel5, [...map.get(groupLevel1).get(groupLevel2).get(groupLevel3).get(groupLevel4).get(groupLevel5) || [], record])
})
return map
}
/**
* Group the records by 6 fields.
*
* @private
* @ignore
* @param {*} records - Records to group
* @param {string[]} fieldIds - Array of field ids by which records should be grouped
* @returns {Map} Where the Map key is the field value (= group) and the Map values are the records belonging to this group
*/
_groupBy6Fields(records, fieldIds) {
let map = new Map()
records.forEach(record => {
let groupLevel1 = record[fieldIds[0]]
if (Array.isArray(groupLevel1)) groupLevel1 = groupLevel1[0]
let groupLevel2 = record[fieldIds[1]]
if (Array.isArray(groupLevel2)) groupLevel2 = groupLevel2[0]
let groupLevel3 = record[fieldIds[2]]
if (Array.isArray(groupLevel3)) groupLevel3 = groupLevel3[0]
let groupLevel4 = record[fieldIds[3]]
if (Array.isArray(groupLevel4)) groupLevel4 = groupLevel4[0]
let groupLevel5 = record[fieldIds[4]]
if (Array.isArray(groupLevel5)) groupLevel5 = groupLevel5[0]
let groupLevel6 = record[fieldIds[5]]
if (Array.isArray(groupLevel6)) groupLevel6 = groupLevel6[0]
if (!map.get(groupLevel1)) map.set(groupLevel1, new Map())
if (!map.get(groupLevel1).get(groupLevel2)) map.get(groupLevel1).set(groupLevel2, new Map())
if (!map.get(groupLevel1).get(groupLevel2).get(groupLevel3)) map.get(groupLevel1).get(groupLevel2).set(groupLevel3, new Map())
if (!map.get(groupLevel1).get(groupLevel2).get(groupLevel3).get(groupLevel4)) map.get(groupLevel1).get(groupLevel2).get(groupLevel3).set(groupLevel4, new Map())
if (!map.get(groupLevel1).get(groupLevel2).get(groupLevel3).get(groupLevel4).get(groupLevel5)) map.get(groupLevel1).get(groupLevel2).get(groupLevel3).get(groupLevel4).set(groupLevel5, new Map())
map.get(groupLevel1).get(groupLevel2).get(groupLevel3).get(groupLevel4).get(groupLevel5).set(groupLevel6, [...map.get(groupLevel1).get(groupLevel2).get(groupLevel3).get(groupLevel4).get(groupLevel5).get(groupLevel6) || [], record])
})
return map
}
/**
* Organize records grouped by a list of fields.
* Each group is reduced to a single Map object which <key> is the value of the grouped field, and <values> are the records of the group.
* For multi-level nested group, the values of intermediate levels are arrays of Maps, and only the last level holds the records.
*
* @private
* @ignore
* @param {boolean} collapsed - Indicates wether the hierarchy of groups should be collapsed or expanded
*/
_groupBuildHierarchy(collapsed) {
this.records = []
this.recordIndex = 0
// Get the list of <number> fields
// Those fields will be aggregated automatically
this.numberFields = this.model.getFields().filter(field => kiss.tools.isNumericField(field))
// Initiate the group level index
// It's a n dimension array where each position corresponds to a depth into the hierarchy of groups
// Example: [2,4] represents the fourth group within the second group.
this.levelIndex = []
// Start the analysis
this._groupParse(this.groupedRecords, {}, 0, collapsed)
this.count = this.records.length
}
/**
* TODO: Work in progress for real-time update of aggregations
* Recompute aggregations (sum, average...) for a group and its parent
* @private
* @ignore
* @param {string} groupId
*/
_groupUpdateAggregations(groupId) {
const numberFieldIds = this.numberFields.map(field => field.id)
const groups = this.records.filter(record => record.$type == "group")
const visibleRecords = this.records.filter(record => record.$groupId == groupId)
// log(this.cachedRecords)
// const hiddenRecords = this.cachedRecords[groupId].filter(record => record.$groupId == groupId)
// log(visibleRecords)
// log(hiddenRecords)
// log(this.groupedRecords)
// log(groupId)
groups.forEach(group => {
// log(group)
// const groupRecords = this.groupedRecords.get(groupId)
numberFieldIds.forEach(fieldId => {
// log(">>>>>>>" + fieldId)
// log(group[fieldId])
})
})
}
/**
* Analyzes a data group and does a few things:
* - injects a "group" record into the dataset (this will allow the view to render it differently compared to normal records)
* - generates a groupId (example: 2.1.3) to keep track of the group into the source dataset (this.groupedRecords)
* - computes the number of records within the group
* - performs aggregations for number fields
* - calls itself recursively to manage nested groups
*
* @private
* @ignore
* @param {Map} group - Map where each key represents a group of records
* @param {object} parentGroupData - Data representing the group, and injected as a "fake" record into the dataset
* @param {number} groupLevel - 0-based group depth
* @param {boolean} collapsed - If true, the records of the group are not injected into the dataset, but rather hold in a cache used when expanding the group
*/
_groupParse(group, parentGroupData, groupLevel, collapsed) {
this.levelIndex[groupLevel] = 0
group.forEach((subgroup, key, map) => {
// Compute the groupId, which is simply its level in the hierarchy. Examples: 7, or 6.5, or 4.3.2.1
this.levelIndex[groupLevel] += 1
const groupId = this.levelIndex.slice(0, groupLevel + 1).join(".")
// Populate the groupId into the source dataset
group.get(key).groupId = groupId
// Add a "group" record into the dataset
const subGroupData = {
$type: "group",
$groupLevel: groupLevel,
$groupId: groupId,
$size: subgroup.length,
$name: key
}
if (!collapsed || groupLevel == 0) {
this.records.push(subGroupData)
} else {
// If the group is collapsed (and not level 0), we put its data in cache instead of inserting it into the datatable
this.cachedRecords[parentGroupData.$groupId] = (this.cachedRecords[parentGroupData.$groupId] || []).concat(subGroupData)
}
if (collapsed) this.collapsedGroups.push(groupId)
if (subgroup instanceof Map) {
// If the group is a Map,
// it means it's a sub-group in the aggregation.
// We parse this subgroup recursively
const deeperSubGroup = this._groupParse(subgroup, subGroupData, groupLevel + 1, collapsed)
// Compute the number of records for this group
parentGroupData.$size = (parentGroupData.$size || 0) + deeperSubGroup.$size
// Perform aggregations for number fields
this.numberFields.forEach(field => {
const parentSum = (parentGroupData[field.id]) ? (parentGroupData[field.id].sum || 0) + deeperSubGroup[field.id].sum : deeperSubGroup[field.id].sum
parentGroupData[field.id] = {
sum: parentSum,
avg: parentSum / parentGroupData.$size
}
})
} else {
// ...else, the group is an Array,
// which means we've reach the last level in the hierarchy, and group items are records.
// Compute the number of records for this group
parentGroupData.$size = (parentGroupData.$size || 0) + subgroup.length
// Perform aggregations for number fields
this.numberFields.forEach(field => {
const sum = subgroup.reduce((sum, record) => {
return sum + (Number(record[field.id]) || 0)
}, 0)
subGroupData[field.id] = {
sum,
avg: sum / subgroup.length
}
const parentSum = (parentGroupData[field.id]) ? (parentGroupData[field.id].sum || 0) + sum : sum
parentGroupData[field.id] = {
sum: parentSum,
avg: parentSum / parentGroupData.$size
}
})
// Assign the groupId to all records belonging to this group
subgroup.forEach(record => {
record.$groupId = groupId + "."
record.$index = this.recordIndex++
// subGroupData.$index = (subGroupData.$index || []).concat(record)
})
if (!collapsed) {
this.records.push(...subgroup)
} else {
// If the group is collapsed, we put its data in cache instead of inserting it into the datatable
this.cachedRecords[groupId] = subgroup
}
}
})
return parentGroupData
}
/**
* Expand a group.
*
* Internally, it retrieves all the hidden records which are hold in cache, and reinjects them in the list of records.
*
* @param {string} groupId - Id of the group to expand/collapse. Example: 3.10.7
* @param {number} rowIndex - Index of the group row into the datatable
*/
groupExpand(groupId, rowIndex) {
this.collapsedGroups.remove(groupId)
// Re-insert the records of the group, at the right row index
let recordsToInsert = this.cachedRecords[groupId]
this.records.splice(Number(rowIndex) + 1, 0, ...recordsToInsert)
// Re-compute record indexes
let index = 0
this.records.forEach(record => {
if (record.$type != "group") record.$index = index++
})
this.count = this.records.length
}
/**
* Collapse a group.
*
* Internally, it does 2 things:
* - builds a new dataset without the collapsed records
* - stores in cache the records that are excluded from the dataset, to use them when expanding the group again
*
* @param {string} groupId - Id of the group to expand/collapse. Example: 3.10.7
*/
groupCollapse(groupId) {
let recordsKept = []
let recordsCached = []
let groupIdToSearch = groupId + "."
this.records.forEach(record => {
if (!(record.$groupId.startsWith(groupIdToSearch))) {
// Records to keep
recordsKept.push(record)
} else {
// Records to exclude and cache
recordsCached.push(record)
}
})
this.records = recordsKept
this.cachedRecords[groupId] = recordsCached
// Re-compute the visible records indexes
let index = 0
this.records.forEach(record => {
if (record.$type != "group") record.$index = index++
})
this.count = this.records.length
// Keep track of the collapsed group
this.collapsedGroups.push(groupId)
}
}
;
Source