const LinkController = require("./LinkController") const { IncludeDocs, getLinkDocuments, createLinkView, getUniqueByProp, getRelatedTableForField, getLinkedTableIDs, getLinkedTable, } = require("./linkUtils") const { flatten } = require("lodash") const CouchDB = require("../../db") const { FieldTypes } = require("../../constants") const { getMultiIDParams, USER_METDATA_PREFIX } = require("../../db/utils") const { partition } = require("lodash") const { getGlobalUsers } = require("../../utilities/global") /** * This functionality makes sure that when rows with links are created, updated or deleted they are processed * correctly - making sure that no stale links are left around and that all links have been made successfully. */ const EventType = { ROW_SAVE: "row:save", ROW_UPDATE: "row:update", ROW_DELETE: "row:delete", TABLE_SAVE: "table:save", TABLE_UPDATED: "table:updated", TABLE_DELETE: "table:delete", } exports.EventType = EventType // re-export search here for ease of use exports.IncludeDocs = IncludeDocs exports.getLinkDocuments = getLinkDocuments exports.createLinkView = createLinkView async function getLinksForRows(appId, rows) { const tableIds = [...new Set(rows.map(el => el.tableId))] // start by getting all the link values for performance reasons const responses = flatten( await Promise.all( tableIds.map(tableId => getLinkDocuments({ appId, tableId: tableId, includeDocs: IncludeDocs.EXCLUDE, }) ) ) ) // have to get unique as the previous table query can // return duplicates, could be querying for both tables in a relation return getUniqueByProp( responses // create a unique ID which we can use for getting only unique ones .map(el => ({ ...el, unique: el.id + el.thisId + el.fieldName })), "unique" ) } async function getFullLinkedDocs(appId, links) { // create DBs const db = new CouchDB(appId) const linkedRowIds = links.map(link => link.id) let linked = (await db.allDocs(getMultiIDParams(linkedRowIds))).rows.map( row => row.doc ) // need to handle users as specific cases let [users, other] = partition(linked, linkRow => linkRow._id.startsWith(USER_METDATA_PREFIX) ) const globalUsers = await getGlobalUsers(appId, users) users = users.map(user => { const globalUser = globalUsers.find( globalUser => globalUser && user._id.includes(globalUser._id) ) return { ...globalUser, // doing user second overwrites the id and rev (always metadata) ...user, } }) return [...other, ...users] } /** * Update link documents for a row or table - this is to be called by the API controller when a change is occurring. * @param {string} eventType states what type of change which is occurring, means this can be expanded upon in the * future quite easily (all updates go through one function). * @param {string} appId The ID of the instance in which the change is occurring. * @param {string} tableId The ID of the of the table which is being changed. * @param {object|null} row The row which is changing, e.g. created, updated or deleted. * @param {object|null} table If the table has already been retrieved this can be used to reduce database gets. * @param {object|null} oldTable If the table is being updated then the old table can be provided for differencing. * @returns {Promise} When the update is complete this will respond successfully. Returns the row for * row operations and the table for table operations. */ exports.updateLinks = async function ({ eventType, appId, row, tableId, table, oldTable, }) { const baseReturnObj = row == null ? table : row if (appId == null) { throw "Cannot operate without an instance ID." } // make sure table ID is set if (tableId == null && table != null) { arguments[0].tableId = table._id } let linkController = new LinkController(arguments[0]) try { if ( !(await linkController.doesTableHaveLinkedFields()) && (oldTable == null || !(await linkController.doesTableHaveLinkedFields(oldTable))) ) { return baseReturnObj } } catch (err) { return baseReturnObj } switch (eventType) { case EventType.ROW_SAVE: case EventType.ROW_UPDATE: return await linkController.rowSaved() case EventType.ROW_DELETE: return await linkController.rowDeleted() case EventType.TABLE_SAVE: return await linkController.tableSaved() case EventType.TABLE_UPDATED: return await linkController.tableUpdated() case EventType.TABLE_DELETE: return await linkController.tableDeleted() default: throw "Type of event is not known, linked row handler requires update." } } /** * Update a row with information about the links that pertain to it. * @param {string} appId The instance in which this row has been created. * @param {object} rows The row(s) themselves which is to be updated with info (if applicable). This can be * a single row object or an array of rows - both will be handled. * @returns {Promise} The updated row (this may be the same if no links were found). If an array was input * then an array will be output, object input -> object output. */ exports.attachLinkIDs = async (appId, rows) => { const links = await getLinksForRows(appId, rows) // now iterate through the rows and all field information for (let row of rows) { // find anything that matches the row's ID we are searching for and join it links .filter(el => el.thisId === row._id) .forEach(link => { if (row[link.fieldName] == null) { row[link.fieldName] = [] } row[link.fieldName].push(link.id) }) } // if it was an array when it came in then handle it as an array in response // otherwise return the first element as there was only one input return rows } /** * Given a table and a list of rows this will retrieve all of the attached docs and enrich them into the row. * This is required for formula fields, this may only be utilised internally (for now). * @param {string} appId The app in which the tables/rows/links exist. * @param {object} table The table from which the rows originated. * @param {array} rows The rows which are to be enriched. * @return {Promise<*>} returns the rows with all of the enriched relationships on it. */ exports.attachFullLinkedDocs = async (appId, table, rows) => { const linkedTableIds = getLinkedTableIDs(table) if (linkedTableIds.length === 0) { return rows } // create DBs const db = new CouchDB(appId) // get all the links const links = (await getLinksForRows(appId, rows)).filter(link => rows.some(row => row._id === link.thisId) ) let linked = await getFullLinkedDocs(appId, links) const linkedTables = [] for (let row of rows) { for (let link of links.filter(link => link.thisId === row._id)) { if (row[link.fieldName] == null) { row[link.fieldName] = [] } const linkedRow = linked.find(row => row._id === link.id) const linkedTableId = linkedRow.tableId || getRelatedTableForField(table, link.fieldName) const linkedTable = await getLinkedTable(db, linkedTableId, linkedTables) if (!linkedRow || !linkedTable) { continue } row[link.fieldName].push(linkedRow) } } return rows } /** * This function will take the given enriched rows and squash the links to only contain the primary display field. * @param {string} appId The app in which the tables/rows/links exist. * @param {object} table The table from which the rows originated. * @param {array} enriched The pre-enriched rows (full docs) which are to be squashed. * @returns {Promise} The rows after having their links squashed to only contain the ID and primary display. */ exports.squashLinksToPrimaryDisplay = async (appId, table, enriched) => { const db = new CouchDB(appId) // will populate this as we find them const linkedTables = [] for (let [column, schema] of Object.entries(table.schema)) { if (schema.type !== FieldTypes.LINK) { continue } for (let row of enriched) { if (!row[column] || !row[column].length) { continue } const newLinks = [] for (let link of row[column]) { const linkTblId = link.tableId || getRelatedTableForField(table, column) // this only fetches the table if its not already in array const linkedTable = await getLinkedTable(db, linkTblId, linkedTables) const obj = { _id: link._id } if (link[linkedTable.primaryDisplay]) { obj.primaryDisplay = link[linkedTable.primaryDisplay] } newLinks.push(obj) } row[column] = newLinks } } return enriched }