From 329156d6cc9c7b1a729f30391ad2ee518f31b462 Mon Sep 17 00:00:00 2001 From: mike12345567 Date: Thu, 28 Oct 2021 19:39:42 +0100 Subject: [PATCH 1/9] SQL table building. --- .../backend/DataTable/DataTable.svelte | 4 +- .../components/backend/DataTable/Table.svelte | 2 +- .../DataTable/modals/CreateEditColumn.svelte | 29 +++- .../popovers/EditTablePopover.svelte | 11 +- .../[selectedDatasource]/index.svelte | 21 +++ .../modals/CreateExternalTableModal.svelte | 44 +++++ packages/builder/src/stores/backend/tables.js | 3 + .../server/src/api/controllers/datasource.js | 4 +- .../api/controllers/row/ExternalRequest.ts | 22 +-- .../server/src/api/controllers/row/utils.js | 13 +- .../src/api/controllers/table/external.js | 116 +++++++++++++ .../server/src/api/controllers/table/index.js | 162 +++--------------- .../src/api/controllers/table/internal.js | 138 +++++++++++++++ packages/server/src/constants/index.js | 3 + packages/server/src/definitions/common.ts | 2 +- packages/server/src/definitions/datasource.ts | 5 + packages/server/src/integrations/base/sql.ts | 33 ++-- .../server/src/integrations/base/sqlTable.ts | 132 ++++++++++++++ .../server/src/integrations/base/utils.ts | 19 ++ packages/server/src/integrations/postgres.ts | 12 +- 20 files changed, 584 insertions(+), 191 deletions(-) create mode 100644 packages/builder/src/pages/builder/app/[application]/data/datasource/[selectedDatasource]/modals/CreateExternalTableModal.svelte create mode 100644 packages/server/src/api/controllers/table/external.js create mode 100644 packages/server/src/api/controllers/table/internal.js create mode 100644 packages/server/src/integrations/base/sqlTable.ts create mode 100644 packages/server/src/integrations/base/utils.ts diff --git a/packages/builder/src/components/backend/DataTable/DataTable.svelte b/packages/builder/src/components/backend/DataTable/DataTable.svelte index 336bb51670..1af703800f 100644 --- a/packages/builder/src/components/backend/DataTable/DataTable.svelte +++ b/packages/builder/src/components/backend/DataTable/DataTable.svelte @@ -98,9 +98,7 @@ on:updatecolumns={onUpdateColumns} on:updaterows={onUpdateRows} > - {#if isInternal} - - {/if} + {#if schema && Object.keys(schema).length > 0} {#if !isUsersTable} editColumn(e.detail)} on:editrow={e => editRow(e.detail)} diff --git a/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte b/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte index cd437bcad2..221d391cbf 100644 --- a/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte +++ b/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte @@ -56,7 +56,7 @@ let deletion $: tableOptions = $tables.list.filter( - table => table._id !== $tables.draft._id && table.type !== "external" + opt => opt._id !== $tables.draft._id && opt.type === table.type ) $: required = !!field?.constraints?.presence || primaryDisplay $: uneditable = @@ -83,6 +83,7 @@ $: canBeRequired = field.type !== LINK_TYPE && !uneditable && field.type !== AUTO_TYPE $: relationshipOptions = getRelationshipOptions(field) + $: external = table.type === "external" async function saveColumn() { if (field.type === AUTO_TYPE) { @@ -193,6 +194,27 @@ }, ] } + + function getAllowedTypes() { + if (!external) { + return [ + ...Object.values(fieldDefinitions), + { name: "Auto Column", type: AUTO_TYPE }, + ] + } else { + return [ + FIELDS.STRING, + FIELDS.LONGFORM, + FIELDS.OPTIONS, + FIELDS.DATETIME, + FIELDS.NUMBER, + FIELDS.BOOLEAN, + FIELDS.ARRAY, + FIELDS.FORMULA, + FIELDS.LINK, + ] + } + } field.name} getOptionValue={field => field.type} /> diff --git a/packages/builder/src/components/backend/TableNavigator/popovers/EditTablePopover.svelte b/packages/builder/src/components/backend/TableNavigator/popovers/EditTablePopover.svelte index 2513c6c7e5..04094b881a 100644 --- a/packages/builder/src/components/backend/TableNavigator/popovers/EditTablePopover.svelte +++ b/packages/builder/src/components/backend/TableNavigator/popovers/EditTablePopover.svelte @@ -1,7 +1,7 @@ @@ -130,6 +137,10 @@ + + + + {#if datasource && integration}
@@ -189,6 +200,11 @@ /> {/if}
+
+ + New table + +
{#each plusTables as table}
onClickTable(table)}>

{table.name}

@@ -325,4 +341,9 @@ .table-buttons div { grid-column-end: -1; } + + .add-table { + margin-right: 0; + margin-left: auto; + } diff --git a/packages/builder/src/pages/builder/app/[application]/data/datasource/[selectedDatasource]/modals/CreateExternalTableModal.svelte b/packages/builder/src/pages/builder/app/[application]/data/datasource/[selectedDatasource]/modals/CreateExternalTableModal.svelte new file mode 100644 index 0000000000..c7a040d89a --- /dev/null +++ b/packages/builder/src/pages/builder/app/[application]/data/datasource/[selectedDatasource]/modals/CreateExternalTableModal.svelte @@ -0,0 +1,44 @@ + + + + Provide a name for your new table; you can add columns once it is created. + + diff --git a/packages/builder/src/stores/backend/tables.js b/packages/builder/src/stores/backend/tables.js index 161877f660..7f90a04a05 100644 --- a/packages/builder/src/stores/backend/tables.js +++ b/packages/builder/src/stores/backend/tables.js @@ -62,6 +62,9 @@ export function createTablesStore() { const response = await api.post(`/api/tables`, updatedTable) const savedTable = await response.json() await fetch() + if (table.type === "external") { + await datasources.fetch() + } await select(savedTable) return savedTable } diff --git a/packages/server/src/api/controllers/datasource.js b/packages/server/src/api/controllers/datasource.js index d75a8f8ced..604fee004e 100644 --- a/packages/server/src/api/controllers/datasource.js +++ b/packages/server/src/api/controllers/datasource.js @@ -9,7 +9,7 @@ const { } = require("../../db/utils") const { BuildSchemaErrors } = require("../../constants") const { integrations } = require("../../integrations") -const { makeExternalQuery } = require("./row/utils") +const { getDatasourceAndQuery } = require("./row/utils") exports.fetch = async function (ctx) { const database = new CouchDB(ctx.appId) @@ -138,7 +138,7 @@ exports.find = async function (ctx) { exports.query = async function (ctx) { const queryJson = ctx.request.body try { - ctx.body = await makeExternalQuery(ctx.appId, queryJson) + ctx.body = await getDatasourceAndQuery(ctx.appId, queryJson) } catch (err) { ctx.throw(400, err) } diff --git a/packages/server/src/api/controllers/row/ExternalRequest.ts b/packages/server/src/api/controllers/row/ExternalRequest.ts index c58364af07..f538e01f73 100644 --- a/packages/server/src/api/controllers/row/ExternalRequest.ts +++ b/packages/server/src/api/controllers/row/ExternalRequest.ts @@ -36,7 +36,7 @@ interface RunConfig { } module External { - const { makeExternalQuery } = require("./utils") + const { getDatasourceAndQuery } = require("./utils") const { DataSourceOperation, FieldTypes, @@ -46,6 +46,7 @@ module External { 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, @@ -225,7 +226,7 @@ module External { manyRelationships: ManyRelationship[] = [] for (let [key, field] of Object.entries(table.schema)) { // if set already, or not set just skip it - if ((!row[key] && row[key] !== "") || newRow[key] || field.autocolumn) { + 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) @@ -361,7 +362,7 @@ module External { relationships ) } - return Object.values(finalRows) + return processFormulas(table, Object.values(finalRows)) } /** @@ -428,7 +429,7 @@ module External { const tableId = isMany ? field.through : field.tableId const manyKey = field.throughFrom || primaryKey const fieldName = isMany ? manyKey : field.fieldName - const response = await makeExternalQuery(this.appId, { + const response = await getDatasourceAndQuery(this.appId, { endpoint: getEndpoint(tableId, DataSourceOperation.READ), filters: { equal: { @@ -479,7 +480,7 @@ module External { : DataSourceOperation.CREATE if (!found) { promises.push( - makeExternalQuery(appId, { + getDatasourceAndQuery(appId, { endpoint: getEndpoint(tableId, operation), // if we're doing many relationships then we're writing, only one response body, @@ -509,7 +510,7 @@ module External { : DataSourceOperation.UPDATE const body = isMany ? null : { [colName]: null } promises.push( - makeExternalQuery(this.appId, { + getDatasourceAndQuery(this.appId, { endpoint: getEndpoint(tableId, op), body, filters, @@ -532,16 +533,17 @@ module External { table: Table, includeRelations: IncludeRelationships = IncludeRelationships.INCLUDE ) { - function extractNonLinkFieldNames(table: Table, existing: string[] = []) { + 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 = extractNonLinkFieldNames(table) + let fields = extractRealFields(table) for (let field of Object.values(table.schema)) { if (field.type !== FieldTypes.LINK || !includeRelations) { continue @@ -549,7 +551,7 @@ module External { const { tableName: linkTableName } = breakExternalTableId(field.tableId) const linkTable = this.tables[linkTableName] if (linkTable) { - const linkedFields = extractNonLinkFieldNames(linkTable, fields) + const linkedFields = extractRealFields(linkTable, fields) fields = fields.concat(linkedFields) } } @@ -609,7 +611,7 @@ module External { }, } // can't really use response right now - const response = await makeExternalQuery(appId, json) + 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 && diff --git a/packages/server/src/api/controllers/row/utils.js b/packages/server/src/api/controllers/row/utils.js index ca6c782713..bd2df084a3 100644 --- a/packages/server/src/api/controllers/row/utils.js +++ b/packages/server/src/api/controllers/row/utils.js @@ -4,8 +4,8 @@ const CouchDB = require("../../../db") const { InternalTables } = require("../../../db/utils") const userController = require("../user") const { FieldTypes } = require("../../../constants") -const { integrations } = require("../../../integrations") const { processStringSync } = require("@budibase/string-templates") +const { makeExternalQuery } = require("../../../integrations/base/utils") validateJs.extend(validateJs.validators.datetime, { parse: function (value) { @@ -17,18 +17,11 @@ validateJs.extend(validateJs.validators.datetime, { }, }) -exports.makeExternalQuery = async (appId, json) => { +exports.getDatasourceAndQuery = async (appId, json) => { const datasourceId = json.endpoint.datasourceId const db = new CouchDB(appId) const datasource = await db.get(datasourceId) - const Integration = integrations[datasource.source] - // query is the opinionated function - if (Integration.prototype.query) { - const integration = new Integration(datasource.config) - return integration.query(json) - } else { - throw "Datasource does not support query." - } + return makeExternalQuery(datasource, json) } exports.findRow = async (ctx, db, tableId, rowId) => { diff --git a/packages/server/src/api/controllers/table/external.js b/packages/server/src/api/controllers/table/external.js new file mode 100644 index 0000000000..42454fd2e8 --- /dev/null +++ b/packages/server/src/api/controllers/table/external.js @@ -0,0 +1,116 @@ +const CouchDB = require("../../../db") +const { + buildExternalTableId, + breakExternalTableId, +} = require("../../../integrations/utils") +const { getTable } = require("./utils") +const { DataSourceOperation, FieldTypes } = require("../../../constants") +const { makeExternalQuery } = require("../../../integrations/base/utils") +const { cloneDeep } = require("lodash/fp") + +async function makeTableRequest( + datasource, + operation, + table, + tables, + oldTable = null +) { + const json = { + endpoint: { + datasourceId: datasource._id, + entityId: table._id, + operation, + }, + meta: { + tables, + }, + table, + } + if (oldTable) { + json.meta.table = oldTable + } + return makeExternalQuery(datasource, json) +} + +function getDatasourceId(table) { + if (!table) { + throw "No table supplied" + } + if (table.sourceId) { + return table.sourceId + } + return breakExternalTableId(table._id).datasourceId +} + +exports.save = async function (ctx) { + const appId = ctx.appId + const table = ctx.request.body + // can't do this + delete table.dataImport + const datasourceId = getDatasourceId(ctx.request.body) + let tableToSave = { + type: "table", + _id: buildExternalTableId(datasourceId, table.name), + ...table, + } + + let oldTable + if (ctx.request.body && ctx.request.body._id) { + oldTable = await getTable(appId, ctx.request.body._id) + } + + const db = new CouchDB(appId) + const datasource = await db.get(datasourceId) + const tables = datasource.entities + + // check if relations need setup + for (let [key, schema] of Object.entries(tableToSave.schema)) { + // TODO: this assumes all relationships are the same, need to handle cardinality and many to many + if (schema.type === FieldTypes.LINK) { + const relatedTable = Object.values(tables).find( + table => table._id === schema.tableId + ) + const relatedField = schema.fieldName + const foreignKey = `fk_${relatedTable.name}_${schema.fieldName}` + // create foreign key + tableToSave.schema[foreignKey] = { type: FieldTypes.NUMBER } + // setup the relation in other table and this one + schema.foreignKey = foreignKey + schema.fieldName = foreignKey + schema.main = true + const relatedSchema = cloneDeep(schema) + relatedSchema.fieldName = key + delete relatedSchema.main + relatedTable.schema[relatedField] = relatedSchema + } + } + + const operation = oldTable + ? DataSourceOperation.UPDATE_TABLE + : DataSourceOperation.CREATE_TABLE + await makeTableRequest(datasource, operation, tableToSave, tables, oldTable) + + // store it into couch now for budibase reference + datasource.entities[tableToSave.name] = tableToSave + await db.put(datasource) + + return tableToSave +} + +exports.destroy = async function (ctx) { + const appId = ctx.appId + const tableToDelete = await getTable(appId, ctx.params.tableId) + const datasourceId = getDatasourceId(tableToDelete) + + const db = new CouchDB(appId) + const datasource = await db.get(datasourceId) + const tables = datasource.entities + + const operation = DataSourceOperation.DELETE_TABLE + await makeTableRequest(datasource, operation, tableToDelete, tables) + + delete datasource.entities[tableToDelete.name] + await db.put(datasource) + + return tableToDelete +} diff --git a/packages/server/src/api/controllers/table/index.js b/packages/server/src/api/controllers/table/index.js index d4356c9c8b..97b48943b8 100644 --- a/packages/server/src/api/controllers/table/index.js +++ b/packages/server/src/api/controllers/table/index.js @@ -1,16 +1,28 @@ const CouchDB = require("../../../db") -const linkRows = require("../../../db/linkedRows") +const internal = require("./internal") +const external = require("./external") const csvParser = require("../../../utilities/csvParser") +const { isExternalTable } = require("../../../integrations/utils") const { - getRowParams, getTableParams, - generateTableID, getDatasourceParams, BudibaseInternalDB, } = require("../../../db/utils") -const { FieldTypes } = require("../../../constants") -const { TableSaveFunctions, getTable } = require("./utils") +const { getTable } = require("./utils") +function pickApi({ tableId, table }) { + if (table && !tableId) { + tableId = table._id + } + if (table && table.type === "external") { + return external + } else if (tableId && isExternalTable(tableId)) { + return external + } + return internal +} + +// covers both internal and external exports.fetch = async function (ctx) { const db = new CouchDB(ctx.appId) @@ -50,143 +62,23 @@ exports.find = async function (ctx) { exports.save = async function (ctx) { const appId = ctx.appId - const db = new CouchDB(appId) - const { dataImport, ...rest } = ctx.request.body - let tableToSave = { - type: "table", - _id: generateTableID(), - views: {}, - ...rest, - } - - // if the table obj had an _id then it will have been retrieved - let oldTable - if (ctx.request.body && ctx.request.body._id) { - oldTable = await db.get(ctx.request.body._id) - } - - // saving a table is a complex operation, involving many different steps, this - // has been broken out into a utility to make it more obvious/easier to manipulate - const tableSaveFunctions = new TableSaveFunctions({ - db, - ctx, - oldTable, - dataImport, - }) - tableToSave = await tableSaveFunctions.before(tableToSave) - - // make sure that types don't change of a column, have to remove - // the column if you want to change the type - if (oldTable && oldTable.schema) { - for (let propKey of Object.keys(tableToSave.schema)) { - let column = tableToSave.schema[propKey] - let oldColumn = oldTable.schema[propKey] - if (oldColumn && oldColumn.type === "internal") { - oldColumn.type = "auto" - } - if (oldColumn && oldColumn.type !== column.type) { - ctx.throw(400, "Cannot change the type of a column") - } - } - } - - // Don't rename if the name is the same - let { _rename } = tableToSave - /* istanbul ignore next */ - if (_rename && _rename.old === _rename.updated) { - _rename = null - delete tableToSave._rename - } - - // rename row fields when table column is renamed - /* istanbul ignore next */ - if (_rename && tableToSave.schema[_rename.updated].type === FieldTypes.LINK) { - ctx.throw(400, "Cannot rename a linked column.") - } - - tableToSave = await tableSaveFunctions.mid(tableToSave) - - // update schema of non-statistics views when new columns are added - for (let view in tableToSave.views) { - const tableView = tableToSave.views[view] - if (!tableView) continue - - if (tableView.schema.group || tableView.schema.field) continue - tableView.schema = tableToSave.schema - } - - // update linked rows - try { - const linkResp = await linkRows.updateLinks({ - appId, - eventType: oldTable - ? linkRows.EventType.TABLE_UPDATED - : linkRows.EventType.TABLE_SAVE, - table: tableToSave, - oldTable: oldTable, - }) - if (linkResp != null && linkResp._rev) { - tableToSave._rev = linkResp._rev - } - } catch (err) { - ctx.throw(400, err) - } - - // don't perform any updates until relationships have been - // checked by the updateLinks function - const updatedRows = tableSaveFunctions.getUpdatedRows() - if (updatedRows && updatedRows.length !== 0) { - await db.bulkDocs(updatedRows) - } - const result = await db.put(tableToSave) - tableToSave._rev = result.rev - - tableToSave = await tableSaveFunctions.after(tableToSave) - - ctx.eventEmitter && - ctx.eventEmitter.emitTable(`table:save`, appId, tableToSave) - + const table = ctx.request.body + const savedTable = await pickApi({ table }).save(ctx) ctx.status = 200 - ctx.message = `Table ${ctx.request.body.name} saved successfully.` - ctx.body = tableToSave + ctx.message = `Table ${table.name} saved successfully.` + ctx.eventEmitter && + ctx.eventEmitter.emitTable(`table:save`, appId, savedTable) + ctx.body = savedTable } exports.destroy = async function (ctx) { const appId = ctx.appId - const db = new CouchDB(appId) - const tableToDelete = await db.get(ctx.params.tableId) - - // Delete all rows for that table - const rows = await db.allDocs( - getRowParams(ctx.params.tableId, null, { - include_docs: true, - }) - ) - await db.bulkDocs(rows.rows.map(row => ({ ...row.doc, _deleted: true }))) - - // update linked rows - await linkRows.updateLinks({ - appId, - eventType: linkRows.EventType.TABLE_DELETE, - table: tableToDelete, - }) - - // don't remove the table itself until very end - await db.remove(tableToDelete) - - // remove table search index - const currentIndexes = await db.getIndexes() - const existingIndex = currentIndexes.indexes.find( - existing => existing.name === `search:${ctx.params.tableId}` - ) - if (existingIndex) { - await db.deleteIndex(existingIndex) - } - + const tableId = ctx.params.tableId + const deletedTable = await pickApi({ tableId }).destroy(ctx) ctx.eventEmitter && - ctx.eventEmitter.emitTable(`table:delete`, appId, tableToDelete) + ctx.eventEmitter.emitTable(`table:delete`, appId, deletedTable) ctx.status = 200 - ctx.body = { message: `Table ${ctx.params.tableId} deleted.` } + ctx.body = { message: `Table ${tableId} deleted.` } } exports.validateCSVSchema = async function (ctx) { diff --git a/packages/server/src/api/controllers/table/internal.js b/packages/server/src/api/controllers/table/internal.js new file mode 100644 index 0000000000..898cd0593b --- /dev/null +++ b/packages/server/src/api/controllers/table/internal.js @@ -0,0 +1,138 @@ +const CouchDB = require("../../../db") +const linkRows = require("../../../db/linkedRows") +const { getRowParams, generateTableID } = require("../../../db/utils") +const { FieldTypes } = require("../../../constants") +const { TableSaveFunctions } = require("./utils") + +exports.save = async function (ctx) { + const appId = ctx.appId + const db = new CouchDB(appId) + const { dataImport, ...rest } = ctx.request.body + let tableToSave = { + type: "table", + _id: generateTableID(), + views: {}, + ...rest, + } + + // if the table obj had an _id then it will have been retrieved + let oldTable + if (ctx.request.body && ctx.request.body._id) { + oldTable = await db.get(ctx.request.body._id) + } + + // saving a table is a complex operation, involving many different steps, this + // has been broken out into a utility to make it more obvious/easier to manipulate + const tableSaveFunctions = new TableSaveFunctions({ + db, + ctx, + oldTable, + dataImport, + }) + tableToSave = await tableSaveFunctions.before(tableToSave) + + // make sure that types don't change of a column, have to remove + // the column if you want to change the type + if (oldTable && oldTable.schema) { + for (let propKey of Object.keys(tableToSave.schema)) { + let column = tableToSave.schema[propKey] + let oldColumn = oldTable.schema[propKey] + if (oldColumn && oldColumn.type === "internal") { + oldColumn.type = "auto" + } + if (oldColumn && oldColumn.type !== column.type) { + ctx.throw(400, "Cannot change the type of a column") + } + } + } + + // Don't rename if the name is the same + let { _rename } = tableToSave + /* istanbul ignore next */ + if (_rename && _rename.old === _rename.updated) { + _rename = null + delete tableToSave._rename + } + + // rename row fields when table column is renamed + /* istanbul ignore next */ + if (_rename && tableToSave.schema[_rename.updated].type === FieldTypes.LINK) { + ctx.throw(400, "Cannot rename a linked column.") + } + + tableToSave = await tableSaveFunctions.mid(tableToSave) + + // update schema of non-statistics views when new columns are added + for (let view in tableToSave.views) { + const tableView = tableToSave.views[view] + if (!tableView) continue + + if (tableView.schema.group || tableView.schema.field) continue + tableView.schema = tableToSave.schema + } + + // update linked rows + try { + const linkResp = await linkRows.updateLinks({ + appId, + eventType: oldTable + ? linkRows.EventType.TABLE_UPDATED + : linkRows.EventType.TABLE_SAVE, + table: tableToSave, + oldTable: oldTable, + }) + if (linkResp != null && linkResp._rev) { + tableToSave._rev = linkResp._rev + } + } catch (err) { + ctx.throw(400, err) + } + + // don't perform any updates until relationships have been + // checked by the updateLinks function + const updatedRows = tableSaveFunctions.getUpdatedRows() + if (updatedRows && updatedRows.length !== 0) { + await db.bulkDocs(updatedRows) + } + const result = await db.put(tableToSave) + tableToSave._rev = result.rev + + tableToSave = await tableSaveFunctions.after(tableToSave) + + return tableToSave +} + +exports.destroy = async function (ctx) { + const appId = ctx.appId + const db = new CouchDB(appId) + const tableToDelete = await db.get(ctx.params.tableId) + + // Delete all rows for that table + const rows = await db.allDocs( + getRowParams(ctx.params.tableId, null, { + include_docs: true, + }) + ) + await db.bulkDocs(rows.rows.map(row => ({ ...row.doc, _deleted: true }))) + + // update linked rows + await linkRows.updateLinks({ + appId, + eventType: linkRows.EventType.TABLE_DELETE, + table: tableToDelete, + }) + + // don't remove the table itself until very end + await db.remove(tableToDelete) + + // remove table search index + const currentIndexes = await db.getIndexes() + const existingIndex = currentIndexes.indexes.find( + existing => existing.name === `search:${ctx.params.tableId}` + ) + if (existingIndex) { + await db.deleteIndex(existingIndex) + } + + return tableToDelete +} diff --git a/packages/server/src/constants/index.js b/packages/server/src/constants/index.js index 7a8958c36a..80c62cd02e 100644 --- a/packages/server/src/constants/index.js +++ b/packages/server/src/constants/index.js @@ -62,6 +62,9 @@ exports.DataSourceOperation = { READ: "READ", UPDATE: "UPDATE", DELETE: "DELETE", + CREATE_TABLE: "CREATE_TABLE", + UPDATE_TABLE: "UPDATE_TABLE", + DELETE_TABLE: "DELETE_TABLE", } exports.SortDirection = { diff --git a/packages/server/src/definitions/common.ts b/packages/server/src/definitions/common.ts index f439fc0d28..b2ab203bee 100644 --- a/packages/server/src/definitions/common.ts +++ b/packages/server/src/definitions/common.ts @@ -36,7 +36,7 @@ export interface TableSchema { export interface Table extends Base { type?: string views?: {} - name?: string + name: string primary?: string[] schema: TableSchema primaryDisplay?: string diff --git a/packages/server/src/definitions/datasource.ts b/packages/server/src/definitions/datasource.ts index c4b248fa17..a82e50b140 100644 --- a/packages/server/src/definitions/datasource.ts +++ b/packages/server/src/definitions/datasource.ts @@ -5,6 +5,9 @@ export enum Operation { READ = "READ", UPDATE = "UPDATE", DELETE = "DELETE", + CREATE_TABLE = "CREATE_TABLE", + UPDATE_TABLE = "UPDATE_TABLE", + DELETE_TABLE = "DELETE_TABLE", } export enum SortDirection { @@ -142,8 +145,10 @@ export interface QueryJson { sort?: SortJson paginate?: PaginationJson body?: object + table?: Table meta?: { table?: Table + tables?: Record } extra?: { idFilter?: SearchFilters diff --git a/packages/server/src/integrations/base/sql.ts b/packages/server/src/integrations/base/sql.ts index 316e20e352..738b44afcc 100644 --- a/packages/server/src/integrations/base/sql.ts +++ b/packages/server/src/integrations/base/sql.ts @@ -1,19 +1,24 @@ import { Knex, knex } from "knex" -const BASE_LIMIT = 5000 import { - QueryJson, - SearchFilters, - QueryOptions, - SortDirection, Operation, + QueryJson, + QueryOptions, RelationshipsJson, + SearchFilters, + SortDirection, } from "../../definitions/datasource" import { isIsoDateString } from "../utils" +import SqlTableQueryBuilder from "./sqlTable" + +const BASE_LIMIT = 5000 type KnexQuery = Knex.QueryBuilder | Knex function parseBody(body: any) { for (let [key, value] of Object.entries(body)) { + if (Array.isArray(value)) { + body[key] = JSON.stringify(value) + } if (typeof value !== "string") { continue } @@ -243,23 +248,14 @@ function buildDelete( } } -class SqlQueryBuilder { - private readonly sqlClient: string +class SqlQueryBuilder extends SqlTableQueryBuilder { private readonly limit: number // pass through client to get flavour of SQL constructor(client: string, limit: number = BASE_LIMIT) { - this.sqlClient = client + super(client) this.limit = limit } - /** - * @param json the input JSON structure from which an SQL query will be built. - * @return {string} the operation that was found in the JSON. - */ - _operation(json: QueryJson): Operation { - return json.endpoint.operation - } - /** * @param json The JSON query DSL which is to be converted to SQL. * @param opts extra options which are to be passed into the query builder, e.g. disableReturning @@ -267,7 +263,8 @@ class SqlQueryBuilder { * @return {{ sql: string, bindings: object }} the query ready to be passed to the driver. */ _query(json: QueryJson, opts: QueryOptions = {}) { - const client = knex({ client: this.sqlClient }) + const sqlClient = this.getSqlClient() + const client = knex({ client: sqlClient }) let query switch (this._operation(json)) { case Operation.CREATE: @@ -282,6 +279,8 @@ class SqlQueryBuilder { case Operation.DELETE: query = buildDelete(client, json, opts) break + case Operation.CREATE_TABLE: case Operation.UPDATE_TABLE: case Operation.DELETE_TABLE: + return this._tableQuery(json) default: throw `Operation type is not supported by SQL query builder` } diff --git a/packages/server/src/integrations/base/sqlTable.ts b/packages/server/src/integrations/base/sqlTable.ts new file mode 100644 index 0000000000..f2e727ce62 --- /dev/null +++ b/packages/server/src/integrations/base/sqlTable.ts @@ -0,0 +1,132 @@ +import { Knex, knex } from "knex" +import { Table } from "../../definitions/common" +import { Operation, QueryJson } from "../../definitions/datasource" +import { breakExternalTableId } from "../utils" +import SchemaBuilder = Knex.SchemaBuilder +import CreateTableBuilder = Knex.CreateTableBuilder +const { FieldTypes } = require("../../constants") + +function generateSchema(schema: CreateTableBuilder, table: Table, tables: Record, oldTable: null | Table = null) { + let primaryKey = table && table.primary ? table.primary[0] : null + // can't change primary once its set for now + if (primaryKey && !oldTable) { + schema.increments(primaryKey).primary() + } + const foreignKeys = Object.values(table.schema).map(col => col.foreignKey) + for (let [key, column] of Object.entries(table.schema)) { + // skip things that are already correct + const oldColumn = oldTable ? oldTable.schema[key] : null + if ((oldColumn && oldColumn.type === column.type) || primaryKey === key) { + continue + } + switch (column.type) { + case FieldTypes.STRING: case FieldTypes.OPTIONS: case FieldTypes.LONGFORM: + schema.string(key) + break + case FieldTypes.NUMBER: + if (foreignKeys.indexOf(key) === -1) { + schema.float(key) + } + break + case FieldTypes.BOOLEAN: + schema.boolean(key) + break + case FieldTypes.DATETIME: + schema.datetime(key) + break + case FieldTypes.ARRAY: + schema.json(key) + break + case FieldTypes.LINK: + if (!column.foreignKey || !column.tableId) { + throw "Invalid relationship schema" + } + const { tableName } = breakExternalTableId(column.tableId) + // @ts-ignore + const relatedTable = tables[tableName] + if (!relatedTable) { + throw "Referenced table doesn't exist" + } + schema.integer(column.foreignKey).unsigned() + schema.foreign(column.foreignKey).references(`${tableName}.${relatedTable.primary[0]}`) + } + } + return schema +} + +function buildCreateTable( + knex: Knex, + table: Table, + tables: Record, +): SchemaBuilder { + return knex.schema.createTable(table.name, schema => { + generateSchema(schema, table, tables) + }) +} + +function buildUpdateTable( + knex: Knex, + table: Table, + tables: Record, + oldTable: Table, +): SchemaBuilder { + return knex.schema.alterTable(table.name, schema => { + generateSchema(schema, table, tables, oldTable) + }) +} + +function buildDeleteTable( + knex: Knex, + table: Table, +): SchemaBuilder { + return knex.schema.dropTable(table.name) +} + +class SqlTableQueryBuilder { + private readonly sqlClient: string + + // pass through client to get flavour of SQL + constructor(client: string) { + this.sqlClient = client + } + + getSqlClient(): string { + return this.sqlClient + } + + /** + * @param json the input JSON structure from which an SQL query will be built. + * @return {string} the operation that was found in the JSON. + */ + _operation(json: QueryJson): Operation { + return json.endpoint.operation + } + + _tableQuery(json: QueryJson): any { + const client = knex({ client: this.sqlClient }) + let query + if (!json.table || !json.meta || !json.meta.tables) { + throw "Cannot execute without table being specified" + } + switch (this._operation(json)) { + case Operation.CREATE_TABLE: + query = buildCreateTable(client, json.table, json.meta.tables) + break + case Operation.UPDATE_TABLE: + if (!json.meta || !json.meta.table) { + throw "Must specify old table for update" + } + query = buildUpdateTable(client, json.table, json.meta.tables, json.meta.table) + break + case Operation.DELETE_TABLE: + query = buildDeleteTable(client, json.table) + break + default: + throw "Table operation is of unknown type" + } + return query.toSQL() + } +} + +export default SqlTableQueryBuilder +module.exports = SqlTableQueryBuilder \ No newline at end of file diff --git a/packages/server/src/integrations/base/utils.ts b/packages/server/src/integrations/base/utils.ts new file mode 100644 index 0000000000..5757232bc7 --- /dev/null +++ b/packages/server/src/integrations/base/utils.ts @@ -0,0 +1,19 @@ +import { QueryJson } from "../../definitions/datasource" +import { Datasource } from "../../definitions/common" + +module DatasourceUtils { + const { integrations } = require("../index") + + export async function makeExternalQuery(datasource: Datasource, json: QueryJson) { + const Integration = integrations[datasource.source] + // query is the opinionated function + if (Integration.prototype.query) { + const integration = new Integration(datasource.config) + return integration.query(json) + } else { + throw "Datasource does not support query." + } + } + + module.exports.makeExternalQuery = makeExternalQuery +} diff --git a/packages/server/src/integrations/postgres.ts b/packages/server/src/integrations/postgres.ts index dd7ecd5762..adae9b5fc1 100644 --- a/packages/server/src/integrations/postgres.ts +++ b/packages/server/src/integrations/postgres.ts @@ -263,8 +263,16 @@ module PostgresModule { async query(json: QueryJson) { const operation = this._operation(json).toLowerCase() const input = this._query(json) - const response = await internalQuery(this.client, input) - return response.rows.length ? response.rows : [{ [operation]: true }] + if (Array.isArray(input)) { + const responses = [] + for (let query of input) { + responses.push(await internalQuery(this.client, query)) + } + return responses + } else { + const response = await internalQuery(this.client, input) + return response.rows.length ? response.rows : [{ [operation]: true }] + } } } From 949c6b8653b61a0a18038e39da61b8730597dccf Mon Sep 17 00:00:00 2001 From: mike12345567 Date: Thu, 28 Oct 2021 22:44:31 +0100 Subject: [PATCH 2/9] Adding SQL relationship building. --- .../DataTable/modals/CreateEditColumn.svelte | 6 ++- .../src/api/controllers/table/external.js | 41 ++++++++++++++----- 2 files changed, 35 insertions(+), 12 deletions(-) diff --git a/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte b/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte index 221d391cbf..54bbb7fadf 100644 --- a/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte +++ b/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte @@ -89,6 +89,10 @@ if (field.type === AUTO_TYPE) { field = buildAutoColumn($tables.draft.name, field.name, field.subtype) } + // for now you can't create other options, just many to many for SQL + if (field.type === LINK_TYPE && external) { + field.relationshipType = RelationshipTypes.ONE_TO_MANY + } await tables.saveField({ originalName, field, @@ -324,7 +328,7 @@ getOptionLabel={table => table.name} getOptionValue={table => table._id} /> - {#if relationshipOptions && relationshipOptions.length > 0} + {#if relationshipOptions && relationshipOptions.length > 0 && !external} table._id === schema.tableId ) + // setup the schema in this table const relatedField = schema.fieldName + const relatedPrimary = relatedTable.primary[0] + // generate a foreign key const foreignKey = `fk_${relatedTable.name}_${schema.fieldName}` - // create foreign key - tableToSave.schema[foreignKey] = { type: FieldTypes.NUMBER } - // setup the relation in other table and this one + + schema.relationshipType = RelationshipTypes.ONE_TO_MANY schema.foreignKey = foreignKey - schema.fieldName = foreignKey + schema.fieldName = relatedPrimary schema.main = true - const relatedSchema = cloneDeep(schema) - relatedSchema.fieldName = key - delete relatedSchema.main - relatedTable.schema[relatedField] = relatedSchema + + relatedTable.schema[relatedField] = generateRelatedSchema(schema, table) + tableToSave.schema[foreignKey] = { + type: FieldTypes.NUMBER, + constraints: {}, + } } } From a94376ce430002c002686d5251e1898ed0681127 Mon Sep 17 00:00:00 2001 From: mike12345567 Date: Fri, 29 Oct 2021 13:34:10 +0100 Subject: [PATCH 3/9] Fixing a lot of issues around dropping columns, updating columns, relationships and bi-directionality, display columns now default to something for SQL tables as well. --- .../backend/DataTable/RowFieldControl.svelte | 4 +- .../DataTable/modals/CreateEditColumn.svelte | 41 ++++++++++++++---- .../[selectedDatasource]/index.svelte | 12 ++---- .../server/src/api/controllers/datasource.js | 10 +++++ .../src/api/controllers/table/external.js | 43 ++++++++++++++++++- .../server/src/integrations/base/sqlTable.ts | 19 +++++++- 6 files changed, 109 insertions(+), 20 deletions(-) diff --git a/packages/builder/src/components/backend/DataTable/RowFieldControl.svelte b/packages/builder/src/components/backend/DataTable/RowFieldControl.svelte index e82c55679a..25ad67b52e 100644 --- a/packages/builder/src/components/backend/DataTable/RowFieldControl.svelte +++ b/packages/builder/src/components/backend/DataTable/RowFieldControl.svelte @@ -16,8 +16,8 @@ export let value = defaultValue || (meta.type === "boolean" ? false : "") export let readonly - $: type = meta.type - $: label = capitalise(meta.name) + $: type = meta?.type + $: label = meta.name ? capitalise(meta.name) : "" {#if type === "options"} diff --git a/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte b/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte index 54bbb7fadf..9af07fe28c 100644 --- a/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte +++ b/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte @@ -31,6 +31,9 @@ const AUTO_TYPE = "auto" const FORMULA_TYPE = FIELDS.FORMULA.type const LINK_TYPE = FIELDS.LINK.type + const STRING_TYPE = FIELDS.STRING.type + const NUMBER_TYPE = FIELDS.NUMBER.type + const dispatch = createEventDispatcher() const PROHIBITED_COLUMN_NAMES = ["type", "_id", "_rev", "tableId"] const { hide } = getContext(Context.Modal) @@ -55,6 +58,7 @@ let confirmDeleteDialog let deletion + $: checkConstraints(field) $: tableOptions = $tables.list.filter( opt => opt._id !== $tables.draft._id && opt.type === table.type ) @@ -180,23 +184,26 @@ } const thisName = truncate(table.name, { length: 14 }), linkName = truncate(linkTable.name, { length: 14 }) - return [ + const options = [ { name: `Many ${thisName} rows → many ${linkName} rows`, alt: `Many ${table.name} rows → many ${linkTable.name} rows`, value: RelationshipTypes.MANY_TO_MANY, }, - { - name: `One ${linkName} row → many ${thisName} rows`, - alt: `One ${linkTable.name} rows → many ${table.name} rows`, - value: RelationshipTypes.ONE_TO_MANY, - }, { name: `One ${thisName} row → many ${linkName} rows`, alt: `One ${table.name} rows → many ${linkTable.name} rows`, value: RelationshipTypes.MANY_TO_ONE, }, ] + if (!external) { + options.push({ + name: `One ${linkName} row → many ${thisName} rows`, + alt: `One ${linkTable.name} rows → many ${table.name} rows`, + value: RelationshipTypes.ONE_TO_MANY, + }) + } + return options } function getAllowedTypes() { @@ -219,6 +226,24 @@ ] } } + + function checkConstraints(fieldToCheck) { + // most types need this, just make sure its always present + if (fieldToCheck && !fieldToCheck.constraints) { + fieldToCheck.constraints = {} + } + // some string types may have been built by server, may not always have constraints + if (fieldToCheck.type === STRING_TYPE && !fieldToCheck.constraints.length) { + fieldToCheck.constraints.length = {} + } + // some number types made server-side will be missing constraints + if ( + fieldToCheck.type === NUMBER_TYPE && + !fieldToCheck.constraints.numericality + ) { + fieldToCheck.constraints.numericality = {} + } + } {/if} - {#if canBeSearched} + {#if canBeSearched && !external}
table.name} getOptionValue={table => table._id} /> - {#if relationshipOptions && relationshipOptions.length > 0 && !external} + {#if relationshipOptions && relationshipOptions.length > 0} {/if}
-
- - New table - -
{#each plusTables as table}
onClickTable(table)}>

{table.name}

@@ -212,6 +206,9 @@

{/each} +
+ +
{#if plusTables?.length !== 0} @@ -343,7 +340,6 @@ } .add-table { - margin-right: 0; - margin-left: auto; + margin-top: var(--spacing-m); } diff --git a/packages/server/src/api/controllers/datasource.js b/packages/server/src/api/controllers/datasource.js index 604fee004e..6f33faf3d4 100644 --- a/packages/server/src/api/controllers/datasource.js +++ b/packages/server/src/api/controllers/datasource.js @@ -152,6 +152,16 @@ const buildSchemaHelper = async datasource => { await connector.buildSchema(datasource._id, datasource.entities) datasource.entities = connector.tables + // make sure they all have a display name selected + for (let entity of Object.values(datasource.entities)) { + if (entity.primaryDisplay) { + continue + } + entity.primaryDisplay = Object.values(entity.schema).find( + schema => !schema.autocolumn + ).name + } + const errors = connector.schemaErrors let error = null if (errors && Object.keys(errors).length > 0) { diff --git a/packages/server/src/api/controllers/table/external.js b/packages/server/src/api/controllers/table/external.js index 5fe923cb96..d0ec01a1d8 100644 --- a/packages/server/src/api/controllers/table/external.js +++ b/packages/server/src/api/controllers/table/external.js @@ -36,6 +36,35 @@ async function makeTableRequest( return makeExternalQuery(datasource, json) } +function cleanupRelationships(table, tables, oldTable = null) { + const tableToIterate = oldTable ? oldTable : table + // clean up relationships in couch table schemas + for (let [key, schema] of Object.entries(tableToIterate.schema)) { + if ( + schema.type === FieldTypes.LINK && + (!oldTable || table.schema[key] == null) + ) { + const relatedTable = Object.values(tables).find( + table => table._id === schema.tableId + ) + const foreignKey = schema.foreignKey + if (!relatedTable || !foreignKey) { + continue + } + for (let [relatedKey, relatedSchema] of Object.entries( + relatedTable.schema + )) { + if ( + relatedSchema.type === FieldTypes.LINK && + relatedSchema.fieldName === foreignKey + ) { + delete relatedSchema[relatedKey] + } + } + } + } +} + function getDatasourceId(table) { if (!table) { throw "No table supplied" @@ -57,6 +86,14 @@ function generateRelatedSchema(linkColumn, table) { return relatedSchema } +function oneToManyRelationshipNeedsSetup(column) { + return ( + column.type === FieldTypes.LINK && + column.relationshipType === RelationshipTypes.ONE_TO_MANY && + !column.foreignKey + ) +} + exports.save = async function (ctx) { const appId = ctx.appId const table = ctx.request.body @@ -81,7 +118,7 @@ exports.save = async function (ctx) { // check if relations need setup for (let schema of Object.values(tableToSave.schema)) { // TODO: many to many handling - if (schema.type === FieldTypes.LINK) { + if (oneToManyRelationshipNeedsSetup(schema)) { const relatedTable = Object.values(tables).find( table => table._id === schema.tableId ) @@ -104,6 +141,8 @@ exports.save = async function (ctx) { } } + cleanupRelationships(tableToSave, tables, oldTable) + const operation = oldTable ? DataSourceOperation.UPDATE_TABLE : DataSourceOperation.CREATE_TABLE @@ -128,6 +167,8 @@ exports.destroy = async function (ctx) { const operation = DataSourceOperation.DELETE_TABLE await makeTableRequest(datasource, operation, tableToDelete, tables) + cleanupRelationships(tableToDelete, tables) + delete datasource.entities[tableToDelete.name] await db.put(datasource) diff --git a/packages/server/src/integrations/base/sqlTable.ts b/packages/server/src/integrations/base/sqlTable.ts index f2e727ce62..87d0f1e3b3 100644 --- a/packages/server/src/integrations/base/sqlTable.ts +++ b/packages/server/src/integrations/base/sqlTable.ts @@ -4,7 +4,7 @@ import { Operation, QueryJson } from "../../definitions/datasource" import { breakExternalTableId } from "../utils" import SchemaBuilder = Knex.SchemaBuilder import CreateTableBuilder = Knex.CreateTableBuilder -const { FieldTypes } = require("../../constants") +const { FieldTypes, RelationshipTypes } = require("../../constants") function generateSchema(schema: CreateTableBuilder, table: Table, tables: Record, oldTable: null | Table = null) { let primaryKey = table && table.primary ? table.primary[0] : null @@ -12,6 +12,8 @@ function generateSchema(schema: CreateTableBuilder, table: Table, tables: Record if (primaryKey && !oldTable) { schema.increments(primaryKey).primary() } + + // check if any columns need added const foreignKeys = Object.values(table.schema).map(col => col.foreignKey) for (let [key, column] of Object.entries(table.schema)) { // skip things that are already correct @@ -38,6 +40,10 @@ function generateSchema(schema: CreateTableBuilder, table: Table, tables: Record schema.json(key) break case FieldTypes.LINK: + // this side of the relationship doesn't need any SQL work + if (column.relationshipType === RelationshipTypes.MANY_TO_ONE) { + break + } if (!column.foreignKey || !column.tableId) { throw "Invalid relationship schema" } @@ -51,6 +57,17 @@ function generateSchema(schema: CreateTableBuilder, table: Table, tables: Record schema.foreign(column.foreignKey).references(`${tableName}.${relatedTable.primary[0]}`) } } + + // need to check if any columns have been deleted + if (oldTable) { + const deletedColumns = Object.entries(oldTable.schema) + .filter(([key, schema]) => schema.type !== FieldTypes.LINK && table.schema[key] == null) + .map(([key]) => key) + deletedColumns.forEach(key => { + schema.dropColumn(key) + }) + } + return schema } From f071cc5219d311a428638bb7bc5514c3c4f58019 Mon Sep 17 00:00:00 2001 From: mike12345567 Date: Fri, 29 Oct 2021 18:37:29 +0100 Subject: [PATCH 4/9] Adding many to many support, generating junction table and setting up constraints. --- .../DataTable/modals/CreateEditColumn.svelte | 19 +-- .../server/src/api/controllers/datasource.js | 7 +- .../src/api/controllers/table/external.js | 156 ++++++++++++++---- .../server/src/api/controllers/table/utils.js | 20 +++ packages/server/src/definitions/common.ts | 5 + .../server/src/integrations/base/sqlTable.ts | 46 ++++-- 6 files changed, 187 insertions(+), 66 deletions(-) diff --git a/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte b/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte index 9af07fe28c..ebfea9cee6 100644 --- a/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte +++ b/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte @@ -93,10 +93,6 @@ if (field.type === AUTO_TYPE) { field = buildAutoColumn($tables.draft.name, field.name, field.subtype) } - // for now you can't create other options, just many to many for SQL - if (field.type === LINK_TYPE && external) { - field.relationshipType = RelationshipTypes.ONE_TO_MANY - } await tables.saveField({ originalName, field, @@ -184,26 +180,23 @@ } const thisName = truncate(table.name, { length: 14 }), linkName = truncate(linkTable.name, { length: 14 }) - const options = [ + return [ { name: `Many ${thisName} rows → many ${linkName} rows`, alt: `Many ${table.name} rows → many ${linkTable.name} rows`, value: RelationshipTypes.MANY_TO_MANY, }, + { + name: `One ${linkName} row → many ${thisName} rows`, + alt: `One ${linkTable.name} rows → many ${table.name} rows`, + value: RelationshipTypes.ONE_TO_MANY, + }, { name: `One ${thisName} row → many ${linkName} rows`, alt: `One ${table.name} rows → many ${linkTable.name} rows`, value: RelationshipTypes.MANY_TO_ONE, }, ] - if (!external) { - options.push({ - name: `One ${linkName} row → many ${thisName} rows`, - alt: `One ${linkTable.name} rows → many ${table.name} rows`, - value: RelationshipTypes.ONE_TO_MANY, - }) - } - return options } function getAllowedTypes() { diff --git a/packages/server/src/api/controllers/datasource.js b/packages/server/src/api/controllers/datasource.js index 6f33faf3d4..643e822a36 100644 --- a/packages/server/src/api/controllers/datasource.js +++ b/packages/server/src/api/controllers/datasource.js @@ -157,9 +157,12 @@ const buildSchemaHelper = async datasource => { if (entity.primaryDisplay) { continue } - entity.primaryDisplay = Object.values(entity.schema).find( + const notAutoColumn = Object.values(entity.schema).find( schema => !schema.autocolumn - ).name + ) + if (notAutoColumn) { + entity.primaryDisplay = notAutoColumn.name + } } const errors = connector.schemaErrors diff --git a/packages/server/src/api/controllers/table/external.js b/packages/server/src/api/controllers/table/external.js index d0ec01a1d8..7d332519ba 100644 --- a/packages/server/src/api/controllers/table/external.js +++ b/packages/server/src/api/controllers/table/external.js @@ -3,7 +3,12 @@ const { buildExternalTableId, breakExternalTableId, } = require("../../../integrations/utils") -const { getTable } = require("./utils") +const { + getTable, + generateForeignKey, + generateJunctionTableName, + foreignKeyStructure, +} = require("./utils") const { DataSourceOperation, FieldTypes, @@ -58,7 +63,7 @@ function cleanupRelationships(table, tables, oldTable = null) { relatedSchema.type === FieldTypes.LINK && relatedSchema.fieldName === foreignKey ) { - delete relatedSchema[relatedKey] + delete relatedTable.schema[relatedKey] } } } @@ -75,23 +80,78 @@ function getDatasourceId(table) { return breakExternalTableId(table._id).datasourceId } -function generateRelatedSchema(linkColumn, table) { - // generate column for other table - const relatedSchema = cloneDeep(linkColumn) - relatedSchema.fieldName = linkColumn.foreignKey - relatedSchema.foreignKey = linkColumn.fieldName - relatedSchema.relationshipType = RelationshipTypes.MANY_TO_ONE - relatedSchema.tableId = table._id - delete relatedSchema.main - return relatedSchema +function otherRelationshipType(type) { + if (type === RelationshipTypes.MANY_TO_MANY) { + return RelationshipTypes.MANY_TO_MANY + } + return type === RelationshipTypes.ONE_TO_MANY + ? RelationshipTypes.MANY_TO_ONE + : RelationshipTypes.ONE_TO_MANY } -function oneToManyRelationshipNeedsSetup(column) { - return ( - column.type === FieldTypes.LINK && - column.relationshipType === RelationshipTypes.ONE_TO_MANY && - !column.foreignKey +function generateManyLinkSchema(datasource, column, table, relatedTable) { + const primary = table.name + table.primary[0] + const relatedPrimary = relatedTable.name + relatedTable.primary[0] + const jcTblName = generateJunctionTableName(column, table, relatedTable) + // first create the new table + const junctionTable = { + _id: buildExternalTableId(datasource._id, jcTblName), + name: jcTblName, + primary: [primary, relatedPrimary], + schema: { + [primary]: foreignKeyStructure(primary, { + toTable: table.name, + toKey: table.primary[0], + }), + [relatedPrimary]: foreignKeyStructure(relatedPrimary, { + toTable: relatedTable.name, + toKey: relatedTable.primary[0], + }), + }, + } + column.through = junctionTable._id + column.throughFrom = primary + column.throughTo = relatedPrimary + column.fieldName = relatedPrimary + return junctionTable +} + +function generateLinkSchema(column, table, relatedTable, type) { + const isOneSide = type === RelationshipTypes.ONE_TO_MANY + const primary = isOneSide ? relatedTable.primary[0] : table.primary[0] + // generate a foreign key + const foreignKey = generateForeignKey(column, relatedTable) + column.relationshipType = type + column.foreignKey = isOneSide ? foreignKey : primary + column.fieldName = isOneSide ? primary : foreignKey + return foreignKey +} + +function generateRelatedSchema(linkColumn, table, relatedTable, columnName) { + // generate column for other table + const relatedSchema = cloneDeep(linkColumn) + // swap them from the main link + if (linkColumn.foreignKey) { + relatedSchema.fieldName = linkColumn.foreignKey + relatedSchema.foreignKey = linkColumn.fieldName + } + // is many to many + else { + // don't need to copy through, already got it + relatedSchema.fieldName = linkColumn.throughFrom + relatedSchema.throughTo = linkColumn.throughFrom + relatedSchema.throughFrom = linkColumn.throughTo + } + relatedSchema.relationshipType = otherRelationshipType( + linkColumn.relationshipType ) + relatedSchema.tableId = relatedTable._id + relatedSchema.name = columnName + table.schema[columnName] = relatedSchema +} + +function isRelationshipSetup(column) { + return column.foreignKey || column.through } exports.save = async function (ctx) { @@ -113,32 +173,50 @@ exports.save = async function (ctx) { const db = new CouchDB(appId) const datasource = await db.get(datasourceId) + const oldTables = cloneDeep(datasource.entities) const tables = datasource.entities + const extraTablesToUpdate = [] + // check if relations need setup for (let schema of Object.values(tableToSave.schema)) { - // TODO: many to many handling - if (oneToManyRelationshipNeedsSetup(schema)) { - const relatedTable = Object.values(tables).find( - table => table._id === schema.tableId + if (schema.type !== FieldTypes.LINK || isRelationshipSetup(schema)) { + continue + } + const relatedTable = Object.values(tables).find( + table => table._id === schema.tableId + ) + const relatedColumnName = schema.fieldName + const relationType = schema.relationshipType + if (relationType === RelationshipTypes.MANY_TO_MANY) { + const junctionTable = generateManyLinkSchema( + datasource, + schema, + table, + relatedTable ) - // setup the schema in this table - const relatedField = schema.fieldName - const relatedPrimary = relatedTable.primary[0] - // generate a foreign key - const foreignKey = `fk_${relatedTable.name}_${schema.fieldName}` - - schema.relationshipType = RelationshipTypes.ONE_TO_MANY - schema.foreignKey = foreignKey - schema.fieldName = relatedPrimary - schema.main = true - - relatedTable.schema[relatedField] = generateRelatedSchema(schema, table) - tableToSave.schema[foreignKey] = { - type: FieldTypes.NUMBER, - constraints: {}, + if (tables[junctionTable.name]) { + throw "Junction table already exists, cannot create another relationship." + } + tables[junctionTable.name] = junctionTable + extraTablesToUpdate.push(junctionTable) + } else { + const fkTable = + relationType === RelationshipTypes.ONE_TO_MANY ? table : relatedTable + const foreignKey = generateLinkSchema( + schema, + table, + relatedTable, + relationType + ) + fkTable.schema[foreignKey] = foreignKeyStructure(foreignKey) + // foreign key is in other table, need to save it to external + if (fkTable._id !== table._id) { + extraTablesToUpdate.push(fkTable) } } + generateRelatedSchema(schema, relatedTable, table, relatedColumnName) + schema.main = true } cleanupRelationships(tableToSave, tables, oldTable) @@ -147,6 +225,14 @@ exports.save = async function (ctx) { ? DataSourceOperation.UPDATE_TABLE : DataSourceOperation.CREATE_TABLE await makeTableRequest(datasource, operation, tableToSave, tables, oldTable) + // update any extra tables (like foreign keys in other tables) + for (let extraTable of extraTablesToUpdate) { + const oldExtraTable = oldTables[extraTable.name] + let op = oldExtraTable + ? DataSourceOperation.UPDATE_TABLE + : DataSourceOperation.CREATE_TABLE + await makeTableRequest(datasource, op, extraTable, tables, oldExtraTable) + } // store it into couch now for budibase reference datasource.entities[tableToSave.name] = tableToSave diff --git a/packages/server/src/api/controllers/table/utils.js b/packages/server/src/api/controllers/table/utils.js index 65c081f90e..1cca4c41d2 100644 --- a/packages/server/src/api/controllers/table/utils.js +++ b/packages/server/src/api/controllers/table/utils.js @@ -315,4 +315,24 @@ exports.checkForViewUpdates = async (db, table, rename, deletedColumns) => { } } +exports.generateForeignKey = (column, relatedTable) => { + return `fk_${relatedTable.name}_${column.fieldName}` +} + +exports.generateJunctionTableName = (column, table, relatedTable) => { + return `jt_${table.name}_${relatedTable.name}_${column.name}_${column.fieldName}` +} + +exports.foreignKeyStructure = (keyName, meta = null) => { + const structure = { + type: FieldTypes.NUMBER, + constraints: {}, + name: keyName, + } + if (meta) { + structure.meta = meta + } + return structure +} + exports.TableSaveFunctions = TableSaveFunctions diff --git a/packages/server/src/definitions/common.ts b/packages/server/src/definitions/common.ts index b2ab203bee..4308aff0a9 100644 --- a/packages/server/src/definitions/common.ts +++ b/packages/server/src/definitions/common.ts @@ -17,6 +17,11 @@ export interface FieldSchema { autocolumn?: boolean throughFrom?: string throughTo?: string + main?: boolean + meta?: { + toTable: string + toKey: string + } constraints?: { type?: string email?: boolean diff --git a/packages/server/src/integrations/base/sqlTable.ts b/packages/server/src/integrations/base/sqlTable.ts index 87d0f1e3b3..6eb6d02c3f 100644 --- a/packages/server/src/integrations/base/sqlTable.ts +++ b/packages/server/src/integrations/base/sqlTable.ts @@ -8,9 +8,15 @@ const { FieldTypes, RelationshipTypes } = require("../../constants") function generateSchema(schema: CreateTableBuilder, table: Table, tables: Record, oldTable: null | Table = null) { let primaryKey = table && table.primary ? table.primary[0] : null + const columns = Object.values(table.schema) + // all columns in a junction table will be meta + let metaCols = columns.filter(col => col.meta) + let isJunction = metaCols.length === columns.length // can't change primary once its set for now - if (primaryKey && !oldTable) { + if (primaryKey && !oldTable && !isJunction) { schema.increments(primaryKey).primary() + } else if (!oldTable && isJunction) { + schema.primary(metaCols.map(col => col.name)) } // check if any columns need added @@ -18,7 +24,7 @@ function generateSchema(schema: CreateTableBuilder, table: Table, tables: Record for (let [key, column] of Object.entries(table.schema)) { // skip things that are already correct const oldColumn = oldTable ? oldTable.schema[key] : null - if ((oldColumn && oldColumn.type === column.type) || primaryKey === key) { + if ((oldColumn && oldColumn.type === column.type) || (primaryKey === key && !isJunction)) { continue } switch (column.type) { @@ -26,7 +32,12 @@ function generateSchema(schema: CreateTableBuilder, table: Table, tables: Record schema.string(key) break case FieldTypes.NUMBER: - if (foreignKeys.indexOf(key) === -1) { + // if meta is specified then this is a junction table entry + if (column.meta && column.meta.toKey && column.meta.toTable) { + const { toKey, toTable } = column.meta + schema.integer(key).unsigned() + schema.foreign(key).references(`${toTable}.${toKey}`) + } else if (foreignKeys.indexOf(key) === -1) { schema.float(key) } break @@ -41,20 +52,23 @@ function generateSchema(schema: CreateTableBuilder, table: Table, tables: Record break case FieldTypes.LINK: // this side of the relationship doesn't need any SQL work - if (column.relationshipType === RelationshipTypes.MANY_TO_ONE) { - break + if ( + column.relationshipType !== RelationshipTypes.MANY_TO_ONE && + column.relationshipType !== RelationshipTypes.MANY_TO_MANY + ) { + if (!column.foreignKey || !column.tableId) { + throw "Invalid relationship schema" + } + const { tableName } = breakExternalTableId(column.tableId) + // @ts-ignore + const relatedTable = tables[tableName] + if (!relatedTable) { + throw "Referenced table doesn't exist" + } + schema.integer(column.foreignKey).unsigned() + schema.foreign(column.foreignKey).references(`${tableName}.${relatedTable.primary[0]}`) } - if (!column.foreignKey || !column.tableId) { - throw "Invalid relationship schema" - } - const { tableName } = breakExternalTableId(column.tableId) - // @ts-ignore - const relatedTable = tables[tableName] - if (!relatedTable) { - throw "Referenced table doesn't exist" - } - schema.integer(column.foreignKey).unsigned() - schema.foreign(column.foreignKey).references(`${tableName}.${relatedTable.primary[0]}`) + break } } From 835b0efe740e6b7172750cd53fac1d1a009986de Mon Sep 17 00:00:00 2001 From: mike12345567 Date: Fri, 29 Oct 2021 18:43:50 +0100 Subject: [PATCH 5/9] Adding mysql support. --- packages/server/src/integrations/mysql.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/server/src/integrations/mysql.ts b/packages/server/src/integrations/mysql.ts index eba288da56..c6f271b6fe 100644 --- a/packages/server/src/integrations/mysql.ts +++ b/packages/server/src/integrations/mysql.ts @@ -262,6 +262,13 @@ module MySQLModule { const operation = this._operation(json) this.client.connect() const input = this._query(json, { disableReturning: true }) + if (Array.isArray(input)) { + const responses = [] + for (let query of input) { + responses.push(await internalQuery(this.client, query)) + } + return responses + } let row // need to manage returning, a feature mySQL can't do if (operation === operation.DELETE) { From 7efa06901e220adb94204fddb2761f469344358f Mon Sep 17 00:00:00 2001 From: mike12345567 Date: Mon, 1 Nov 2021 16:03:53 +0000 Subject: [PATCH 6/9] Adding error scenario for table name already in use. --- .../modals/CreateExternalTableModal.svelte | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/builder/src/pages/builder/app/[application]/data/datasource/[selectedDatasource]/modals/CreateExternalTableModal.svelte b/packages/builder/src/pages/builder/app/[application]/data/datasource/[selectedDatasource]/modals/CreateExternalTableModal.svelte index c7a040d89a..1d9e246d20 100644 --- a/packages/builder/src/pages/builder/app/[application]/data/datasource/[selectedDatasource]/modals/CreateExternalTableModal.svelte +++ b/packages/builder/src/pages/builder/app/[application]/data/datasource/[selectedDatasource]/modals/CreateExternalTableModal.svelte @@ -6,8 +6,9 @@ export let datasource let name = "" - - $: valid = name && name.length > 0 + $: valid = name && name.length > 0 && !datasource?.entities[name] + $: error = + name && datasource?.entities[name] ? "Table name already in use." : null function buildDefaultTable(tableName, datasourceId) { return { @@ -40,5 +41,5 @@ Provide a name for your new table; you can add columns once it is created. - + From c82cf534d80e70e997595c81de234e7108d226bd Mon Sep 17 00:00:00 2001 From: mike12345567 Date: Mon, 1 Nov 2021 21:15:46 +0000 Subject: [PATCH 7/9] Updating external table UI a bit, adding the concept of defining an existing relationship, updating the data sources UI to make it a bit less cluttered and make the creation of tables more obvious. --- .../backend/DataTable/DataTable.svelte | 7 +++ .../buttons/ExistingRelationshipButton.svelte | 54 +++++++++++++++++++ .../CreateEditRelationship.svelte | 30 ++++++++++- .../backend/Datasources}/TableSelect.svelte | 0 .../[selectedDatasource]/index.svelte | 32 ++++++++--- packages/builder/src/stores/backend/tables.js | 1 + 6 files changed, 114 insertions(+), 10 deletions(-) create mode 100644 packages/builder/src/components/backend/DataTable/buttons/ExistingRelationshipButton.svelte rename packages/builder/src/{pages/builder/app/[application]/data/datasource/[selectedDatasource]/CreateEditRelationship => components/backend/Datasources}/CreateEditRelationship.svelte (92%) rename packages/builder/src/{pages/builder/app/[application]/data/datasource/[selectedDatasource]/CreateEditRelationship => components/backend/Datasources}/TableSelect.svelte (100%) diff --git a/packages/builder/src/components/backend/DataTable/DataTable.svelte b/packages/builder/src/components/backend/DataTable/DataTable.svelte index 1af703800f..6bebf2ca02 100644 --- a/packages/builder/src/components/backend/DataTable/DataTable.svelte +++ b/packages/builder/src/components/backend/DataTable/DataTable.svelte @@ -4,6 +4,7 @@ import CreateRowButton from "./buttons/CreateRowButton.svelte" import CreateColumnButton from "./buttons/CreateColumnButton.svelte" import CreateViewButton from "./buttons/CreateViewButton.svelte" + import ExistingRelationshipButton from "./buttons/ExistingRelationshipButton.svelte" import ExportButton from "./buttons/ExportButton.svelte" import EditRolesButton from "./buttons/EditRolesButton.svelte" import ManageAccessButton from "./buttons/ManageAccessButton.svelte" @@ -114,6 +115,12 @@ {#if isUsersTable} {/if} + {#if !isInternal} + + {/if} diff --git a/packages/builder/src/components/backend/DataTable/buttons/ExistingRelationshipButton.svelte b/packages/builder/src/components/backend/DataTable/buttons/ExistingRelationshipButton.svelte new file mode 100644 index 0000000000..4a7abf487f --- /dev/null +++ b/packages/builder/src/components/backend/DataTable/buttons/ExistingRelationshipButton.svelte @@ -0,0 +1,54 @@ + + +{#if table.sourceId} +
+ + Define existing relationship + +
+ + + +{/if} diff --git a/packages/builder/src/pages/builder/app/[application]/data/datasource/[selectedDatasource]/CreateEditRelationship/CreateEditRelationship.svelte b/packages/builder/src/components/backend/Datasources/CreateEditRelationship.svelte similarity index 92% rename from packages/builder/src/pages/builder/app/[application]/data/datasource/[selectedDatasource]/CreateEditRelationship/CreateEditRelationship.svelte rename to packages/builder/src/components/backend/Datasources/CreateEditRelationship.svelte index 583ca5e887..81032da716 100644 --- a/packages/builder/src/pages/builder/app/[application]/data/datasource/[selectedDatasource]/CreateEditRelationship/CreateEditRelationship.svelte +++ b/packages/builder/src/components/backend/Datasources/CreateEditRelationship.svelte @@ -18,10 +18,19 @@ export let fromRelationship = {} export let toRelationship = {} export let close + export let selectedFromTable let originalFromName = fromRelationship.name, originalToName = toRelationship.name + if (fromRelationship && !fromRelationship.relationshipType) { + fromRelationship.relationshipType = RelationshipTypes.MANY_TO_ONE + } + + if (toRelationship && selectedFromTable) { + toRelationship.tableId = selectedFromTable._id + } + function inSchema(table, prop, ogName) { if (!table || !prop || prop === ogName) { return false @@ -114,6 +123,7 @@ }, ] $: updateRelationshipType(fromRelationship?.relationshipType) + $: tableChanged(fromTable, toTable) function updateRelationshipType(fromType) { if (fromType === RelationshipTypes.MANY_TO_MANY) { @@ -205,7 +215,6 @@ originalToName = toRelationship.name originalFromName = fromRelationship.name await save() - await tables.fetch() } async function deleteRelationship() { @@ -215,10 +224,26 @@ await tables.fetch() close() } + + function tableChanged(fromTbl, toTbl) { + fromRelationship.name = toTbl?.name || "" + errors.fromCol = "" + toRelationship.name = fromTbl?.name || "" + errors.toCol = "" + if (toTbl || fromTbl) { + checkForErrors( + fromTable, + toTable, + through, + fromRelationship, + toRelationship + ) + } + } ($touched.from = true)} bind:error={errors.from} bind:value={toRelationship.tableId} diff --git a/packages/builder/src/pages/builder/app/[application]/data/datasource/[selectedDatasource]/CreateEditRelationship/TableSelect.svelte b/packages/builder/src/components/backend/Datasources/TableSelect.svelte similarity index 100% rename from packages/builder/src/pages/builder/app/[application]/data/datasource/[selectedDatasource]/CreateEditRelationship/TableSelect.svelte rename to packages/builder/src/components/backend/Datasources/TableSelect.svelte diff --git a/packages/builder/src/pages/builder/app/[application]/data/datasource/[selectedDatasource]/index.svelte b/packages/builder/src/pages/builder/app/[application]/data/datasource/[selectedDatasource]/index.svelte index 7c56d502e4..9bd2e10a08 100644 --- a/packages/builder/src/pages/builder/app/[application]/data/datasource/[selectedDatasource]/index.svelte +++ b/packages/builder/src/pages/builder/app/[application]/data/datasource/[selectedDatasource]/index.svelte @@ -8,11 +8,12 @@ Layout, Modal, InlineAlert, + ActionButton, } from "@budibase/bbui" import { datasources, integrations, queries, tables } from "stores/backend" import { notifications } from "@budibase/bbui" import IntegrationConfigForm from "components/backend/DatasourceNavigator/TableIntegrationMenu/IntegrationConfigForm.svelte" - import CreateEditRelationship from "./CreateEditRelationship/CreateEditRelationship.svelte" + import CreateEditRelationship from "components/backend/Datasources/CreateEditRelationship.svelte" import CreateExternalTableModal from "./modals/CreateExternalTableModal.svelte" import DisplayColumnModal from "./modals/EditDisplayColumnsModal.svelte" import ICONS from "components/backend/DatasourceNavigator/icons" @@ -174,14 +175,24 @@ Tables
{#if plusTables && plusTables.length !== 0} - + {/if}
- +
@@ -214,9 +225,15 @@
Relationships - + Define existing relationship +
Tell budibase how your tables are related to get even more smart @@ -331,7 +348,6 @@ .table-buttons { display: grid; - grid-gap: var(--spacing-l); grid-template-columns: 1fr 1fr; } diff --git a/packages/builder/src/stores/backend/tables.js b/packages/builder/src/stores/backend/tables.js index 7f90a04a05..baa9e38ab9 100644 --- a/packages/builder/src/stores/backend/tables.js +++ b/packages/builder/src/stores/backend/tables.js @@ -11,6 +11,7 @@ export function createTablesStore() { const tablesResponse = await api.get(`/api/tables`) const tables = await tablesResponse.json() update(state => ({ ...state, list: tables })) + return tables } async function select(table) { From 5d40882c665af02494fdf8224f29411811a377cc Mon Sep 17 00:00:00 2001 From: mike12345567 Date: Mon, 1 Nov 2021 21:17:51 +0000 Subject: [PATCH 8/9] Removing the edit display columns modal as it was very specific and a little confusing, these are defaulted now anyway like internal tables. --- .../[selectedDatasource]/index.svelte | 20 --------- .../modals/EditDisplayColumnsModal.svelte | 43 ------------------- 2 files changed, 63 deletions(-) delete mode 100644 packages/builder/src/pages/builder/app/[application]/data/datasource/[selectedDatasource]/modals/EditDisplayColumnsModal.svelte diff --git a/packages/builder/src/pages/builder/app/[application]/data/datasource/[selectedDatasource]/index.svelte b/packages/builder/src/pages/builder/app/[application]/data/datasource/[selectedDatasource]/index.svelte index 9bd2e10a08..b1867db248 100644 --- a/packages/builder/src/pages/builder/app/[application]/data/datasource/[selectedDatasource]/index.svelte +++ b/packages/builder/src/pages/builder/app/[application]/data/datasource/[selectedDatasource]/index.svelte @@ -15,12 +15,10 @@ import IntegrationConfigForm from "components/backend/DatasourceNavigator/TableIntegrationMenu/IntegrationConfigForm.svelte" import CreateEditRelationship from "components/backend/Datasources/CreateEditRelationship.svelte" import CreateExternalTableModal from "./modals/CreateExternalTableModal.svelte" - import DisplayColumnModal from "./modals/EditDisplayColumnsModal.svelte" import ICONS from "components/backend/DatasourceNavigator/icons" import { capitalise } from "helpers" let relationshipModal - let displayColumnModal let createExternalTableModal let selectedFromRelationship, selectedToRelationship @@ -113,10 +111,6 @@ relationshipModal.show() } - function openDisplayColumnModal() { - displayColumnModal.show() - } - function createNewTable() { createExternalTableModal.show() } @@ -133,10 +127,6 @@ /> - - - - @@ -174,16 +164,6 @@
Tables
- {#if plusTables && plusTables.length !== 0} - - Update display columns - - {/if}
- import { ModalContent, Select, Body } from "@budibase/bbui" - import { tables } from "stores/backend" - - export let datasource - export let plusTables - export let save - - async function saveDisplayColumns() { - // be explicit about copying over - for (let table of plusTables) { - datasource.entities[table.name].primaryDisplay = table.primaryDisplay - } - save() - await tables.fetch() - } - - function getColumnOptions(table) { - if (!table || !table.schema) { - return [] - } - return Object.entries(table.schema) - .filter(field => field[1].type !== "link") - .map(([fieldName]) => fieldName) - } - - - - Select the columns that will be shown when displaying relationships. - {#each plusTables as table} -