Source

common/dataRelations.js

/**
 * 
 * kiss.relations
 * 
 * This module handles the relationships between models inside a NoSQL environment.
 * 
 * Context:
 * 
 * In modern applications, we are used to update the database as soon as a single field is modified.
 * It brings a better user experience than filling a bunch of fields then click on a "Save" button.
 * It also prevents too much data from being lost in case of a crash when a user is filling tons of data.
 * 
 * Problem:
 * 
 * In NoSQL, databases are denormalized: data is redundant accross records.
 * When the field's value of a record depends on foreign records, we need some logic to keep data in sync between records.
 * 
 * To keep things simple, we will say that some fields can be a "source" for other fields.
 * We simply call them: "source fields".
 * When a source field is updated, we need to update all the fields that depends on this source.
 * 
 * In business applications, we have identified 2 common scenarios:
 * - **lookup** fields:
 *   A lookup field takes its value inside another field of a foreign record.
 *   When the value of the foreign source field is updated, the lookup field must be updated too.
 * 
 * - **summary** fields:
 *   A summary field summarizes (aggregates) data of multiple foreign records.
 *   When the value of a single foreign source field is updated, the summary field must re-compute the whole aggregation
 * 
 * Solution:
 * 
 * When a field is updated, we track all the mutations that are triggered:
 * 1. inside the same record (because of computed fields)
 * 2. inside foreign records (because of the relationships between records, defined by special "link" records)
 * 
 * To fit all possible scenarios (1-1, 1-N, N-N), KissJS manage all relations as N-N.
 * The links are maintained in a single external table: the "link" table.
 * 
 * When a **lookup** field or a **summary** field needs to be computed, the process is:
 * - search for the foreign records (thanks to the **link** fields of the record)
 * - get data from foreign records
 * - compute the new field value, but **do not** update the field immediately
 * - instead, add the upcoming change to a transaction
 * 
 * Because a field change can trigger a chain reaction over other computed fields,
 * the process is called recursively until there is no more field to update.
 * 
 * At each cycle, the change is added to the transaction.
 * At the end, the transaction is processed, performing all required database mutations at once.
 * 
 * To boost performances, an architectural choice was to load every links into cache,
 * amd maintain this cache each time a link is added or removed.
 * For bigger applications with tens of thousands of records and links,
 * this choice might need some optimization process.
 * 
 */
