forked from tsai/budibase
20 changed files with 583 additions and 190 deletions
@ -0,0 +1,44 @@ |
|||||
|
<script> |
||||
|
import { ModalContent, Body, Input } from "@budibase/bbui" |
||||
|
import { tables, datasources } from "stores/backend" |
||||
|
import { goto } from "@roxi/routify" |
||||
|
|
||||
|
export let datasource |
||||
|
|
||||
|
let name = "" |
||||
|
|
||||
|
$: valid = name && name.length > 0 |
||||
|
|
||||
|
function buildDefaultTable(tableName, datasourceId) { |
||||
|
return { |
||||
|
name: tableName, |
||||
|
type: "external", |
||||
|
primary: ["id"], |
||||
|
sourceId: datasourceId, |
||||
|
schema: { |
||||
|
id: { |
||||
|
autocolumn: true, |
||||
|
type: "number", |
||||
|
}, |
||||
|
}, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async function saveTable() { |
||||
|
const table = await tables.save(buildDefaultTable(name, datasource._id)) |
||||
|
await datasources.fetch() |
||||
|
$goto(`../../table/${table._id}`) |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
<ModalContent |
||||
|
title="Create new table" |
||||
|
confirmText="Create" |
||||
|
onConfirm={saveTable} |
||||
|
disabled={!valid} |
||||
|
> |
||||
|
<Body |
||||
|
>Provide a name for your new table; you can add columns once it is created.</Body |
||||
|
> |
||||
|
<Input label="Table Name" bind:value={name} /> |
||||
|
</ModalContent> |
||||
@ -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 |
||||
|
} |
||||
@ -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 |
||||
|
} |
||||
@ -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<string, Table>, 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<string, Table>, |
||||
|
): SchemaBuilder { |
||||
|
return knex.schema.createTable(table.name, schema => { |
||||
|
generateSchema(schema, table, tables) |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
function buildUpdateTable( |
||||
|
knex: Knex, |
||||
|
table: Table, |
||||
|
tables: Record<string, Table>, |
||||
|
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 |
||||
@ -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 |
||||
|
} |
||||
Loading…
Reference in new issue