Merge pull request #14128 from Budibase/budi-8445-is-in-filter-broken

Fix bug in oneOf search.
This commit is contained in:
Sam Rose 2024-07-10 11:39:22 +01:00 committed by GitHub
commit 776fbf1724
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 148 additions and 91 deletions

View File

@ -108,7 +108,7 @@ jobs:
- name: Pull testcontainers images - name: Pull testcontainers images
run: | run: |
docker pull testcontainers/ryuk:0.5.1 & docker pull testcontainers/ryuk:0.5.1 &
docker pull budibase/couchdb:v3.2.1-sql & docker pull budibase/couchdb:v3.2.1-sqs &
docker pull redis & docker pull redis &
wait $(jobs -p) wait $(jobs -p)

View File

@ -18,9 +18,10 @@ import {
CouchFindOptions, CouchFindOptions,
DatabaseQueryOpts, DatabaseQueryOpts,
SearchFilters, SearchFilters,
SearchFilterOperator,
SearchUsersRequest, SearchUsersRequest,
User, User,
BasicOperator,
ArrayOperator,
} from "@budibase/types" } from "@budibase/types"
import * as context from "../context" import * as context from "../context"
import { getGlobalDB } from "../context" import { getGlobalDB } from "../context"
@ -46,9 +47,9 @@ function removeUserPassword(users: User | User[]) {
export function isSupportedUserSearch(query: SearchFilters) { export function isSupportedUserSearch(query: SearchFilters) {
const allowed = [ const allowed = [
{ op: SearchFilterOperator.STRING, key: "email" }, { op: BasicOperator.STRING, key: "email" },
{ op: SearchFilterOperator.EQUAL, key: "_id" }, { op: BasicOperator.EQUAL, key: "_id" },
{ op: SearchFilterOperator.ONE_OF, key: "_id" }, { op: ArrayOperator.ONE_OF, key: "_id" },
] ]
for (let [key, operation] of Object.entries(query)) { for (let [key, operation] of Object.entries(query)) {
if (typeof operation !== "object") { if (typeof operation !== "object") {

View File

@ -11,7 +11,7 @@
Label, Label,
Multiselect, Multiselect,
} from "@budibase/bbui" } from "@budibase/bbui"
import { FieldType, SearchFilterOperator } from "@budibase/types" import { ArrayOperator, FieldType } from "@budibase/types"
import { generate } from "shortid" import { generate } from "shortid"
import { QueryUtils, Constants } from "@budibase/frontend-core" import { QueryUtils, Constants } from "@budibase/frontend-core"
import { getContext } from "svelte" import { getContext } from "svelte"
@ -268,7 +268,7 @@
<slot name="binding" {filter} /> <slot name="binding" {filter} />
{:else if [FieldType.STRING, FieldType.LONGFORM, FieldType.NUMBER, FieldType.BIGINT, FieldType.FORMULA].includes(filter.type)} {:else if [FieldType.STRING, FieldType.LONGFORM, FieldType.NUMBER, FieldType.BIGINT, FieldType.FORMULA].includes(filter.type)}
<Input disabled={filter.noValue} bind:value={filter.value} /> <Input disabled={filter.noValue} bind:value={filter.value} />
{:else if filter.type === FieldType.ARRAY || (filter.type === FieldType.OPTIONS && filter.operator === SearchFilterOperator.ONE_OF)} {:else if filter.type === FieldType.ARRAY || (filter.type === FieldType.OPTIONS && filter.operator === ArrayOperator.ONE_OF)}
<Multiselect <Multiselect
disabled={filter.noValue} disabled={filter.noValue}
options={getFieldOptions(filter.field)} options={getFieldOptions(filter.field)}

View File

@ -780,6 +780,32 @@ describe.each([
it("fails to find nonexistent row", async () => { it("fails to find nonexistent row", async () => {
await expectQuery({ oneOf: { name: ["none"] } }).toFindNothing() await expectQuery({ oneOf: { name: ["none"] } }).toFindNothing()
}) })
it("can have multiple values for same column", async () => {
await expectQuery({
oneOf: {
name: ["foo", "bar"],
},
}).toContainExactly([{ name: "foo" }, { name: "bar" }])
})
it("splits comma separated strings", async () => {
await expectQuery({
oneOf: {
// @ts-ignore
name: "foo,bar",
},
}).toContainExactly([{ name: "foo" }, { name: "bar" }])
})
it("trims whitespace", async () => {
await expectQuery({
oneOf: {
// @ts-ignore
name: "foo, bar",
},
}).toContainExactly([{ name: "foo" }, { name: "bar" }])
})
}) })
describe("fuzzy", () => { describe("fuzzy", () => {
@ -1002,6 +1028,32 @@ describe.each([
it("fails to find nonexistent row", async () => { it("fails to find nonexistent row", async () => {
await expectQuery({ oneOf: { age: [2] } }).toFindNothing() await expectQuery({ oneOf: { age: [2] } }).toFindNothing()
}) })
// I couldn't find a way to make this work in Lucene and given that
// we're getting rid of Lucene soon I wasn't inclined to spend time on
// it.
!isLucene &&
it("can convert from a string", async () => {
await expectQuery({
oneOf: {
// @ts-ignore
age: "1",
},
}).toContainExactly([{ age: 1 }])
})
// I couldn't find a way to make this work in Lucene and given that
// we're getting rid of Lucene soon I wasn't inclined to spend time on
// it.
!isLucene &&
it("can find multiple values for same column", async () => {
await expectQuery({
oneOf: {
// @ts-ignore
age: "1,10",
},
}).toContainExactly([{ age: 1 }, { age: 10 }])
})
}) })
describe("range", () => { describe("range", () => {

View File

@ -9,7 +9,6 @@ import {
QuotaUsageType, QuotaUsageType,
Row, Row,
SaveTableRequest, SaveTableRequest,
SearchFilterOperator,
SortOrder, SortOrder,
SortType, SortType,
StaticQuotaName, StaticQuotaName,
@ -19,6 +18,7 @@ import {
ViewUIFieldMetadata, ViewUIFieldMetadata,
ViewV2, ViewV2,
SearchResponse, SearchResponse,
BasicOperator,
} from "@budibase/types" } from "@budibase/types"
import { generator, mocks } from "@budibase/backend-core/tests" import { generator, mocks } from "@budibase/backend-core/tests"
import { DatabaseName, getDatasource } from "../../../integrations/tests/utils" import { DatabaseName, getDatasource } from "../../../integrations/tests/utils"
@ -149,7 +149,7 @@ describe.each([
primaryDisplay: "id", primaryDisplay: "id",
query: [ query: [
{ {
operator: SearchFilterOperator.EQUAL, operator: BasicOperator.EQUAL,
field: "field", field: "field",
value: "value", value: "value",
}, },
@ -561,7 +561,7 @@ describe.each([
...view, ...view,
query: [ query: [
{ {
operator: SearchFilterOperator.EQUAL, operator: BasicOperator.EQUAL,
field: "newField", field: "newField",
value: "thatValue", value: "thatValue",
}, },
@ -589,7 +589,7 @@ describe.each([
primaryDisplay: "Price", primaryDisplay: "Price",
query: [ query: [
{ {
operator: SearchFilterOperator.EQUAL, operator: BasicOperator.EQUAL,
field: generator.word(), field: generator.word(),
value: generator.word(), value: generator.word(),
}, },
@ -673,7 +673,7 @@ describe.each([
tableId: generator.guid(), tableId: generator.guid(),
query: [ query: [
{ {
operator: SearchFilterOperator.EQUAL, operator: BasicOperator.EQUAL,
field: "newField", field: "newField",
value: "thatValue", value: "thatValue",
}, },
@ -1194,7 +1194,7 @@ describe.each([
name: generator.guid(), name: generator.guid(),
query: [ query: [
{ {
operator: SearchFilterOperator.EQUAL, operator: BasicOperator.EQUAL,
field: "two", field: "two",
value: "bar2", value: "bar2",
}, },

View File

@ -2,7 +2,6 @@ import {
EmptyFilterOption, EmptyFilterOption,
Row, Row,
RowSearchParams, RowSearchParams,
SearchFilterOperator,
SearchFilters, SearchFilters,
SearchResponse, SearchResponse,
SortOrder, SortOrder,
@ -66,37 +65,12 @@ export function removeEmptyFilters(filters: SearchFilters) {
return filters return filters
} }
// The frontend can send single values for array fields sometimes, so to handle
// this we convert them to arrays at the controller level so that nothing below
// this has to worry about the non-array values.
function fixupFilterArrays(filters: SearchFilters) {
const arrayFields = [
SearchFilterOperator.ONE_OF,
SearchFilterOperator.CONTAINS,
SearchFilterOperator.NOT_CONTAINS,
SearchFilterOperator.CONTAINS_ANY,
]
for (const searchField of arrayFields) {
const field = filters[searchField]
if (field == null) {
continue
}
for (const key of Object.keys(field)) {
if (!Array.isArray(field[key])) {
field[key] = [field[key]]
}
}
}
return filters
}
export async function search( export async function search(
options: RowSearchParams options: RowSearchParams
): Promise<SearchResponse<Row>> { ): Promise<SearchResponse<Row>> {
const isExternalTable = isExternalTableID(options.tableId) const isExternalTable = isExternalTableID(options.tableId)
options.query = removeEmptyFilters(options.query || {}) options.query = removeEmptyFilters(options.query || {})
options.query = fixupFilterArrays(options.query) options.query = dataFilters.fixupFilterArrays(options.query)
if ( if (
!dataFilters.hasFilters(options.query) && !dataFilters.hasFilters(options.query) &&
options.query.onEmptyFilter === EmptyFilterOption.RETURN_NONE options.query.onEmptyFilter === EmptyFilterOption.RETURN_NONE

View File

@ -6,6 +6,7 @@ import {
SearchFilter, SearchFilter,
SearchFilters, SearchFilters,
SearchQueryFields, SearchQueryFields,
ArrayOperator,
SearchFilterOperator, SearchFilterOperator,
SortType, SortType,
FieldConstraints, FieldConstraints,
@ -14,11 +15,13 @@ import {
EmptyFilterOption, EmptyFilterOption,
SearchResponse, SearchResponse,
Table, Table,
BasicOperator,
RangeOperator,
} from "@budibase/types" } from "@budibase/types"
import dayjs from "dayjs" import dayjs from "dayjs"
import { OperatorOptions, SqlNumberTypeRangeMap } from "./constants" import { OperatorOptions, SqlNumberTypeRangeMap } from "./constants"
import { deepGet, schema } from "./helpers" import { deepGet, schema } from "./helpers"
import _ from "lodash" import { isPlainObject, isEmpty } from "lodash"
const HBS_REGEX = /{{([^{].*?)}}/g const HBS_REGEX = /{{([^{].*?)}}/g
@ -323,6 +326,32 @@ export const buildQuery = (filter: SearchFilter[]) => {
return query return query
} }
// The frontend can send single values for array fields sometimes, so to handle
// this we convert them to arrays at the controller level so that nothing below
// this has to worry about the non-array values.
export function fixupFilterArrays(filters: SearchFilters) {
for (const searchField of Object.values(ArrayOperator)) {
const field = filters[searchField]
if (field == null || !isPlainObject(field)) {
continue
}
for (const key of Object.keys(field)) {
if (Array.isArray(field[key])) {
continue
}
const value = field[key] as any
if (typeof value === "string") {
field[key] = value.split(",").map((x: string) => x.trim())
} else {
field[key] = [value]
}
}
}
return filters
}
export const search = ( export const search = (
docs: Record<string, any>[], docs: Record<string, any>[],
query: RowSearchParams query: RowSearchParams
@ -356,6 +385,7 @@ export const runQuery = (docs: Record<string, any>[], query: SearchFilters) => {
} }
query = cleanupQuery(query) query = cleanupQuery(query)
query = fixupFilterArrays(query)
if ( if (
!hasFilters(query) && !hasFilters(query) &&
@ -382,7 +412,7 @@ export const runQuery = (docs: Record<string, any>[], query: SearchFilters) => {
} }
const stringMatch = match( const stringMatch = match(
SearchFilterOperator.STRING, BasicOperator.STRING,
(docValue: any, testValue: any) => { (docValue: any, testValue: any) => {
if (!(typeof docValue === "string")) { if (!(typeof docValue === "string")) {
return false return false
@ -395,7 +425,7 @@ export const runQuery = (docs: Record<string, any>[], query: SearchFilters) => {
) )
const fuzzyMatch = match( const fuzzyMatch = match(
SearchFilterOperator.FUZZY, BasicOperator.FUZZY,
(docValue: any, testValue: any) => { (docValue: any, testValue: any) => {
if (!(typeof docValue === "string")) { if (!(typeof docValue === "string")) {
return false return false
@ -408,17 +438,17 @@ export const runQuery = (docs: Record<string, any>[], query: SearchFilters) => {
) )
const rangeMatch = match( const rangeMatch = match(
SearchFilterOperator.RANGE, RangeOperator.RANGE,
(docValue: any, testValue: any) => { (docValue: any, testValue: any) => {
if (docValue == null || docValue === "") { if (docValue == null || docValue === "") {
return false return false
} }
if (_.isObject(testValue.low) && _.isEmpty(testValue.low)) { if (isPlainObject(testValue.low) && isEmpty(testValue.low)) {
testValue.low = undefined testValue.low = undefined
} }
if (_.isObject(testValue.high) && _.isEmpty(testValue.high)) { if (isPlainObject(testValue.high) && isEmpty(testValue.high)) {
testValue.high = undefined testValue.high = undefined
} }
@ -497,11 +527,8 @@ export const runQuery = (docs: Record<string, any>[], query: SearchFilters) => {
(...args: T): boolean => (...args: T): boolean =>
!f(...args) !f(...args)
const equalMatch = match(SearchFilterOperator.EQUAL, _valueMatches) const equalMatch = match(BasicOperator.EQUAL, _valueMatches)
const notEqualMatch = match( const notEqualMatch = match(BasicOperator.NOT_EQUAL, not(_valueMatches))
SearchFilterOperator.NOT_EQUAL,
not(_valueMatches)
)
const _empty = (docValue: any) => { const _empty = (docValue: any) => {
if (typeof docValue === "string") { if (typeof docValue === "string") {
@ -516,26 +543,24 @@ export const runQuery = (docs: Record<string, any>[], query: SearchFilters) => {
return docValue == null return docValue == null
} }
const emptyMatch = match(SearchFilterOperator.EMPTY, _empty) const emptyMatch = match(BasicOperator.EMPTY, _empty)
const notEmptyMatch = match(SearchFilterOperator.NOT_EMPTY, not(_empty)) const notEmptyMatch = match(BasicOperator.NOT_EMPTY, not(_empty))
const oneOf = match( const oneOf = match(ArrayOperator.ONE_OF, (docValue: any, testValue: any) => {
SearchFilterOperator.ONE_OF, if (typeof testValue === "string") {
(docValue: any, testValue: any) => { testValue = testValue.split(",")
if (typeof testValue === "string") {
testValue = testValue.split(",")
if (typeof docValue === "number") {
testValue = testValue.map((item: string) => parseFloat(item))
}
}
if (!Array.isArray(testValue)) {
return false
}
return testValue.some(item => _valueMatches(docValue, item))
} }
)
if (typeof docValue === "number") {
testValue = testValue.map((item: string) => parseFloat(item))
}
if (!Array.isArray(testValue)) {
return false
}
return testValue.some(item => _valueMatches(docValue, item))
})
const _contains = const _contains =
(f: "some" | "every") => (docValue: any, testValue: any) => { (f: "some" | "every") => (docValue: any, testValue: any) => {
@ -562,7 +587,7 @@ export const runQuery = (docs: Record<string, any>[], query: SearchFilters) => {
} }
const contains = match( const contains = match(
SearchFilterOperator.CONTAINS, ArrayOperator.CONTAINS,
(docValue: any, testValue: any) => { (docValue: any, testValue: any) => {
if (Array.isArray(testValue) && testValue.length === 0) { if (Array.isArray(testValue) && testValue.length === 0) {
return true return true
@ -571,7 +596,7 @@ export const runQuery = (docs: Record<string, any>[], query: SearchFilters) => {
} }
) )
const notContains = match( const notContains = match(
SearchFilterOperator.NOT_CONTAINS, ArrayOperator.NOT_CONTAINS,
(docValue: any, testValue: any) => { (docValue: any, testValue: any) => {
// Not sure if this is logically correct, but at the time this code was // Not sure if this is logically correct, but at the time this code was
// written the search endpoint behaved this way and we wanted to make this // written the search endpoint behaved this way and we wanted to make this
@ -582,10 +607,7 @@ export const runQuery = (docs: Record<string, any>[], query: SearchFilters) => {
return not(_contains("every"))(docValue, testValue) return not(_contains("every"))(docValue, testValue)
} }
) )
const containsAny = match( const containsAny = match(ArrayOperator.CONTAINS_ANY, _contains("some"))
SearchFilterOperator.CONTAINS_ANY,
_contains("some")
)
const docMatch = (doc: Record<string, any>) => { const docMatch = (doc: Record<string, any>) => {
const filterFunctions = { const filterFunctions = {

View File

@ -3,20 +3,28 @@ import { Row, Table, DocumentType } from "../documents"
import { SortOrder, SortType } from "../api" import { SortOrder, SortType } from "../api"
import { Knex } from "knex" import { Knex } from "knex"
export enum SearchFilterOperator { export enum BasicOperator {
STRING = "string",
FUZZY = "fuzzy",
RANGE = "range",
EQUAL = "equal", EQUAL = "equal",
NOT_EQUAL = "notEqual", NOT_EQUAL = "notEqual",
EMPTY = "empty", EMPTY = "empty",
NOT_EMPTY = "notEmpty", NOT_EMPTY = "notEmpty",
ONE_OF = "oneOf", FUZZY = "fuzzy",
STRING = "string",
}
export enum ArrayOperator {
CONTAINS = "contains", CONTAINS = "contains",
NOT_CONTAINS = "notContains", NOT_CONTAINS = "notContains",
CONTAINS_ANY = "containsAny", CONTAINS_ANY = "containsAny",
ONE_OF = "oneOf",
} }
export enum RangeOperator {
RANGE = "range",
}
export type SearchFilterOperator = BasicOperator | ArrayOperator | RangeOperator
export enum InternalSearchFilterOperator { export enum InternalSearchFilterOperator {
COMPLEX_ID_OPERATOR = "_complexIdOperator", COMPLEX_ID_OPERATOR = "_complexIdOperator",
} }
@ -52,17 +60,17 @@ export interface SearchFilters {
// 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]?: BasicFilter<string> [BasicOperator.STRING]?: BasicFilter<string>
[SearchFilterOperator.FUZZY]?: BasicFilter<string> [BasicOperator.FUZZY]?: BasicFilter<string>
[SearchFilterOperator.RANGE]?: RangeFilter [RangeOperator.RANGE]?: RangeFilter
[SearchFilterOperator.EQUAL]?: BasicFilter [BasicOperator.EQUAL]?: BasicFilter
[SearchFilterOperator.NOT_EQUAL]?: BasicFilter [BasicOperator.NOT_EQUAL]?: BasicFilter
[SearchFilterOperator.EMPTY]?: BasicFilter [BasicOperator.EMPTY]?: BasicFilter
[SearchFilterOperator.NOT_EMPTY]?: BasicFilter [BasicOperator.NOT_EMPTY]?: BasicFilter
[SearchFilterOperator.ONE_OF]?: ArrayFilter [ArrayOperator.ONE_OF]?: ArrayFilter
[SearchFilterOperator.CONTAINS]?: ArrayFilter [ArrayOperator.CONTAINS]?: ArrayFilter
[SearchFilterOperator.NOT_CONTAINS]?: ArrayFilter [ArrayOperator.NOT_CONTAINS]?: ArrayFilter
[SearchFilterOperator.CONTAINS_ANY]?: ArrayFilter [ArrayOperator.CONTAINS_ANY]?: ArrayFilter
// 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)