1
0
Fork 0
mirror of synced 2024-08-05 13:21:26 +12:00

Merge branch 'master' of github.com:budibase/budibase into budi-8220-support-updating-existing-rows-for-external-databases-using-2

This commit is contained in:
Sam Rose 2024-06-24 15:51:03 +01:00
commit 01b2f69b74
No known key found for this signature in database
18 changed files with 364 additions and 224 deletions

View file

@ -72,4 +72,4 @@ export const DEFAULT_JOBS_TABLE_ID = "ta_bb_jobs"
export const DEFAULT_INVENTORY_TABLE_ID = "ta_bb_inventory" export const DEFAULT_INVENTORY_TABLE_ID = "ta_bb_inventory"
export const DEFAULT_EXPENSES_TABLE_ID = "ta_bb_expenses" export const DEFAULT_EXPENSES_TABLE_ID = "ta_bb_expenses"
export const DEFAULT_EMPLOYEE_TABLE_ID = "ta_bb_employee" export const DEFAULT_EMPLOYEE_TABLE_ID = "ta_bb_employee"
export const DEFAULT_BB_DATASOURCE_ID = "datasource_internal_bb_default" export { DEFAULT_BB_DATASOURCE_ID } from "@budibase/shared-core"

View file

@ -109,8 +109,10 @@ function generateSchema(
const { tableName } = breakExternalTableId(column.tableId) const { tableName } = breakExternalTableId(column.tableId)
// @ts-ignore // @ts-ignore
const relatedTable = tables[tableName] const relatedTable = tables[tableName]
if (!relatedTable) { if (!relatedTable || !relatedTable.primary) {
throw new Error("Referenced table doesn't exist") throw new Error(
"Referenced table doesn't exist or has no primary keys"
)
} }
const relatedPrimary = relatedTable.primary[0] const relatedPrimary = relatedTable.primary[0]
const externalType = relatedTable.schema[relatedPrimary].externalType const externalType = relatedTable.schema[relatedPrimary].externalType

View file

@ -55,10 +55,7 @@ export function buildExternalTableId(datasourceId: string, tableName: string) {
return `${datasourceId}${DOUBLE_SEPARATOR}${tableName}` return `${datasourceId}${DOUBLE_SEPARATOR}${tableName}`
} }
export function breakExternalTableId(tableId: string | undefined) { export function breakExternalTableId(tableId: string) {
if (!tableId) {
return {}
}
const parts = tableId.split(DOUBLE_SEPARATOR) const parts = tableId.split(DOUBLE_SEPARATOR)
let datasourceId = parts.shift() let datasourceId = parts.shift()
// if they need joined // if they need joined
@ -67,6 +64,9 @@ export function breakExternalTableId(tableId: string | undefined) {
if (tableName.includes(ENCODED_SPACE)) { if (tableName.includes(ENCODED_SPACE)) {
tableName = decodeURIComponent(tableName) tableName = decodeURIComponent(tableName)
} }
if (!datasourceId || !tableName) {
throw new Error("Unable to get datasource/table name from table ID")
}
return { datasourceId, tableName } return { datasourceId, tableName }
} }

View file

@ -18,7 +18,7 @@
import FilterUsers from "./FilterUsers.svelte" import FilterUsers from "./FilterUsers.svelte"
import { getFields } from "../utils/searchFields" import { getFields } from "../utils/searchFields"
const { OperatorOptions } = Constants const { OperatorOptions, DEFAULT_BB_DATASOURCE_ID } = Constants
export let schemaFields export let schemaFields
export let filters = [] export let filters = []
@ -28,6 +28,23 @@
export let allowBindings = false export let allowBindings = false
export let filtersLabel = "Filters" export let filtersLabel = "Filters"
$: {
if (
tables.find(
table =>
table._id === datasource.tableId &&
table.sourceId === DEFAULT_BB_DATASOURCE_ID
) &&
!schemaFields.some(field => field.name === "_id")
) {
schemaFields = [
...schemaFields,
{ name: "_id", type: "string" },
{ name: "_rev", type: "string" },
]
}
}
$: matchAny = filters?.find(filter => filter.operator === "allOr") != null $: matchAny = filters?.find(filter => filter.operator === "allOr") != null
$: onEmptyFilter = $: onEmptyFilter =
filters?.find(filter => filter.onEmptyFilter)?.onEmptyFilter ?? "all" filters?.find(filter => filter.onEmptyFilter)?.onEmptyFilter ?? "all"
@ -35,7 +52,6 @@
$: fieldFilters = filters.filter( $: fieldFilters = filters.filter(
filter => filter.operator !== "allOr" && !filter.onEmptyFilter filter => filter.operator !== "allOr" && !filter.onEmptyFilter
) )
const behaviourOptions = [ const behaviourOptions = [
{ value: "and", label: "Match all filters" }, { value: "and", label: "Match all filters" },
{ value: "or", label: "Match any filter" }, { value: "or", label: "Match any filter" },
@ -44,7 +60,6 @@
{ value: "all", label: "Return all table rows" }, { value: "all", label: "Return all table rows" },
{ value: "none", label: "Return no rows" }, { value: "none", label: "Return no rows" },
] ]
const context = getContext("context") const context = getContext("context")
$: fieldOptions = getFields(tables, schemaFields || [], { $: fieldOptions = getFields(tables, schemaFields || [], {

View file

@ -1,7 +1,11 @@
/** /**
* Operator options for lucene queries * Operator options for lucene queries
*/ */
export { OperatorOptions, SqlNumberTypeRangeMap } from "@budibase/shared-core" export {
OperatorOptions,
SqlNumberTypeRangeMap,
DEFAULT_BB_DATASOURCE_ID,
} from "@budibase/shared-core"
export { Feature as Features } from "@budibase/types" export { Feature as Features } from "@budibase/types"
import { BpmCorrelationKey } from "@budibase/shared-core" import { BpmCorrelationKey } from "@budibase/shared-core"
import { FieldType, BBReferenceFieldSubType } from "@budibase/types" import { FieldType, BBReferenceFieldSubType } from "@budibase/types"

View file

@ -72,92 +72,6 @@ export type ExternalRequestReturnType<T extends Operation> =
? number ? number
: { row: Row; table: Table } : { row: Row; table: Table }
function buildFilters(
id: string | undefined | string[],
filters: SearchFilters,
table: Table
) {
const primary = table.primary
// if passed in array need to copy for shifting etc
let idCopy: undefined | string | any[] = cloneDeep(id)
if (filters) {
// need to map over the filters and make sure the _id field isn't present
let prefix = 1
for (let operator of Object.values(filters)) {
for (let field of Object.keys(operator || {})) {
if (dbCore.removeKeyNumbering(field) === "_id") {
if (primary) {
const parts = breakRowIdField(operator[field])
for (let field of primary) {
operator[`${prefix}:${field}`] = parts.shift()
}
prefix++
}
// make sure this field doesn't exist on any filter
delete operator[field]
}
}
}
}
// 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,
}
}
async function removeManyToManyRelationships(
rowId: string,
table: Table,
colName: string
) {
const tableId = table._id!
const filters = buildFilters(rowId, {}, table)
// safety check, if there are no filters on deletion bad things happen
if (Object.keys(filters).length !== 0) {
return getDatasourceAndQuery({
endpoint: getEndpoint(tableId, Operation.DELETE),
body: { [colName]: null },
filters,
meta: {
table,
},
})
} else {
return []
}
}
async function removeOneToManyRelationships(rowId: string, table: Table) {
const tableId = table._id!
const filters = buildFilters(rowId, {}, table)
// safety check, if there are no filters on deletion bad things happen
if (Object.keys(filters).length !== 0) {
return getDatasourceAndQuery({
endpoint: getEndpoint(tableId, Operation.UPDATE),
filters,
meta: {
table,
},
})
} else {
return []
}
}
/** /**
* This function checks the incoming parameters to make sure all the inputs are * 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 * valid based on on the table schema. The main thing this is looking for is when a
@ -212,8 +126,8 @@ function getEndpoint(tableId: string | undefined, operation: string) {
} }
const { datasourceId, tableName } = breakExternalTableId(tableId) const { datasourceId, tableName } = breakExternalTableId(tableId)
return { return {
datasourceId: datasourceId!, datasourceId: datasourceId,
entityId: tableName!, entityId: tableName,
operation: operation as Operation, operation: operation as Operation,
} }
} }
@ -240,6 +154,7 @@ export class ExternalRequest<T extends Operation> {
private readonly tableId: string private readonly tableId: string
private datasource?: Datasource private datasource?: Datasource
private tables: { [key: string]: Table } = {} private tables: { [key: string]: Table } = {}
private tableList: Table[]
constructor(operation: T, tableId: string, datasource?: Datasource) { constructor(operation: T, tableId: string, datasource?: Datasource) {
this.operation = operation this.operation = operation
@ -248,22 +163,134 @@ export class ExternalRequest<T extends Operation> {
if (datasource && datasource.entities) { if (datasource && datasource.entities) {
this.tables = datasource.entities this.tables = datasource.entities
} }
this.tableList = Object.values(this.tables)
}
private prepareFilters(
id: string | undefined | string[],
filters: SearchFilters,
table: Table
): SearchFilters {
// replace any relationship columns initially, table names and relationship column names are acceptable
const relationshipColumns = sdk.rows.filters.getRelationshipColumns(table)
filters = sdk.rows.filters.updateFilterKeys(
filters,
relationshipColumns.map(({ name, definition }) => {
const { tableName } = breakExternalTableId(definition.tableId)
return {
original: name,
updated: tableName,
}
})
)
const primary = table.primary
// if passed in array need to copy for shifting etc
let idCopy: undefined | string | any[] = cloneDeep(id)
if (filters) {
// need to map over the filters and make sure the _id field isn't present
let prefix = 1
for (let operator of Object.values(filters)) {
for (let field of Object.keys(operator || {})) {
if (dbCore.removeKeyNumbering(field) === "_id") {
if (primary) {
const parts = breakRowIdField(operator[field])
for (let field of primary) {
operator[`${prefix}:${field}`] = parts.shift()
}
prefix++
}
// make sure this field doesn't exist on any filter
delete operator[field]
}
}
}
}
// 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: SearchFilters["equal"] = {}
if (primary && idCopy) {
for (let field of primary) {
// work through the ID and get the parts
equal[field] = idCopy.shift()
}
}
return {
equal,
}
}
private async removeManyToManyRelationships(
rowId: string,
table: Table,
colName: string
) {
const tableId = table._id!
const filters = this.prepareFilters(rowId, {}, table)
// safety check, if there are no filters on deletion bad things happen
if (Object.keys(filters).length !== 0) {
return getDatasourceAndQuery({
endpoint: getEndpoint(tableId, Operation.DELETE),
body: { [colName]: null },
filters,
meta: {
table,
},
})
} else {
return []
}
}
private async removeOneToManyRelationships(rowId: string, table: Table) {
const tableId = table._id!
const filters = this.prepareFilters(rowId, {}, table)
// safety check, if there are no filters on deletion bad things happen
if (Object.keys(filters).length !== 0) {
return getDatasourceAndQuery({
endpoint: getEndpoint(tableId, Operation.UPDATE),
filters,
meta: {
table,
},
})
} else {
return []
}
} }
getTable(tableId: string | undefined): Table | undefined { getTable(tableId: string | undefined): Table | undefined {
if (!tableId) { if (!tableId) {
throw "Table ID is unknown, cannot find table" throw new Error("Table ID is unknown, cannot find table")
} }
const { tableName } = breakExternalTableId(tableId) const { tableName } = breakExternalTableId(tableId)
if (tableName) { return this.tables[tableName]
return this.tables[tableName] }
// seeds the object with table and datasource information
async retrieveMetadata(
datasourceId: string
): Promise<{ tables: Record<string, Table>; datasource: Datasource }> {
if (!this.datasource) {
this.datasource = await sdk.datasources.get(datasourceId)
if (!this.datasource || !this.datasource.entities) {
throw "No tables found, fetch tables before query."
}
this.tables = this.datasource.entities
this.tableList = Object.values(this.tables)
} }
return { tables: this.tables, datasource: this.datasource }
} }
async getRow(table: Table, rowId: string): Promise<Row> { async getRow(table: Table, rowId: string): Promise<Row> {
const response = await getDatasourceAndQuery({ const response = await getDatasourceAndQuery({
endpoint: getEndpoint(table._id!, Operation.READ), endpoint: getEndpoint(table._id!, Operation.READ),
filters: buildFilters(rowId, {}, table), filters: this.prepareFilters(rowId, {}, table),
meta: { meta: {
table, table,
}, },
@ -302,9 +329,7 @@ export class ExternalRequest<T extends Operation> {
if (field.type === FieldType.NUMBER && !isNaN(parseFloat(row[key]))) { if (field.type === FieldType.NUMBER && !isNaN(parseFloat(row[key]))) {
newRow[key] = parseFloat(row[key]) newRow[key] = parseFloat(row[key])
} else if (field.type === FieldType.LINK) { } else if (field.type === FieldType.LINK) {
const { tableName: linkTableName } = breakExternalTableId( const { tableName: linkTableName } = breakExternalTableId(field.tableId)
field?.tableId
)
// table has to exist for many to many // table has to exist for many to many
if (!linkTableName || !this.tables[linkTableName]) { if (!linkTableName || !this.tables[linkTableName]) {
continue continue
@ -385,9 +410,6 @@ export class ExternalRequest<T extends Operation> {
[key: string]: { rows: Row[]; isMany: boolean; tableId: string } [key: string]: { rows: Row[]; isMany: boolean; tableId: string }
} = {} } = {}
const { tableName } = breakExternalTableId(tableId) const { tableName } = breakExternalTableId(tableId)
if (!tableName) {
return related
}
const table = this.tables[tableName] const table = this.tables[tableName]
// @ts-ignore // @ts-ignore
const primaryKey = table.primary[0] const primaryKey = table.primary[0]
@ -520,7 +542,7 @@ export class ExternalRequest<T extends Operation> {
endpoint: getEndpoint(tableId, operation), endpoint: getEndpoint(tableId, operation),
// if we're doing many relationships then we're writing, only one response // if we're doing many relationships then we're writing, only one response
body, body,
filters: buildFilters(id, {}, linkTable), filters: this.prepareFilters(id, {}, linkTable),
meta: { meta: {
table: linkTable, table: linkTable,
}, },
@ -544,8 +566,8 @@ export class ExternalRequest<T extends Operation> {
for (let row of rows) { for (let row of rows) {
const rowId = generateIdForRow(row, table) const rowId = generateIdForRow(row, table)
const promise: Promise<any> = isMany const promise: Promise<any> = isMany
? removeManyToManyRelationships(rowId, table, colName) ? this.removeManyToManyRelationships(rowId, table, colName)
: removeOneToManyRelationships(rowId, table) : this.removeOneToManyRelationships(rowId, table)
if (promise) { if (promise) {
promises.push(promise) promises.push(promise)
} }
@ -568,12 +590,12 @@ export class ExternalRequest<T extends Operation> {
rows.map(row => { rows.map(row => {
const rowId = generateIdForRow(row, table) const rowId = generateIdForRow(row, table)
return isMany return isMany
? removeManyToManyRelationships( ? this.removeManyToManyRelationships(
rowId, rowId,
table, table,
relationshipColumn.fieldName relationshipColumn.fieldName
) )
: removeOneToManyRelationships(rowId, table) : this.removeOneToManyRelationships(rowId, table)
}) })
) )
} }
@ -581,21 +603,21 @@ export class ExternalRequest<T extends Operation> {
async run(config: RunConfig): Promise<ExternalRequestReturnType<T>> { async run(config: RunConfig): Promise<ExternalRequestReturnType<T>> {
const { operation, tableId } = this const { operation, tableId } = this
let { datasourceId, tableName } = breakExternalTableId(tableId) if (!tableId) {
if (!tableName) { throw new Error("Unable to run without a table ID")
throw "Unable to run without a table name"
} }
if (!this.datasource) { let { datasourceId, tableName } = breakExternalTableId(tableId)
this.datasource = await sdk.datasources.get(datasourceId!) let datasource = this.datasource
if (!this.datasource || !this.datasource.entities) { if (!datasource) {
throw "No tables found, fetch tables before query." const { datasource: ds } = await this.retrieveMetadata(datasourceId)
} datasource = ds
this.tables = this.datasource.entities
} }
const table = this.tables[tableName] const table = this.tables[tableName]
let isSql = isSQL(this.datasource) let isSql = isSQL(datasource)
if (!table) { if (!table) {
throw `Unable to process query, table "${tableName}" not defined.` throw new Error(
`Unable to process query, table "${tableName}" not defined.`
)
} }
// look for specific components of config which may not be considered acceptable // look for specific components of config which may not be considered acceptable
let { id, row, filters, sort, paginate, rows } = cleanupConfig( let { id, row, filters, sort, paginate, rows } = cleanupConfig(
@ -618,7 +640,7 @@ export class ExternalRequest<T extends Operation> {
break break
} }
} }
filters = buildFilters(id, filters || {}, table) filters = this.prepareFilters(id, filters || {}, table)
const relationships = buildExternalRelationships(table, this.tables) const relationships = buildExternalRelationships(table, this.tables)
const incRelationships = const incRelationships =
@ -666,7 +688,11 @@ export class ExternalRequest<T extends Operation> {
body: row || rows, body: row || rows,
// pass an id filter into extra, purely for mysql/returning // pass an id filter into extra, purely for mysql/returning
extra: { extra: {
idFilter: buildFilters(id || generateIdForRow(row, table), {}, table), idFilter: this.prepareFilters(
id || generateIdForRow(row, table),
{},
table
),
}, },
meta: { meta: {
table, table,

View file

@ -136,10 +136,7 @@ export async function fetchEnrichedRow(ctx: UserCtx) {
const id = ctx.params.rowId const id = ctx.params.rowId
const tableId = utils.getTableId(ctx) const tableId = utils.getTableId(ctx)
const { datasourceId, tableName } = breakExternalTableId(tableId) const { datasourceId, tableName } = breakExternalTableId(tableId)
const datasource: Datasource = await sdk.datasources.get(datasourceId!) const datasource: Datasource = await sdk.datasources.get(datasourceId)
if (!tableName) {
ctx.throw(400, "Unable to find table.")
}
if (!datasource || !datasource.entities) { if (!datasource || !datasource.entities) {
ctx.throw(400, "Datasource has not been configured for plus API.") ctx.throw(400, "Datasource has not been configured for plus API.")
} }
@ -163,7 +160,7 @@ export async function fetchEnrichedRow(ctx: UserCtx) {
} }
const links = row[fieldName] const links = row[fieldName]
const linkedTableId = field.tableId const linkedTableId = field.tableId
const linkedTableName = breakExternalTableId(linkedTableId).tableName! const linkedTableName = breakExternalTableId(linkedTableId).tableName
const linkedTable = tables[linkedTableName] const linkedTable = tables[linkedTableName]
// don't support composite keys right now // don't support composite keys right now
const linkedIds = links.map((link: Row) => breakRowIdField(link._id!)[0]) const linkedIds = links.map((link: Row) => breakRowIdField(link._id!)[0])

View file

@ -99,7 +99,7 @@ export function basicProcessing({
row, row,
tableName: table._id!, tableName: table._id!,
fieldName: internalColumn, fieldName: internalColumn,
isLinked: false, isLinked,
}) })
} }
} }

View file

@ -2,6 +2,8 @@ import {
DatasourcePlusQueryResponse, DatasourcePlusQueryResponse,
DSPlusOperation, DSPlusOperation,
FieldType, FieldType,
isManyToOne,
isOneToMany,
ManyToManyRelationshipFieldMetadata, ManyToManyRelationshipFieldMetadata,
RelationshipFieldMetadata, RelationshipFieldMetadata,
RelationshipsJson, RelationshipsJson,
@ -93,12 +95,12 @@ export function buildExternalRelationships(
): RelationshipsJson[] { ): RelationshipsJson[] {
const relationships = [] const relationships = []
for (let [fieldName, field] of Object.entries(table.schema)) { for (let [fieldName, field] of Object.entries(table.schema)) {
if (field.type !== FieldType.LINK) { if (field.type !== FieldType.LINK || !field.tableId) {
continue continue
} }
const { tableName: linkTableName } = breakExternalTableId(field.tableId) const { tableName: linkTableName } = breakExternalTableId(field.tableId)
// no table to link to, this is not a valid relationships // no table to link to, this is not a valid relationships
if (!linkTableName || !tables[linkTableName]) { if (!tables[linkTableName]) {
continue continue
} }
const linkTable = tables[linkTableName] const linkTable = tables[linkTableName]
@ -110,7 +112,7 @@ export function buildExternalRelationships(
// need to specify where to put this back into // need to specify where to put this back into
column: fieldName, column: fieldName,
} }
if (isManyToMany(field)) { if (isManyToMany(field) && field.through) {
const { tableName: throughTableName } = breakExternalTableId( const { tableName: throughTableName } = breakExternalTableId(
field.through field.through
) )
@ -120,7 +122,7 @@ export function buildExternalRelationships(
definition.to = field.throughFrom || linkTable.primary[0] definition.to = field.throughFrom || linkTable.primary[0]
definition.fromPrimary = table.primary[0] definition.fromPrimary = table.primary[0]
definition.toPrimary = linkTable.primary[0] definition.toPrimary = linkTable.primary[0]
} else { } else if (isManyToOne(field) || isOneToMany(field)) {
// if no foreign key specified then use the name of the field in other table // if no foreign key specified then use the name of the field in other table
definition.from = field.foreignKey || table.primary[0] definition.from = field.foreignKey || table.primary[0]
definition.to = field.fieldName definition.to = field.fieldName
@ -180,16 +182,18 @@ export function buildSqlFieldList(
} }
let fields = extractRealFields(table) let fields = extractRealFields(table)
for (let field of Object.values(table.schema)) { for (let field of Object.values(table.schema)) {
if (field.type !== FieldType.LINK || !opts?.relationships) { if (
field.type !== FieldType.LINK ||
!opts?.relationships ||
!field.tableId
) {
continue continue
} }
const { tableName: linkTableName } = breakExternalTableId(field.tableId) const { tableName: linkTableName } = breakExternalTableId(field.tableId)
if (linkTableName) { const linkTable = tables[linkTableName]
const linkTable = tables[linkTableName] if (linkTable) {
if (linkTable) { const linkedFields = extractRealFields(linkTable, fields)
const linkedFields = extractRealFields(linkTable, fields) fields = fields.concat(linkedFields)
fields = fields.concat(linkedFields)
}
} }
} }
return fields return fields

View file

@ -20,11 +20,14 @@ import { isEqual } from "lodash"
function getDatasourceId(table: Table) { function getDatasourceId(table: Table) {
if (!table) { if (!table) {
throw "No table supplied" throw new Error("No table supplied")
} }
if (table.sourceId) { if (table.sourceId) {
return table.sourceId return table.sourceId
} }
if (!table._id) {
throw new Error("No table ID supplied")
}
return breakExternalTableId(table._id).datasourceId return breakExternalTableId(table._id).datasourceId
} }

View file

@ -76,9 +76,9 @@ describe.each([
} }
}) })
async function createTable(schema: TableSchema) { async function createTable(schema: TableSchema, name?: string) {
return await config.api.table.save( return await config.api.table.save(
tableForDatasource(datasource, { schema }) tableForDatasource(datasource, { schema, name })
) )
} }
@ -1956,53 +1956,74 @@ describe.each([
// isn't available. // isn't available.
!isInMemory && !isInMemory &&
describe("relations", () => { describe("relations", () => {
let otherTable: Table let productCategoryTable: Table, productCatRows: Row[]
let otherRows: Row[]
beforeAll(async () => { beforeAll(async () => {
otherTable = await createTable({ productCategoryTable = await createTable(
one: { name: "one", type: FieldType.STRING }, {
}) name: { name: "name", type: FieldType.STRING },
table = await createTable({ },
two: { name: "two", type: FieldType.STRING }, "productCategory"
other: { )
type: FieldType.LINK, table = await createTable(
relationshipType: RelationshipType.ONE_TO_MANY, {
name: "other", name: { name: "name", type: FieldType.STRING },
fieldName: "other", productCat: {
tableId: otherTable._id!, type: FieldType.LINK,
constraints: { relationshipType: RelationshipType.ONE_TO_MANY,
type: "array", name: "productCat",
fieldName: "product",
tableId: productCategoryTable._id!,
constraints: {
type: "array",
},
}, },
}, },
}) "product"
)
otherRows = await Promise.all([ productCatRows = await Promise.all([
config.api.row.save(otherTable._id!, { one: "foo" }), config.api.row.save(productCategoryTable._id!, { name: "foo" }),
config.api.row.save(otherTable._id!, { one: "bar" }), config.api.row.save(productCategoryTable._id!, { name: "bar" }),
]) ])
await Promise.all([ await Promise.all([
config.api.row.save(table._id!, { config.api.row.save(table._id!, {
two: "foo", name: "foo",
other: [otherRows[0]._id], productCat: [productCatRows[0]._id],
}), }),
config.api.row.save(table._id!, { config.api.row.save(table._id!, {
two: "bar", name: "bar",
other: [otherRows[1]._id], productCat: [productCatRows[1]._id],
}),
config.api.row.save(table._id!, {
name: "baz",
productCat: [],
}), }),
]) ])
rows = await config.api.row.fetch(table._id!)
}) })
it("can search through relations", async () => { it("should be able to filter by relationship using column name", async () => {
await expectQuery({ await expectQuery({
equal: { [`${otherTable.name}.one`]: "foo" }, equal: { ["productCat.name"]: "foo" },
}).toContainExactly([ }).toContainExactly([
{ two: "foo", other: [{ _id: otherRows[0]._id }] }, { name: "foo", productCat: [{ _id: productCatRows[0]._id }] },
]) ])
}) })
it("should be able to filter by relationship using table name", async () => {
await expectQuery({
equal: { ["productCategory.name"]: "foo" },
}).toContainExactly([
{ name: "foo", productCat: [{ _id: productCatRows[0]._id }] },
])
})
it("shouldn't return any relationship for last row", async () => {
await expectQuery({
equal: { ["name"]: "baz" },
}).toContainExactly([{ name: "baz", productCat: undefined }])
})
}) })
// lucene can't count the total rows // lucene can't count the total rows

View file

@ -3,12 +3,14 @@ import * as rows from "./rows"
import * as search from "./search" import * as search from "./search"
import * as utils from "./utils" import * as utils from "./utils"
import * as external from "./external" import * as external from "./external"
import * as filters from "./search/filters"
import AliasTables from "./sqlAlias" import AliasTables from "./sqlAlias"
export default { export default {
...attachments, ...attachments,
...rows, ...rows,
...search, ...search,
filters,
utils, utils,
external, external,
AliasTables, AliasTables,

View file

@ -145,6 +145,10 @@ export async function exportRows(
delimiter, delimiter,
customHeaders, customHeaders,
} = options } = options
if (!tableId) {
throw new HTTPError("No table ID for search provided.", 400)
}
const { datasourceId, tableName } = breakExternalTableId(tableId) const { datasourceId, tableName } = breakExternalTableId(tableId)
let requestQuery: SearchFilters = {} let requestQuery: SearchFilters = {}
@ -167,7 +171,7 @@ export async function exportRows(
requestQuery = query || {} requestQuery = query || {}
} }
const datasource = await sdk.datasources.get(datasourceId!) const datasource = await sdk.datasources.get(datasourceId)
const table = await sdk.tables.getTable(tableId) const table = await sdk.tables.getTable(tableId)
if (!datasource || !datasource.entities) { if (!datasource || !datasource.entities) {
throw new HTTPError("Datasource has not been configured for plus API.", 400) throw new HTTPError("Datasource has not been configured for plus API.", 400)
@ -180,10 +184,6 @@ export async function exportRows(
let rows: Row[] = [] let rows: Row[] = []
let headers let headers
if (!tableName) {
throw new HTTPError("Could not find table name.", 400)
}
// Filter data to only specified columns if required // Filter data to only specified columns if required
if (columns && columns.length) { if (columns && columns.length) {
for (let i = 0; i < result.rows.length; i++) { for (let i = 0; i < result.rows.length; i++) {

View file

@ -0,0 +1,62 @@
import {
FieldType,
RelationshipFieldMetadata,
SearchFilters,
Table,
} from "@budibase/types"
import { isPlainObject } from "lodash"
export function getRelationshipColumns(table: Table): {
name: string
definition: RelationshipFieldMetadata
}[] {
// performing this with a for loop rather than an array filter improves
// type guarding, as no casts are required
const linkEntries: [string, RelationshipFieldMetadata][] = []
for (let entry of Object.entries(table.schema)) {
if (entry[1].type === FieldType.LINK) {
const linkColumn: RelationshipFieldMetadata = entry[1]
linkEntries.push([entry[0], linkColumn])
}
}
return linkEntries.map(entry => ({
name: entry[0],
definition: entry[1],
}))
}
export function getTableIDList(
tables: Table[]
): { name: string; id: string }[] {
return tables
.filter(table => table.originalName && table._id)
.map(table => ({ id: table._id!, name: table.originalName! }))
}
export function updateFilterKeys(
filters: SearchFilters,
updates: { original: string; updated: string }[]
): SearchFilters {
const makeFilterKeyRegex = (str: string) =>
new RegExp(`^${str}\\.|:${str}\\.`)
for (let filter of Object.values(filters)) {
if (!isPlainObject(filter)) {
continue
}
for (let [key, keyFilter] of Object.entries(filter)) {
if (keyFilter === "") {
delete filter[key]
}
const possibleKey = updates.find(({ original }) =>
key.match(makeFilterKeyRegex(original))
)
if (possibleKey && possibleKey.original !== possibleKey.updated) {
// only replace the first, not replaceAll
filter[key.replace(possibleKey.original, possibleKey.updated)] =
filter[key]
delete filter[key]
}
}
}
return filters
}

View file

@ -1,4 +1,5 @@
import { import {
Datasource,
DocumentType, DocumentType,
FieldType, FieldType,
Operation, Operation,
@ -12,7 +13,6 @@ import {
SortType, SortType,
SqlClient, SqlClient,
Table, Table,
Datasource,
} from "@budibase/types" } from "@budibase/types"
import { import {
buildInternalRelationships, buildInternalRelationships,
@ -30,6 +30,11 @@ import AliasTables from "../sqlAlias"
import { outputProcessing } from "../../../../utilities/rowProcessor" import { outputProcessing } from "../../../../utilities/rowProcessor"
import pick from "lodash/pick" import pick from "lodash/pick"
import { processRowCountResponse } from "../utils" import { processRowCountResponse } from "../utils"
import {
updateFilterKeys,
getRelationshipColumns,
getTableIDList,
} from "./filters"
const builder = new sql.Sql(SqlClient.SQL_LITE) const builder = new sql.Sql(SqlClient.SQL_LITE)
@ -60,34 +65,31 @@ function buildInternalFieldList(
return fieldList return fieldList
} }
function tableNameInFieldRegex(tableName: string) { function cleanupFilters(
return new RegExp(`^${tableName}.|:${tableName}.`, "g") filters: SearchFilters,
} table: Table,
allTables: Table[]
function cleanupFilters(filters: SearchFilters, tables: Table[]) { ) {
for (let filter of Object.values(filters)) { // get a list of all relationship columns in the table for updating
if (typeof filter !== "object") { const relationshipColumns = getRelationshipColumns(table)
continue // get table names to ID map for relationships
} const tableNameToID = getTableIDList(allTables)
for (let [key, keyFilter] of Object.entries(filter)) { // all should be applied at once
if (keyFilter === "") { filters = updateFilterKeys(
delete filter[key] filters,
} relationshipColumns
.map(({ name, definition }) => ({
// relationship, switch to table ID original: name,
const tableRelated = tables.find( updated: definition.tableId,
table => }))
table.originalName && .concat(
key.match(tableNameInFieldRegex(table.originalName)) tableNameToID.map(({ name, id }) => ({
original: name,
updated: id,
}))
) )
if (tableRelated && tableRelated.originalName) { )
// only replace the first, not replaceAll
filter[key.replace(tableRelated.originalName, tableRelated._id!)] =
filter[key]
delete filter[key]
}
}
}
return filters return filters
} }
@ -176,7 +178,7 @@ export async function search(
operation: Operation.READ, operation: Operation.READ,
}, },
filters: { filters: {
...cleanupFilters(query, allTables), ...cleanupFilters(query, table, allTables),
documentType: DocumentType.ROW, documentType: DocumentType.ROW,
}, },
table, table,

View file

@ -90,10 +90,10 @@ export async function getExternalTable(
export async function getTable(tableId: string): Promise<Table> { export async function getTable(tableId: string): Promise<Table> {
const db = context.getAppDB() const db = context.getAppDB()
let output: Table let output: Table
if (isExternalTableID(tableId)) { if (tableId && isExternalTableID(tableId)) {
let { datasourceId, tableName } = breakExternalTableId(tableId) let { datasourceId, tableName } = breakExternalTableId(tableId)
const datasource = await datasources.get(datasourceId!) const datasource = await datasources.get(datasourceId)
const table = await getExternalTable(datasourceId!, tableName!) const table = await getExternalTable(datasourceId, tableName)
output = { ...table, sql: isSQL(datasource) } output = { ...table, sql: isSQL(datasource) }
} else { } else {
output = await db.get<Table>(tableId) output = await db.get<Table>(tableId)

View file

@ -10,9 +10,9 @@ export async function get(viewId: string): Promise<ViewV2> {
const { tableId } = utils.extractViewInfoFromID(viewId) const { tableId } = utils.extractViewInfoFromID(viewId)
const { datasourceId, tableName } = breakExternalTableId(tableId) const { datasourceId, tableName } = breakExternalTableId(tableId)
const ds = await sdk.datasources.get(datasourceId!) const ds = await sdk.datasources.get(datasourceId)
const table = ds.entities![tableName!] const table = ds.entities![tableName]
const views = Object.values(table.views!).filter(isV2) const views = Object.values(table.views!).filter(isV2)
const found = views.find(v => v.id === viewId) const found = views.find(v => v.id === viewId)
if (!found) { if (!found) {
@ -25,9 +25,9 @@ export async function getEnriched(viewId: string): Promise<ViewV2Enriched> {
const { tableId } = utils.extractViewInfoFromID(viewId) const { tableId } = utils.extractViewInfoFromID(viewId)
const { datasourceId, tableName } = breakExternalTableId(tableId) const { datasourceId, tableName } = breakExternalTableId(tableId)
const ds = await sdk.datasources.get(datasourceId!) const ds = await sdk.datasources.get(datasourceId)
const table = ds.entities![tableName!] const table = ds.entities![tableName]
const views = Object.values(table.views!).filter(isV2) const views = Object.values(table.views!).filter(isV2)
const found = views.find(v => v.id === viewId) const found = views.find(v => v.id === viewId)
if (!found) { if (!found) {
@ -49,9 +49,9 @@ export async function create(
const db = context.getAppDB() const db = context.getAppDB()
const { datasourceId, tableName } = breakExternalTableId(tableId) const { datasourceId, tableName } = breakExternalTableId(tableId)
const ds = await sdk.datasources.get(datasourceId!) const ds = await sdk.datasources.get(datasourceId)
ds.entities![tableName!].views ??= {} ds.entities![tableName].views ??= {}
ds.entities![tableName!].views![view.name] = view ds.entities![tableName].views![view.name] = view
await db.put(ds) await db.put(ds)
return view return view
} }
@ -60,9 +60,9 @@ export async function update(tableId: string, view: ViewV2): Promise<ViewV2> {
const db = context.getAppDB() const db = context.getAppDB()
const { datasourceId, tableName } = breakExternalTableId(tableId) const { datasourceId, tableName } = breakExternalTableId(tableId)
const ds = await sdk.datasources.get(datasourceId!) const ds = await sdk.datasources.get(datasourceId)
ds.entities![tableName!].views ??= {} ds.entities![tableName].views ??= {}
const views = ds.entities![tableName!].views! const views = ds.entities![tableName].views!
const existingView = Object.values(views).find( const existingView = Object.values(views).find(
v => isV2(v) && v.id === view.id v => isV2(v) && v.id === view.id
@ -87,9 +87,9 @@ export async function remove(viewId: string): Promise<ViewV2> {
} }
const { datasourceId, tableName } = breakExternalTableId(view.tableId) const { datasourceId, tableName } = breakExternalTableId(view.tableId)
const ds = await sdk.datasources.get(datasourceId!) const ds = await sdk.datasources.get(datasourceId)
delete ds.entities![tableName!].views![view?.name] delete ds.entities![tableName].views![view?.name]
await db.put(ds) await db.put(ds)
return view return view
} }

View file

@ -180,3 +180,5 @@ export enum BpmStatusValue {
VERIFYING_EMAIL = "verifying_email", VERIFYING_EMAIL = "verifying_email",
COMPLETED = "completed", COMPLETED = "completed",
} }
export const DEFAULT_BB_DATASOURCE_ID = "datasource_internal_bb_default"