Merge pull request #4073 from Budibase/feature/sql-relationship-filtering

SQL relationship filtering
This commit is contained in:
Michael Drury 2022-01-18 13:10:58 +00:00 committed by GitHub
commit 7cf78f8c8b
18 changed files with 242 additions and 56 deletions

View File

@ -14,6 +14,7 @@
import ClientBindingPanel from "components/common/bindings/ClientBindingPanel.svelte" import ClientBindingPanel from "components/common/bindings/ClientBindingPanel.svelte"
import { generate } from "shortid" import { generate } from "shortid"
import { getValidOperatorsForType, OperatorOptions } from "constants/lucene" import { getValidOperatorsForType, OperatorOptions } from "constants/lucene"
import { getFields } from "helpers/searchFields"
export let schemaFields export let schemaFields
export let filters = [] export let filters = []
@ -21,11 +22,8 @@
export let panel = ClientBindingPanel export let panel = ClientBindingPanel
export let allowBindings = true export let allowBindings = true
const BannedTypes = ["link", "attachment", "formula", "json", "jsonarray"] $: enrichedSchemaFields = getFields(schemaFields || [])
$: fieldOptions = enrichedSchemaFields.map(field => field.name) || []
$: fieldOptions = (schemaFields ?? [])
.filter(field => !BannedTypes.includes(field.type))
.map(field => field.name)
$: valueTypeOptions = allowBindings ? ["Value", "Binding"] : ["Value"] $: valueTypeOptions = allowBindings ? ["Value", "Binding"] : ["Value"]
const addFilter = () => { const addFilter = () => {
@ -53,7 +51,7 @@
const onFieldChange = (expression, field) => { const onFieldChange = (expression, field) => {
// Update the field type // Update the field type
expression.type = schemaFields.find(x => x.name === field)?.type expression.type = enrichedSchemaFields.find(x => x.name === field)?.type
// Ensure a valid operator is set // Ensure a valid operator is set
const validOperators = getValidOperatorsForType(expression.type).map( const validOperators = getValidOperatorsForType(expression.type).map(
@ -85,7 +83,7 @@
} }
const getFieldOptions = field => { const getFieldOptions = field => {
const schema = schemaFields.find(x => x.name === field) const schema = enrichedSchemaFields.find(x => x.name === field)
return schema?.constraints?.inclusion || [] return schema?.constraints?.inclusion || []
} }
</script> </script>

View File

@ -0,0 +1,47 @@
<script>
import { Multiselect } from "@budibase/bbui"
import {
getDatasourceForProvider,
getSchemaForDatasource,
} from "builderStore/dataBinding"
import { currentAsset } from "builderStore"
import { tables } from "stores/backend"
import { createEventDispatcher } from "svelte"
import { getFields } from "helpers/searchFields"
export let componentInstance = {}
export let value = ""
export let placeholder
const dispatch = createEventDispatcher()
$: datasource = getDatasourceForProvider($currentAsset, componentInstance)
$: schema = getSchemaForDatasource($currentAsset, datasource).schema
$: options = getOptions(datasource, schema || {})
$: boundValue = getSelectedOption(value, options)
function getOptions(ds, dsSchema) {
let base = Object.values(dsSchema)
if (!ds?.tableId) {
return base
}
const currentTable = $tables.list.find(table => table._id === ds.tableId)
return getFields(base, { allowLinks: currentTable.sql }).map(
field => field.name
)
}
function getSelectedOption(selectedOptions, allOptions) {
// Fix the hardcoded default string value
if (!Array.isArray(selectedOptions)) {
selectedOptions = []
}
return selectedOptions.filter(val => allOptions.indexOf(val) !== -1)
}
const setValue = value => {
boundValue = getSelectedOption(value.detail, options)
dispatch("change", boundValue)
}
</script>
<Multiselect {placeholder} value={boundValue} on:change={setValue} {options} />

View File

@ -7,6 +7,7 @@ import ColorPicker from "./ColorPicker.svelte"
import { IconSelect } from "./IconSelect" import { IconSelect } from "./IconSelect"
import FieldSelect from "./FieldSelect.svelte" import FieldSelect from "./FieldSelect.svelte"
import MultiFieldSelect from "./MultiFieldSelect.svelte" import MultiFieldSelect from "./MultiFieldSelect.svelte"
import SearchFieldSelect from "./SearchFieldSelect.svelte"
import SchemaSelect from "./SchemaSelect.svelte" import SchemaSelect from "./SchemaSelect.svelte"
import SectionSelect from "./SectionSelect.svelte" import SectionSelect from "./SectionSelect.svelte"
import NavigationEditor from "./NavigationEditor/NavigationEditor.svelte" import NavigationEditor from "./NavigationEditor/NavigationEditor.svelte"
@ -30,6 +31,7 @@ const componentMap = {
icon: IconSelect, icon: IconSelect,
field: FieldSelect, field: FieldSelect,
multifield: MultiFieldSelect, multifield: MultiFieldSelect,
searchfield: SearchFieldSelect,
options: OptionsEditor, options: OptionsEditor,
schema: SchemaSelect, schema: SchemaSelect,
section: SectionSelect, section: SectionSelect,

View File

@ -229,3 +229,11 @@ export const PaginationLocations = [
{ label: "Query parameters", value: "query" }, { label: "Query parameters", value: "query" },
{ label: "Request body", value: "body" }, { label: "Request body", value: "body" },
] ]
export const BannedSearchTypes = [
"link",
"attachment",
"formula",
"json",
"jsonarray",
]

View File

@ -0,0 +1,31 @@
import { tables } from "../stores/backend"
import { BannedSearchTypes } from "../constants/backend"
import { get } from "svelte/store"
export function getTableFields(linkField) {
const table = get(tables).list.find(table => table._id === linkField.tableId)
if (!table || !table.sql) {
return []
}
const linkFields = getFields(Object.values(table.schema), {
allowLinks: false,
})
return linkFields.map(field => ({
...field,
name: `${table.name}.${field.name}`,
}))
}
export function getFields(fields, { allowLinks } = { allowLinks: true }) {
let filteredFields = fields.filter(
field => !BannedSearchTypes.includes(field.type)
)
if (allowLinks) {
const linkFields = fields.filter(field => field.type === "link")
for (let linkField of linkFields) {
// only allow one depth of SQL relationship filtering
filteredFields = filteredFields.concat(getTableFields(linkField))
}
}
return filteredFields
}

View File

@ -2811,7 +2811,7 @@
"key": "dataSource" "key": "dataSource"
}, },
{ {
"type": "multifield", "type": "searchfield",
"label": "Search Columns", "label": "Search Columns",
"key": "searchColumns", "key": "searchColumns",
"placeholder": "Choose search columns" "placeholder": "Choose search columns"
@ -2958,7 +2958,7 @@
"key": "dataSource" "key": "dataSource"
}, },
{ {
"type": "multifield", "type": "searchfield",
"label": "Search Columns", "label": "Search Columns",
"key": "searchColumns", "key": "searchColumns",
"placeholder": "Choose search columns" "placeholder": "Choose search columns"

View File

@ -67,6 +67,7 @@
$: dataContext = { $: dataContext = {
rows: $fetch.rows, rows: $fetch.rows,
info: $fetch.info, info: $fetch.info,
datasource: dataSource || {},
schema: $fetch.schema, schema: $fetch.schema,
rowsLength: $fetch.rows.length, rowsLength: $fetch.rows.length,

View File

@ -71,12 +71,13 @@
const enrichFilter = (filter, columns, formId) => { const enrichFilter = (filter, columns, formId) => {
let enrichedFilter = [...(filter || [])] let enrichedFilter = [...(filter || [])]
columns?.forEach(column => { columns?.forEach(column => {
const safePath = column.name.split(".").map(safe).join(".")
enrichedFilter.push({ enrichedFilter.push({
field: column.name, field: column.name,
operator: column.type === "string" ? "string" : "equal", operator: column.type === "string" ? "string" : "equal",
type: column.type === "string" ? "string" : "number", type: column.type === "string" ? "string" : "number",
valueType: "Binding", valueType: "Binding",
value: `{{ [${formId}].[${column.name}] }}`, value: `{{ ${safe(formId)}.${safePath} }}`,
}) })
}) })
return enrichedFilter return enrichedFilter
@ -112,7 +113,9 @@
// Load the datasource schema so we can determine column types // Load the datasource schema so we can determine column types
const fetchSchema = async dataSource => { const fetchSchema = async dataSource => {
if (dataSource) { if (dataSource) {
schema = await fetchDatasourceSchema(dataSource) schema = await fetchDatasourceSchema(dataSource, {
enrichRelationships: true,
})
} }
schemaLoaded = true schemaLoaded = true
} }

View File

@ -59,12 +59,13 @@
const enrichFilter = (filter, columns, formId) => { const enrichFilter = (filter, columns, formId) => {
let enrichedFilter = [...(filter || [])] let enrichedFilter = [...(filter || [])]
columns?.forEach(column => { columns?.forEach(column => {
const safePath = column.name.split(".").map(safe).join(".")
enrichedFilter.push({ enrichedFilter.push({
field: column.name, field: column.name,
operator: column.type === "string" ? "string" : "equal", operator: column.type === "string" ? "string" : "equal",
type: column.type === "string" ? "string" : "number", type: column.type === "string" ? "string" : "number",
valueType: "Binding", valueType: "Binding",
value: `{{ ${safe(formId)}.${safe(column.name)} }}`, value: `{{ ${safe(formId)}.${safePath} }}`,
}) })
}) })
return enrichedFilter return enrichedFilter
@ -90,7 +91,9 @@
// Load the datasource schema so we can determine column types // Load the datasource schema so we can determine column types
const fetchSchema = async dataSource => { const fetchSchema = async dataSource => {
if (dataSource) { if (dataSource) {
schema = await fetchDatasourceSchema(dataSource) schema = await fetchDatasourceSchema(dataSource, {
enrichRelationships: true,
})
} }
schemaLoaded = true schemaLoaded = true
} }

View File

@ -11,11 +11,14 @@
export let size = "M" export let size = "M"
const component = getContext("component") const component = getContext("component")
const { builderStore, ActionTypes, getAction } = getContext("sdk") const { builderStore, ActionTypes, getAction, fetchDatasourceSchema } =
getContext("sdk")
let modal let modal
let tmpFilters = [] let tmpFilters = []
let filters = [] let filters = []
let schemaLoaded = false,
schema
$: dataProviderId = dataProvider?.id $: dataProviderId = dataProvider?.id
$: addExtension = getAction( $: addExtension = getAction(
@ -26,7 +29,7 @@
dataProviderId, dataProviderId,
ActionTypes.RemoveDataProviderQueryExtension ActionTypes.RemoveDataProviderQueryExtension
) )
$: schema = dataProvider?.schema $: fetchSchema(dataProvider || {})
$: schemaFields = getSchemaFields(schema, allowedFields) $: schemaFields = getSchemaFields(schema, allowedFields)
// Add query extension to data provider // Add query extension to data provider
@ -39,7 +42,20 @@
} }
} }
const getSchemaFields = (schema, allowedFields) => { async function fetchSchema(dataProvider) {
const datasource = dataProvider?.datasource
if (datasource) {
schema = await fetchDatasourceSchema(datasource, {
enrichRelationships: true,
})
}
schemaLoaded = true
}
function getSchemaFields(schema, allowedFields) {
if (!schemaLoaded) {
return {}
}
let clonedSchema = {} let clonedSchema = {}
if (!allowedFields?.length) { if (!allowedFields?.length) {
clonedSchema = schema clonedSchema = schema
@ -68,18 +84,20 @@
}) })
</script> </script>
<Button {#if schemaLoaded}
onClick={openEditor} <Button
icon="Properties" onClick={openEditor}
text="Filter" icon="Properties"
{size} text="Filter"
type="secondary" {size}
quiet type="secondary"
active={filters?.length > 0} quiet
/> active={filters?.length > 0}
/>
<Modal bind:this={modal}> <Modal bind:this={modal}>
<ModalContent title="Edit filters" size="XL" onConfirm={updateQuery}> <ModalContent title="Edit filters" size="XL" onConfirm={updateQuery}>
<FilterModal bind:filters={tmpFilters} {schemaFields} /> <FilterModal bind:filters={tmpFilters} {schemaFields} />
</ModalContent> </ModalContent>
</Modal> </Modal>
{/if}

View File

@ -67,7 +67,6 @@ export default class DataFetch {
this.getPage = this.getPage.bind(this) this.getPage = this.getPage.bind(this)
this.getInitialData = this.getInitialData.bind(this) this.getInitialData = this.getInitialData.bind(this)
this.determineFeatureFlags = this.determineFeatureFlags.bind(this) this.determineFeatureFlags = this.determineFeatureFlags.bind(this)
this.enrichSchema = this.enrichSchema.bind(this)
this.refresh = this.refresh.bind(this) this.refresh = this.refresh.bind(this)
this.update = this.update.bind(this) this.update = this.update.bind(this)
this.hasNextPage = this.hasNextPage.bind(this) this.hasNextPage = this.hasNextPage.bind(this)
@ -129,7 +128,7 @@ export default class DataFetch {
// Fetch and enrich schema // Fetch and enrich schema
let schema = this.constructor.getSchema(datasource, definition) let schema = this.constructor.getSchema(datasource, definition)
schema = this.enrichSchema(schema) schema = DataFetch.enrichSchema(schema)
if (!schema) { if (!schema) {
return return
} }
@ -248,7 +247,7 @@ export default class DataFetch {
* @param schema the datasource schema * @param schema the datasource schema
* @return {object} the enriched datasource schema * @return {object} the enriched datasource schema
*/ */
enrichSchema(schema) { static enrichSchema(schema) {
if (schema == null) { if (schema == null) {
return null return null
} }

View File

@ -6,13 +6,19 @@ import RelationshipFetch from "./fetch/RelationshipFetch.js"
import NestedProviderFetch from "./fetch/NestedProviderFetch.js" import NestedProviderFetch from "./fetch/NestedProviderFetch.js"
import FieldFetch from "./fetch/FieldFetch.js" import FieldFetch from "./fetch/FieldFetch.js"
import JSONArrayFetch from "./fetch/JSONArrayFetch.js" import JSONArrayFetch from "./fetch/JSONArrayFetch.js"
import DataFetch from "./fetch/DataFetch.js"
/** /**
* Fetches the schema of any kind of datasource. * Fetches the schema of any kind of datasource.
* All datasource fetch classes implement their own functionality to get the * All datasource fetch classes implement their own functionality to get the
* schema of a datasource of their respective types. * schema of a datasource of their respective types.
* @param datasource the datasource to fetch the schema for
* @param options options for enriching the schema
*/ */
export const fetchDatasourceSchema = async datasource => { export const fetchDatasourceSchema = async (
datasource,
options = { enrichRelationships: false }
) => {
const handler = { const handler = {
table: TableFetch, table: TableFetch,
view: ViewFetch, view: ViewFetch,
@ -28,7 +34,7 @@ export const fetchDatasourceSchema = async datasource => {
// Get the datasource definition and then schema // Get the datasource definition and then schema
const definition = await handler.getDefinition(datasource) const definition = await handler.getDefinition(datasource)
const schema = handler.getSchema(datasource, definition) let schema = handler.getSchema(datasource, definition)
if (!schema) { if (!schema) {
return null return null
} }
@ -49,5 +55,28 @@ export const fetchDatasourceSchema = async datasource => {
}) })
} }
}) })
return { ...schema, ...jsonAdditions } schema = { ...schema, ...jsonAdditions }
// Check for any relationship fields if required
if (options?.enrichRelationships && definition.sql) {
let relationshipAdditions = {}
for (let fieldKey of Object.keys(schema)) {
const fieldSchema = schema[fieldKey]
if (fieldSchema?.type === "link") {
const linkSchema = await fetchDatasourceSchema({
type: "table",
tableId: fieldSchema?.tableId,
})
Object.keys(linkSchema || {}).forEach(linkKey => {
relationshipAdditions[`${fieldKey}.${linkKey}`] = {
type: linkSchema[linkKey].type,
}
})
}
}
schema = { ...schema, ...relationshipAdditions }
}
// Ensure schema structure is correct
return DataFetch.enrichSchema(schema)
} }

View File

@ -2,7 +2,7 @@ const CouchDB = require("../../../db")
const internal = require("./internal") const internal = require("./internal")
const external = require("./external") const external = require("./external")
const csvParser = require("../../../utilities/csvParser") const csvParser = require("../../../utilities/csvParser")
const { isExternalTable } = require("../../../integrations/utils") const { isExternalTable, isSQL } = require("../../../integrations/utils")
const { const {
getTableParams, getTableParams,
getDatasourceParams, getDatasourceParams,
@ -32,8 +32,8 @@ exports.fetch = async function (ctx) {
}) })
) )
const internal = internalTables.rows.map(row => ({ const internal = internalTables.rows.map(tableDoc => ({
...row.doc, ...tableDoc.doc,
type: "internal", type: "internal",
sourceId: BudibaseInternalDB._id, sourceId: BudibaseInternalDB._id,
})) }))
@ -44,12 +44,18 @@ exports.fetch = async function (ctx) {
}) })
) )
const external = externalTables.rows.flatMap(row => { const external = externalTables.rows.flatMap(tableDoc => {
return Object.values(row.doc.entities || {}).map(entity => ({ let entities = tableDoc.doc.entities
...entity, if (entities) {
type: "external", return Object.values(entities).map(entity => ({
sourceId: row.doc._id, ...entity,
})) type: "external",
sourceId: tableDoc.doc._id,
sql: isSQL(tableDoc.doc),
}))
} else {
return []
}
}) })
ctx.body = [...internal, ...external] ctx.body = [...internal, ...external]

