import LinkController from "./LinkController" import { getLinkDocuments, getLinkedTable, getLinkedTableIDs, getRelatedTableForField, getUniqueByProp, } from "./linkUtils" import flatten from "lodash/flatten" import { USER_METDATA_PREFIX } from "../utils" import partition from "lodash/partition" import { getGlobalUsersFromMetadata } from "../../utilities/global" import { coreOutputProcessing, processFormulas, } from "../../utilities/rowProcessor" import { context, features } from "@budibase/backend-core" import { ContextUser, FeatureFlag, FieldType, LinkDocumentValue, Row, Table, TableSchema, ViewV2, ViewV2Schema, } from "@budibase/types" import sdk from "../../sdk" import { helpers } from "@budibase/shared-core" export { IncludeDocs, getLinkDocuments, createLinkView } from "./linkUtils" const INVALID_DISPLAY_COLUMN_TYPE = [ FieldType.LINK, FieldType.ATTACHMENTS, FieldType.ATTACHMENT_SINGLE, FieldType.SIGNATURE_SINGLE, FieldType.BB_REFERENCE, FieldType.BB_REFERENCE_SINGLE, ] /** * 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. */ export 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", } function clearRelationshipFields(schema: TableSchema, rows: Row[]) { for (let [key, field] of Object.entries(schema)) { if (field.type === FieldType.LINK) { rows = rows.map(row => { delete row[key] return row }) } } return rows } async function getLinksForRows(rows: Row[]): Promise { const tableIds = [...new Set(rows.map(el => el.tableId))] // start by getting all the link values for performance reasons const promises = tableIds.map(tableId => getLinkDocuments({ tableId: tableId, }) ) const responses = flatten(await Promise.all(promises)) // have to get unique as the previous table query can // return duplicates, could be querying for both tables in a relation return getUniqueByProp( responses .filter(el => el != null) // 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(links: LinkDocumentValue[]) { // create DBs const db = context.getAppDB() const linkedRowIds = links.map(link => link.id) const uniqueRowIds = [...new Set(linkedRowIds)] let dbRows = await db.getMultiple(uniqueRowIds, { allowMissing: true }) // convert the unique db rows back to a full list of linked rows const linked = linkedRowIds .map(id => dbRows.find(row => row && row._id === id)) .filter(row => row != null) as Row[] // need to handle users as specific cases let [users, other] = partition(linked, linkRow => linkRow._id!.startsWith(USER_METDATA_PREFIX) ) users = await getGlobalUsersFromMetadata(users as ContextUser[]) 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 args.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 args.tableId The ID of the of the table which is being changed. * @param args.row The row which is changing, e.g. created, updated or deleted. * @param args.table If the table has already been retrieved this can be used to reduce database gets. * @param args.oldTable If the table is being updated then the old table can be provided for differencing. * @returns When the update is complete this will respond successfully. Returns the row for * row operations and the table for table operations. */ export async function updateLinks(args: { tableId?: string eventType: string row?: Row table?: Table oldTable?: Table }) { const { eventType, row, tableId, table, oldTable } = args const baseReturnObj = row == null ? table : row // make sure table ID is set if (tableId == null && table != null) { args.tableId = table._id! } let linkController = new LinkController(args) try { if ( !(await linkController.doesTableHaveLinkedFields(table)) && (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." } } /** * 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). * @return returns the rows with all of the enriched relationships on it. */ export async function attachFullLinkedDocs( schema: TableSchema, rows: Row[], opts?: { fromRow?: Row } ) { const linkedTableIds = getLinkedTableIDs(schema) if (linkedTableIds.length === 0) { return rows } // get tables and links let response = await Promise.all([ getLinksForRows(rows), sdk.tables.getTables(linkedTableIds), ]) // find the links that pertain to one of the rows that is being enriched const links = (response[0] as LinkDocumentValue[]).filter(link => rows.some(row => row._id === link.thisId) ) // if fromRow has been passed in, then we don't need to fetch it (optimisation) let linksWithoutFromRow = links if (opts?.fromRow) { linksWithoutFromRow = links.filter(link => link.id !== opts?.fromRow?._id) } const linkedTables = response[1] as Table[] // clear any existing links that could be dupe'd rows = clearRelationshipFields(schema, rows) // now get the docs and combine into the rows let linked: Row[] = [] if (linksWithoutFromRow.length > 0) { linked = await getFullLinkedDocs(linksWithoutFromRow) } for (let row of rows) { for (let link of links.filter(link => link.thisId === row._id)) { if (row[link.fieldName] == null) { row[link.fieldName] = [] } let linkedRow: Row if (opts?.fromRow && opts?.fromRow?._id === link.id) { linkedRow = opts.fromRow! } else { linkedRow = linked.find(row => row._id === link.id)! } if (linkedRow) { const linkedTableId = linkedRow.tableId || getRelatedTableForField(schema, link.fieldName) const linkedTable = linkedTables.find( table => table._id === linkedTableId ) if (linkedTable) { const processed = await processFormulas(linkedTable, linkedRow) row[link.fieldName].push(processed) } } } } return rows } /** * Finds a valid value for the primary display, avoiding columns which break things * like relationships (can be circular). * @param row The row to lift a value from for the primary display. * @param table The related table to attempt to work out the primary display column from. */ function getPrimaryDisplayValue(row: Row, table?: Table) { const primaryDisplay = table?.primaryDisplay let invalid = true if (primaryDisplay) { const primaryDisplaySchema = table?.schema[primaryDisplay] invalid = INVALID_DISPLAY_COLUMN_TYPE.includes(primaryDisplaySchema.type) } if (invalid || !primaryDisplay) { const validKey = Object.keys(table?.schema || {}).find( key => table?.schema[key].type && !INVALID_DISPLAY_COLUMN_TYPE.includes(table?.schema[key].type) ) return validKey ? row[validKey] : undefined } else { return row[primaryDisplay] } } export type SquashTableFields = Record /** * This function will take the given enriched rows and squash the links to only * contain the primary display field. * * @returns The rows after having their links squashed to only contain the ID * and primary display. */ export async function squashLinks( source: Table | ViewV2, enriched: T ): Promise { const allowRelationshipSchemas = await features.flags.isEnabled( FeatureFlag.ENRICHED_RELATIONSHIPS ) let viewSchema: ViewV2Schema = {} if (sdk.views.isView(source)) { if (helpers.views.isCalculationView(source)) { return enriched } if (allowRelationshipSchemas) { viewSchema = source.schema || {} } } let table: Table if (sdk.views.isView(source)) { table = await sdk.views.getTable(source.id) } else { table = source } // will populate this as we find them const linkedTables = [table] const isArray = Array.isArray(enriched) const enrichedArray = !isArray ? [enriched as Row] : (enriched as Row[]) for (const row of enrichedArray) { // this only fetches the table if its not already in array const rowTable = await getLinkedTable(row.tableId!, linkedTables) for (let [column, schema] of Object.entries(rowTable.schema)) { if (schema.type !== FieldType.LINK || !Array.isArray(row[column])) { continue } const relatedTable = await getLinkedTable(schema.tableId, linkedTables) if (viewSchema[column]?.columns) { row[column] = await coreOutputProcessing(relatedTable, row[column]) } row[column] = row[column].map((link: Row) => { const obj: any = { _id: link._id } obj.primaryDisplay = getPrimaryDisplayValue(link, relatedTable) if (viewSchema[column]?.columns) { const squashFields = Object.entries(viewSchema[column].columns || {}) .filter(([columnName, viewColumnConfig]) => { const tableColumn = relatedTable.schema[columnName] if (!tableColumn) { return false } if ( [FieldType.LINK, FieldType.FORMULA, FieldType.AI].includes( tableColumn.type ) ) { return false } return ( tableColumn.visible !== false && viewColumnConfig.visible !== false ) }) .map(([columnName]) => columnName) for (const relField of squashFields) { if (link[relField] != null) { obj[relField] = link[relField] } } } return obj }) } } return (isArray ? enrichedArray : enrichedArray[0]) as T }