kiss.data.relations = {
    /**
     * Connect 2 models together by adding a LINK field on each side.
     * 
     * @param {string} modelIdA 
     * @param {string} modelIdB 
     * @param {string} cardinality - "11" | "1N" | "N1" | "NN"
     */
    async connectModels(modelIdA, modelIdB, cardinality) {
        const modelA = kiss.app.models[modelIdA]
        const modelB = kiss.app.models[modelIdB]

        // Prepare link fields
        const linkIdA = kiss.tools.shortUid()
        const linkIdB = kiss.tools.shortUid()
        const cardinalityA = cardinality[0]
        const cardinalityB = cardinality[1]

        let linkFieldA = {
            id: linkIdA,
            type: "link",
            label: (cardinalityB == "N") ? modelB.namePlural : modelB.name,
            multiple: (cardinalityB == "N") ? true : false,
            link: {
                modelId: modelIdB,
                fieldId: linkIdB
            }
        }

        let linkFieldB = {
            id: linkIdB,
            type: "link",
            label: (cardinalityA == "N") ? modelA.namePlural : modelA.name,
            multiple: (cardinalityA == "N") ? true : false,
            link: {
                modelId: modelIdA,
                fieldId: linkIdA
            }
        }

        // Check if models already have connections together
        const modelAlinks = modelA.getFieldsByType("link")
        const modelBlinks = modelB.getFieldsByType("link")

        linkAtoB = []
        modelAlinks.forEach(field => {
            if (field.link.modelId == modelB.id) linkAtoB.push(field)
        })

        linkBtoA = []
        modelBlinks.forEach(field => {
            if (field.link.modelId == modelA.id) linkBtoA.push(field)
        })

        // Set operations to link the 2 models
        let AhasLinkToB = (linkAtoB.length > 0)
        let BhasLinkToA = (linkBtoA.length > 0)

        if (AhasLinkToB) {
            linkFieldA = linkAtoB[0]
            linkFieldA.label = (cardinalityB == "N") ? modelB.namePlural : modelB.name
            linkFieldA.multiple = (cardinalityB == "N") ? true : false
        }

        if (BhasLinkToA) {
            linkFieldB = linkBtoA[0]
            linkFieldB.label = (cardinalityA == "N") ? modelA.namePlural : modelA.name
            linkFieldB.multiple = (cardinalityA == "N") ? true : false
        }

        await Promise.all([
            (AhasLinkToB) ? modelA.updateField(linkFieldA.id, linkFieldA) : modelA.addField(linkFieldA),
            (BhasLinkToA) ? modelB.updateField(linkFieldB.id, linkFieldB) : modelB.addField(linkFieldB)
        ])
    },

    /**
     * Update relationships of a model
     * 
     * @param {string} modelId 
     */
    update(modelId) {
        const model = kiss.app.models[modelId]
        if (model) {
            model._defineRelationships()
            log("kiss.data.relations - Building relationships for model " + model.name)
        }
    },

    /**
     * Update a single field or all fields of a record, and propagate the changes:
     * - to the computed fields of the same records
     * - to the **lookup** and **summary** fields of foreign records
     * 
     * All the database mutations are processed inside a single transaction
     * 
     * @param {object} model 
     * @param {object} record 
     * @param {string} [update] - If not specified, update all fields
     * @param {string} userId - user who updated the record
     * @returns The transaction result
     */
    async updateOneDeep(model, record, update, userId) {
        // Prepare temp cache
        const cacheId = kiss.tools.uid()
        kiss.cache[cacheId] = {}
        kiss.cache[cacheId].deletedRecords = []

        try {
            const transaction = new kiss.data.Transaction({
                userId
            })

            await kiss.data.relations.computeTransactionToUpdate(model, record, update, transaction, cacheId)
            const operations = await transaction.process()

            // Clear cache
            delete kiss.cache[cacheId]

            return operations

        } catch (err) {
            log.err("kiss.dataRelations - updateOneDeep - Error:", err)
            return []
        }
    },

    /**
     * Re-compute computed fields on a selection of records of a collection.
     * 
     * Note: this is used when updating data from an XLS or a CSV file.
     * 
     * @param {string} modelId
     * @param {string[]} ids - ids of the records to update
     * @returns The transaction result
     */
    async updateManyDeep(modelId, ids) {
        try {
            const model = kiss.app.models[modelId]
            const transaction = new kiss.data.Transaction()
            const records = await kiss.db.findById(modelId, ids)

            const cacheId = "cache-" + kiss.tools.uid()
            await kiss.data.relations.buildCache(cacheId, modelId, records)

            for (const record of records) {
                await kiss.data.relations.computeTransactionToUpdate(model, record, null, transaction, cacheId)
            }

            const operations = await transaction.process()

            // Clear cache
            delete kiss.cache[cacheId]

            return operations

        } catch (err) {
            log.err("kiss.dataRelations - updateManyDeep - Error:", err)
            return []
        }
    },

    /**
     * Re-compute computed fields on all records of a collection.
     * 
     * Note: this is used when changing a computed field formula.
     * 
     * @param {string} modelId
     * @returns The transaction result
     */
    async updateAllDeep(modelId) {
        try {
            const model = kiss.app.models[modelId]
            const transaction = new kiss.data.Transaction()
            const records = await kiss.db.find(modelId, {})

            const cacheId = "cache-" + kiss.tools.uid()
            await kiss.data.relations.buildCache(cacheId, modelId, records)

            for (const record of records) {
                await kiss.data.relations.computeTransactionToUpdate(model, record, null, transaction, cacheId)
            }

            const operations = await transaction.process()

            // Clear cache
            delete kiss.cache[cacheId]

            return operations

        } catch (err) {
            log.err("kiss.dataRelations - updateAllDeep - Error:", err)
            return []
        }
    },

    /**
     * Build a cache of a set of records used for updateAllDeep and updateManyDeep operations.
     * Without pre-caching, these operations would trigger a HUGE number of database requests.
     * 
     * @param {string} cacheId 
     * @param {string} modelId 
     */
    async buildCache(cacheId, modelId) {
        kiss.cache[cacheId] = {}
        kiss.cache[cacheId].deletedRecords = []

        // Whatever happen, we need to clear this cache at some time
        setTimeout(() => {
            log.ack("kiss.data.relations - Cleaning cache " + cacheId)
            delete kiss.cache[cacheId]
        }, 60 * 1000)

        if (!cacheId.startsWith("cache")) return

        const model = kiss.app.models[modelId]
        console.log("kiss.data.relations - Building cache for relationships of model: " + model.name)

        const accountId = model.accountId
        const modelsToExplore = Object.values(kiss.app.models).filter(model => model.accountId == accountId)
        const connectedModelsToCache = kiss.data.relations.getConnectedModels(modelId, modelsToExplore, [model.id])
        const modelsToCache = [model].concat(connectedModelsToCache)

        let count = 0

        for (let modelToCache of modelsToCache) {
            kiss.cache[cacheId][modelToCache.id] = {}
            const records = await kiss.db.find(modelToCache.id, {})
            records.forEach(record => {
                count++
                kiss.cache[cacheId][modelToCache.id][record.id] = record
            })
        }

        console.log(`kiss.data.cache - ${count} records cached from ${modelsToCache.length} collections:`)
        modelsToCache.forEach(model => {
            console.log(`kiss.data.cache - ${model.name} - ${Object.keys(kiss.cache[cacheId][model.id]).length} records`)
        })
    },

    /**
     * Get all the models connected to a model
     */
    getConnectedModels(modelId, modelsToExplore, exploredModels) {
        let connectedModels = modelsToExplore.filter(model => !exploredModels.includes(model.id) && model.sourceFor && model.sourceFor.includes(modelId))
        if (connectedModels.length == 0) return []

        exploredModels = exploredModels.concat(connectedModels.map(model => model.id))

        for (let connectedModel of connectedModels) {
            let deeperModels = kiss.data.relations.getConnectedModels(connectedModel.id, modelsToExplore, exploredModels)
            connectedModels = connectedModels.concat(deeperModels)
        }

        return connectedModels
    },

    /**
     * !NOT USED AT THE MOMENT, TOO CPU INTENSIVE
     * Build a cache exploring the links between records
     */
    async buildSmartCache(cacheId, modelId, records) {
        kiss.cache[cacheId] = {}
        kiss.cache[cacheId].deletedRecords = []

        // Whatever happen, we need to clear this cache at some time
        setTimeout(() => {
            if (kiss.cache[cacheId]) {
                log("kiss.data.relations - Cleaning cache " + cacheId)
                delete kiss.cache[cacheId]
            }
        }, 60 * 1000)

        if (!cacheId.startsWith("cache")) return

        // Expore all links of all records
        let links = []
        let exploredNodes = []

        for (const record of records) {
            const recordLinks = kiss.data.relations.getRelationTree(modelId, record.id, exploredNodes)
            links = links.concat(recordLinks)
            exploredNodes = links.map(link => link.recordId).unique()
        }

        // Group links by model
        const linksByModel = links.reduce((map, link) => {
            map[link.modelId] = map[link.modelId] || {}
            map[link.modelId][link.recordId] = 0
            return map
        }, {})

        // Retrieve all the records we must cache from db
        for (modelId of Object.keys(linksByModel)) {
            if (kiss.app.models[modelId]) {
                const cachedRecords = await kiss.db.findById(modelId, Object.keys(linksByModel[modelId]))
                kiss.cache[cacheId][modelId] = {}
                cachedRecords.forEach(record => {
                    kiss.cache[cacheId][modelId][record.id] = record
                })
            }
        }

        console.log("kiss.data.relations - Explored links: " + links.length)
        console.log("kiss.data.relations - Unique links: " + links.map(link => link.recordId).unique().length)
    },

    /**
     * !NOT USED AT THE MOMENT, TOO CPU INTENSIVE
     * Get the tree of all relations of a record
     * 
     * @param {string} modelId 
     * @param {string} recordId 
     * @param {string[]} exploredNodes 
     * @param {number} depth 
     * @returns {object[]} Array of links
     */
    getRelationTree(modelId, recordId, exploredNodes = [], depth = 0) {
        const model = kiss.app.models[modelId]
        if (!model) return []

        exploredNodes.push(recordId)
        let nodeLinks = kiss.data.relations.getLinks(modelId, recordId)

        nodeLinks = nodeLinks.filter(link => {
            const foreignModel = kiss.app.models[link.modelId]
            if (!foreignModel) return false
            return !exploredNodes.includes(link.recordId) && foreignModel.sourceFor.includes(modelId)
        })

        let allLinks = []

        for (link of nodeLinks) {
            exploredNodes.push(link.recordId)
            const foreignLinks = kiss.data.relations.getRelationTree(link.modelId, link.recordId, exploredNodes, exploredLinks, depth)
            exploredNodes = exploredNodes.concat(foreignLinks.map(foreignLink => foreignLink.recordId))
            allLinks = allLinks.concat(foreignLinks)
        }

        allLinks = allLinks
            .concat(nodeLinks)
            .concat({
                modelId,
                recordId
            })
        return allLinks
    },

    /**
     * Re-compute computed fields on 2 records connected by a link.
     * 
     * Note: this is used when linking / unlinking records together.
     * 
     * @param {object} linkRecord
     * @param {string} userId - user who linked / unlinked the records
     * @returns The transaction result
     */
    async updateLink(linkRecord, userId) {
        let recordX
        let recordY

        const {
            id,
            mX,
            rX,
            fX,
            mY,
            rY,
            fY
        } = linkRecord

        const modelX = kiss.app.models[mX]
        const modelY = kiss.app.models[mY]

        if (kiss.isServer) {
            recordX = await kiss.db.findOne(mX, {
                _id: rX
            })
            recordY = await kiss.db.findOne(mY, {
                _id: rY
            })
        } else {
            recordX = await kiss.db.findOne(mX, rX)
            recordY = await kiss.db.findOne(mY, rY)
        }

        // Temp cache
        const cacheId = kiss.tools.uid()
        kiss.cache[cacheId] = {}
        kiss.cache[cacheId].deletedRecords = []

        const transaction = new kiss.data.Transaction({
            userId
        })
        await kiss.data.relations.computeTransactionToUpdate(modelX, recordX, null, transaction, cacheId)
        await kiss.data.relations.computeTransactionToUpdate(modelY, recordY, null, transaction, cacheId)
        const operations = await transaction.process()

        // Clear cache
        delete kiss.cache[cacheId]

        return operations
    },

    /**
     * Update all the foreign records of a given record
     * 
     * @param {string} modelId 
     * @param {string} recordId 
     * @returns The transaction result
     */
    async updateForeignRecords(modelId, recordId) {
        // Prepare temp cache
        const cacheId = kiss.tools.uid()
        kiss.cache[cacheId] = {}
        kiss.cache[cacheId].deletedRecords = []

        const transaction = new kiss.data.Transaction()
        await kiss.data.relations.computeTransactionToUpdateForeignRecords(modelId, recordId, transaction, cacheId)
        const operations = await transaction.process()

        // Clear cache
        delete kiss.cache[cacheId]

        return operations
    },

    /**
     * Update all the foreign records of multiple records.
     * Currently used by deleteMany operation, which can trigger multiple mutations on foreign records
     * 
     * @param {string} modelId 
     * @param {string[]} ids
     * @returns The transaction result
     */
    async updateForeignRecordsForMultipleRecords(modelId, ids) {
        // Prepare temp cache
        const cacheId = kiss.tools.uid()
        kiss.cache[cacheId] = {}
        kiss.cache[cacheId].deletedRecords = []

        const transaction = new kiss.data.Transaction()
        for (recordId of ids) {
            await kiss.data.relations.computeTransactionToUpdateForeignRecords(modelId, recordId, transaction, cacheId)
        }
        const operations = await transaction.process()

        // Clear cache
        delete kiss.cache[cacheId]

        return operations
    },

    /**
     * Compute the transaction to update all the foreign records linked to a specific record
     * 
     * @param {object} modelId
     * @param {object} recordId
     */
    async computeTransactionToUpdateForeignRecords(modelId, recordId, transaction, cacheId) {
        const model = kiss.app.models[modelId]
        const linkFields = model.fields.filter(field => field.type == "link")

        // For each "link" fields...
        for (const linkField of linkFields) {
            const foreignModel = kiss.app.models[linkField.link.modelId]

            // ... get the foreign records given by this "link" field
            const foreignRecords = await kiss.data.relations.getLinkedRecordsFrom(modelId, recordId, linkField.id, transaction, cacheId)

            for (const foreignRecord of foreignRecords) {
                await kiss.data.relations.computeTransactionToUpdate(foreignModel, foreignRecord, null, transaction, cacheId)
            }
        }
    },

    /**
     * Compute the transaction to update a record with its relationships
     * 
     * The method is recursive: for each update, it re-checks which computed fields is impacted by the new change.
     * This method does **not** update the fields, but only returns the changes to apply to the record.
     * All the field updates are performed later in a single transaction.
     * 
     * @param {object} model
     * @param {object} record 
     * @param {string} [update]
     * @param {object} transaction 
     */
    async computeTransactionToUpdate(model, record, update, transaction, cacheId) {
        let recordUpdates = {}

        // Update the record if it's specified
        if (update) {
            Object.assign(record, update)
            recordUpdates = update
        }

        // Recompute other fields of the same record, then cache all the updates to be done for this record
        recordUpdates = await kiss.data.relations._computeFields(model, record, update, recordUpdates, 0, transaction, cacheId)

        // Remove empty properties from the updates to perform
        Object.keys(recordUpdates).forEach(property => {
            if (recordUpdates[property] == undefined) delete recordUpdates[property]
        })

        // No updates to perform: exit
        if (Object.keys(recordUpdates).length == 0) return

        // Add operations to the global transaction
        transaction.addOperation({
            modelId: model.id,
            recordId: record.id,
            updates: recordUpdates
        })

        if (!kiss.global.ops) kiss.global.ops = 0
        kiss.global.ops++

        // Define all the foreign models impacted by this update.
        // For each of them, store the impacted fields too
        let foreignModelTargetFields = {}

        Object.keys(recordUpdates).forEach(updatedFieldId => {
            const field = model.getField(updatedFieldId)
            if (field && field.sourceFor) {
                field.sourceFor.forEach(source => {
                    foreignModelTargetFields[source.modelId] = foreignModelTargetFields[source.modelId] || []
                    foreignModelTargetFields[source.modelId].push(source.fieldId)
                })
            }
        })

        // Loop over foreign models
        for (const foreignModelId of Object.keys(foreignModelTargetFields)) {
            const foreignModel = kiss.app.models[foreignModelId]
            const fieldsToUpdateInForeignRecord = foreignModelTargetFields[foreignModelId]
            const linkField = model.getLinkField(foreignModelId)

            if (linkField) {
                const foreignRecordsToUpdate = await kiss.data.relations.getLinkedRecordsFrom(model.id, record.id, linkField.id, transaction, cacheId)

                // Loop over foreign records
                for (const foreignRecord of foreignRecordsToUpdate) {

                    // Loop over foreign fields
                    for (const foreignFieldId of fieldsToUpdateInForeignRecord) {
                        const foreignField = foreignModel.getField(foreignFieldId)
                        const newForeignRecordValue = await kiss.data.relations._computeField(foreignModel, foreignRecord, foreignField, transaction, cacheId)

                        // The new value might impact other fields, so, we recursively update the impacted fields
                        let foreignFieldUpdate = {}
                        foreignFieldUpdate[foreignFieldId] = newForeignRecordValue
                        await kiss.data.relations.computeTransactionToUpdate(foreignModel, foreignRecord, foreignFieldUpdate, transaction, cacheId)
                    }
                }
            }
        }
    },

    /**
     * Calculate the computed fields values based on their source fields
     * (source fields = other fields involved in their formula).
     * 
     * BEWARE:
     * Highly sensitive recursive algorithm.
     * Any mistake while updating this code can impact the NoSQL relational model deeply.
     * 
     * @ignore
     * @private
     * @async
     * @param {object} model
     * @param {object} record
     * @param {string} [update] - original update which triggered the re-compute. If not passed, recomputes all fields.
     * @param {object} changes
     * @param {number} depth - Max number of iterations in the recursive loop
     * @returns {object} Object containing all updates to perform on the record after all the computed fields have been recalculated
     */
    async _computeFields(model, record, update, changes = {}, depth = 0, transaction, cacheId) {
        // Limit the field dependency depth to 10 to avoid infinite loops
        if (depth > 10) {
            return changes
        }
        depth++

        const updatedFieldIds = (update) ? Object.keys(update) : []
        const recomputeAllFields = (updatedFieldIds.length == 0)

        for (let computedFieldId of model.computedFields) {
            let skip = false
            const computedField = model.getField(computedFieldId)

            // Check if the computed field's formula relies on the field that has changed.
            // If yes => re-compute the computed field value
            if (
                !updatedFieldIds.includes(computedFieldId) &&
                (
                    recomputeAllFields ||
                    kiss.tools.intersects(computedField.formulaSourceFieldIds, updatedFieldIds)
                )
            ) {
                let newComputedFieldValue = await kiss.data.relations._computeField(model, record, computedField, transaction, cacheId)

                if (
                    newComputedFieldValue === undefined ||
                    newComputedFieldValue === record[computedField.id] ||
                    (kiss.tools.isNumericField(computedField) && isNaN(newComputedFieldValue))
                ) skip = true

                if (!skip) {
                    record[computedField.id] = changes[computedField.id] = newComputedFieldValue
                    await kiss.data.relations._computeFields(model, record, changes, changes, depth, transaction, cacheId)
                }
            }
        }
        return changes
    },

    /**
     * Compute a single field
     * 
     * @ignore
     * @private
     * @async
     * @param {object} model 
     * @param {object} record 
     * @param {object} field 
     * @param {object} [transaction]
     * @returns The new value or undefined in case of error
     */
    async _computeField(model, record, field, transaction, cacheId) {
        try {
            let newValue

            switch (field.type) {
                case "lookup":
                    newValue = await kiss.data.relations._computeLookupField(model.id, record.id, field.id, transaction, cacheId)
                    break
                case "summary":
                    newValue = await kiss.data.relations._computeSummaryField(model.id, record.id, field.id, transaction, cacheId)
                    break
                default:
                    newValue = kiss.formula.execute(field.formula, record, model.getActiveFields())
            }

            // console.log(`... updating field: ${field.label} - New value: ${newValue}`)
            return newValue

        } catch (err) {
            // log.err("kiss.db - computeField - Error:", err)
        }
    },

    /**
     * Compute a **lookup** field
     * 
     * A lookup field is taking its value from another field inside a foreign record
     * 
     * @private
     * @ignore
     * @param {string} modelId
     * @param {string} recordId
     * @param {string} fieldId
     * @param {object} transaction
     * @returns {*} The value(s) found in the foreign record
     */
    async _computeLookupField(modelId, recordId, fieldId, transaction, cacheId) {
        const model = kiss.app.models[modelId]
        const field = model.getField(fieldId)

        // Get the foreign records associated to the <link> field
        const foreignRecords = await kiss.data.relations.getLinkedRecordsFrom(modelId, recordId, field.lookup.linkId, transaction, cacheId)

        // Retrieve the foreign value from the source field
        if (foreignRecords.length == 0) return ""
        if (foreignRecords.length == 1) return foreignRecords[0][field.lookup.fieldId]

        // If there are multiple values to lookup, we perform a summary "LIST" operation
        return kiss.data.relations._summarizeField(foreignRecords, field.lookup.fieldId, "LIST")
    },

    /**
     * Compute a **summary** field
     * 
     * A summary field get all the foreign records connected through a link field, then summarize the information of a foreign field.
     * For example, imagine a "Project" record connected to multiple "Tasks" records, where each task has a **workload**.
     * You could have a "Total workload" field in the Project, and this field is a **summary** field that gather the informations of all "Workload" fields.
     * 
     * Summary operations can be:
     * - SUM
     * - AVERAGE
     * - COUNT
     * - MIN
     * - MAX
     * - CONCATENATE
     * - LIST
     * - ... more to come?
     * 
     * @private
     * @ignore
     * @async
     * @param {string} modelId
     * @param {string} recordId
     * @param {string} fieldId
     * @param {object} transaction
     * @returns {*} The summary of all values found in the foreign records
     */
    async _computeSummaryField(modelId, recordId, fieldId, transaction, cacheId) {
        const model = kiss.app.models[modelId]
        let field = model.getField(fieldId)

        // Get the foreign records associated to the <link> field
        const foreignRecords = await kiss.data.relations.getLinkedRecordsFrom(modelId, recordId, field.summary.linkId, transaction, cacheId)

        // If there are no foreign records to "summup", return 0 or "" depending on the field type
        if (foreignRecords.length == 0) {
            if (field.summary.type == "number") return 0
            return ""
        }

        // Gather all foreign records and summarize their data
        return kiss.data.relations._summarizeField(foreignRecords, field.summary.fieldId, field.summary.operation, field.precision)
    },

    /**
     * Summarize the information of a given field for a given set of records
     * 
     * @private
     * @ignore
     * @param {object} collection - The collection which holds the records
     * @param {string[]} recordIds - List of records from which we want to collect information
     * @param {string} fieldId - The field from which we want to collect information
     * @param {string} operation - SUM, MIN, MAX, AVERAGE, CONCATENATE
     * @param {number} [precision] - Uses a fixed number of digits in case the operation returns a number
     * @returns {*} The summarized value
     */
    _summarizeField(records, fieldId, operation, precision) {
        let values = []

        records.forEach(record => {
            if (record) values.push(record[fieldId])
        })

        if (operation == "CONCATENATE" || operation == "LIST" || operation == "LIST_NAMES") {
            return kiss.formula[operation](...values)
        } else {
            if (precision) return Number((kiss.formula[operation](...values)).toFixed(precision))
            return Number(kiss.formula[operation](...values))
        }
    },

    /**
     * Get the foreign records associated to a specific link field
     * and use cache to optimize database access
     * 
     * @private
     * @ignore
     * @async
     * @param {string} modelId
     * @param {string} recordId
     * @param {string} linkFieldId
     * @param {object} [transaction]
     * @param {string} cacheId
     * @returns {object[]} Array of records
     */
    async getLinkedRecordsFrom(modelId, recordId, linkFieldId, transaction, cacheId) {

        if (!cacheId) {
            // Build temp cache
            cacheId = kiss.tools.uid()
            await kiss.data.relations.buildCache(cacheId)
        } else if (cacheId.startsWith("cache")) {
            // Check if records where already cached to limit the number of database access
            return await kiss.data.relations.getLinkedRecordsFromCache(modelId, recordId, linkFieldId, transaction, cacheId)
        }

        const links = kiss.data.relations.getLinksFromField(modelId, recordId, linkFieldId)
        if (links.length == 0) {
            return []
        }

        // Get links to foreign records and filters out links to deleted records
        const foreignModelId = links[0].modelId
        const ids = links
            .map(link => link.recordId)
            .filter(recordId => !kiss.cache[cacheId].deletedRecords.includes(recordId))

        let records = []
        let remainingIds = []

        // Get linked records from cache and stack missing ids for future retrieval
        ids.forEach(id => {
            if (kiss.cache[cacheId][id]) {
                records.push(kiss.cache[cacheId][id])
            } else {
                remainingIds.push(id)
            }
        })

        // Retrieve the records missing from cache
        let dbRecords = []

        if (remainingIds.length > 0) {
            dbRecords = await kiss.db.findById(foreignModelId, remainingIds)

            dbRecords.forEach(record => {
                kiss.cache[cacheId][record.id] = record
            })

            // If some records were not found, add them to the cache of deleted records, to not try anymore retrieving them
            if (links.length != dbRecords.length) {
                const foundRecordIds = dbRecords.map(record => record.id)
                links.forEach(link => {
                    if (!foundRecordIds.includes(link.recordId)) {
                        kiss.cache[cacheId].deletedRecords = (kiss.cache[cacheId].deletedRecords || []).concat(link.recordId)
                    }
                })
            }
        }

        records = records.concat(dbRecords)

        if (transaction) {
            records = kiss.data.relations._patchRecordsFromTransactionCache(foreignModelId, records, transaction)
        }

        // Prevent duplicates to be returned (should never happen, though)
        // records = records.uniqueObjectId()
        return records
    },

    /**
     * Get FROM CACHE the foreign records associated to a specific link field
     * 
     * @private
     * @ignore
     * @async
     * @param {string} modelId
     * @param {string} recordId
     * @param {string} linkFieldId
     * @param {object} transaction
     * @param {string} cacheId
     * @returns {object[]} Array of records
     */
    async getLinkedRecordsFromCache(modelId, recordId, linkFieldId, transaction, cacheId) {
        const links = kiss.data.relations.getLinksFromField(modelId, recordId, linkFieldId)

        if (links.length == 0) {
            return []
        }

        // Get links to foreign records and filters out links that points to deleted records
        const foreignModelId = links[0].modelId
        const ids = links.map(link => link.recordId)
        let records = []

        if (kiss.cache[cacheId][foreignModelId]) {
            let missingCount = 0
            ids.forEach(id => {
                const cachedRecord = kiss.cache[cacheId][foreignModelId][id]
                if (cachedRecord) {
                    records.push(cachedRecord)
                } else {
                    missingCount++
                }
            })
            // if (missingCount) console.log("kiss.data.relations - getLinkedRecordsFromCache - Record missing from cache or deleted: " + kiss.app.models[foreignModelId].name + " / " + missingCount + " records")
        } else {
            const foreignModel = kiss.app.models[foreignModelId]
            const foreignModelName = (foreignModel) ? foreignModel.name : "Unknown model name (maybe deleted?)"
            console.log("kiss.data.relations - getLinkedRecordsFromCache - Model records missing from cache: " + foreignModelId + " / " + foreignModelName)
        }

        if (transaction) {
            records = kiss.data.relations._patchRecordsFromTransactionCache(foreignModelId, records, transaction)
        }

        return records
    },

    /**
     * Get the foreign links associated to a specific link field.
     * Look for all the linked records where the current record id match rX (left) or rY (right).
     * 
     * @param {string} modelId
     * @param {string} recordId
     * @param {string} linkFieldId - Field that makes the link between models
     * @returns {object[]} Array of objects holding the links, or empty array
     */
    getLinksFromField(modelId, recordId, linkFieldId) {
        const model = kiss.app.models[modelId]
        const accountId = model.accountId
        const foreignLinkField = model.getField(linkFieldId)

        if (!foreignLinkField) return []

        const foreignModelId = foreignLinkField.link.modelId
        const foreignLinkFieldId = foreignLinkField.link.fieldId
        const linkModel = kiss.app.models.link

        // Get the dynamic links between records
        // They are kept in cache to improve lookup performances
        let links
        if (kiss.isClient) {
            links = linkModel.collection.records
        } else {
            links = kiss.global.links[accountId] || []
        }

        // Get the links where the id of the record is in the **left** column of the join table
        const left = links
            .filter(link => link.mX == modelId && link.rX == recordId && link.fX == linkFieldId)
            .map(link => {
                return {
                    linkId: link.id,
                    modelId: link.mY,
                    recordId: link.rY
                }
            })

        // Get the links where the id of the record is in the **right** column of the join table
        const right = (modelId == foreignModelId) ? [] : links
            .filter(link => link.mY == modelId && link.rY == recordId && link.fX == foreignLinkFieldId)
            .map(link => {
                return {
                    linkId: link.id,
                    modelId: link.mX,
                    recordId: link.rX
                }
            })

        // Lookup records from their ids
        const join = [].concat(left.concat(right))
        return join
    },

    /**
     * Delete all the links from multiple records
     * 
     * @param {object} params
     * @param {object} params.req - The original request
     * @param {object} params.records - The records from which we have to delete the links
     */
    async deleteLinksFromRecords({
        req,
        records
    }) {
        const linkIds = kiss.data.relations.getLinksFromRecords(records)
        if (linkIds.length == 0) return

        // Remove links from db
        await kiss.db.deleteMany("links" + req.targetCollectionSuffix, {
            _id: {
                $in: linkIds
            }
        })

        // Remove links from server cache
        const accountId = req.token.currentAccountId
        const countBeforeDeletion = kiss.global.links[accountId].length

        kiss.global.links[accountId] = kiss.global.links[accountId].filter(link => !linkIds.includes(link.id))

        const deleteLinks = countBeforeDeletion - kiss.global.links[accountId].length
        log.info(`kiss.data.relations - ${req.token.userId } deleted ${deleteLinks} link(s)`)
    },

    /**
     * Get all the links of multiple records
     * 
     * @param {object[]} records 
     * @returns {object[]} Array of links
     */
    getLinksFromRecords(records) {
        let linkIds = []
        records.forEach(record => {
            let links = kiss.data.relations.getLinks(record.sourceModelId, record.id)
            linkIds = linkIds.concat(links.map(link => link.linkId))
        })
        return linkIds
    },

    /**
     * Get the all foreign links of a record.
     * Look for all the linked records where the current record id match rX (left) or rY (right).
     * Each link is returned as:
     * 
     *  {
     *      linkId: "...",
     *      modelId: "...",
     *      recordId: "..."
     *  }
     * 
     * @param {string} modelId 
     * @param {string} recordId 
     * @returns {object[]} Array of link objects
     */
    getLinks(modelId, recordId) {
        const model = kiss.app.models[modelId]
        if (!model) return []

        // Get the dynamic links between records
        // They are kept in cache to improve lookup performances
        let links
        if (kiss.isClient) {
            const linkModel = kiss.app.models.link
            links = linkModel.collection.records
        } else {
            const accountId = model.accountId
            links = kiss.global.links[accountId] || []
        }

        // Get the links where the id of the record is in the **left** column of the join table
        const left = links
            .filter(link => link.mX == modelId && link.rX == recordId)
            .map(link => {
                return {
                    linkId: link.id,
                    modelId: link.mY,
                    recordId: link.rY
                }
            })

        // Get the links where the id of the record is in the **right** column of the join table
        const right = links
            .filter(link => link.mY == modelId && link.rY == recordId)
            .map(link => {
                return {
                    linkId: link.id,
                    modelId: link.mX,
                    recordId: link.rX
                }
            })

        // Lookup records from their ids
        const join = [].concat(left.concat(right))
        return join
    },

    /**
     * Get the foreign records informations associated to a specific link field:
     * - filters out links that point to deleted records
     * - default sort by creation date
     * 
     * @private
     * @ignore
     * @async
     * @param {string} modelId
     * @param {string} recordId
     * @param {string} fieldId - The link field id
     * @param {object[]|object} [sort] - Optional sort options
     * @param {object} [sortSyntax] - "normalized" | "mongo". Sort syntax. Default to "normalized"
     * @returns {object[]} Array of objects containing the links informations
     */
    async getLinksAndRecords(modelId, recordId, fieldId, sort, sortSyntax = "normalized") {
        try {
            const model = kiss.app.models[modelId]
            const field = model.getField(fieldId)
            const foreignModel = kiss.app.models[field.link.modelId]
            let links = kiss.data.relations.getLinksFromField(modelId, recordId, fieldId)
            const ids = links.map(link => link.recordId)
            const records = await kiss.db.findById(foreignModel.id, ids, sort, sortSyntax)

            return records.map(record => {
                const link = links.find(link => link.recordId == record.id)
                return Object.assign(link, {
                    record
                })
            })

        } catch (err) {
            console.log("kiss.data.relations - getLinksAndRecords - Could not retrieve links")
            console.log(err)
            return []
        }
    },

    /**
     * Patch the records with previous mutations which are already in the transaction's stack of operations
     * 
     * @private
     * @ignore
     * @param {string} modelId
     * @param {object} records - records to patch in memory
     * @param {object} transaction - transaction that holds the current state mutations
     * @returns {object} 
     */
    _patchRecordsFromTransactionCache(modelId, records, transaction) {
        records.forEach(record => {
            transaction.operations.every(operation => {
                if (operation.modelId == modelId && operation.recordId == record.id) {
                    Object.assign(record, operation.updates)
                    return false
                }
                return true
            })
        })
        return records
    }
}