View File

@ -12,6 +12,7 @@ const { USERS_TABLE_SCHEMA, SwitchableTypes } = require("../../../constants")
const { const {
isExternalTable, isExternalTable,
breakExternalTableId, breakExternalTableId,
isSQL,
} = require("../../../integrations/utils") } = require("../../../integrations/utils")
const { getViews, saveView } = require("../view/utils") const { getViews, saveView } = require("../view/utils")
const viewTemplate = require("../view/viewBuilder") const viewTemplate = require("../view/viewBuilder")
@ -242,7 +243,9 @@ exports.getTable = async (appId, tableId) => {
const db = new CouchDB(appId) const db = new CouchDB(appId)
if (isExternalTable(tableId)) { if (isExternalTable(tableId)) {
let { datasourceId, tableName } = breakExternalTableId(tableId) let { datasourceId, tableName } = breakExternalTableId(tableId)
return exports.getExternalTable(appId, datasourceId, tableName) const datasource = await db.get(datasourceId)
const table = await exports.getExternalTable(appId, datasourceId, tableName)
return { ...table, sql: isSQL(datasource) }
} else { } else {
return db.get(tableId) return db.get(tableId)
} }

View File

@ -72,16 +72,22 @@ class InternalBuilder {
// right now we only do filters on the specific table being queried // right now we only do filters on the specific table being queried
addFilters( addFilters(
tableName: string,
query: KnexQuery, query: KnexQuery,
filters: SearchFilters | undefined filters: SearchFilters | undefined,
opts: { relationship?: boolean; tableName?: string }
): KnexQuery { ): KnexQuery {
function iterate( function iterate(
structure: { [key: string]: any }, structure: { [key: string]: any },
fn: (key: string, value: any) => void fn: (key: string, value: any) => void
) { ) {
for (let [key, value] of Object.entries(structure)) { for (let [key, value] of Object.entries(structure)) {
fn(`${tableName}.${key}`, value) const isRelationshipField = key.includes(".")
if (!opts.relationship && !isRelationshipField) {
fn(`${opts.tableName}.${key}`, value)
}
if (opts.relationship && isRelationshipField) {
fn(key, value)
}
} }
} }
if (!filters) { if (!filters) {
@ -272,7 +278,7 @@ class InternalBuilder {
if (foundOffset) { if (foundOffset) {
query = query.offset(foundOffset) query = query.offset(foundOffset)
} }
query = this.addFilters(tableName, query, filters) query = this.addFilters(query, filters, { tableName })
// add sorting to pre-query // add sorting to pre-query
query = this.addSorting(query, json) query = this.addSorting(query, json)
// @ts-ignore // @ts-ignore
@ -285,20 +291,21 @@ class InternalBuilder {
preQuery = this.addSorting(preQuery, json) preQuery = this.addSorting(preQuery, json)
} }
// handle joins // handle joins
return this.addRelationships( query = this.addRelationships(
knex, knex,
preQuery, preQuery,
selectStatement, selectStatement,
tableName, tableName,
relationships relationships
) )
return this.addFilters(query, filters, { relationship: true })
} }
update(knex: Knex, json: QueryJson, opts: QueryOptions): KnexQuery { update(knex: Knex, json: QueryJson, opts: QueryOptions): KnexQuery {
const { endpoint, body, filters } = json const { endpoint, body, filters } = json
let query: KnexQuery = knex(endpoint.entityId) let query: KnexQuery = knex(endpoint.entityId)
const parsedBody = parseBody(body) const parsedBody = parseBody(body)
query = this.addFilters(endpoint.entityId, query, filters) query = this.addFilters(query, filters, { tableName: endpoint.entityId })
// mysql can't use returning // mysql can't use returning
if (opts.disableReturning) { if (opts.disableReturning) {
return query.update(parsedBody) return query.update(parsedBody)
@ -310,7 +317,7 @@ class InternalBuilder {
delete(knex: Knex, json: QueryJson, opts: QueryOptions): KnexQuery { delete(knex: Knex, json: QueryJson, opts: QueryOptions): KnexQuery {
const { endpoint, filters } = json const { endpoint, filters } = json
let query: KnexQuery = knex(endpoint.entityId) let query: KnexQuery = knex(endpoint.entityId)
query = this.addFilters(endpoint.entityId, query, filters) query = this.addFilters(query, filters, { tableName: endpoint.entityId })
// mysql can't use returning // mysql can't use returning
if (opts.disableReturning) { if (opts.disableReturning) {
return query.delete() return query.delete()

View File

@ -119,6 +119,22 @@ describe("SQL query builder", () => {
}) })
}) })
it("should allow filtering on a related field", () => {
const query = sql._query(generateReadJson({
filters: {
equal: {
age: 10,
"task.name": "task 1",
},
},
}))
// order of bindings changes because relationship filters occur outside inner query
expect(query).toEqual({
bindings: [10, limit, "task 1"],
sql: `select * from (select * from "${TABLE_NAME}" where "${TABLE_NAME}"."age" = $1 limit $2) as "${TABLE_NAME}" where "task"."name" = $3`
})
})
it("should test an create statement", () => { it("should test an create statement", () => {
const query = sql._query(generateCreateJson(TABLE_NAME, { const query = sql._query(generateCreateJson(TABLE_NAME, {
name: "Michael", name: "Michael",

View File

@ -1,6 +1,5 @@
import { SqlQuery } from "../definitions/datasource" import { SourceNames, SqlQuery } from "../definitions/datasource"
import { Datasource, Table } from "../definitions/common" import { Datasource, Table } from "../definitions/common"
import { SourceNames } from "../definitions/datasource"
import { DocumentTypes, SEPARATOR } from "../db/utils" import { DocumentTypes, SEPARATOR } from "../db/utils"
import { FieldTypes, BuildSchemaErrors, InvalidColumns } from "../constants" import { FieldTypes, BuildSchemaErrors, InvalidColumns } from "../constants"
@ -127,7 +126,12 @@ export function isSQL(datasource: Datasource): boolean {
if (!datasource || !datasource.source) { if (!datasource || !datasource.source) {
return false return false
} }
const SQL = [SourceNames.POSTGRES, SourceNames.SQL_SERVER, SourceNames.MYSQL] const SQL = [
SourceNames.POSTGRES,
SourceNames.SQL_SERVER,
SourceNames.MYSQL,
SourceNames.ORACLE,
]
return SQL.indexOf(datasource.source) !== -1 return SQL.indexOf(datasource.source) !== -1
} }

View File

@ -146,3 +146,14 @@ describe("check manifest", () => {
) )
}) })
}) })
describe("check full stops that are safe", () => {
it("should allow using an escaped full stop", async () => {
const data = {
"c53a4a604fa754d33baaafd5bca4d3658-YXuUBqt5vI": { "persons.firstname": "1" }
}
const template = "{{ [c53a4a604fa754d33baaafd5bca4d3658-YXuUBqt5vI].[persons.firstname] }}"
const output = await processString(template, data)
expect(output).toEqual("1")
})
})