Merge branch 'master' into sqs-per-tenant

This commit is contained in:
Sam Rose 2024-07-09 17:30:33 +01:00 committed by GitHub
commit 2fa14ab35f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 144 additions and 95 deletions

View File

@ -1,6 +1,6 @@
{ {
"$schema": "node_modules/lerna/schemas/lerna-schema.json", "$schema": "node_modules/lerna/schemas/lerna-schema.json",
"version": "2.29.14", "version": "2.29.15",
"npmClient": "yarn", "npmClient": "yarn",
"packages": [ "packages": [
"packages/*", "packages/*",

View File

@ -11,10 +11,12 @@ import {
import { SqlStatements } from "./sqlStatements" import { SqlStatements } from "./sqlStatements"
import SqlTableQueryBuilder from "./sqlTable" import SqlTableQueryBuilder from "./sqlTable"
import { import {
AnySearchFilter,
BBReferenceFieldMetadata, BBReferenceFieldMetadata,
FieldSchema, FieldSchema,
FieldType, FieldType,
INTERNAL_TABLE_SOURCE_ID, INTERNAL_TABLE_SOURCE_ID,
InternalSearchFilterOperator,
JsonFieldMetadata, JsonFieldMetadata,
JsonTypes, JsonTypes,
Operation, Operation,
@ -40,7 +42,7 @@ const envLimit = environment.SQL_MAX_ROWS
: null : null
const BASE_LIMIT = envLimit || 5000 const BASE_LIMIT = envLimit || 5000
function likeKey(client: string, key: string): string { function likeKey(client: string | string[], key: string): string {
let start: string, end: string let start: string, end: string
switch (client) { switch (client) {
case SqlClient.MY_SQL: case SqlClient.MY_SQL:
@ -206,17 +208,32 @@ class InternalBuilder {
return alias || name return alias || name
} }
function iterate( function iterate(
structure: { [key: string]: any }, structure: AnySearchFilter,
fn: (key: string, value: any) => void fn: (key: string, value: any) => void,
complexKeyFn?: (key: string[], value: any) => void
) { ) {
for (let [key, value] of Object.entries(structure)) { for (const key in structure) {
const value = structure[key]
const updatedKey = dbCore.removeKeyNumbering(key) const updatedKey = dbCore.removeKeyNumbering(key)
const isRelationshipField = updatedKey.includes(".") const isRelationshipField = updatedKey.includes(".")
if (!opts.relationship && !isRelationshipField) {
let castedTypeValue
if (
key === InternalSearchFilterOperator.COMPLEX_ID_OPERATOR &&
(castedTypeValue = structure[key]) &&
complexKeyFn
) {
const alias = getTableAlias(tableName)
complexKeyFn(
castedTypeValue.id.map((x: string) =>
alias ? `${alias}.${x}` : x
),
castedTypeValue.values
)
} else if (!opts.relationship && !isRelationshipField) {
const alias = getTableAlias(tableName) const alias = getTableAlias(tableName)
fn(alias ? `${alias}.${updatedKey}` : updatedKey, value) fn(alias ? `${alias}.${updatedKey}` : updatedKey, value)
} } else if (opts.relationship && isRelationshipField) {
if (opts.relationship && isRelationshipField) {
const [filterTableName, property] = updatedKey.split(".") const [filterTableName, property] = updatedKey.split(".")
const alias = getTableAlias(filterTableName) const alias = getTableAlias(filterTableName)
fn(alias ? `${alias}.${property}` : property, value) fn(alias ? `${alias}.${property}` : property, value)
@ -239,7 +256,7 @@ class InternalBuilder {
} }
} }
const contains = (mode: object, any: boolean = false) => { const contains = (mode: AnySearchFilter, any: boolean = false) => {
const rawFnc = allOr ? "orWhereRaw" : "whereRaw" const rawFnc = allOr ? "orWhereRaw" : "whereRaw"
const not = mode === filters?.notContains ? "NOT " : "" const not = mode === filters?.notContains ? "NOT " : ""
function stringifyArray(value: Array<any>, quoteStyle = '"'): string { function stringifyArray(value: Array<any>, quoteStyle = '"'): string {
@ -251,7 +268,7 @@ class InternalBuilder {
return `[${value.join(",")}]` return `[${value.join(",")}]`
} }
if (this.client === SqlClient.POSTGRES) { if (this.client === SqlClient.POSTGRES) {
iterate(mode, (key: string, value: Array<any>) => { iterate(mode, (key, value) => {
const wrap = any ? "" : "'" const wrap = any ? "" : "'"
const op = any ? "\\?| array" : "@>" const op = any ? "\\?| array" : "@>"
const fieldNames = key.split(/\./g) const fieldNames = key.split(/\./g)
@ -266,7 +283,7 @@ class InternalBuilder {
}) })
} else if (this.client === SqlClient.MY_SQL) { } else if (this.client === SqlClient.MY_SQL) {
const jsonFnc = any ? "JSON_OVERLAPS" : "JSON_CONTAINS" const jsonFnc = any ? "JSON_OVERLAPS" : "JSON_CONTAINS"
iterate(mode, (key: string, value: Array<any>) => { iterate(mode, (key, value) => {
query = query[rawFnc]( query = query[rawFnc](
`${not}COALESCE(${jsonFnc}(${key}, '${stringifyArray( `${not}COALESCE(${jsonFnc}(${key}, '${stringifyArray(
value value
@ -275,7 +292,7 @@ class InternalBuilder {
}) })
} else { } else {
const andOr = mode === filters?.containsAny ? " OR " : " AND " const andOr = mode === filters?.containsAny ? " OR " : " AND "
iterate(mode, (key: string, value: Array<any>) => { iterate(mode, (key, value) => {
let statement = "" let statement = ""
for (let i in value) { for (let i in value) {
if (typeof value[i] === "string") { if (typeof value[i] === "string") {
@ -299,10 +316,16 @@ class InternalBuilder {
} }
if (filters.oneOf) { if (filters.oneOf) {
iterate(filters.oneOf, (key, array) => { const fnc = allOr ? "orWhereIn" : "whereIn"
const fnc = allOr ? "orWhereIn" : "whereIn" iterate(
query = query[fnc](key, Array.isArray(array) ? array : [array]) filters.oneOf,
}) (key: string, array) => {
query = query[fnc](key, Array.isArray(array) ? array : [array])
},
(key: string[], array) => {
query = query[fnc](key, Array.isArray(array) ? array : [array])
}
)
} }
if (filters.string) { if (filters.string) {
iterate(filters.string, (key, value) => { iterate(filters.string, (key, value) => {
@ -744,6 +767,7 @@ class InternalBuilder {
class SqlQueryBuilder extends SqlTableQueryBuilder { class SqlQueryBuilder extends SqlTableQueryBuilder {
private readonly limit: number private readonly limit: number
// pass through client to get flavour of SQL // pass through client to get flavour of SQL
constructor(client: string, limit: number = BASE_LIMIT) { constructor(client: string, limit: number = BASE_LIMIT) {
super(client) super(client)

View File

@ -3,12 +3,11 @@
import { Modal, ActionButton, TooltipType, TempTooltip } from "@budibase/bbui" import { Modal, ActionButton, TooltipType, TempTooltip } from "@budibase/bbui"
import GridCreateViewModal from "../../modals/grid/GridCreateViewModal.svelte" import GridCreateViewModal from "../../modals/grid/GridCreateViewModal.svelte"
const { rows, columns, filter } = getContext("grid") const { filter } = getContext("grid")
let modal let modal
let firstFilterUsage = false let firstFilterUsage = false
$: disabled = !$columns.length || !$rows.length
$: { $: {
if ($filter?.length && !firstFilterUsage) { if ($filter?.length && !firstFilterUsage) {
firstFilterUsage = true firstFilterUsage = true
@ -21,7 +20,7 @@
type={TooltipType.Info} type={TooltipType.Info}
condition={firstFilterUsage} condition={firstFilterUsage}
> >
<ActionButton {disabled} icon="CollectionAdd" quiet on:click={modal.show}> <ActionButton icon="CollectionAdd" quiet on:click={modal.show}>
Create view Create view
</ActionButton> </ActionButton>
</TempTooltip> </TempTooltip>

View File

@ -146,13 +146,13 @@ const automationActions = store => ({
await store.actions.save(automation) await store.actions.save(automation)
notifications.success( notifications.success(
`Automation ${ `Automation ${
automation.disabled ? "enabled" : "disabled" automation.disabled ? "disabled" : "enabled"
} successfully` } successfully`
) )
} catch (error) { } catch (error) {
notifications.error( notifications.error(
`Error ${ `Error ${
automation && automation.disabled ? "enabling" : "disabling" automation && automation.disabled ? "disabling" : "enabling"
} automation` } automation`
) )
} }

View File

@ -56,7 +56,7 @@
<Modal <Modal
on:cancel={handleModalClose} on:cancel={handleModalClose}
bind:this={modal} bind:this={modal}
disableCancel={$builderStore.inBuilder} disableCancel={$builderStore.inBuilder || ignoreClicksOutside}
zIndex={2} zIndex={2}
> >
<div use:styleable={$component.styles} class={`modal-content ${size}`}> <div use:styleable={$component.styles} class={`modal-content ${size}`}>

@ -1 +1 @@
Subproject commit 11379517b76264a7f938c2d520bd259f586edada Subproject commit 7dbe323aec724ae6336b13c06aaefa4a89837edf

View File

@ -7,6 +7,7 @@ import {
FieldType, FieldType,
FilterType, FilterType,
IncludeRelationship, IncludeRelationship,
InternalSearchFilterOperator,
isManyToOne, isManyToOne,
OneToManyRelationshipFieldMetadata, OneToManyRelationshipFieldMetadata,
Operation, Operation,
@ -189,15 +190,22 @@ export class ExternalRequest<T extends Operation> {
if (filters) { if (filters) {
// need to map over the filters and make sure the _id field isn't present // need to map over the filters and make sure the _id field isn't present
let prefix = 1 let prefix = 1
for (let operator of Object.values(filters)) { for (const operator of Object.values(filters)) {
for (let field of Object.keys(operator || {})) { for (const field of Object.keys(operator || {})) {
if (dbCore.removeKeyNumbering(field) === "_id") { if (dbCore.removeKeyNumbering(field) === "_id") {
if (primary) { if (primary) {
const parts = breakRowIdField(operator[field]) const parts = breakRowIdField(operator[field])
for (let field of primary) { if (primary.length > 1) {
operator[`${prefix}:${field}`] = parts.shift() operator[InternalSearchFilterOperator.COMPLEX_ID_OPERATOR] = {
id: primary,
values: parts[0],
}
} else {
for (let field of primary) {
operator[`${prefix}:${field}`] = parts.shift()
}
prefix++
} }
prefix++
} }
// make sure this field doesn't exist on any filter // make sure this field doesn't exist on any filter
delete operator[field] delete operator[field]

View File

@ -1428,22 +1428,6 @@ describe.each([
expect(row._id).toEqual(existing._id) expect(row._id).toEqual(existing._id)
}) })
it("should return an error on composite keys", async () => {
const existing = await config.api.row.save(table._id!, {})
await config.api.row.exportRows(
table._id!,
{
rows: [`['${existing._id!}']`, "['d001', '10111']"],
},
{
status: 400,
body: {
message: "Export data does not support composite keys.",
},
}
)
})
it("should return an error if no table is found", async () => { it("should return an error if no table is found", async () => {
const existing = await config.api.row.save(table._id!, {}) const existing = await config.api.row.save(table._id!, {})
await config.api.row.exportRows( await config.api.row.exportRows(
@ -1452,6 +1436,46 @@ describe.each([
{ status: 404 } { status: 404 }
) )
}) })
// MSSQL needs a setting called IDENTITY_INSERT to be set to ON to allow writing
// to identity columns. This is not something Budibase does currently.
providerType !== DatabaseName.SQL_SERVER &&
it("should handle filtering by composite primary keys", async () => {
const tableRequest = saveTableRequest({
primary: ["number", "string"],
schema: {
string: {
type: FieldType.STRING,
name: "string",
},
number: {
type: FieldType.NUMBER,
name: "number",
},
},
})
delete tableRequest.schema.id
const table = await config.api.table.save(tableRequest)
const rows = await Promise.all(
generator
.unique(
() => ({
string: generator.word({ length: 30 }),
number: generator.integer({ min: 0, max: 10000 }),
}),
10
)
.map(d => config.api.row.save(table._id!, d))
)
const res = await config.api.row.exportRows(table._id!, {
rows: _.sampleSize(rows, 3).map(r => r._id!),
})
const results = JSON.parse(res)
expect(results.length).toEqual(3)
})
}) })
let o2mTable: Table let o2mTable: Table

View File

@ -158,10 +158,7 @@ export async function exportRows(
_id: rowIds.map((row: string) => { _id: rowIds.map((row: string) => {
const ids = breakRowIdField(row) const ids = breakRowIdField(row)
if (ids.length > 1) { if (ids.length > 1) {
throw new HTTPError( return ids
"Export data does not support composite keys.",
400
)
} }
return ids[0] return ids[0]
}), }),

View File

@ -310,16 +310,12 @@ export const buildQuery = (filter: SearchFilter[]) => {
query.equal = query.equal || {} query.equal = query.equal || {}
query.equal[field] = true query.equal[field] = true
} else { } else {
query[queryOperator] = { query[queryOperator] ??= {}
...query[queryOperator], query[queryOperator]![field] = value
[field]: value,
}
} }
} else { } else {
query[queryOperator] = { query[queryOperator] ??= {}
...query[queryOperator], query[queryOperator]![field] = value
[field]: value,
}
} }
} }
}) })

View File

@ -17,51 +17,52 @@ export enum SearchFilterOperator {
CONTAINS_ANY = "containsAny", CONTAINS_ANY = "containsAny",
} }
export enum InternalSearchFilterOperator {
COMPLEX_ID_OPERATOR = "_complexIdOperator",
}
type BasicFilter<T = any> = Record<string, T> & {
[InternalSearchFilterOperator.COMPLEX_ID_OPERATOR]?: never
}
type ArrayFilter = Record<string, any[]> & {
[InternalSearchFilterOperator.COMPLEX_ID_OPERATOR]?: {
id: string[]
values: string[]
}
}
type RangeFilter = Record<
string,
| {
high: number | string
low: number | string
}
| { high: number | string }
| { low: number | string }
> & {
[InternalSearchFilterOperator.COMPLEX_ID_OPERATOR]?: never
}
export type AnySearchFilter = BasicFilter | ArrayFilter | RangeFilter
export interface SearchFilters { export interface SearchFilters {
allOr?: boolean allOr?: boolean
// TODO: this is just around for now - we need a better way to do or/and // TODO: this is just around for now - we need a better way to do or/and
// allows just fuzzy to be or - all the fuzzy/like parameters // allows just fuzzy to be or - all the fuzzy/like parameters
fuzzyOr?: boolean fuzzyOr?: boolean
onEmptyFilter?: EmptyFilterOption onEmptyFilter?: EmptyFilterOption
[SearchFilterOperator.STRING]?: { [SearchFilterOperator.STRING]?: BasicFilter<string>
[key: string]: string [SearchFilterOperator.FUZZY]?: BasicFilter<string>
} [SearchFilterOperator.RANGE]?: RangeFilter
[SearchFilterOperator.FUZZY]?: { [SearchFilterOperator.EQUAL]?: BasicFilter
[key: string]: string [SearchFilterOperator.NOT_EQUAL]?: BasicFilter
} [SearchFilterOperator.EMPTY]?: BasicFilter
[SearchFilterOperator.RANGE]?: { [SearchFilterOperator.NOT_EMPTY]?: BasicFilter
[key: string]: [SearchFilterOperator.ONE_OF]?: ArrayFilter
| { [SearchFilterOperator.CONTAINS]?: ArrayFilter
high: number | string [SearchFilterOperator.NOT_CONTAINS]?: ArrayFilter
low: number | string [SearchFilterOperator.CONTAINS_ANY]?: ArrayFilter
}
| { high: number | string }
| { low: number | string }
}
[SearchFilterOperator.EQUAL]?: {
[key: string]: any
}
[SearchFilterOperator.NOT_EQUAL]?: {
[key: string]: any
}
[SearchFilterOperator.EMPTY]?: {
[key: string]: any
}
[SearchFilterOperator.NOT_EMPTY]?: {
[key: string]: any
}
[SearchFilterOperator.ONE_OF]?: {
[key: string]: any[]
}
[SearchFilterOperator.CONTAINS]?: {
[key: string]: any[]
}
[SearchFilterOperator.NOT_CONTAINS]?: {
[key: string]: any[]
}
[SearchFilterOperator.CONTAINS_ANY]?: {
[key: string]: any[]
}
// specific to SQS/SQLite search on internal tables this can be used // specific to SQS/SQLite search on internal tables this can be used
// to make sure the documents returned are always filtered down to a // to make sure the documents returned are always filtered down to a
// specific document type (such as just rows) // specific document type (such as just rows)