Source

client/core/db/api.js

/**
 * 
 * ## NoSQL database wrapper API
 * 
 * - built to work seamlessly in memory, offline, or online
 * - if connected to KissJS server:
 *      - online mode pushes updates dynamically over all connected clients through WebSocket
 *      - field updates handle relationships with foreign records, and compute the required updates to keep data coherent
 * 
 * @namespace
 * @param {object} kiss.db.memory - In-memory database wrapper
 * @param {object} kiss.db.offline - Offline database wrapper
 * @param {object} kiss.db.online - Online database wrapper
 * 
 */
kiss.db = {

    // Reserved namespaces for database
    memory: {},
    offline: {},
    online: {},
    faker: {},

    /**
     * Database mode:
     * - online: persist data on the server - requires a connection
     * - offline: persist data on the client - no connection required
     * - memory: no persistence at all - a browser refresh flushes the data
     */
    mode: "online",

    /**
     * Set the datatabase mode
     * 
     * @param {string} mode - memory | offline | online
     * 
     * @example
     * // Setting offline mode
     * kiss.db.setMode("offline")
     */
    setMode(mode) {
        if (!mode) return
        if (mode != "memory" && mode != "offline" && mode != "online") return

        kiss.db.mode = mode

        // Align models and collections with the database mode
        for (modelId in kiss.app.models) {
            const model = kiss.app.models[modelId]
            model.mode = mode
            model.db = kiss.db[mode]
        }

        for (modelId in kiss.app.collections) {
            const collection = kiss.app.collections[modelId]
            collection.mode = mode
            collection.db = kiss.db[mode]
        }
    },

    /**
     * Insert one record in a collection
     * 
     * @async
     * @param {string} modelId
     * @param {object} record - A single record
     * @returns {object} The inserted record data
     * 
     * @example
     * let newUser = await kiss.db.insertOne("user", {firstName: "Bob", lastName: "Wilson"})
     * console.log(newUser) // returns {firstName: "Bob", lastName: "Wilson"}
     */
    async insertOne(modelId, record) {
        return await kiss.db[this.mode].insertOne(modelId, record)
    },

    /**
     * Insert many records in a collection
     * 
     * @async
     * @param {string} modelId
     * @param {object[]} records - An array of records [{...}, {...}] for bulk insert
     * @returns {object[]} The array of inserted records data
     * 
     * @example
     * let newUsers = await kiss.db.insertMany("user", [
     *      {firstName: "Will", lastName: "Smith"},
     *      {firstName: "Joe", lastName: "Dalton"},
     *      {firstName: "Albert", lastName: "Einstein"}
     * ])
     */
    async insertMany(modelId, records) {
        return await kiss.db[this.mode].insertMany(modelId, records)
    },

    /**
     * Insert some fake records in a collection, for testing purpose.
     * 
     * See [db.faker](kiss.db.faker.html) documentation for more details.
     * 
     * @async
     * @param {string} modelId - The target collection
     * @param {object[]} fields - Array of fields that defines the model
     * @param {integer} numberOfRecords - Number of fake records to insert
     * @returns {object[]} The array of inserted records data
     * 
     * @example
     * // Insert 100 products into the collection
     * await kiss.db.insertFakeRecords("product", [
     *  {primary: true, label: "Title", type: "text"},
     *  {label: "Release date", type: "date"}
     *  {label: "Category", type: "select", multiple: true, options: [{value: "Adventure", color: "#00aaee"}, {value: "Action", color: "#00eeaa"}]}
     *  {label: "Life time", type: "integer", min: 0, max: 5}
     * ], 100)
     */
    async insertFakeRecords(modelId, fields, numberOfRecords) {
        let records = kiss.db.faker.generate(fields, numberOfRecords)
        return await kiss.db[this.mode].insertMany(modelId, records)
    },

    /**
     * Delete all the fake records inserted using the method insertFakeRecords.
     * Other records remain untouched.
     * 
     * @async
     * @param {string} modelId - The target collection
     * 
     * @example
     * await kiss.db.deleteFakeRecords()
     */
    async deleteFakeRecords(modelId) {
        return await kiss.db[this.mode].deleteMany(modelId, {
            isFake: true
        })
    },

    /**
     * Update a single record in a collection
     * 
     * @async
     * @param {string} modelId
     * @param {string} recordId
     * @param {object} update
     * @returns {object} The request's result
     * 
     * @example
     * let updatedUser = await kiss.db.updateOne("user", "f07xF008d", {lastName: "Smith"})
     * console.log(updatedUser) // returns {firstName: "Bob", lastName: "Smith"}
     */
    async updateOne(modelId, recordId, update) {
        return await kiss.db[this.mode].updateOne(modelId, recordId, update)
    },

    /**
     * Update a record then propagate the mutation to foreign records
     * 
     * @async
     * @param {string} modelId
     * @param {string} recordId
     * @param {string} [update] - If not specified, re-compute all the computed fields
     * @returns The request's result
     * 
     * @example
     * await kiss.db.updateOneDeep("company", "f07xF008d", {name: "pickaform"})
     */
    async updateOneDeep(modelId, recordId, update) {
        return await kiss.db[this.mode].updateOneDeep(modelId, recordId, update)
    },

    /**
     * Update the 2 records connected by a link
     * 
     * @param {object} link 
     * @returns The transaction result
     */    
    async updateLink(link) {
        return await kiss.db[this.mode].updateLink(link)
    },

    /**
     * Update many records in a single collection
     * 
     * @async
     * @param {string} modelId
     * @param {object} query
     * @param {object} update
     * @returns The request's result
     * 
     */
    async updateMany(modelId, query, update) {
        return await kiss.db[this.mode].updateMany(modelId, query, update)
    },

    /**
     * Update multiple records in multiple collections
     * 
     * @async
     * @param {object[]} operations - The list of updates to perform
     * @returns The request's result
     * 
     * @example
     * kiss.db.updateBulk(
     *  [{
     *		modelId: "project",
     *		recordId: "6ab2f4fd-e6f3-4fc3-998f-96629e7ef109",
     *		updates: {
     *			projectName: "Yet another Javascript project"
     *		}
     *	}, {
     *		modelId: "task",
     *		recordId: "5eb85fe3-2634-466c-839f-08423fc1cac1",
     *		updates: {
     *			taskName: "Task 1"
     *		}
     *	}, {
     *		modelId: "task",
     *		recordId: "5ae68056-f099-473b-8f5f-af9eeec9ddff",
     *		updates: {
     *			taskName: "Task 2",
                status: "done"
     *		}
     *	}, {
     *		modelId: "task",
     *		recordId: "1f7f9d6a-2cbc-42f1-80c4-8ad795141493",
     *		updates: {
     *			status: "pending"
     *		}
     *	}]
     * )
     */
    async updateBulk(operations = []) {
        if (operations.length == 0) return false
        return await kiss.db[this.mode].updateBulk(operations)
    },

    /**
     * Find a single record by id
     * 
     * @async
     * @param {string} modelId 
     * @param {string} recordId
     * @returns {object} The found record
     * 
     * @example
     * let Bob = await kiss.db.findOne("user", "fa7f9d6a-2cbc-42f1-80c4-dad795141eee")
     */
    async findOne(modelId, recordId) {
        return await kiss.db[this.mode].findOne(modelId, recordId)
    },

    /**
     * Find multiple records by id
     * 
     * @async
     * @param {string} modelId 
     * @param {string[]} ids - 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
     * @returns {object[]} The found records
     * 
     * @example
     * let users = await kiss.db.findById("user", ["fa7f9d6a-2cbc-42f1-80c4-dad795141eee", "0e7f9d6a-2cbc-42f1-80c4-dad795141547"])
     * let sortedUsers = await kiss.db.findById("user", ["fa7f9d6a-2cbc-42f1-80c4-dad795141eee", "0e7f9d6a-2cbc-42f1-80c4-dad795141547"], [{lastName: "asc"}, {age: "desc"}])
     * let mongoSortedUsers = await kiss.db.findById("user", ["fa7f9d6a-2cbc-42f1-80c4-dad795141eee", "0e7f9d6a-2cbc-42f1-80c4-dad795141547"], {lastName: 1, age: -1}, "mongo")
     */
    async findById(modelId, ids, sort = [], sortSyntax = "normalized") {
        return await kiss.db[this.mode].findById(modelId, ids, sort, sortSyntax)
    },

    /**
     * Find records applying:
     * - filter
     * - sort
     * - group
     * - project
     * - skip
     * - limit
     * 
     * The query can be a normalized object, easier to serialize / deserialize.
     * 
     * Without a filter parameter, it returns all the records of the collection.
     * The filter can be a **group** of filters, where each **filter** can be:
     * - a field condition (example: country = "France")
     * - another group of filters. Check the example below.
     * 
     * Important: KissJS doesn't use server-side grouping and paging (using skip/limit) at the moment because:
     * - KissJS must work offline
     * - it's faster in memory for small / medium datasets
     * We'll see how it evolves in the future with the project requirements.
     * 
     * @async
     * @param {string} modelId
     * @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
     * @returns {object[]} An array containing the records data
     * 
     * @example
     * // Sample filter: "Get all people born in France within years 2000 and 2020, which last name is Dupont or Dupond"
     * const filter = {
     *  type: "group",
     *  operator: "and",
     *  filters: [
     *      {
     *          type: "group",
     *          operator: "and",
     *          filters: [
     *              {
     *                  type: "filter",
     *                  fieldId: "country",
     *                  operator: "=",
     *                  value: "France"
     *              },
     *              {
     *                  type: "filter",
     *                  fieldId: "birthDate",
     *                  operator: ">=",
     *                  value: "2000-01-01"
     *              },
     *              {
     *                  type: "filter",
     *                  fieldId: "birthDate",
     *                  operator: "<",
     *                  value: "2020-01-01"
     *              }
     *          ]
     *      },
     *      {
     *          type: "group",
     *          operator: "or",
     *          filters: [
     *              {
     *                  type: "filter",
     *                  fieldId: "lastName",
     *                  operator: "=",
     *                  value: "dupond"
     *              },
     *              {
     *                  type: "filter",
     *                  fieldId: "lastName",
     *                  operator: "=",
     *                  value: "dupont"
     *              }
     *          ]
     *      }
     *   ]
     * }
     * 
     * let users = await kiss.db.find("user", {
     *      filter: filter,
     *      sort: [{lastName: "asc"}, {birthDate: "desc"}],
     *      projection: {password: 0},
     *      skip: 100,
     *      limit: 50
     * })
     */
    async find(modelId, query) {
        if (!query) return await kiss.db[this.mode].find(modelId)

        // Sanitize the query
        let search = {
            operation: "search",
            filter: query.filter || {},
            filterSyntax: query.filterSyntax || "normalized",
            sort: query.sort || {},
            sortSyntax: query.sortSyntax || "normalized",
            group: query.group || [],
            projection: query.projection || {},
            skip: query.skip,
            limit: query.limit,
            groupUnwind: query.groupUnwind
        }
        return await kiss.db[this.mode].find(modelId, search)
    },

    /**
     * Delete a record from a collection
     * 
     * @async
     * @param {string} modelId
     * @param {string} recordId
     * @param {boolean} [sendToTrash] - If true, keeps the original record in a "trash" collection. Default = false
     * @returns The request's result
     * 
     * @example
     * await kiss.db.deleteOne("user", "007f9d6a-2cbc-42f1-80c4-dad7951414af")
     */
    async deleteOne(modelId, recordId, sendToTrash) {
        return await kiss.db[this.mode].deleteOne(modelId, recordId, sendToTrash)
    },

    /**
     * Delete many records from a collection
     * 
     * @async
     * @param {string} modelId 
     * @param {object} query
     * @param {boolean} [sendToTrash] - If true, keeps the original record in a "trash" collection
     * @returns The request's result
     */
    async deleteMany(modelId, query, sendToTrash) {
        return await kiss.db[this.mode].deleteMany(modelId, query, sendToTrash)
    },

    /**
     * Count the number of records that match a query
     * 
     * @async
     * @param {string} modelId
     * @param {object} query - Use same query format as for find() method
     * @returns {number} The number of records
     */
    async count(modelId, query) {
        return await kiss.db[this.mode].count(modelId, query)
    },

    /**
     * Helper functions to convert filter and sort options to MongoDb syntax
     * 
     * @namespace
     */
    mongo: {
        /**
         * Convert an array of sort options to Mongo style
         * 
         * @param {object[]} sortArray - The array to format to Mongo style
         * @returns {object} - A single object with sort options
         * 
         * @example
         * // input:
         * [{birthDate: "asc"}, {lastName: "desc"}]
         * 
         * // output:
         * {birthDate: 1, lastName: -1}
         */
        convertSort(sortArray) {
            let mongoSort = {}
            for (let i = 0, length = sortArray.length; i < length; i++) {
                let sortOption = sortArray[i]
                let sortField = Object.keys(sortOption)[0]
                let sortDirection = sortOption[sortField]
                mongoSort[sortField] = ((sortDirection == "asc") ? 1 : -1)
            }

            return mongoSort
        },

        /**
         * Convert a filter config into a Mongo query expression
         * 
         * @param {array} filter - The filter config to convert to Mongo syntax
         * @returns {object} The Mongo query expression
         * 
         * @example
         * // If the filter config is:
         * {
         *   type: "filter",
         *   fieldId: "firstName",
         *   operator: "contains",
         *   value: "wilson"
         * }
         * 
         * // It will return:
         * {firstName: /wilson/}
         * 
         */
        convertFilter(filterConfig) {
            let query = {}

            // Copy the filter config to not alter the original
            let filter = {}
            Object.assign(filter, filterConfig)

            // Convert dynamic values:
            let isUserTest
            if (filter.value == "$userId") {
                isUserTest = true
                filter.value = kiss.session.getACL() // Connected user

            } else if (filter.value == "$today") {
                filter.value = kiss.formula.TODAY()

            } else if (filter.fieldType == "date") {
                if (filter.dateOperator == "today") {
                    filter.value = kiss.formula.TODAY()
                }
                else if (filter.dateOperator == "days from now") {
                    const today = new Date()
                    const adjustedDate = kiss.formula.ADJUST_DATE(today, 0, 0, filter.value)
                    filter.value = adjustedDate
                }
                else if (filter.dateOperator == "days ago") {
                    const today = new Date()
                    const adjustedDate = kiss.formula.ADJUST_DATE(today, 0, 0, -filter.value)
                    filter.value = adjustedDate
                }                
            }

            switch (filter.operator) {
                case "=":
                    if (!isUserTest) {
                        query[filter.fieldId] = filter.value
                    } else {
                        query[filter.fieldId] = {
                            "$in": filter.value
                        }
                    }
                    break

                case "<>":
                    if (!isUserTest) {
                        query[filter.fieldId] = {
                            $ne: filter.value
                        }
                    } else {
                        query[filter.fieldId] = {
                            "$nin": filter.value
                        }
                    }
                    break

                case "<":
                    query[filter.fieldId] = {
                        $lt: filter.value
                    }
                    break

                case ">":
                    query[filter.fieldId] = {
                        $gt: filter.value
                    }
                    break

                case "<=":
                    query[filter.fieldId] = {
                        $lte: filter.value
                    }
                    break

                case ">=":
                    query[filter.fieldId] = {
                        $gte: filter.value
                    }
                    break

                case "contains":
                    if (!isUserTest) {
                        query[filter.fieldId] = new RegExp(filter.value, "i")
                    } else {
                        query[filter.fieldId] = {
                            "$in": filter.value
                        }
                    }
                    break

                case "does not contain":
                    if (!isUserTest) {
                        query = {
                            [filter.fieldId]: {
                                $not: new RegExp(filter.value, "i")
                            }
                        }
                    } else {
                        query[filter.fieldId] = {
                            "$nin": filter.value
                        }
                    }
                    break
    
                case "is empty":
                    query = {
                        $or: [{
                                [filter.fieldId]: ""
                            },
                            {
                                [filter.fieldId]: []
                            },
                            {
                                [filter.fieldId]: {
                                    $exists: false
                                }
                            }
                        ]
                    }
                    break

                case "is not empty":
                    query = {
                        $and: [{
                                [filter.fieldId]: {
                                    $ne: ""
                                }
                            },
                            {
                                [filter.fieldId]: {
                                    $ne: []
                                }
                            },
                            {
                                [filter.fieldId]: {
                                    $exists: true
                                }
                            }
                        ]
                    }
                    break
            }

            return query
        },

        /**
         * Convert a filter config into a Mongo query expression
         * 
         * @param {array} filterGroup - The filter config to convert to Mongo syntax
         * @returns {object} The Mongo query expression
         * 
         * @example
         * // If the filter config is:
         * {
         *      type: "group",
         *      operator: "and",
         *      filters: [
         *          {
         *              type: "filter",
         *              fieldId: "firstName",
         *              operator: "contains",
         *              value: "wilson"
         *          },
         *          {
         *              type: "filter",
         *              fieldId: "birthDate",
         *              operator: ">",
         *              value: "2020-01-01"
         *          }
         *      ]
         * }
         * 
         * // It will return:
         * {$and: [
         *      {firstName: /wilson/},
         *      {birthDate: {$gt: "2000-01-01"}}
         * ]}
         */
        convertFilterGroup(filterGroup) {
            if (filterGroup.type != "group") return kiss.db.mongo.convertFilter(filterGroup)

            let filters = []

            filterGroup.filters.forEach(filter => {
                if (filter) {
                    if (filter.type == "group") {
                        // If it's a filter group, then we get the filters of the group recursively
                        filters.push(kiss.db.mongo.convertFilterGroup(filter))
                    } else {
                        // If it's a single filter, we directly get the filter values
                        filters.push(kiss.db.mongo.convertFilter(filter))
                    }
                }
            })

            let mongoFilter = {}
            mongoFilter["$" + filterGroup.operator] = filters
            return mongoFilter
        },

        /**
         * Get all the fields involved in a filter
         * 
         * @param {object} filter 
         * @returns {string[]} The list of field ids
         */
        getFilterFields(filter) {
            if (!filter) return []
            
            let fields = []
            if (filter.type == "filter") {
                fields.push(filter.fieldId)
            } else if (filter.type == "group") {
                filter.filters.forEach(filter => {
                    fields = fields.concat(kiss.db.mongo.getFilterFields(filter))
                })
            }
            return fields.unique()
        }
    }
}

;