import { FilterTypes, IncludeRelationships, Operation, PaginationJson, RelationshipsJson, SearchFilters, SortJson, } from "../../../definitions/datasource" import { Datasource, FieldSchema, Row, Table, } from "../../../definitions/common" import { breakRowIdField, generateRowIdField, isRowId, convertRowId, } from "../../../integrations/utils" interface ManyRelationship { tableId?: string id?: string isUpdate?: boolean key: string [key: string]: any } interface RunConfig { id?: string filters?: SearchFilters sort?: SortJson paginate?: PaginationJson row?: Row rows?: Row[] } module External { const { getDatasourceAndQuery } = require("./utils") const { DataSourceOperation, FieldTypes, RelationshipTypes, } = require("../../../constants") const { breakExternalTableId, isSQL } = require("../../../integrations/utils") const { processObjectSync } = require("@budibase/string-templates") const { cloneDeep } = require("lodash/fp") const CouchDB = require("../../../db") const { processFormulas } = require("../../../utilities/rowProcessor/utils") function buildFilters( id: string | undefined, filters: SearchFilters, table: Table ) { const primary = table.primary // if passed in array need to copy for shifting etc let idCopy = cloneDeep(id) if (filters) { // need to map over the filters and make sure the _id field isn't present for (let filter of Object.values(filters)) { if (filter._id && primary) { const parts = breakRowIdField(filter._id) for (let field of primary) { filter[field] = parts.shift() } } // make sure this field doesn't exist on any filter delete filter._id } } // there is no id, just use the user provided filters if (!idCopy || !table) { return filters } // if used as URL parameter it will have been joined if (!Array.isArray(idCopy)) { idCopy = breakRowIdField(idCopy) } const equal: any = {} if (primary && idCopy) { for (let field of primary) { // work through the ID and get the parts equal[field] = idCopy.shift() } } return { equal, } } /** * This function checks the incoming parameters to make sure all the inputs are * valid based on on the table schema. The main thing this is looking for is when a * user has made use of the _id field of a row for a foreign key or a search parameter. * In these cases the key will be sent up as [1], rather than 1. In these cases we will * simplify it down to the requirements. This function is quite complex as we try to be * relatively restrictive over what types of columns we will perform this action for. */ function cleanupConfig(config: RunConfig, table: Table): RunConfig { const primaryOptions = [ FieldTypes.STRING, FieldTypes.LONGFORM, FieldTypes.OPTIONS, FieldTypes.NUMBER, ] // filter out fields which cannot be keys const fieldNames = Object.entries(table.schema) .filter(schema => primaryOptions.find(val => val === schema[1].type)) .map(([fieldName]) => fieldName) const iterateObject = (obj: { [key: string]: any }) => { for (let [field, value] of Object.entries(obj)) { if (fieldNames.find(name => name === field) && isRowId(value)) { obj[field] = convertRowId(value) } } } // check the row and filters to make sure they aren't a key of some sort if (config.filters) { for (let [key, filter] of Object.entries(config.filters)) { // oneOf is an array, don't iterate it if ( typeof filter !== "object" || Object.keys(filter).length === 0 || key === FilterTypes.ONE_OF ) { continue } iterateObject(filter) } } if (config.row) { iterateObject(config.row) } return config } function generateIdForRow(row: Row | undefined, table: Table): string { const primary = table.primary if (!row || !primary) { return "" } // build id array let idParts = [] for (let field of primary) { // need to handle table name + field or just field, depending on if relationships used const fieldValue = row[`${table.name}.${field}`] || row[field] if (fieldValue) { idParts.push(fieldValue) } } if (idParts.length === 0) { return "" } return generateRowIdField(idParts) } function getEndpoint(tableId: string | undefined, operation: string) { if (!tableId) { return {} } const { datasourceId, tableName } = breakExternalTableId(tableId) return { datasourceId, entityId: tableName, operation, } } function basicProcessing(row: Row, table: Table): Row { const thisRow: Row = {} // filter the row down to what is actually the row (not joined) for (let fieldName of Object.keys(table.schema)) { const value = row[`${table.name}.${fieldName}`] || row[fieldName] // all responses include "select col as table.col" so that overlaps are handled if (value) { thisRow[fieldName] = value } } thisRow._id = generateIdForRow(row, table) thisRow.tableId = table._id thisRow._rev = "rev" return thisRow } function fixArrayTypes(row: Row, table: Table) { for (let [fieldName, schema] of Object.entries(table.schema)) { if ( schema.type === FieldTypes.ARRAY && typeof row[fieldName] === "string" ) { try { row[fieldName] = JSON.parse(row[fieldName]) } catch (err) { // couldn't convert back to array, ignore delete row[fieldName] } } } return row } function isOneSide(field: FieldSchema) { return ( field.relationshipType && field.relationshipType.split("-")[0] === "one" ) } class ExternalRequest { private readonly appId: string private operation: Operation private tableId: string private datasource: Datasource private tables: { [key: string]: Table } = {} constructor( appId: string, operation: Operation, tableId: string, datasource: Datasource ) { this.appId = appId this.operation = operation this.tableId = tableId this.datasource = datasource if (datasource && datasource.entities) { this.tables = datasource.entities } } getTable(tableId: string | undefined): Table { if (!tableId) { throw "Table ID is unknown, cannot find table" } const { tableName } = breakExternalTableId(tableId) return this.tables[tableName] } inputProcessing(row: Row | undefined, table: Table) { if (!row) { return { row, manyRelationships: [] } } // we don't really support composite keys for relationships, this is why [0] is used // @ts-ignore const tablePrimary: string = table.primary[0] let newRow: Row = {}, manyRelationships: ManyRelationship[] = [] for (let [key, field] of Object.entries(table.schema)) { // if set already, or not set just skip it if ( row[key] == null || newRow[key] || field.autocolumn || field.type === FieldTypes.FORMULA ) { continue } // if its an empty string then it means return the column to null (if possible) if (row[key] === "") { newRow[key] = null continue } // parse floats/numbers if (field.type === FieldTypes.NUMBER && !isNaN(parseFloat(row[key]))) { newRow[key] = parseFloat(row[key]) } // if its not a link then just copy it over if (field.type !== FieldTypes.LINK) { newRow[key] = row[key] continue } const { tableName: linkTableName } = breakExternalTableId(field.tableId) // table has to exist for many to many if (!this.tables[linkTableName]) { continue } const linkTable = this.tables[linkTableName] // @ts-ignore const linkTablePrimary = linkTable.primary[0] // one to many if (isOneSide(field)) { newRow[field.foreignKey || linkTablePrimary] = breakRowIdField( row[key][0] )[0] } // many to many else if (field.through) { // we're not inserting a doc, will be a bunch of update calls const otherKey: string = field.throughFrom || linkTablePrimary const thisKey: string = field.throughTo || tablePrimary row[key].map((relationship: any) => { manyRelationships.push({ tableId: field.through || field.tableId, isUpdate: false, key: otherKey, [otherKey]: breakRowIdField(relationship)[0], // leave the ID for enrichment later [thisKey]: `{{ literal ${tablePrimary} }}`, }) }) } // many to one else { const thisKey: string = "id" // @ts-ignore const otherKey: string = field.fieldName row[key].map((relationship: any) => { manyRelationships.push({ tableId: field.tableId, isUpdate: true, key: otherKey, [thisKey]: breakRowIdField(relationship)[0], // leave the ID for enrichment later [otherKey]: `{{ literal ${tablePrimary} }}`, }) }) } } // we return the relationships that may need to be created in the through table // we do this so that if the ID is generated by the DB it can be inserted // after the fact return { row: newRow, manyRelationships } } /** * This iterates through the returned rows and works out what elements of the rows * actually match up to another row (based on primary keys) - this is pretty specific * to SQL and the way that SQL relationships are returned based on joins. */ updateRelationshipColumns( row: Row, rows: { [key: string]: Row }, relationships: RelationshipsJson[] ) { const columns: { [key: string]: any } = {} for (let relationship of relationships) { const linkedTable = this.tables[relationship.tableName] if (!linkedTable) { continue } let linked = basicProcessing(row, linkedTable) if (!linked._id) { continue } // if not returning full docs then get the minimal links out const display = linkedTable.primaryDisplay linked = { primaryDisplay: display ? linked[display] : undefined, _id: linked._id, } columns[relationship.column] = linked } for (let [column, related] of Object.entries(columns)) { if (!row._id) { continue } const rowId: string = row._id if (!Array.isArray(rows[rowId][column])) { rows[rowId][column] = [] } // make sure relationship hasn't been found already if ( !rows[rowId][column].find( (relation: Row) => relation._id === related._id ) ) { rows[rowId][column].push(related) } } return rows } outputProcessing( rows: Row[] = [], table: Table, relationships: RelationshipsJson[] ) { if (!rows || rows.length === 0 || rows[0].read === true) { return [] } let finalRows: { [key: string]: Row } = {} for (let row of rows) { const rowId = generateIdForRow(row, table) row._id = rowId // this is a relationship of some sort if (finalRows[rowId]) { finalRows = this.updateRelationshipColumns( row, finalRows, relationships ) continue } const thisRow = fixArrayTypes(basicProcessing(row, table), table) if (thisRow._id == null) { throw "Unable to generate row ID for SQL rows" } finalRows[thisRow._id] = thisRow // do this at end once its been added to the final rows finalRows = this.updateRelationshipColumns( row, finalRows, relationships ) } return processFormulas(table, Object.values(finalRows)) } /** * Gets the list of relationship JSON structures based on the columns in the table, * this will be used by the underlying library to build whatever relationship mechanism * it has (e.g. SQL joins). */ buildRelationships(table: Table): RelationshipsJson[] { const relationships = [] for (let [fieldName, field] of Object.entries(table.schema)) { if (field.type !== FieldTypes.LINK) { continue } const { tableName: linkTableName } = breakExternalTableId(field.tableId) // no table to link to, this is not a valid relationships if (!this.tables[linkTableName]) { continue } const linkTable = this.tables[linkTableName] if (!table.primary || !linkTable.primary) { continue } const definition: any = { // if no foreign key specified then use the name of the field in other table from: field.foreignKey || table.primary[0], to: field.fieldName, tableName: linkTableName, // need to specify where to put this back into column: fieldName, } if (field.through) { const { tableName: throughTableName } = breakExternalTableId( field.through ) definition.through = throughTableName // don't support composite keys for relationships definition.from = field.throughTo || table.primary[0] definition.to = field.throughFrom || linkTable.primary[0] definition.fromPrimary = table.primary[0] definition.toPrimary = linkTable.primary[0] } relationships.push(definition) } return relationships } /** * This is a cached lookup, of relationship records, this is mainly for creating/deleting junction * information. */ async lookupRelations(tableId: string, row: Row) { const related: { [key: string]: any } = {} const { tableName } = breakExternalTableId(tableId) const table = this.tables[tableName] // @ts-ignore const primaryKey = table.primary[0] // make a new request to get the row with all its relationships // we need this to work out if any relationships need removed for (let field of Object.values(table.schema)) { if ( field.type !== FieldTypes.LINK || !field.fieldName || isOneSide(field) ) { continue } const isMany = field.relationshipType === RelationshipTypes.MANY_TO_MANY const tableId = isMany ? field.through : field.tableId const { tableName: relatedTableName } = breakExternalTableId(tableId) // @ts-ignore const linkPrimaryKey = this.tables[relatedTableName].primary[0] const manyKey = field.throughTo || primaryKey const lookupField = isMany ? primaryKey : field.foreignKey const fieldName = isMany ? manyKey : field.fieldName if (!lookupField || !row[lookupField]) { continue } const response = await getDatasourceAndQuery(this.appId, { endpoint: getEndpoint(tableId, DataSourceOperation.READ), filters: { equal: { [fieldName]: row[lookupField], }, }, }) // this is the response from knex if no rows found const rows = !response[0].read ? response : [] const storeTo = isMany ? field.throughFrom || linkPrimaryKey : manyKey related[storeTo] = { rows, isMany, tableId } } return related } /** * Once a row has been written we may need to update a many field, e.g. updating foreign keys * in a bunch of rows in another table, or inserting/deleting rows from a junction table (many to many). * This is quite a complex process and is handled by this function, there are a few things going on here: * 1. If updating foreign keys its relatively simple, just create a filter for the row that needs updated * and write the various components. * 2. If junction table, then we lookup what exists already, write what doesn't exist, work out what * isn't supposed to exist anymore and delete those. This is better than the usual method of delete them * all and then re-create, as theres no chance of losing data (e.g. delete succeed, but write fail). */ async handleManyRelationships( mainTableId: string, row: Row, relationships: ManyRelationship[] ) { const { appId } = this // if we're creating (in a through table) need to wipe the existing ones first const promises = [] const related = await this.lookupRelations(mainTableId, row) for (let relationship of relationships) { const { key, tableId, isUpdate, id, ...rest } = relationship const body = processObjectSync(rest, row) const linkTable = this.getTable(tableId) // @ts-ignore const linkPrimary = linkTable.primary[0] const rows = related[key].rows || [] const found = rows.find( (row: { [key: string]: any }) => row[linkPrimary] === relationship.id || row[linkPrimary] === body[linkPrimary] ) const operation = isUpdate ? DataSourceOperation.UPDATE : DataSourceOperation.CREATE if (!found) { promises.push( getDatasourceAndQuery(appId, { endpoint: getEndpoint(tableId, operation), // if we're doing many relationships then we're writing, only one response body, filters: buildFilters(id, {}, linkTable), }) ) } else { // remove the relationship from cache so it isn't adjusted again rows.splice(rows.indexOf(found), 1) } } // finally cleanup anything that needs to be removed for (let [colName, { isMany, rows, tableId }] of Object.entries( related )) { const table: Table = this.getTable(tableId) // if its not the foreign key skip it, nothing to do if (table.primary && table.primary.indexOf(colName) !== -1) { continue } for (let row of rows) { const filters = buildFilters(generateIdForRow(row, table), {}, table) // safety check, if there are no filters on deletion bad things happen if (Object.keys(filters).length !== 0) { const op = isMany ? DataSourceOperation.DELETE : DataSourceOperation.UPDATE const body = isMany ? null : { [colName]: null } promises.push( getDatasourceAndQuery(this.appId, { endpoint: getEndpoint(tableId, op), body, filters, }) ) } } } await Promise.all(promises) } /** * This function is a bit crazy, but the exact purpose of it is to protect against the scenario in which * you have column overlap in relationships, e.g. we join a few different tables and they all have the * concept of an ID, but for some of them it will be null (if they say don't have a relationship). * Creating the specific list of fields that we desire, and excluding the ones that are no use to us * is more performant and has the added benefit of protecting against this scenario. */ buildFields( table: Table, includeRelations: IncludeRelationships = IncludeRelationships.INCLUDE ) { function extractRealFields(table: Table, existing: string[] = []) { return Object.entries(table.schema) .filter( column => column[1].type !== FieldTypes.LINK && column[1].type !== FieldTypes.FORMULA && !existing.find((field: string) => field === column[0]) ) .map(column => `${table.name}.${column[0]}`) } let fields = extractRealFields(table) for (let field of Object.values(table.schema)) { if (field.type !== FieldTypes.LINK || !includeRelations) { continue } const { tableName: linkTableName } = breakExternalTableId(field.tableId) const linkTable = this.tables[linkTableName] if (linkTable) { const linkedFields = extractRealFields(linkTable, fields) fields = fields.concat(linkedFields) } } return fields } async run(config: RunConfig) { const { appId, operation, tableId } = this let { datasourceId, tableName } = breakExternalTableId(tableId) if (!this.datasource) { const db = new CouchDB(appId) this.datasource = await db.get(datasourceId) if (!this.datasource || !this.datasource.entities) { throw "No tables found, fetch tables before query." } this.tables = this.datasource.entities } const table = this.tables[tableName] let isSql = isSQL(this.datasource) if (!table) { throw `Unable to process query, table "${tableName}" not defined.` } // look for specific components of config which may not be considered acceptable let { id, row, filters, sort, paginate, rows } = cleanupConfig( config, table ) filters = buildFilters(id, filters || {}, table) const relationships = this.buildRelationships(table) // clean up row on ingress using schema const processed = this.inputProcessing(row, table) row = processed.row if ( operation === DataSourceOperation.DELETE && (filters == null || Object.keys(filters).length === 0) ) { throw "Deletion must be filtered" } let json = { endpoint: { datasourceId, entityId: tableName, operation, }, resource: { // have to specify the fields to avoid column overlap (for SQL) fields: isSql ? this.buildFields(table) : [], }, filters, sort, paginate, relationships, body: row || rows, // pass an id filter into extra, purely for mysql/returning extra: { idFilter: buildFilters(id || generateIdForRow(row, table), {}, table), }, meta: { table, }, } // can't really use response right now const response = await getDatasourceAndQuery(appId, json) // handle many to many relationships now if we know the ID (could be auto increment) if ( operation !== DataSourceOperation.READ && processed.manyRelationships ) { await this.handleManyRelationships( table._id || "", response[0], processed.manyRelationships ) } const output = this.outputProcessing(response, table, relationships) // if reading it'll just be an array of rows, return whole thing return operation === DataSourceOperation.READ && Array.isArray(response) ? output : { row: output[0], table } } } module.exports = ExternalRequest }