1
0
Fork 0
mirror of synced 2024-06-29 19:41:03 +12:00

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 d64f5ea513
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 242 additions and 56 deletions

View file

@ -14,6 +14,7 @@
import ClientBindingPanel from "components/common/bindings/ClientBindingPanel.svelte"
import { generate } from "shortid"
import { getValidOperatorsForType, OperatorOptions } from "constants/lucene"
import { getFields } from "helpers/searchFields"
export let schemaFields
export let filters = []
@ -21,11 +22,8 @@
export let panel = ClientBindingPanel
export let allowBindings = true
const BannedTypes = ["link", "attachment", "formula", "json", "jsonarray"]
$: fieldOptions = (schemaFields ?? [])
.filter(field => !BannedTypes.includes(field.type))
.map(field => field.name)
$: enrichedSchemaFields = getFields(schemaFields || [])
$: fieldOptions = enrichedSchemaFields.map(field => field.name) || []
$: valueTypeOptions = allowBindings ? ["Value", "Binding"] : ["Value"]
const addFilter = () => {
@ -53,7 +51,7 @@
const onFieldChange = (expression, field) => {
// 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
const validOperators = getValidOperatorsForType(expression.type).map(
@ -85,7 +83,7 @@
}
const getFieldOptions = field => {
const schema = schemaFields.find(x => x.name === field)
const schema = enrichedSchemaFields.find(x => x.name === field)
return schema?.constraints?.inclusion || []
}
</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 FieldSelect from "./FieldSelect.svelte"
import MultiFieldSelect from "./MultiFieldSelect.svelte"
import SearchFieldSelect from "./SearchFieldSelect.svelte"
import SchemaSelect from "./SchemaSelect.svelte"
import SectionSelect from "./SectionSelect.svelte"
import NavigationEditor from "./NavigationEditor/NavigationEditor.svelte"
@ -30,6 +31,7 @@ const componentMap = {
icon: IconSelect,
field: FieldSelect,
multifield: MultiFieldSelect,
searchfield: SearchFieldSelect,
options: OptionsEditor,
schema: SchemaSelect,
section: SectionSelect,

View file

@ -229,3 +229,11 @@ export const PaginationLocations = [
{ label: "Query parameters", value: "query" },
{ 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"
},
{
"type": "multifield",
"type": "searchfield",
"label": "Search Columns",
"key": "searchColumns",
"placeholder": "Choose search columns"
@ -2958,7 +2958,7 @@
"key": "dataSource"
},
{
"type": "multifield",
"type": "searchfield",
"label": "Search Columns",
"key": "searchColumns",
"placeholder": "Choose search columns"

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -6,13 +6,19 @@ import RelationshipFetch from "./fetch/RelationshipFetch.js"
import NestedProviderFetch from "./fetch/NestedProviderFetch.js"
import FieldFetch from "./fetch/FieldFetch.js"
import JSONArrayFetch from "./fetch/JSONArrayFetch.js"
import DataFetch from "./fetch/DataFetch.js"
/**
* Fetches the schema of any kind of datasource.
* All datasource fetch classes implement their own functionality to get the
* 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 = {
table: TableFetch,
view: ViewFetch,
@ -28,7 +34,7 @@ export const fetchDatasourceSchema = async datasource => {
// Get the datasource definition and then schema
const definition = await handler.getDefinition(datasource)
const schema = handler.getSchema(datasource, definition)
let schema = handler.getSchema(datasource, definition)
if (!schema) {
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 external = require("./external")
const csvParser = require("../../../utilities/csvParser")
const { isExternalTable } = require("../../../integrations/utils")
const { isExternalTable, isSQL } = require("../../../integrations/utils")
const {
getTableParams,
getDatasourceParams,
@ -32,8 +32,8 @@ exports.fetch = async function (ctx) {
})
)
const internal = internalTables.rows.map(row => ({
...row.doc,
const internal = internalTables.rows.map(tableDoc => ({
...tableDoc.doc,
type: "internal",
sourceId: BudibaseInternalDB._id,
}))
@ -44,12 +44,18 @@ exports.fetch = async function (ctx) {
})
)
const external = externalTables.rows.flatMap(row => {
return Object.values(row.doc.entities || {}).map(entity => ({
...entity,
type: "external",
sourceId: row.doc._id,
}))
const external = externalTables.rows.flatMap(tableDoc => {
let entities = tableDoc.doc.entities
if (entities) {
return Object.values(entities).map(entity => ({
...entity,
type: "external",
sourceId: tableDoc.doc._id,
sql: isSQL(tableDoc.doc),
}))
} else {
return []
}
})
ctx.body = [...internal, ...external]

View file

@ -12,6 +12,7 @@ const { USERS_TABLE_SCHEMA, SwitchableTypes } = require("../../../constants")
const {
isExternalTable,
breakExternalTableId,
isSQL,
} = require("../../../integrations/utils")
const { getViews, saveView } = require("../view/utils")
const viewTemplate = require("../view/viewBuilder")
@ -242,7 +243,9 @@ exports.getTable = async (appId, tableId) => {
const db = new CouchDB(appId)
if (isExternalTable(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 {
return db.get(tableId)
}

View file

@ -72,16 +72,22 @@ class InternalBuilder {
// right now we only do filters on the specific table being queried
addFilters(
tableName: string,
query: KnexQuery,
filters: SearchFilters | undefined
filters: SearchFilters | undefined,
opts: { relationship?: boolean; tableName?: string }
): KnexQuery {
function iterate(
structure: { [key: string]: any },
fn: (key: string, value: any) => void
) {
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) {
@ -272,7 +278,7 @@ class InternalBuilder {
if (foundOffset) {
query = query.offset(foundOffset)
}
query = this.addFilters(tableName, query, filters)
query = this.addFilters(query, filters, { tableName })
// add sorting to pre-query
query = this.addSorting(query, json)
// @ts-ignore
@ -285,20 +291,21 @@ class InternalBuilder {
preQuery = this.addSorting(preQuery, json)
}
// handle joins
return this.addRelationships(
query = this.addRelationships(
knex,
preQuery,
selectStatement,
tableName,
relationships
)
return this.addFilters(query, filters, { relationship: true })
}
update(knex: Knex, json: QueryJson, opts: QueryOptions): KnexQuery {
const { endpoint, body, filters } = json
let query: KnexQuery = knex(endpoint.entityId)
const parsedBody = parseBody(body)
query = this.addFilters(endpoint.entityId, query, filters)
query = this.addFilters(query, filters, { tableName: endpoint.entityId })
// mysql can't use returning
if (opts.disableReturning) {
return query.update(parsedBody)
@ -310,7 +317,7 @@ class InternalBuilder {
delete(knex: Knex, json: QueryJson, opts: QueryOptions): KnexQuery {
const { endpoint, filters } = json
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
if (opts.disableReturning) {
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", () => {
const query = sql._query(generateCreateJson(TABLE_NAME, {
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 { SourceNames } from "../definitions/datasource"
import { DocumentTypes, SEPARATOR } from "../db/utils"
import { FieldTypes, BuildSchemaErrors, InvalidColumns } from "../constants"
@ -127,7 +126,12 @@ export function isSQL(datasource: Datasource): boolean {
if (!datasource || !datasource.source) {
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
}

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")
})
})