diff --git a/.github/workflows/budibase_ci.yml b/.github/workflows/budibase_ci.yml index 6120290d0d..fd4d8cf7c8 100644 --- a/.github/workflows/budibase_ci.yml +++ b/.github/workflows/budibase_ci.yml @@ -64,10 +64,11 @@ jobs: - run: yarn --frozen-lockfile # Run build all the projects - - name: Build - run: | - yarn build:oss - yarn build:account-portal + - name: Build OSS + run: yarn build:oss + - name: Build account portal + run: yarn build:account-portal + if: ${{ env.IS_OSS_CONTRIBUTOR == 'false' }} # Check the types of the projects built via esbuild - name: Check types run: | diff --git a/hosting/proxy/nginx.prod.conf b/hosting/proxy/nginx.prod.conf index 65cc3ff390..79007da311 100644 --- a/hosting/proxy/nginx.prod.conf +++ b/hosting/proxy/nginx.prod.conf @@ -51,11 +51,11 @@ http { proxy_buffering off; set $csp_default "default-src 'self'"; - set $csp_script "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://*.budibase.net https://cdn.budi.live https://js.intercomcdn.com https://widget.intercom.io https://d2l5prqdbvm3op.cloudfront.net"; + set $csp_script "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://*.budibase.net https://cdn.budi.live https://js.intercomcdn.com https://widget.intercom.io https://d2l5prqdbvm3op.cloudfront.net https://us-assets.i.posthog.com"; set $csp_style "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com https://rsms.me https://maxcdn.bootstrapcdn.com"; set $csp_object "object-src 'none'"; set $csp_base_uri "base-uri 'self'"; - set $csp_connect "connect-src 'self' https://*.budibase.app https://*.budibaseqa.app https://*.budibase.net https://api-iam.intercom.io https://api-iam.intercom.io https://api-ping.intercom.io https://app.posthog.com wss://nexus-websocket-a.intercom.io wss://nexus-websocket-b.intercom.io https://nexus-websocket-a.intercom.io https://nexus-websocket-b.intercom.io https://uploads.intercomcdn.com https://uploads.intercomusercontent.com https://*.amazonaws.com https://*.s3.amazonaws.com https://*.s3.us-east-2.amazonaws.com https://*.s3.us-east-1.amazonaws.com https://*.s3.us-west-1.amazonaws.com https://*.s3.us-west-2.amazonaws.com https://*.s3.af-south-1.amazonaws.com https://*.s3.ap-east-1.amazonaws.com https://*.s3.ap-southeast-3.amazonaws.com https://*.s3.ap-south-1.amazonaws.com https://*.s3.ap-northeast-3.amazonaws.com https://*.s3.ap-northeast-2.amazonaws.com https://*.s3.ap-southeast-1.amazonaws.com https://*.s3.ap-southeast-2.amazonaws.com https://*.s3.ap-northeast-1.amazonaws.com https://*.s3.ca-central-1.amazonaws.com https://*.s3.cn-north-1.amazonaws.com https://*.s3.cn-northwest-1.amazonaws.com https://*.s3.eu-central-1.amazonaws.com https://*.s3.eu-west-1.amazonaws.com https://*.s3.eu-west-2.amazonaws.com https://*.s3.eu-south-1.amazonaws.com https://*.s3.eu-west-3.amazonaws.com https://*.s3.eu-north-1.amazonaws.com https://*.s3.sa-east-1.amazonaws.com https://*.s3.me-south-1.amazonaws.com https://*.s3.us-gov-east-1.amazonaws.com https://*.s3.us-gov-west-1.amazonaws.com https://api.github.com"; + set $csp_connect "connect-src 'self' https://*.budibase.app https://*.budibaseqa.app https://*.budibase.net https://api-iam.intercom.io https://api-iam.intercom.io https://api-ping.intercom.io https://app.posthog.com https://us.i.posthog.com wss://nexus-websocket-a.intercom.io wss://nexus-websocket-b.intercom.io https://nexus-websocket-a.intercom.io https://nexus-websocket-b.intercom.io https://uploads.intercomcdn.com https://uploads.intercomusercontent.com https://*.amazonaws.com https://*.s3.amazonaws.com https://*.s3.us-east-2.amazonaws.com https://*.s3.us-east-1.amazonaws.com https://*.s3.us-west-1.amazonaws.com https://*.s3.us-west-2.amazonaws.com https://*.s3.af-south-1.amazonaws.com https://*.s3.ap-east-1.amazonaws.com https://*.s3.ap-southeast-3.amazonaws.com https://*.s3.ap-south-1.amazonaws.com https://*.s3.ap-northeast-3.amazonaws.com https://*.s3.ap-northeast-2.amazonaws.com https://*.s3.ap-southeast-1.amazonaws.com https://*.s3.ap-southeast-2.amazonaws.com https://*.s3.ap-northeast-1.amazonaws.com https://*.s3.ca-central-1.amazonaws.com https://*.s3.cn-north-1.amazonaws.com https://*.s3.cn-northwest-1.amazonaws.com https://*.s3.eu-central-1.amazonaws.com https://*.s3.eu-west-1.amazonaws.com https://*.s3.eu-west-2.amazonaws.com https://*.s3.eu-south-1.amazonaws.com https://*.s3.eu-west-3.amazonaws.com https://*.s3.eu-north-1.amazonaws.com https://*.s3.sa-east-1.amazonaws.com https://*.s3.me-south-1.amazonaws.com https://*.s3.us-gov-east-1.amazonaws.com https://*.s3.us-gov-west-1.amazonaws.com https://api.github.com"; set $csp_font "font-src 'self' data: https://cdn.jsdelivr.net https://fonts.gstatic.com https://rsms.me https://maxcdn.bootstrapcdn.com https://js.intercomcdn.com https://fonts.intercomcdn.com"; set $csp_frame "frame-src 'self' https:"; set $csp_img "img-src http: https: data: blob:"; diff --git a/lerna.json b/lerna.json index e5a2623766..78a3aa13e9 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "2.23.0", + "version": "2.23.4", "npmClient": "yarn", "packages": [ "packages/*", diff --git a/packages/backend-core/src/db/couch/DatabaseImpl.ts b/packages/backend-core/src/db/couch/DatabaseImpl.ts index c1347f4f2b..d220d0a8ac 100644 --- a/packages/backend-core/src/db/couch/DatabaseImpl.ts +++ b/packages/backend-core/src/db/couch/DatabaseImpl.ts @@ -12,6 +12,7 @@ import { isDocument, RowResponse, RowValue, + SqlQueryBinding, } from "@budibase/types" import { getCouchInfo } from "./connections" import { directCouchUrlCall } from "./utils" @@ -248,14 +249,20 @@ export class DatabaseImpl implements Database { }) } - async sql(sql: string): Promise { + async sql( + sql: string, + parameters?: SqlQueryBinding + ): Promise { const dbName = this.name const url = `/${dbName}/${SQLITE_DESIGN_DOC_ID}` const response = await directCouchUrlCall({ url: `${this.couchInfo.sqlUrl}/${url}`, method: "POST", cookie: this.couchInfo.cookie, - body: sql, + body: { + query: sql, + args: parameters, + }, }) if (response.status > 300) { throw new Error(await response.text()) diff --git a/packages/backend-core/src/db/instrumentation.ts b/packages/backend-core/src/db/instrumentation.ts index 880f0a3c72..32ba81ebd8 100644 --- a/packages/backend-core/src/db/instrumentation.ts +++ b/packages/backend-core/src/db/instrumentation.ts @@ -13,6 +13,7 @@ import { DatabaseQueryOpts, Document, RowValue, + SqlQueryBinding, } from "@budibase/types" import tracer from "dd-trace" import { Writable } from "stream" @@ -150,10 +151,13 @@ export class DDInstrumentedDatabase implements Database { }) } - sql(sql: string): Promise { + sql( + sql: string, + parameters?: SqlQueryBinding + ): Promise { return tracer.trace("db.sql", span => { span?.addTags({ db_name: this.name }) - return this.db.sql(sql) + return this.db.sql(sql, parameters) }) } } diff --git a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/_components/ComponentList/ComponentKeyHandler.svelte b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/_components/ComponentList/ComponentKeyHandler.svelte index 7e9c113a77..6b27d79c15 100644 --- a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/_components/ComponentList/ComponentKeyHandler.svelte +++ b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/_components/ComponentList/ComponentKeyHandler.svelte @@ -137,8 +137,12 @@ const activeTag = document.activeElement?.tagName.toLowerCase() const inCodeEditor = document.activeElement?.classList?.contains("cm-content") + const inPosthogSurvey = + document.activeElement?.classList?.[0]?.startsWith("PostHogSurvey") if ( - (inCodeEditor || ["input", "textarea"].indexOf(activeTag) !== -1) && + (inCodeEditor || + inPosthogSurvey || + ["input", "textarea"].indexOf(activeTag) !== -1) && e.key !== "Escape" ) { return diff --git a/packages/server/specs/openapi.json b/packages/server/specs/openapi.json index 1cf2d0ebce..7d07b424f0 100644 --- a/packages/server/specs/openapi.json +++ b/packages/server/specs/openapi.json @@ -853,6 +853,7 @@ "array", "datetime", "attachment", + "attachment_single", "link", "formula", "auto", @@ -1059,6 +1060,7 @@ "array", "datetime", "attachment", + "attachment_single", "link", "formula", "auto", @@ -1276,6 +1278,7 @@ "array", "datetime", "attachment", + "attachment_single", "link", "formula", "auto", @@ -1752,7 +1755,7 @@ }, "fuzzy": { "type": "object", - "description": "A fuzzy search, only supported by internal tables." + "description": "Searches for a sub-string within a string column, e.g. searching for 'dib' will match 'Budibase'." }, "range": { "type": "object", @@ -1786,6 +1789,36 @@ "oneOf": { "type": "object", "description": "Searches for rows which have a column value that is any of the specified values. The format of this must be columnName -> [value1, value2]." + }, + "contains": { + "type": "object", + "description": "Searches for a value, or set of values in array column types (such as a multi-select). If an array of search options is provided then it must match all.", + "example": { + "arrayColumn": [ + "a", + "b" + ] + } + }, + "notContains": { + "type": "object", + "description": "The logical inverse of contains. Only works on array column types. If an array of values is passed, the row must not match any of them to be returned in the response.", + "example": { + "arrayColumn": [ + "a", + "b" + ] + } + }, + "containsAny": { + "type": "object", + "description": "As with the contains search, only works on array column types and searches for any of the provided values when given an array.", + "example": { + "arrayColumn": [ + "a", + "b" + ] + } } } }, diff --git a/packages/server/specs/openapi.yaml b/packages/server/specs/openapi.yaml index a1615e7426..3a798c424b 100644 --- a/packages/server/specs/openapi.yaml +++ b/packages/server/specs/openapi.yaml @@ -775,6 +775,7 @@ components: - array - datetime - attachment + - attachment_single - link - formula - auto @@ -940,6 +941,7 @@ components: - array - datetime - attachment + - attachment_single - link - formula - auto @@ -1112,6 +1114,7 @@ components: - array - datetime - attachment + - attachment_single - link - formula - auto @@ -1492,7 +1495,8 @@ components: description: The value to search for in the column. fuzzy: type: object - description: A fuzzy search, only supported by internal tables. + description: Searches for a sub-string within a string column, e.g. searching + for 'dib' will match 'Budibase'. range: type: object description: Searches within a range, the format of this must be in the format @@ -1524,6 +1528,32 @@ components: description: Searches for rows which have a column value that is any of the specified values. The format of this must be columnName -> [value1, value2]. + contains: + type: object + description: Searches for a value, or set of values in array column types (such + as a multi-select). If an array of search options is provided + then it must match all. + example: + arrayColumn: + - a + - b + notContains: + type: object + description: The logical inverse of contains. Only works on array column types. + If an array of values is passed, the row must not match any of + them to be returned in the response. + example: + arrayColumn: + - a + - b + containsAny: + type: object + description: As with the contains search, only works on array column types and + searches for any of the provided values when given an array. + example: + arrayColumn: + - a + - b paginate: type: boolean description: Enables pagination, by default this is disabled. diff --git a/packages/server/specs/resources/misc.ts b/packages/server/specs/resources/misc.ts index 16cb831c86..f56dff3301 100644 --- a/packages/server/specs/resources/misc.ts +++ b/packages/server/specs/resources/misc.ts @@ -27,7 +27,8 @@ export default new Resource().setSchemas({ }, fuzzy: { type: "object", - description: "A fuzzy search, only supported by internal tables.", + description: + "Searches for a sub-string within a string column, e.g. searching for 'dib' will match 'Budibase'.", }, range: { type: "object", @@ -67,6 +68,30 @@ export default new Resource().setSchemas({ description: "Searches for rows which have a column value that is any of the specified values. The format of this must be columnName -> [value1, value2].", }, + contains: { + type: "object", + description: + "Searches for a value, or set of values in array column types (such as a multi-select). If an array of search options is provided then it must match all.", + example: { + arrayColumn: ["a", "b"], + }, + }, + notContains: { + type: "object", + description: + "The logical inverse of contains. Only works on array column types. If an array of values is passed, the row must not match any of them to be returned in the response.", + example: { + arrayColumn: ["a", "b"], + }, + }, + containsAny: { + type: "object", + description: + "As with the contains search, only works on array column types and searches for any of the provided values when given an array.", + example: { + arrayColumn: ["a", "b"], + }, + }, }, }, paginate: { diff --git a/packages/server/src/api/controllers/row/external.ts b/packages/server/src/api/controllers/row/external.ts index 8ce9ff0f9d..e0e3cb6c18 100644 --- a/packages/server/src/api/controllers/row/external.ts +++ b/packages/server/src/api/controllers/row/external.ts @@ -17,11 +17,9 @@ import { Row, Table, UserCtx, - EmptyFilterOption, } from "@budibase/types" import sdk from "../../../sdk" import * as utils from "./utils" -import { dataFilters } from "@budibase/shared-core" import { inputProcessing, outputProcessing, @@ -33,17 +31,6 @@ export async function handleRequest( tableId: string, opts?: RunConfig ): Promise> { - // make sure the filters are cleaned up, no empty strings for equals, fuzzy or string - if (opts && opts.filters) { - opts.filters = sdk.rows.removeEmptyFilters(opts.filters) - } - if ( - !dataFilters.hasFilters(opts?.filters) && - opts?.filters?.onEmptyFilter === EmptyFilterOption.RETURN_NONE - ) { - return [] as any - } - return new ExternalRequest(operation, tableId, opts?.datasource).run( opts || {} ) diff --git a/packages/server/src/api/routes/tests/search.spec.ts b/packages/server/src/api/routes/tests/search.spec.ts index f5d107d0de..fdf1ed7603 100644 --- a/packages/server/src/api/routes/tests/search.spec.ts +++ b/packages/server/src/api/routes/tests/search.spec.ts @@ -6,9 +6,12 @@ import { Datasource, EmptyFilterOption, FieldType, + RowSearchParams, SearchFilters, Table, + TableSchema, } from "@budibase/types" +import _ from "lodash" jest.unmock("mssql") @@ -24,8 +27,8 @@ describe.each([ const config = setup.getConfig() let envCleanup: (() => void) | undefined - let table: Table let datasource: Datasource | undefined + let table: Table beforeAll(async () => { if (isSqs) { @@ -46,237 +49,217 @@ describe.each([ } }) - describe("strings", () => { - beforeEach(async () => { - table = await config.api.table.save( - tableForDatasource(datasource, { - schema: { - name: { - name: "name", - type: FieldType.STRING, - }, - }, - }) - ) - }) + async function createTable(schema: TableSchema) { + table = await config.api.table.save( + tableForDatasource(datasource, { schema }) + ) + } - const rows = [{ name: "foo" }, { name: "bar" }] + async function createRows(rows: Record[]) { + await Promise.all(rows.map(r => config.api.row.save(table._id!, r))) + } - interface StringSearchTest { - query: SearchFilters - expected: (typeof rows)[number][] - } + class SearchAssertion { + constructor(private readonly query: RowSearchParams) {} - const stringSearchTests: StringSearchTest[] = [ - { query: {}, expected: rows }, - { - query: { onEmptyFilter: EmptyFilterOption.RETURN_ALL }, - expected: rows, - }, - { - query: { onEmptyFilter: EmptyFilterOption.RETURN_NONE }, - expected: [], - }, - { query: { string: { name: "foo" } }, expected: [rows[0]] }, - { query: { string: { name: "none" } }, expected: [] }, - { query: { fuzzy: { name: "oo" } }, expected: [rows[0]] }, - { query: { equal: { name: "foo" } }, expected: [rows[0]] }, - { query: { notEqual: { name: "foo" } }, expected: [rows[1]] }, - { query: { oneOf: { name: ["foo"] } }, expected: [rows[0]] }, - // { query: { contains: { name: "f" } }, expected: [0] }, - // { query: { notContains: { name: ["f"] } }, expected: [1] }, - // { query: { containsAny: { name: ["f"] } }, expected: [0] }, - ] + async toFind(expectedRows: any[]) { + const { rows: foundRows } = await config.api.row.search(table._id!, { + ...this.query, + tableId: table._id!, + }) - it.each(stringSearchTests)( - `should be able to run query: $query`, - async ({ query, expected }) => { - const savedRows = await Promise.all( - rows.map(r => config.api.row.save(table._id!, r)) - ) - const { rows: foundRows } = await config.api.row.search(table._id!, { - tableId: table._id!, - query, - }) - expect(foundRows).toEqual( - expect.arrayContaining( - expected.map(r => - expect.objectContaining(savedRows.find(sr => sr.name === r.name)!) + // eslint-disable-next-line jest/no-standalone-expect + expect(foundRows).toHaveLength(expectedRows.length) + // eslint-disable-next-line jest/no-standalone-expect + expect(foundRows).toEqual( + expect.arrayContaining( + expectedRows.map((expectedRow: any) => + expect.objectContaining( + foundRows.find(foundRow => _.isMatch(foundRow, expectedRow)) ) ) ) - } - ) + ) + } + + async toFindNothing() { + await this.toFind([]) + } + } + + function expectSearch(query: Omit) { + return new SearchAssertion({ ...query, tableId: table._id! }) + } + + function expectQuery(query: SearchFilters) { + return expectSearch({ query }) + } + + describe("strings", () => { + beforeAll(async () => { + await createTable({ + name: { name: "name", type: FieldType.STRING }, + }) + await createRows([{ name: "foo" }, { name: "bar" }]) + }) + + describe("misc", () => { + it("should return all if no query is passed", () => + expectSearch({} as RowSearchParams).toFind([ + { name: "foo" }, + { name: "bar" }, + ])) + + it("should return all if empty query is passed", () => + expectQuery({}).toFind([{ name: "foo" }, { name: "bar" }])) + + it("should return all if onEmptyFilter is RETURN_ALL", () => + expectQuery({ + onEmptyFilter: EmptyFilterOption.RETURN_ALL, + }).toFind([{ name: "foo" }, { name: "bar" }])) + + it("should return nothing if onEmptyFilter is RETURN_NONE", () => + expectQuery({ + onEmptyFilter: EmptyFilterOption.RETURN_NONE, + }).toFindNothing()) + }) + + describe("equal", () => { + it("successfully finds a row", () => + expectQuery({ equal: { name: "foo" } }).toFind([{ name: "foo" }])) + + it("fails to find nonexistent row", () => + expectQuery({ equal: { name: "none" } }).toFindNothing()) + }) + + describe("notEqual", () => { + it("successfully finds a row", () => + expectQuery({ notEqual: { name: "foo" } }).toFind([{ name: "bar" }])) + + it("fails to find nonexistent row", () => + expectQuery({ notEqual: { name: "bar" } }).toFind([{ name: "foo" }])) + }) + + describe("oneOf", () => { + it("successfully finds a row", () => + expectQuery({ oneOf: { name: ["foo"] } }).toFind([{ name: "foo" }])) + + it("fails to find nonexistent row", () => + expectQuery({ oneOf: { name: ["none"] } }).toFindNothing()) + }) + + describe("fuzzy", () => { + it("successfully finds a row", () => + expectQuery({ fuzzy: { name: "oo" } }).toFind([{ name: "foo" }])) + + it("fails to find nonexistent row", () => + expectQuery({ fuzzy: { name: "none" } }).toFindNothing()) + }) }) - describe("number", () => { - beforeEach(async () => { - table = await config.api.table.save( - tableForDatasource(datasource, { - schema: { - age: { - name: "age", - type: FieldType.NUMBER, - }, - }, - }) - ) + describe("numbers", () => { + beforeAll(async () => { + await createTable({ + age: { name: "age", type: FieldType.NUMBER }, + }) + await createRows([{ age: 1 }, { age: 10 }]) }) - const rows = [{ age: 1 }, { age: 10 }] + describe("equal", () => { + it("successfully finds a row", () => + expectQuery({ equal: { age: 1 } }).toFind([{ age: 1 }])) - interface NumberSearchTest { - query: SearchFilters - expected: (typeof rows)[number][] - } + it("fails to find nonexistent row", () => + expectQuery({ equal: { age: 2 } }).toFindNothing()) + }) - const numberSearchTests: NumberSearchTest[] = [ - { query: {}, expected: rows }, - { - query: { onEmptyFilter: EmptyFilterOption.RETURN_ALL }, - expected: rows, - }, - { - query: { onEmptyFilter: EmptyFilterOption.RETURN_NONE }, - expected: [], - }, - { query: { equal: { age: 1 } }, expected: [rows[0]] }, - { query: { equal: { age: 2 } }, expected: [] }, - { query: { notEqual: { age: 1 } }, expected: [rows[1]] }, - { query: { oneOf: { age: [1] } }, expected: [rows[0]] }, - { query: { range: { age: { low: 1, high: 5 } } }, expected: [rows[0]] }, - { query: { range: { age: { low: 0, high: 1 } } }, expected: [rows[0]] }, - { query: { range: { age: { low: 3, high: 4 } } }, expected: [] }, - { query: { range: { age: { low: 0, high: 11 } } }, expected: rows }, - ] + describe("notEqual", () => { + it("successfully finds a row", () => + expectQuery({ notEqual: { age: 1 } }).toFind([{ age: 10 }])) - it.each(numberSearchTests)( - `should be able to run query: $query`, - async ({ query, expected }) => { - const savedRows = await Promise.all( - rows.map(r => config.api.row.save(table._id!, r)) - ) - const { rows: foundRows } = await config.api.row.search(table._id!, { - tableId: table._id!, - query, - }) - expect(foundRows).toEqual( - expect.arrayContaining( - expected.map(r => - expect.objectContaining(savedRows.find(sr => sr.age === r.age)!) - ) - ) - ) - } - ) + it("fails to find nonexistent row", () => + expectQuery({ notEqual: { age: 10 } }).toFind([{ age: 1 }])) + }) + + describe("oneOf", () => { + it("successfully finds a row", () => + expectQuery({ oneOf: { age: [1] } }).toFind([{ age: 1 }])) + + it("fails to find nonexistent row", () => + expectQuery({ oneOf: { age: [2] } }).toFindNothing()) + }) + + describe("range", () => { + it("successfully finds a row", () => + expectQuery({ + range: { age: { low: 1, high: 5 } }, + }).toFind([{ age: 1 }])) + + it("successfully finds multiple rows", () => + expectQuery({ + range: { age: { low: 1, high: 10 } }, + }).toFind([{ age: 1 }, { age: 10 }])) + + it("successfully finds a row with a high bound", () => + expectQuery({ + range: { age: { low: 5, high: 10 } }, + }).toFind([{ age: 10 }])) + }) }) describe("dates", () => { - beforeEach(async () => { - table = await config.api.table.save( - tableForDatasource(datasource, { - schema: { - dob: { - name: "dob", - type: FieldType.DATETIME, - }, - }, - }) - ) + const JAN_1ST = "2020-01-01T00:00:00.000Z" + const JAN_2ND = "2020-01-02T00:00:00.000Z" + const JAN_5TH = "2020-01-05T00:00:00.000Z" + const JAN_10TH = "2020-01-10T00:00:00.000Z" + + beforeAll(async () => { + await createTable({ + dob: { name: "dob", type: FieldType.DATETIME }, + }) + + await createRows([{ dob: JAN_1ST }, { dob: JAN_10TH }]) }) - const rows = [ - { dob: new Date("2020-01-01") }, - { dob: new Date("2020-01-10") }, - ] + describe("equal", () => { + it("successfully finds a row", () => + expectQuery({ equal: { dob: JAN_1ST } }).toFind([{ dob: JAN_1ST }])) - interface DateSearchTest { - query: SearchFilters - expected: (typeof rows)[number][] - } + it("fails to find nonexistent row", () => + expectQuery({ equal: { dob: JAN_2ND } }).toFindNothing()) + }) - const dateSearchTests: DateSearchTest[] = [ - { query: {}, expected: rows }, - { - query: { onEmptyFilter: EmptyFilterOption.RETURN_ALL }, - expected: rows, - }, - { - query: { onEmptyFilter: EmptyFilterOption.RETURN_NONE }, - expected: [], - }, - { - query: { equal: { dob: new Date("2020-01-01") } }, - expected: [rows[0]], - }, - { query: { equal: { dob: new Date("2020-01-02") } }, expected: [] }, - { - query: { notEqual: { dob: new Date("2020-01-01") } }, - expected: [rows[1]], - }, - { - query: { oneOf: { dob: [new Date("2020-01-01")] } }, - expected: [rows[0]], - }, - { - query: { - range: { - dob: { - low: new Date("2020-01-01").toISOString(), - high: new Date("2020-01-05").toISOString(), - }, - }, - }, - expected: [rows[0]], - }, - { - query: { - range: { - dob: { - low: new Date("2020-01-01").toISOString(), - high: new Date("2020-01-10").toISOString(), - }, - }, - }, - expected: rows, - }, - { - query: { - range: { - dob: { - low: new Date("2020-01-05").toISOString(), - high: new Date("2020-01-10").toISOString(), - }, - }, - }, - expected: [rows[1]], - }, - ] + describe("notEqual", () => { + it("successfully finds a row", () => + expectQuery({ notEqual: { dob: JAN_1ST } }).toFind([{ dob: JAN_10TH }])) - it.each(dateSearchTests)( - `should be able to run query: $query`, - async ({ query, expected }) => { - // TODO(samwho): most of these work for SQS, but not all. Fix 'em. - if (isSqs) { - return - } - const savedRows = await Promise.all( - rows.map(r => config.api.row.save(table._id!, r)) - ) - const { rows: foundRows } = await config.api.row.search(table._id!, { - tableId: table._id!, - query, - }) - expect(foundRows).toEqual( - expect.arrayContaining( - expected.map(r => - expect.objectContaining( - savedRows.find(sr => sr.dob === r.dob.toISOString())! - ) - ) - ) - ) - } - ) + it("fails to find nonexistent row", () => + expectQuery({ notEqual: { dob: JAN_10TH } }).toFind([{ dob: JAN_1ST }])) + }) + + describe("oneOf", () => { + it("successfully finds a row", () => + expectQuery({ oneOf: { dob: [JAN_1ST] } }).toFind([{ dob: JAN_1ST }])) + + it("fails to find nonexistent row", () => + expectQuery({ oneOf: { dob: [JAN_2ND] } }).toFindNothing()) + }) + + describe("range", () => { + it("successfully finds a row", () => + expectQuery({ + range: { dob: { low: JAN_1ST, high: JAN_5TH } }, + }).toFind([{ dob: JAN_1ST }])) + + it("successfully finds multiple rows", () => + expectQuery({ + range: { dob: { low: JAN_1ST, high: JAN_10TH } }, + }).toFind([{ dob: JAN_1ST }, { dob: JAN_10TH }])) + + it("successfully finds a row with a high bound", () => + expectQuery({ + range: { dob: { low: JAN_5TH, high: JAN_10TH } }, + }).toFind([{ dob: JAN_10TH }])) + }) }) }) diff --git a/packages/server/src/definitions/openapi.ts b/packages/server/src/definitions/openapi.ts index 34014ba626..cc3c8a7d4d 100644 --- a/packages/server/src/definitions/openapi.ts +++ b/packages/server/src/definitions/openapi.ts @@ -273,6 +273,7 @@ export interface components { | "array" | "datetime" | "attachment" + | "attachment_single" | "link" | "formula" | "auto" @@ -381,6 +382,7 @@ export interface components { | "array" | "datetime" | "attachment" + | "attachment_single" | "link" | "formula" | "auto" @@ -491,6 +493,7 @@ export interface components { | "array" | "datetime" | "attachment" + | "attachment_single" | "link" | "formula" | "auto" @@ -693,7 +696,7 @@ export interface components { * @example [object Object] */ string?: { [key: string]: string }; - /** @description A fuzzy search, only supported by internal tables. */ + /** @description Searches for a sub-string within a string column, e.g. searching for 'dib' will match 'Budibase'. */ fuzzy?: { [key: string]: unknown }; /** * @description Searches within a range, the format of this must be in the format of an object with a "low" and "high" property. @@ -713,6 +716,21 @@ export interface components { notEmpty?: { [key: string]: unknown }; /** @description Searches for rows which have a column value that is any of the specified values. The format of this must be columnName -> [value1, value2]. */ oneOf?: { [key: string]: unknown }; + /** + * @description Searches for a value, or set of values in array column types (such as a multi-select). If an array of search options is provided then it must match all. + * @example [object Object] + */ + contains?: { [key: string]: unknown }; + /** + * @description The logical inverse of contains. Only works on array column types. If an array of values is passed, the row must not match any of them to be returned in the response. + * @example [object Object] + */ + notContains?: { [key: string]: unknown }; + /** + * @description As with the contains search, only works on array column types and searches for any of the provided values when given an array. + * @example [object Object] + */ + containsAny?: { [key: string]: unknown }; }; /** @description Enables pagination, by default this is disabled. */ paginate?: boolean; diff --git a/packages/server/src/integrations/base/sql.ts b/packages/server/src/integrations/base/sql.ts index e99e34ab0f..f5828f9419 100644 --- a/packages/server/src/integrations/base/sql.ts +++ b/packages/server/src/integrations/base/sql.ts @@ -334,6 +334,7 @@ class InternalBuilder { if (filters.containsAny) { contains(filters.containsAny, true) } + return query } diff --git a/packages/server/src/sdk/app/rows/search.ts b/packages/server/src/sdk/app/rows/search.ts index 928c0f6780..f681bfeb90 100644 --- a/packages/server/src/sdk/app/rows/search.ts +++ b/packages/server/src/sdk/app/rows/search.ts @@ -1,4 +1,5 @@ import { + EmptyFilterOption, Row, RowSearchParams, SearchFilters, @@ -11,6 +12,7 @@ import { NoEmptyFilterStrings } from "../../../constants" import * as sqs from "./search/sqs" import env from "../../../environment" import { ExportRowsParams, ExportRowsResult } from "./search/types" +import { dataFilters } from "@budibase/shared-core" export { isValidFilter } from "../../../integrations/utils" @@ -60,6 +62,16 @@ export async function search( options: RowSearchParams ): Promise> { const isExternalTable = isExternalTableID(options.tableId) + options.query = removeEmptyFilters(options.query || {}) + if ( + !dataFilters.hasFilters(options.query) && + options.query.onEmptyFilter === EmptyFilterOption.RETURN_NONE + ) { + return { + rows: [], + } + } + if (isExternalTable) { return external.search(options) } else if (env.SQS_SEARCH_ENABLE) { diff --git a/packages/server/src/sdk/app/rows/search/sqs.ts b/packages/server/src/sdk/app/rows/search/sqs.ts index 89dae7628f..5b0b6e3bc7 100644 --- a/packages/server/src/sdk/app/rows/search/sqs.ts +++ b/packages/server/src/sdk/app/rows/search/sqs.ts @@ -156,21 +156,21 @@ export async function search( try { const query = builder._query(request, { disableReturning: true, - disableBindings: true, }) if (Array.isArray(query)) { throw new Error("SQS cannot currently handle multiple queries") } - let sql = query.sql + let sql = query.sql, + bindings = query.bindings // quick hack for docIds sql = sql.replace(/`doc1`.`rowId`/g, "`doc1.rowId`") sql = sql.replace(/`doc2`.`rowId`/g, "`doc2.rowId`") const db = context.getAppDB() - const rows = await db.sql(sql) + const rows = await db.sql(sql, bindings) return { rows: await sqlOutputProcessing( diff --git a/packages/types/src/sdk/db.ts b/packages/types/src/sdk/db.ts index 692ddcf737..c723f5f8d6 100644 --- a/packages/types/src/sdk/db.ts +++ b/packages/types/src/sdk/db.ts @@ -4,6 +4,7 @@ import { AnyDocument, Document, RowValue, + SqlQueryBinding, ViewTemplateOpts, } from "../" import { Writable } from "stream" @@ -143,7 +144,10 @@ export interface Database { opts?: DatabasePutOpts ): Promise bulkDocs(documents: AnyDocument[]): Promise - sql(sql: string): Promise + sql( + sql: string, + parameters?: SqlQueryBinding + ): Promise allDocs( params: DatabaseQueryOpts ): Promise>