UNPKG

@budibase/server

Version:
1,294 lines (1,165 loc) • 159 kB
import { tableForDatasource } from "../../../tests/utilities/structures" import { datasourceDescribe } from "../../../integrations/tests/utils" import { context, db as dbCore, MAX_VALID_DATE, MIN_VALID_DATE, setEnv, SQLITE_DESIGN_DOC_ID, utils, withEnv as withCoreEnv, } from "@budibase/backend-core" import { AIOperationEnum, AutoFieldSubType, BBReferenceFieldSubType, Datasource, EmptyFilterOption, FieldType, JsonFieldSubType, LogicalOperator, RelationshipType, RequiredKeys, Row, RowSearchParams, SearchFilters, SearchResponse, SearchRowRequest, SortOrder, SortType, Table, TableSchema, User, ViewV2Schema, } from "@budibase/types" import _ from "lodash" import tk from "timekeeper" import { encodeJSBinding } from "@budibase/string-templates" import { dataFilters, isViewId } from "@budibase/shared-core" import { Knex } from "knex" import { generator, structures, mocks } from "@budibase/backend-core/tests" import { DEFAULT_EMPLOYEE_TABLE_SCHEMA } from "../../../db/defaultData/datasource_bb_default" import { generateRowIdField } from "../../../integrations/utils" import { cloneDeep } from "lodash/fp" import { mockChatGPTResponse } from "../../../tests/utilities/mocks/ai/openai" const descriptions = datasourceDescribe({ plus: true }) if (descriptions.length) { describe.each(descriptions)( "search ($dbName)", ({ config, dsProvider, isInternal, isOracle, isSql }) => { let datasource: Datasource | undefined let client: Knex | undefined let tableOrViewId: string let rows: Row[] async function basicRelationshipTables( type: RelationshipType, opts?: { tableName?: string primaryColumn?: string otherColumn?: string } ) { const relatedTable = await config.api.table.save( tableForDatasource(datasource, { name: opts?.tableName, schema: { name: { name: "name", type: FieldType.STRING } }, }) ) const columnName = opts?.primaryColumn || "productCat" const table = await config.api.table.save( tableForDatasource(datasource, { // @ts-expect-error - API accepts this structure, will build out rest of definition schema: { name: { name: "name", type: FieldType.STRING }, [columnName]: { type: FieldType.LINK, relationshipType: type, name: columnName, fieldName: opts?.otherColumn || "product", tableId: relatedTable._id!, constraints: { type: "array", }, }, }, }) ) return { relatedTable: await config.api.table.get(relatedTable._id!), tableId: table._id!, } } beforeAll(async () => { const ds = await dsProvider() datasource = ds.datasource client = ds.client config.app = await config.api.application.update(config.getAppId(), { snippets: [ { name: "WeeksAgo", code: ` return function (weeks) { const currentTime = new Date(${Date.now()}); currentTime.setDate(currentTime.getDate()-(7 * (weeks || 1))); return currentTime.toISOString(); } `, }, ], }) }) async function createTableWithSchema(schema?: TableSchema) { const table = await config.api.table.save( tableForDatasource(datasource, { schema }) ) return table._id! } async function createViewWithSchema( tableId: string, schema?: ViewV2Schema ) { const view = await config.api.viewV2.create({ tableId: tableId, name: generator.guid(), schema, }) return view.id } async function createRows(arr: Record<string, any>[]) { // Shuffling to avoid false positives given a fixed order for (const row of _.shuffle(arr)) { await config.api.row.save(tableOrViewId, row) } rows = await config.api.row.fetch(tableOrViewId) } async function getTable(tableOrViewId: string): Promise<Table> { if (isViewId(tableOrViewId)) { const view = await config.api.viewV2.get(tableOrViewId) return await config.api.table.get(view.tableId) } else { return await config.api.table.get(tableOrViewId) } } async function assertTableExists(nameOrTable: string | Table) { const name = typeof nameOrTable === "string" ? nameOrTable : nameOrTable.name expect(await client!.schema.hasTable(name)).toBeTrue() } async function assertTableNumRows( nameOrTable: string | Table, numRows: number ) { const name = typeof nameOrTable === "string" ? nameOrTable : nameOrTable.name const row = await client!.from(name).count() const count = parseInt(Object.values(row[0])[0] as string) expect(count).toEqual(numRows) } describe.each([true, false])("in-memory: %s", isInMemory => { // We only run the in-memory tests during the SQS (isInternal) run if (isInMemory && !isInternal) { return } type CreateFn = (schema?: TableSchema) => Promise<string> let tableOrView: [string, CreateFn][] = [ ["table", createTableWithSchema], ] if (!isInMemory) { tableOrView.push([ "view", async (schema?: TableSchema) => { const tableId = await createTableWithSchema(schema) const viewId = await createViewWithSchema( tableId, Object.keys(schema || {}).reduce<ViewV2Schema>( (viewSchema, fieldName) => { const field = schema![fieldName] viewSchema[fieldName] = { visible: field.visible ?? true, readonly: false, } return viewSchema }, {} ) ) return viewId }, ]) } describe.each(tableOrView)( "from %s", (sourceType, createTableOrView) => { const isView = sourceType === "view" class SearchAssertion { constructor(private readonly query: SearchRowRequest) {} private async performSearch(): Promise<SearchResponse<Row>> { if (isInMemory) { const inMemoryQuery: RequiredKeys< Omit<RowSearchParams, "tableId"> > = { sort: this.query.sort ?? undefined, query: { ...this.query.query }, paginate: this.query.paginate, bookmark: this.query.bookmark ?? undefined, limit: this.query.limit, sortOrder: this.query.sortOrder, sortType: this.query.sortType ?? undefined, version: this.query.version, disableEscaping: this.query.disableEscaping, countRows: this.query.countRows, viewId: undefined, fields: undefined, indexer: undefined, rows: undefined, } return dataFilters.search(_.cloneDeep(rows), inMemoryQuery) } else { return config.api.row.search(tableOrViewId, this.query) } } // We originally used _.isMatch to compare rows, but found that when // comparing arrays it would return true if the source array was a subset of // the target array. This would sometimes create false matches. This // function is a more strict version of _.isMatch that only returns true if // the source array is an exact match of the target. // // _.isMatch("100", "1") also returns true which is not what we want. private isMatch<T extends Record<string, any>>( expected: T, found: T ) { if (!expected) { throw new Error("Expected is undefined") } if (!found) { return false } for (const key of Object.keys(expected)) { if (Array.isArray(expected[key])) { if (!Array.isArray(found[key])) { return false } if (expected[key].length !== found[key].length) { return false } if (!_.isMatch(found[key], expected[key])) { return false } } else if (typeof expected[key] === "object") { if (!this.isMatch(expected[key], found[key])) { return false } } else { if (expected[key] !== found[key]) { return false } } } return true } // This function exists to ensure that the same row is not matched twice. // When a row gets matched, we make sure to remove it from the list of rows // we're matching against. private popRow<T extends { [key: string]: any }>( expectedRow: T, foundRows: T[] ): NonNullable<T> { const row = foundRows.find(row => this.isMatch(expectedRow, row) ) if (!row) { const fields = Object.keys(expectedRow) // To make the error message more readable, we only include the fields // that are present in the expected row. const searchedObjects = foundRows.map(row => _.pick(row, fields) ) throw new Error( `Failed to find row:\n\n${JSON.stringify( expectedRow, null, 2 )}\n\nin\n\n${JSON.stringify(searchedObjects, null, 2)}` ) } foundRows.splice(foundRows.indexOf(row), 1) return row } // Asserts that the query returns rows matching exactly the set of rows // passed in. The order of the rows matters. Rows returned in an order // different to the one passed in will cause the assertion to fail. Extra // rows returned by the query will also cause the assertion to fail. async toMatchExactly(expectedRows: any[]) { const response = await this.performSearch() const cloned = cloneDeep(response) const foundRows = response.rows expect(foundRows).toHaveLength(expectedRows.length) expect([...foundRows]).toEqual( expectedRows.map((expectedRow: any) => expect.objectContaining(this.popRow(expectedRow, foundRows)) ) ) return cloned } // Asserts that the query returns rows matching exactly the set of rows // passed in. The order of the rows is not important, but extra rows will // cause the assertion to fail. async toContainExactly(expectedRows: any[]) { const response = await this.performSearch() const cloned = cloneDeep(response) const foundRows = response.rows expect(foundRows).toHaveLength(expectedRows.length) expect([...foundRows]).toEqual( expect.arrayContaining( expectedRows.map((expectedRow: any) => expect.objectContaining( this.popRow(expectedRow, foundRows) ) ) ) ) return cloned } // Asserts that the query returns some property values - this cannot be used // to check row values, however this shouldn't be important for checking properties // typing for this has to be any, Jest doesn't expose types for matchers like expect.any(...) async toMatch(properties: Record<string, any>) { const response = await this.performSearch() const cloned = cloneDeep(response) const keys = Object.keys(properties) as Array< keyof SearchResponse<Row> > for (let key of keys) { expect(response[key]).toBeDefined() if (properties[key]) { expect(response[key]).toEqual(properties[key]) } } return cloned } // Asserts that the query doesn't return a property, e.g. pagination parameters. async toNotHaveProperty( properties: (keyof SearchResponse<Row>)[] ) { const response = await this.performSearch() const cloned = cloneDeep(response) for (let property of properties) { expect(response[property]).toBeUndefined() } return cloned } // Asserts that the query returns rows matching the set of rows passed in. // The order of the rows is not important. Extra rows will not cause the // assertion to fail. async toContain(expectedRows: any[]) { const response = await this.performSearch() const cloned = cloneDeep(response) const foundRows = response.rows expect([...foundRows]).toEqual( expect.arrayContaining( expectedRows.map((expectedRow: any) => expect.objectContaining( this.popRow(expectedRow, foundRows) ) ) ) ) return cloned } async toFindNothing() { await this.toContainExactly([]) } async toHaveLength(length: number) { const { rows: foundRows } = await this.performSearch() expect(foundRows).toHaveLength(length) } } function expectSearch(query: SearchRowRequest) { return new SearchAssertion(query) } function expectQuery(query: SearchFilters) { return expectSearch({ query }) } describe("boolean", () => { beforeAll(async () => { tableOrViewId = await createTableOrView({ isTrue: { name: "isTrue", type: FieldType.BOOLEAN }, }) await createRows([{ isTrue: true }, { isTrue: false }]) }) describe("equal", () => { it("successfully finds true row", async () => { await expectQuery({ equal: { isTrue: true } }).toMatchExactly( [{ isTrue: true }] ) }) it("successfully finds false row", async () => { await expectQuery({ equal: { isTrue: false }, }).toMatchExactly([{ isTrue: false }]) }) }) describe("notEqual", () => { it("successfully finds false row", async () => { await expectQuery({ notEqual: { isTrue: true }, }).toContainExactly([{ isTrue: false }]) }) it("successfully finds true row", async () => { await expectQuery({ notEqual: { isTrue: false }, }).toContainExactly([{ isTrue: true }]) }) }) describe("oneOf", () => { it("successfully finds true row", async () => { await expectQuery({ oneOf: { isTrue: [true] }, }).toContainExactly([{ isTrue: true }]) }) it("successfully finds false row", async () => { await expectQuery({ oneOf: { isTrue: [false] }, }).toContainExactly([{ isTrue: false }]) }) }) describe("sort", () => { it("sorts ascending", async () => { await expectSearch({ query: {}, sort: "isTrue", sortOrder: SortOrder.ASCENDING, }).toMatchExactly([{ isTrue: false }, { isTrue: true }]) }) it("sorts descending", async () => { await expectSearch({ query: {}, sort: "isTrue", sortOrder: SortOrder.DESCENDING, }).toMatchExactly([{ isTrue: true }, { isTrue: false }]) }) }) }) !isInMemory && describe("bindings", () => { let globalUsers: any = [] const serverTime = new Date() // In MariaDB and MySQL we only store dates to second precision, so we need // to remove milliseconds from the server time to ensure searches work as // expected. serverTime.setMilliseconds(0) const future = new Date( serverTime.getTime() + 1000 * 60 * 60 * 24 * 30 ) const rows = (currentUser: User) => { return [ { name: "foo", appointment: "1982-01-05T00:00:00.000Z" }, { name: "bar", appointment: "1995-05-06T00:00:00.000Z" }, { name: currentUser.firstName, appointment: future.toISOString(), }, { name: "serverDate", appointment: serverTime.toISOString(), }, { name: "single user, session user", single_user: currentUser, }, { name: "single user", single_user: globalUsers[0], }, { name: "deprecated single user, session user", deprecated_single_user: [currentUser], }, { name: "deprecated single user", deprecated_single_user: [globalUsers[0]], }, { name: "multi user", multi_user: globalUsers, }, { name: "multi user with session user", multi_user: [...globalUsers, currentUser], }, { name: "deprecated multi user", deprecated_multi_user: globalUsers, }, { name: "deprecated multi user with session user", deprecated_multi_user: [...globalUsers, currentUser], }, ] } beforeAll(async () => { // Set up some global users globalUsers = await Promise.all( Array(2) .fill(0) .map(async () => { const globalUser = await config.globalUser() const userMedataId = globalUser._id ? dbCore.generateUserMetadataID(globalUser._id) : null return { _id: globalUser._id, _meta: userMedataId, } }) ) tableOrViewId = await createTableOrView({ name: { name: "name", type: FieldType.STRING }, appointment: { name: "appointment", type: FieldType.DATETIME, }, single_user: { name: "single_user", type: FieldType.BB_REFERENCE_SINGLE, subtype: BBReferenceFieldSubType.USER, }, deprecated_single_user: { name: "deprecated_single_user", type: FieldType.BB_REFERENCE, subtype: BBReferenceFieldSubType.USER, }, multi_user: { name: "multi_user", type: FieldType.BB_REFERENCE, subtype: BBReferenceFieldSubType.USER, constraints: { type: "array", }, }, deprecated_multi_user: { name: "deprecated_multi_user", type: FieldType.BB_REFERENCE, subtype: BBReferenceFieldSubType.USERS, constraints: { type: "array", }, }, }) await createRows(rows(config.getUser())) }) // !! Current User is auto generated per run it("should return all rows matching the session user firstname", async () => { await expectQuery({ equal: { name: "{{ [user].firstName }}" }, }).toContainExactly([ { name: config.getUser().firstName, appointment: future.toISOString(), }, ]) }) it("should return all rows matching the session user firstname when logical operator used", async () => { await expectQuery({ $and: { conditions: [ { equal: { name: "{{ [user].firstName }}" } }, ], }, }).toContainExactly([ { name: config.getUser().firstName, appointment: future.toISOString(), }, ]) }) it("should parse the date binding and return all rows after the resolved value", async () => { await tk.withFreeze(serverTime, async () => { await expectQuery({ range: { appointment: { low: "{{ [now] }}", high: "9999-00-00T00:00:00.000Z", }, }, }).toContainExactly([ { name: config.getUser().firstName, appointment: future.toISOString(), }, { name: "serverDate", appointment: serverTime.toISOString(), }, ]) }) }) it("should parse the date binding and return all rows before the resolved value", async () => { await expectQuery({ range: { appointment: { low: "0000-00-00T00:00:00.000Z", high: "{{ [now] }}", }, }, }).toContainExactly([ { name: "foo", appointment: "1982-01-05T00:00:00.000Z" }, { name: "bar", appointment: "1995-05-06T00:00:00.000Z" }, { name: "serverDate", appointment: serverTime.toISOString(), }, ]) }) it("should parse the encoded js snippet. Return rows with appointments up to 1 week in the past", async () => { const jsBinding = "return snippets.WeeksAgo();" const encodedBinding = encodeJSBinding(jsBinding) await expectQuery({ range: { appointment: { low: "0000-00-00T00:00:00.000Z", high: encodedBinding, }, }, }).toContainExactly([ { name: "foo", appointment: "1982-01-05T00:00:00.000Z" }, { name: "bar", appointment: "1995-05-06T00:00:00.000Z" }, ]) }) it("should parse the encoded js binding. Return rows with appointments 2 weeks in the past", async () => { const jsBinding = `const currentTime = new Date(${Date.now()})\ncurrentTime.setDate(currentTime.getDate()-14);\nreturn currentTime.toISOString();` const encodedBinding = encodeJSBinding(jsBinding) await expectQuery({ range: { appointment: { low: "0000-00-00T00:00:00.000Z", high: encodedBinding, }, }, }).toContainExactly([ { name: "foo", appointment: "1982-01-05T00:00:00.000Z" }, { name: "bar", appointment: "1995-05-06T00:00:00.000Z" }, ]) }) it("should match a single user row by the session user id", async () => { await expectQuery({ equal: { single_user: "{{ [user]._id }}" }, }).toContainExactly([ { name: "single user, session user", single_user: { _id: config.getUser()._id }, }, ]) }) it("should match a deprecated single user row by the session user id", async () => { await expectQuery({ equal: { deprecated_single_user: "{{ [user]._id }}" }, }).toContainExactly([ { name: "deprecated single user, session user", deprecated_single_user: [{ _id: config.getUser()._id }], }, ]) }) it("should match the session user id in a multi user field", async () => { const allUsers = [...globalUsers, config.getUser()].map( (user: any) => { return { _id: user._id } } ) await expectQuery({ contains: { multi_user: ["{{ [user]._id }}"] }, }).toContainExactly([ { name: "multi user with session user", multi_user: allUsers, }, ]) }) it("should match the session user id in a deprecated multi user field", async () => { const allUsers = [...globalUsers, config.getUser()].map( (user: any) => { return { _id: user._id } } ) await expectQuery({ contains: { deprecated_multi_user: ["{{ [user]._id }}"] }, }).toContainExactly([ { name: "deprecated multi user with session user", deprecated_multi_user: allUsers, }, ]) }) it("should not match the session user id in a multi user field", async () => { await expectQuery({ notContains: { multi_user: ["{{ [user]._id }}"] }, notEmpty: { multi_user: true }, }).toContainExactly([ { name: "multi user", multi_user: globalUsers.map((user: any) => { return { _id: user._id } }), }, ]) }) it("should not match the session user id in a deprecated multi user field", async () => { await expectQuery({ notContains: { deprecated_multi_user: ["{{ [user]._id }}"], }, notEmpty: { deprecated_multi_user: true }, }).toContainExactly([ { name: "deprecated multi user", deprecated_multi_user: globalUsers.map((user: any) => { return { _id: user._id } }), }, ]) }) it("should match the session user id and a user table row id using helpers, user binding and a static user id.", async () => { await expectQuery({ oneOf: { single_user: [ "{{ default [user]._id '_empty_' }}", globalUsers[0]._id, ], }, }).toContainExactly([ { name: "single user, session user", single_user: { _id: config.getUser()._id }, }, { name: "single user", single_user: { _id: globalUsers[0]._id }, }, ]) }) it("should match the session user id and a user table row id using helpers, user binding and a static user id. (deprecated single user)", async () => { await expectQuery({ oneOf: { deprecated_single_user: [ "{{ default [user]._id '_empty_' }}", globalUsers[0]._id, ], }, }).toContainExactly([ { name: "deprecated single user, session user", deprecated_single_user: [{ _id: config.getUser()._id }], }, { name: "deprecated single user", deprecated_single_user: [{ _id: globalUsers[0]._id }], }, ]) }) it("should resolve 'default' helper to '_empty_' when binding resolves to nothing", async () => { await expectQuery({ oneOf: { single_user: [ "{{ default [user]._idx '_empty_' }}", globalUsers[0]._id, ], }, }).toContainExactly([ { name: "single user", single_user: { _id: globalUsers[0]._id }, }, ]) }) it("should resolve 'default' helper to '_empty_' when binding resolves to nothing (deprecated single user)", async () => { await expectQuery({ oneOf: { deprecated_single_user: [ "{{ default [user]._idx '_empty_' }}", globalUsers[0]._id, ], }, }).toContainExactly([ { name: "deprecated single user", deprecated_single_user: [{ _id: globalUsers[0]._id }], }, ]) }) }) const stringTypes = [ FieldType.STRING, FieldType.LONGFORM, FieldType.BARCODEQR, ] as const describe.each(stringTypes)("%s", type => { beforeAll(async () => { tableOrViewId = await createTableOrView({ name: { name: "name", type }, }) await createRows([{ name: "foo" }, { name: "bar" }]) }) describe("misc", () => { it("should return all if no query is passed", async () => { await expectSearch({} as RowSearchParams).toContainExactly([ { name: "foo" }, { name: "bar" }, ]) }) it("should return all if empty query is passed", async () => { await expectQuery({}).toContainExactly([ { name: "foo" }, { name: "bar" }, ]) }) it("should return all if onEmptyFilter is RETURN_ALL", async () => { await expectQuery({ onEmptyFilter: EmptyFilterOption.RETURN_ALL, }).toContainExactly([{ name: "foo" }, { name: "bar" }]) }) // onEmptyFilter cannot be sent to view searches !isView && it("should return nothing if onEmptyFilter is RETURN_NONE", async () => { await expectQuery({ onEmptyFilter: EmptyFilterOption.RETURN_NONE, }).toFindNothing() }) it("should respect limit", async () => { await expectSearch({ limit: 1, paginate: true, query: {}, }).toHaveLength(1) }) }) describe("equal", () => { it("successfully finds a row", async () => { await expectQuery({ equal: { name: "foo" }, }).toContainExactly([{ name: "foo" }]) }) it("fails to find nonexistent row", async () => { await expectQuery({ equal: { name: "none" } }).toFindNothing() }) it("works as an or condition", async () => { await expectQuery({ allOr: true, equal: { name: "foo" }, oneOf: { name: ["bar"] }, }).toContainExactly([{ name: "foo" }, { name: "bar" }]) }) it("can have multiple values for same column", async () => { await expectQuery({ allOr: true, equal: { "1:name": "foo", "2:name": "bar" }, }).toContainExactly([{ name: "foo" }, { name: "bar" }]) }) }) describe("non-existent fields", () => { it("should return 400 when searching for non-existent fields", async () => { await config.api.row.search( tableOrViewId, { query: { equal: { nonExistentField: "value" }, }, }, { status: 400, body: { message: expect.stringContaining("nonExistentField"), }, } ) }) }) describe("notEqual", () => { it("successfully finds a row", async () => { await expectQuery({ notEqual: { name: "foo" }, }).toContainExactly([{ name: "bar" }]) }) it("fails to find nonexistent row", async () => { await expectQuery({ notEqual: { name: "bar" }, }).toContainExactly([{ name: "foo" }]) }) }) describe("oneOf", () => { it("successfully finds a row", async () => { await expectQuery({ oneOf: { name: ["foo"] }, }).toContainExactly([{ name: "foo" }]) }) it("fails to find nonexistent row", async () => { 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" }]) }) it("empty arrays returns all when onEmptyFilter is set to return 'all'", async () => { await expectQuery({ onEmptyFilter: EmptyFilterOption.RETURN_ALL, oneOf: { name: [] }, }).toContainExactly([{ name: "foo" }, { name: "bar" }]) }) // onEmptyFilter cannot be sent to view searches !isView && it("empty arrays returns all when onEmptyFilter is set to return 'none'", async () => { await expectQuery({ onEmptyFilter: EmptyFilterOption.RETURN_NONE, oneOf: { name: [] }, }).toContainExactly([]) }) }) describe("fuzzy", () => { it("successfully finds a row", async () => { await expectQuery({ fuzzy: { name: "oo" } }).toContainExactly( [{ name: "foo" }] ) }) it("fails to find nonexistent row", async () => { await expectQuery({ fuzzy: { name: "none" } }).toFindNothing() }) }) describe("string", () => { it("successfully finds a row", async () => { await expectQuery({ string: { name: "fo" }, }).toContainExactly([{ name: "foo" }]) }) it("fails to find nonexistent row", async () => { await expectQuery({ string: { name: "none" }, }).toFindNothing() }) it("is case-insensitive", async () => { await expectQuery({ string: { name: "FO" }, }).toContainExactly([{ name: "foo" }]) }) it("should not coerce string to date for string columns", async () => { await expectQuery({ string: { name: "2020-01-01" }, }).toFindNothing() }) }) describe("range", () => { it("successfully finds multiple rows", async () => { await expectQuery({ range: { name: { low: "a", high: "z" } }, }).toContainExactly([{ name: "bar" }, { name: "foo" }]) }) it("successfully finds a row with a high bound", async () => { await expectQuery({ range: { name: { low: "a", high: "c" } }, }).toContainExactly([{ name: "bar" }]) }) it("successfully finds a row with a low bound", async () => { await expectQuery({ range: { name: { low: "f", high: "z" } }, }).toContainExactly([{ name: "foo" }]) }) it("successfully finds no rows", async () => { await expectQuery({ range: { name: { low: "g", high: "h" } }, }).toFindNothing() }) it("ignores low if it's an empty object", async () => { await expectQuery({ // @ts-ignore range: { name: { low: {}, high: "z" } }, }).toContainExactly([{ name: "foo" }, { name: "bar" }]) }) it("ignores high if it's an empty object", async () => { await expectQuery({ // @ts-ignore range: { name: { low: "a", high: {} } }, }).toContainExactly([{ name: "foo" }, { name: "bar" }]) }) }) describe("empty", () => { it("finds no empty rows", async () => { await expectQuery({ empty: { name: null } }).toFindNothing() }) it("should not be affected by when filter empty behaviour", async () => { await expectQuery({ empty: { name: null }, onEmptyFilter: EmptyFilterOption.RETURN_ALL, }).toFindNothing() }) }) describe("notEmpty", () => { it("finds all non-empty rows", async () => { await expectQuery({ notEmpty: { name: null }, }).toContainExactly([{ name: "foo" }, { name: "bar" }]) }) it("should not be affected by when filter empty behaviour", async () => { await expectQuery({ notEmpty: { name: null }, onEmptyFilter: EmptyFilterOption.RETURN_NONE, }).toContainExactly([{ name: "foo" }, { name: "bar" }]) }) }) describe("sort", () => { it("sorts ascending", async () => { await expectSearch({ query: {}, sort: "name", sortOrder: SortOrder.ASCENDING, }).toMatchExactly([{ name: "bar" }, { name: "foo" }]) }) it("sorts descending", async () => { await expectSearch({ query: {}, sort: "name", sortOrder: SortOrder.DESCENDING, }).toMatchExactly([{ name: "foo" }, { name: "bar" }]) }) describe("sortType STRING", () => { it("sorts ascending", async () => { await expectSearch({ query: {}, sort: "name", sortType: SortType.STRING, sortOrder: SortOrder.ASCENDING, }).toMatchExactly([{ name: "bar" }, { name: "foo" }]) }) it("sorts descending", async () => { await expectSearch({ query: {}, sort: "name", sortType: SortType.STRING, sortOrder: SortOrder.DESCENDING, }).toMatchExactly([{ name: "foo" }, { name: "bar" }]) }) }) !isInternal && !isInMemory && // This test was added because we automatically add in a sort by the // primary key, and we used to do this unconditionally which caused // problems because it was possible for the primary key to appear twice // in the resulting SQL ORDER BY clause, resulting in an SQL error. // We now check first to make sure that the primary key isn't already // in the sort before adding it. describe("sort on primary key", () => { beforeAll(async () => { const tableName = structures.uuid().substring(0, 10) await client!.schema.createTable(tableName, t => { t.string("name").primary() }) const resp = await config.api.datasource.fetchSchema({ datasourceId: datasource!._id!, }) tableOrViewId = resp.datasource.entities![tableName]._id! await createRows([{ name: "foo" }, { name: "bar" }]) }) it("should be able to sort by a primary key column ascending", async () => expectSearch({ query: {}, sort: "name", sortOrder: SortOrder.ASCENDING, }).toMatchExactly([{ name: "bar" }, { name: "foo" }])) it("should be able to sort by a primary key column descending", async () => expectSearch({ query: {}, sort: "name", sortOrder: SortOrder.DESCENDING, }).toMatchExactly([{ name: "foo" }, { name: "bar" }])) }) }) }) describe("numbers", () => { beforeAll(async () => { tableOrViewId = await createTableOrView({ age: { name: "age", type: FieldType.NUMBER }, }) await createRows([{ age: 1 }, { age: 10 }]) }) describe("equal", () => { it("successfully finds a row", async () => { await expectQuery({ equal: { age: 1 } }).toContainExactly([ { age: 1 }, ]) }) it("fails to find nonexistent row", async () => { await expectQuery({ equal: { age: 2 } }).toFindNothing() }) }) describe("notEqual", () => { it("successfully finds a row", async () => { await expectQuery({ notEqual: { age: 1 } }).toContainExactly([ { age: 10 }, ]) }) it("fails to find nonexistent row", async () => { await expectQuery({ notEqual: { age: 10 } }).toContainExactly( [{ age: 1 }] ) }) }) describe("oneOf", () => { it("successfully finds a row", async () => { await expectQuery({ oneOf: { age: [1] } }).toContainExactly([ { age: 1 }, ]) }) it("fails to find nonexistent row", async () => { await expectQuery({ oneOf: { age: [2] } }).toFindNothing() }) it("can convert from a string", async () => { await expectQuery({ oneOf: { // @ts-ignore age: "1", }, }).toContainExactly([{ age: 1 }]) }) it("can find multiple values for same column", async () => { await expectQuery({ oneOf: { // @ts-ignore age: "1,10", }, }).toContainExactly([{ age: 1 }, { age: 10 }]) }) }) describe("range", () => { it("successfully finds a row", async () => { await expectQuery({ range: { age: { low: 1, high: 5 } }, }).toContainExactly([{ age: 1 }]) }) it("successfully finds multiple rows", async () => { await expectQuery({ range: { age: { low: 1, high: 10 } }, }).toContainExactly([{ age: 1 }, { age: 10 }]) }) it("successfully finds a row with a high bound", async () => { a