budibase/packages/server/src/api/routes/tests/search.spec.ts

4123 lines
154 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { tableForDatasource } from "../../../tests/utilities/structures"
import { datasourceDescribe } from "../../../integrations/tests/utils"
import {
context,
db as dbCore,
docIds,
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 } 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/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 createTable({
name: { name: opts?.tableName || "name", type: FieldType.STRING },
})
const columnName = opts?.primaryColumn || "productCat"
//@ts-ignore - API accepts this structure, will build out rest of definition
const tableId = await createTable({
name: { name: opts?.tableName || "name", type: FieldType.STRING },
[columnName]: {
type: FieldType.LINK,
relationshipType: type,
name: columnName,
fieldName: opts?.otherColumn || "product",
tableId: relatedTable,
constraints: {
type: "array",
},
},
})
return {
relatedTable: await config.api.table.get(relatedTable),
tableId,
}
}
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 createTable(schema?: TableSchema) {
const table = await config.api.table.save(
tableForDatasource(datasource, { schema })
)
return table._id!
}
async function createView(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 (docIds.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", createTable]]
if (!isInMemory) {
tableOrView.push([
"view",
async (schema?: TableSchema) => {
const tableId = await createTable(schema)
const viewId = await createView(
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] 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("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 () => {
await expectQuery({
range: { age: { low: 5, high: 10 } },
}).toContainExactly([{ age: 10 }])
})
it("successfully finds no rows", async () => {
await expectQuery({
range: { age: { low: 5, high: 9 } },
}).toFindNothing()
})
it("greater than equal to", async () => {
await expectQuery({
range: {
age: { low: 10, high: Number.MAX_SAFE_INTEGER },
},
}).toContainExactly([{ age: 10 }])
})
it("greater than", async () => {
await expectQuery({
range: {
age: { low: 5, high: Number.MAX_SAFE_INTEGER },
},
}).toContainExactly([{ age: 10 }])
})
it("less than equal to", async () => {
await expectQuery({
range: {
age: { high: 1, low: Number.MIN_SAFE_INTEGER },
},
}).toContainExactly([{ age: 1 }])
})
it("less than", async () => {
await expectQuery({
range: {
age: { high: 5, low: Number.MIN_SAFE_INTEGER },
},
}).toContainExactly([{ age: 1 }])
})
})
describe("sort", () => {
it("sorts ascending", async () => {
await expectSearch({
query: {},
sort: "age",
sortOrder: SortOrder.ASCENDING,
}).toMatchExactly([{ age: 1 }, { age: 10 }])
})
it("sorts descending", async () => {
await expectSearch({
query: {},
sort: "age",
sortOrder: SortOrder.DESCENDING,
}).toMatchExactly([{ age: 10 }, { age: 1 }])
})
})
describe("sortType NUMBER", () => {
it("sorts ascending", async () => {
await expectSearch({
query: {},
sort: "age",
sortType: SortType.NUMBER,
sortOrder: SortOrder.ASCENDING,
}).toMatchExactly([{ age: 1 }, { age: 10 }])
})
it("sorts descending", async () => {
await expectSearch({
query: {},
sort: "age",
sortType: SortType.NUMBER,
sortOrder: SortOrder.DESCENDING,
}).toMatchExactly([{ age: 10 }, { age: 1 }])
})
})
})
describe("dates", () => {
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_9TH = "2020-01-09T00:00:00.000Z"
const JAN_10TH = "2020-01-10T00:00:00.000Z"
beforeAll(async () => {
tableOrViewId = await createTableOrView({
dob: { name: "dob", type: FieldType.DATETIME },
})
await createRows([{ dob: JAN_1ST }, { dob: JAN_10TH }])
})
describe("equal", () => {
it("successfully finds a row", async () => {
await expectQuery({
equal: { dob: JAN_1ST },
}).toContainExactly([{ dob: JAN_1ST }])
})
it("fails to find nonexistent row", async () => {
await expectQuery({ equal: { dob: JAN_2ND } }).toFindNothing()
})
})
describe("notEqual", () => {
it("successfully finds a row", async () => {
await expectQuery({
notEqual: { dob: JAN_1ST },
}).toContainExactly([{ dob: JAN_10TH }])
})
it("fails to find nonexistent row", async () => {
await expectQuery({
notEqual: { dob: JAN_10TH },
}).toContainExactly([{ dob: JAN_1ST }])
})
})
describe("oneOf", () => {
it("successfully finds a row", async () => {
await expectQuery({
oneOf: { dob: [JAN_1ST] },
}).toContainExactly([{ dob: JAN_1ST }])
})
it("fails to find nonexistent row", async () => {
await expectQuery({
oneOf: { dob: [JAN_2ND] },
}).toFindNothing()
})
})
describe("range", () => {
it("successfully finds a row", async () => {
await expectQuery({
range: { dob: { low: JAN_1ST, high: JAN_5TH } },
}).toContainExactly([{ dob: JAN_1ST }])
})
it("successfully finds multiple rows", async () => {
await expectQuery({
range: { dob: { low: JAN_1ST, high: JAN_10TH } },
}).toContainExactly([{ dob: JAN_1ST }, { dob: JAN_10TH }])
})
it("successfully finds a row with a high bound", async () => {
await expectQuery({
range: { dob: { low: JAN_5TH, high: JAN_10TH } },
}).toContainExactly([{ dob: JAN_10TH }])
})
it("successfully finds no rows", async () => {
await expectQuery({
range: { dob: { low: JAN_5TH, high: JAN_9TH } },
}).toFindNothing()
})
it("greater than equal to", async () => {
await expectQuery({
range: {
dob: {
low: JAN_10TH,
high: MAX_VALID_DATE.toISOString(),
},
},
}).toContainExactly([{ dob: JAN_10TH }])
})
it("greater than", async () => {
await expectQuery({
range: {
dob: { low: JAN_5TH, high: MAX_VALID_DATE.toISOString() },
},
}).toContainExactly([{ dob: JAN_10TH }])
})
it("less than equal to", async () => {
await expectQuery({
range: {
dob: { high: JAN_1ST, low: MIN_VALID_DATE.toISOString() },
},
}).toContainExactly([{ dob: JAN_1ST }])
})
it("less than", async () => {
await expectQuery({
range: {
dob: { high: JAN_5TH, low: MIN_VALID_DATE.toISOString() },
},
}).toContainExactly([{ dob: JAN_1ST }])
})
})
describe("sort", () => {
it("sorts ascending", async () => {
await expectSearch({
query: {},
sort: "dob",
sortOrder: SortOrder.ASCENDING,
}).toMatchExactly([{ dob: JAN_1ST }, { dob: JAN_10TH }])
})
it("sorts descending", async () => {
await expectSearch({
query: {},
sort: "dob",
sortOrder: SortOrder.DESCENDING,
}).toMatchExactly([{ dob: JAN_10TH }, { dob: JAN_1ST }])
})
describe("sortType STRING", () => {
it("sorts ascending", async () => {
await expectSearch({
query: {},
sort: "dob",
sortType: SortType.STRING,
sortOrder: SortOrder.ASCENDING,
}).toMatchExactly([{ dob: JAN_1ST }, { dob: JAN_10TH }])
})
it("sorts descending", async () => {
await expectSearch({
query: {},
sort: "dob",
sortType: SortType.STRING,
sortOrder: SortOrder.DESCENDING,
}).toMatchExactly([{ dob: JAN_10TH }, { dob: JAN_1ST }])
})
})
})
})
!isInternal &&
describe("datetime - time only", () => {
const T_1000 = "10:00:00"
const T_1045 = "10:45:00"
const T_1200 = "12:00:00"
const T_1530 = "15:30:00"
const T_0000 = "00:00:00"
const UNEXISTING_TIME = "10:01:00"
const NULL_TIME__ID = `null_time__id`
beforeAll(async () => {
tableOrViewId = await createTableOrView({
timeid: { name: "timeid", type: FieldType.STRING },
time: {
name: "time",
type: FieldType.DATETIME,
timeOnly: true,
},
})
await createRows([
{ timeid: NULL_TIME__ID, time: null },
{ time: T_1000 },
{ time: T_1045 },
{ time: T_1200 },
{ time: T_1530 },
{ time: T_0000 },
])
})
describe("equal", () => {
it("successfully finds a row", async () => {
await expectQuery({
equal: { time: T_1000 },
}).toContainExactly([{ time: "10:00:00" }])
})
it("fails to find nonexistent row", async () => {
await expectQuery({
equal: { time: UNEXISTING_TIME },
}).toFindNothing()
})
})
describe("notEqual", () => {
it("successfully finds a row", async () => {
await expectQuery({
notEqual: { time: T_1000 },
}).toContainExactly([
{ timeid: NULL_TIME__ID },
{ time: "10:45:00" },
{ time: "12:00:00" },
{ time: "15:30:00" },
{ time: "00:00:00" },
])
})
it("return all when requesting non-existing", async () => {
await expectQuery({
notEqual: { time: UNEXISTING_TIME },
}).toContainExactly([
{ timeid: NULL_TIME__ID },
{ time: "10:00:00" },
{ time: "10:45:00" },
{ time: "12:00:00" },
{ time: "15:30:00" },
{ time: "00:00:00" },
])
})
})
describe("oneOf", () => {
it("successfully finds a row", async () => {
await expectQuery({
oneOf: { time: [T_1000] },
}).toContainExactly([{ time: "10:00:00" }])
})
it("fails to find nonexistent row", async () => {
await expectQuery({
oneOf: { time: [UNEXISTING_TIME] },
}).toFindNothing()
})
})
describe("range", () => {
it("successfully finds a row", async () => {
await expectQuery({
range: { time: { low: T_1045, high: T_1045 } },
}).toContainExactly([{ time: "10:45:00" }])
})
it("successfully finds multiple rows", async () => {
await expectQuery({
range: { time: { low: T_1045, high: T_1530 } },
}).toContainExactly([
{ time: "10:45:00" },
{ time: "12:00:00" },
{ time: "15:30:00" },
])
})
it("successfully finds no rows", async () => {
await expectQuery({
range: {
time: { low: UNEXISTING_TIME, high: UNEXISTING_TIME },
},
}).toFindNothing()
})
})
describe("sort", () => {
it("sorts ascending", async () => {
await expectSearch({
query: {},
sort: "time",
sortOrder: SortOrder.ASCENDING,
}).toMatchExactly([
{ timeid: NULL_TIME__ID },
{ time: "00:00:00" },
{ time: "10:00:00" },
{ time: "10:45:00" },
{ time: "12:00:00" },
{ time: "15:30:00" },
])
})
it("sorts descending", async () => {
await expectSearch({
query: {},
sort: "time",
sortOrder: SortOrder.DESCENDING,
}).toMatchExactly([
{ time: "15:30:00" },
{ time: "12:00:00" },
{ time: "10:45:00" },
{ time: "10:00:00" },
{ time: "00:00:00" },
{ timeid: NULL_TIME__ID },
])
})
describe("sortType STRING", () => {
it("sorts ascending", async () => {
await expectSearch({
query: {},
sort: "time",
sortType: SortType.STRING,
sortOrder: SortOrder.ASCENDING,
}).toMatchExactly([
{ timeid: NULL_TIME__ID },
{ time: "00:00:00" },
{ time: "10:00:00" },
{ time: "10:45:00" },
{ time: "12:00:00" },
{ time: "15:30:00" },
])
})
it("sorts descending", async () => {
await expectSearch({
query: {},
sort: "time",
sortType: SortType.STRING,
sortOrder: SortOrder.DESCENDING,
}).toMatchExactly([
{ time: "15:30:00" },
{ time: "12:00:00" },
{ time: "10:45:00" },
{ time: "10:00:00" },
{ time: "00:00:00" },
{ timeid: NULL_TIME__ID },
])
})
})
})
})
describe("datetime - date only", () => {
describe.each([true, false])(
"saved with timestamp: %s",
saveWithTimestamp => {
describe.each([true, false])(
"search with timestamp: %s",
searchWithTimestamp => {
const SAVE_SUFFIX = saveWithTimestamp
? "T00:00:00.000Z"
: ""
const SEARCH_SUFFIX = searchWithTimestamp
? "T00:00:00.000Z"
: ""
const JAN_1ST = `2020-01-01`
const JAN_10TH = `2020-01-10`
const JAN_30TH = `2020-01-30`
const UNEXISTING_DATE = `2020-01-03`
const NULL_DATE__ID = `null_date__id`
beforeAll(async () => {
tableOrViewId = await createTableOrView({
dateid: {
name: "dateid",
type: FieldType.STRING,
},
date: {
name: "date",
type: FieldType.DATETIME,
dateOnly: true,
},
})
await createRows([
{ dateid: NULL_DATE__ID, date: null },
{ date: `${JAN_1ST}${SAVE_SUFFIX}` },
{ date: `${JAN_10TH}${SAVE_SUFFIX}` },
])
})
describe("equal", () => {
it("successfully finds a row", async () => {
await expectQuery({
equal: { date: `${JAN_1ST}${SEARCH_SUFFIX}` },
}).toContainExactly([{ date: JAN_1ST }])
})
it("successfully finds an ISO8601 row", async () => {
await expectQuery({
equal: { date: `${JAN_10TH}${SEARCH_SUFFIX}` },
}).toContainExactly([{ date: JAN_10TH }])
})
it("finds a row with ISO8601 timestamp", async () => {
await expectQuery({
equal: { date: `${JAN_1ST}${SEARCH_SUFFIX}` },
}).toContainExactly([{ date: JAN_1ST }])
})
it("fails to find nonexistent row", async () => {
await expectQuery({
equal: {
date: `${UNEXISTING_DATE}${SEARCH_SUFFIX}`,
},
}).toFindNothing()
})
})
describe("notEqual", () => {
it("successfully finds a row", async () => {
await expectQuery({
notEqual: {
date: `${JAN_1ST}${SEARCH_SUFFIX}`,
},
}).toContainExactly([
{ date: JAN_10TH },
{ dateid: NULL_DATE__ID },
])
})
it("fails to find nonexistent row", async () => {
await expectQuery({
notEqual: {
date: `${JAN_30TH}${SEARCH_SUFFIX}`,
},
}).toContainExactly([
{ date: JAN_1ST },
{ date: JAN_10TH },
{ dateid: NULL_DATE__ID },
])
})
})
describe("oneOf", () => {
it("successfully finds a row", async () => {
await expectQuery({
oneOf: { date: [`${JAN_1ST}${SEARCH_SUFFIX}`] },
}).toContainExactly([{ date: JAN_1ST }])
})
it("fails to find nonexistent row", async () => {
await expectQuery({
oneOf: {
date: [`${UNEXISTING_DATE}${SEARCH_SUFFIX}`],
},
}).toFindNothing()
})
})
describe("range", () => {
it("successfully finds a row", async () => {
await expectQuery({
range: {
date: {
low: `${JAN_1ST}${SEARCH_SUFFIX}`,
high: `${JAN_1ST}${SEARCH_SUFFIX}`,
},
},
}).toContainExactly([{ date: JAN_1ST }])
})
it("successfully finds multiple rows", async () => {
await expectQuery({
range: {
date: {
low: `${JAN_1ST}${SEARCH_SUFFIX}`,
high: `${JAN_10TH}${SEARCH_SUFFIX}`,
},
},
}).toContainExactly([
{ date: JAN_1ST },
{ date: JAN_10TH },
])
})
it("successfully finds no rows", async () => {
await expectQuery({
range: {
date: {
low: `${JAN_30TH}${SEARCH_SUFFIX}`,
high: `${JAN_30TH}${SEARCH_SUFFIX}`,
},
},
}).toFindNothing()
})
})
describe("sort", () => {
it("sorts ascending", async () => {
await expectSearch({
query: {},
sort: "date",
sortOrder: SortOrder.ASCENDING,
}).toMatchExactly([
{ dateid: NULL_DATE__ID },
{ date: JAN_1ST },
{ date: JAN_10TH },
])
})
it("sorts descending", async () => {
await expectSearch({
query: {},
sort: "date",
sortOrder: SortOrder.DESCENDING,
}).toMatchExactly([
{ date: JAN_10TH },
{ date: JAN_1ST },
{ dateid: NULL_DATE__ID },
])
})
describe("sortType STRING", () => {
it("sorts ascending", async () => {
await expectSearch({
query: {},
sort: "date",
sortType: SortType.STRING,
sortOrder: SortOrder.ASCENDING,
}).toMatchExactly([
{ dateid: NULL_DATE__ID },
{ date: JAN_1ST },
{ date: JAN_10TH },
])
})
it("sorts descending", async () => {
await expectSearch({
query: {},
sort: "date",
sortType: SortType.STRING,
sortOrder: SortOrder.DESCENDING,
}).toMatchExactly([
{ date: JAN_10TH },
{ date: JAN_1ST },
{ dateid: NULL_DATE__ID },
])
})
})
})
}
)
}
)
})
isInternal &&
!isInMemory &&
describe("AI Column", () => {
const UNEXISTING_AI_COLUMN = "Real LLM Response"
let envCleanup: () => void
beforeAll(async () => {
mocks.licenses.useBudibaseAI()
mocks.licenses.useAICustomConfigs()
envCleanup = setEnv({ OPENAI_API_KEY: "mock" })
mockChatGPTResponse("Mock LLM Response")
tableOrViewId = await createTableOrView({
product: { name: "product", type: FieldType.STRING },
ai: {
name: "AI",
type: FieldType.AI,
operation: AIOperationEnum.PROMPT,
prompt: "Translate '{{ product }}' into German",
},
})
await createRows([
{ product: "Big Mac" },
{ product: "McCrispy" },
])
})
afterAll(() => {
envCleanup()
})
describe("equal", () => {
it("successfully finds rows based on AI column", async () => {
await expectQuery({
equal: { ai: "Mock LLM Response" },
}).toContainExactly([
{ product: "Big Mac" },
{ product: "McCrispy" },
])
})
it("fails to find nonexistent row", async () => {
await expectQuery({
equal: { ai: UNEXISTING_AI_COLUMN },
}).toFindNothing()
})
})
describe("notEqual", () => {
it("Returns nothing when searching notEqual on the mock AI response", async () => {
await expectQuery({
notEqual: { ai: "Mock LLM Response" },
}).toContainExactly([])
})
it("return all when requesting non-existing response", async () => {
await expectQuery({
notEqual: { ai: "Real LLM Response" },
}).toContainExactly([
{ product: "Big Mac" },
{ product: "McCrispy" },
])
})
})
describe("oneOf", () => {
it("successfully finds a row", async () => {
await expectQuery({
oneOf: {
ai: ["Mock LLM Response", "Other LLM Response"],
},
}).toContainExactly([
{ product: "Big Mac" },
{ product: "McCrispy" },
])
})
it("fails to find nonexistent row", async () => {
await expectQuery({
oneOf: { ai: ["Whopper"] },
}).toFindNothing()
})
})
})
describe("arrays", () => {
beforeAll(async () => {
tableOrViewId = await createTableOrView({
numbers: {
name: "numbers",
type: FieldType.ARRAY,
constraints: {
type: JsonFieldSubType.ARRAY,
inclusion: ["one", "two", "three"],
},
},
})
await createRows([
{ numbers: ["one", "two"] },
{ numbers: ["three"] },
])
})
describe("contains", () => {
it("successfully finds a row", async () => {
await expectQuery({
contains: { numbers: ["one"] },
}).toContainExactly([{ numbers: ["one", "two"] }])
})
it("fails to find nonexistent row", async () => {
await expectQuery({
contains: { numbers: ["none"] },
}).toFindNothing()
})
it("fails to find row containing all", async () => {
await expectQuery({
contains: { numbers: ["one", "two", "three"] },
}).toFindNothing()
})
it("finds all with empty list", async () => {
await expectQuery({
contains: { numbers: [] },
}).toContainExactly([
{ numbers: ["one", "two"] },
{ numbers: ["three"] },
])
})
})
describe("notContains", () => {
it("successfully finds a row", async () => {
await expectQuery({
notContains: { numbers: ["one"] },
}).toContainExactly([{ numbers: ["three"] }])
})
it("fails to find nonexistent row", async () => {
await expectQuery({
notContains: { numbers: ["one", "two", "three"] },
}).toContainExactly([
{ numbers: ["one", "two"] },
{ numbers: ["three"] },
])
})
// Not sure if this is correct behaviour but changing it would be a
// breaking change.
it("finds all with empty list", async () => {
await expectQuery({
notContains: { numbers: [] },
}).toContainExactly([
{ numbers: ["one", "two"] },
{ numbers: ["three"] },
])
})
})
describe("containsAny", () => {
it("successfully finds rows", async () => {
await expectQuery({
containsAny: { numbers: ["one", "two", "three"] },
}).toContainExactly([
{ numbers: ["one", "two"] },
{ numbers: ["three"] },
])
})
it("fails to find nonexistent row", async () => {
await expectQuery({
containsAny: { numbers: ["none"] },
}).toFindNothing()
})
it("finds all with empty list", async () => {
await expectQuery({
containsAny: { numbers: [] },
}).toContainExactly([
{ numbers: ["one", "two"] },
{ numbers: ["three"] },
])
})
})
})
describe("bigints", () => {
const SMALL = "1"
const MEDIUM = "10000000"
// Our bigints are int64s in most datasources.
let BIG = "9223372036854775807"
beforeAll(async () => {
tableOrViewId = await createTableOrView({
num: { name: "num", type: FieldType.BIGINT },
})
await createRows([
{ num: SMALL },
{ num: MEDIUM },
{ num: BIG },
])
})
describe("equal", () => {
it("successfully finds a row", async () => {
await expectQuery({ equal: { num: SMALL } }).toContainExactly(
[{ num: SMALL }]
)
})
it("successfully finds a big value", async () => {
await expectQuery({ equal: { num: BIG } }).toContainExactly([
{ num: BIG },
])
})
it("fails to find nonexistent row", async () => {
await expectQuery({ equal: { num: "2" } }).toFindNothing()
})
})
describe("notEqual", () => {
it("successfully finds a row", async () => {
await expectQuery({
notEqual: { num: SMALL },
}).toContainExactly([{ num: MEDIUM }, { num: BIG }])
})
it("fails to find nonexistent row", async () => {
await expectQuery({ notEqual: { num: 10 } }).toContainExactly(
[{ num: SMALL }, { num: MEDIUM }, { num: BIG }]
)
})
})
describe("oneOf", () => {
it("successfully finds a row", async () => {
await expectQuery({
oneOf: { num: [SMALL] },
}).toContainExactly([{ num: SMALL }])
})
it("successfully finds all rows", async () => {
await expectQuery({
oneOf: { num: [SMALL, MEDIUM, BIG] },
}).toContainExactly([
{ num: SMALL },
{ num: MEDIUM },
{ num: BIG },
])
})
it("fails to find nonexistent row", async () => {
await expectQuery({ oneOf: { num: [2] } }).toFindNothing()
})
})
describe("range", () => {
it("successfully finds a row", async () => {
await expectQuery({
range: { num: { low: SMALL, high: "5" } },
}).toContainExactly([{ num: SMALL }])
})
it("successfully finds multiple rows", async () => {
await expectQuery({
range: { num: { low: SMALL, high: MEDIUM } },
}).toContainExactly([{ num: SMALL }, { num: MEDIUM }])
})
it("successfully finds a row with a high bound", async () => {
await expectQuery({
range: { num: { low: MEDIUM, high: BIG } },
}).toContainExactly([{ num: MEDIUM }, { num: BIG }])
})
it("successfully finds no rows", async () => {
await expectQuery({
range: { num: { low: "5", high: "5" } },
}).toFindNothing()
})
it("can search using just a low value", async () => {
await expectQuery({
range: { num: { low: MEDIUM } },
}).toContainExactly([{ num: MEDIUM }, { num: BIG }])
})
it("can search using just a high value", async () => {
await expectQuery({
range: { num: { high: MEDIUM } },
}).toContainExactly([{ num: SMALL }, { num: MEDIUM }])
})
})
})
isInternal &&
describe("auto", () => {
beforeAll(async () => {
tableOrViewId = await createTableOrView({
auto: {
name: "auto",
type: FieldType.AUTO,
autocolumn: true,
subtype: AutoFieldSubType.AUTO_ID,
},
})
await createRows(new Array(10).fill({}))
})
describe("equal", () => {
it("successfully finds a row", async () => {
await expectQuery({ equal: { auto: 1 } }).toContainExactly([
{ auto: 1 },
])
})
it("fails to find nonexistent row", async () => {
await expectQuery({ equal: { auto: 0 } }).toFindNothing()
})
})
describe("not equal", () => {
it("successfully finds a row", async () => {
await expectQuery({
notEqual: { auto: 1 },
}).toContainExactly([
{ auto: 2 },
{ auto: 3 },
{ auto: 4 },
{ auto: 5 },
{ auto: 6 },
{ auto: 7 },
{ auto: 8 },
{ auto: 9 },
{ auto: 10 },
])
})
it("fails to find nonexistent row", async () => {
await expectQuery({
notEqual: { auto: 0 },
}).toContainExactly([
{ auto: 1 },
{ auto: 2 },
{ auto: 3 },
{ auto: 4 },
{ auto: 5 },
{ auto: 6 },
{ auto: 7 },
{ auto: 8 },
{ auto: 9 },
{ auto: 10 },
])
})
})
describe("oneOf", () => {
it("successfully finds a row", async () => {
await expectQuery({
oneOf: { auto: [1] },
}).toContainExactly([{ auto: 1 }])
})
it("fails to find nonexistent row", async () => {
await expectQuery({ oneOf: { auto: [0] } }).toFindNothing()
})
})
describe("range", () => {
it("successfully finds a row", async () => {
await expectQuery({
range: { auto: { low: 1, high: 1 } },
}).toContainExactly([{ auto: 1 }])
})
it("successfully finds multiple rows", async () => {
await expectQuery({
range: { auto: { low: 1, high: 2 } },
}).toContainExactly([{ auto: 1 }, { auto: 2 }])
})
it("successfully finds a row with a high bound", async () => {
await expectQuery({
range: { auto: { low: 2, high: 2 } },
}).toContainExactly([{ auto: 2 }])
})
it("successfully finds no rows", async () => {
await expectQuery({
range: { auto: { low: 0, high: 0 } },
}).toFindNothing()
})
it("can search using just a low value", async () => {
await expectQuery({
range: { auto: { low: 9 } },
}).toContainExactly([{ auto: 9 }, { auto: 10 }])
})
it("can search using just a high value", async () => {
await expectQuery({
range: { auto: { high: 2 } },
}).toContainExactly([{ auto: 1 }, { auto: 2 }])
})
})
describe("sort", () => {
it("sorts ascending", async () => {
await expectSearch({
query: {},
sort: "auto",
sortOrder: SortOrder.ASCENDING,
sortType: SortType.NUMBER,
}).toMatchExactly([
{ auto: 1 },
{ auto: 2 },
{ auto: 3 },
{ auto: 4 },
{ auto: 5 },
{ auto: 6 },
{ auto: 7 },
{ auto: 8 },
{ auto: 9 },
{ auto: 10 },
])
})
it("sorts descending", async () => {
await expectSearch({
query: {},
sort: "auto",
sortOrder: SortOrder.DESCENDING,
sortType: SortType.NUMBER,
}).toMatchExactly([
{ auto: 10 },
{ auto: 9 },
{ auto: 8 },
{ auto: 7 },
{ auto: 6 },
{ auto: 5 },
{ auto: 4 },
{ auto: 3 },
{ auto: 2 },
{ auto: 1 },
])
})
// This is important for pagination. The order of results must always
// be stable or pagination will break. We don't want the user to need
// to specify an order for pagination to work.
it("is stable without a sort specified", async () => {
let { rows: fullRowList } = await config.api.row.search(
tableOrViewId,
{
tableId: tableOrViewId,
query: {},
}
)
// repeat the search many times to check the first row is always the same
let bookmark: string | number | undefined,
hasNextPage: boolean | undefined = true,
rowCount = 0
do {
const response = await config.api.row.search(
tableOrViewId,
{
tableId: tableOrViewId,
limit: 1,
paginate: true,
query: {},
bookmark,
}
)
bookmark = response.bookmark
hasNextPage = response.hasNextPage
expect(response.rows.length).toEqual(1)
const foundRow = response.rows[0]
expect(foundRow).toEqual(fullRowList[rowCount++])
} while (hasNextPage)
})
})
describe("pagination", () => {
it("should paginate through all rows", async () => {
// @ts-ignore
let bookmark: string | number = undefined
let rows: Row[] = []
while (true) {
const response = await config.api.row.search(
tableOrViewId,
{
tableId: tableOrViewId,
limit: 3,
query: {},
bookmark,
paginate: true,
}
)
rows.push(...response.rows)
if (!response.bookmark || !response.hasNextPage) {
break
}
bookmark = response.bookmark
}
const autoValues = rows
.map(row => row.auto)
.sort((a, b) => a - b)
expect(autoValues).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
})
})
})
describe("field name 1:name", () => {
beforeAll(async () => {
tableOrViewId = await createTableOrView({
"1:name": { name: "1:name", type: FieldType.STRING },
})
await createRows([{ "1:name": "bar" }, { "1:name": "foo" }])
})
it("successfully finds a row", async () => {
await expectQuery({
equal: { "1:1:name": "bar" },
}).toContainExactly([{ "1:name": "bar" }])
})
it("fails to find nonexistent row", async () => {
await expectQuery({
equal: { "1:1:name": "none" },
}).toFindNothing()
})
})
isSql &&
describe("related formulas", () => {
beforeAll(async () => {
const arrayTable = await createTable({
name: { name: "name", type: FieldType.STRING },
array: {
name: "array",
type: FieldType.ARRAY,
constraints: {
type: JsonFieldSubType.ARRAY,
inclusion: ["option 1", "option 2"],
},
},
})
tableOrViewId = await createTableOrView({
relationship: {
type: FieldType.LINK,
relationshipType: RelationshipType.MANY_TO_ONE,
name: "relationship",
fieldName: "relate",
tableId: arrayTable,
constraints: {
type: "array",
},
},
formula: {
type: FieldType.FORMULA,
name: "formula",
formula: encodeJSBinding(
`let array = [];$("relationship").forEach(rel => array = array.concat(rel.array));return array.sort().join(",")`
),
},
})
const arrayRows = await Promise.all([
config.api.row.save(arrayTable, {
name: "foo",
array: ["option 1"],
}),
config.api.row.save(arrayTable, {
name: "bar",
array: ["option 2"],
}),
])
await Promise.all([
config.api.row.save(tableOrViewId, {
relationship: [arrayRows[0]._id, arrayRows[1]._id],
}),
])
})
it("formula is correct with relationship arrays", async () => {
await expectQuery({}).toContain([
{ formula: "option 1,option 2" },
])
})
})
describe("user", () => {
let user1: User
let user2: User
beforeAll(async () => {
user1 = await config.createUser({ _id: `us_${utils.newid()}` })
user2 = await config.createUser({ _id: `us_${utils.newid()}` })
tableOrViewId = await createTableOrView({
user: {
name: "user",
type: FieldType.BB_REFERENCE_SINGLE,
subtype: BBReferenceFieldSubType.USER,
},
})
await createRows([
{ user: user1 },
{ user: user2 },
{ user: null },
])
})
describe("equal", () => {
it("successfully finds a row", async () => {
await expectQuery({
equal: { user: user1._id },
}).toContainExactly([{ user: { _id: user1._id } }])
})
it("fails to find nonexistent row", async () => {
await expectQuery({
equal: { user: "us_none" },
}).toFindNothing()
})
})
describe("notEqual", () => {
it("successfully finds a row", async () => {
await expectQuery({
notEqual: { user: user1._id },
}).toContainExactly([{ user: { _id: user2._id } }, {}])
})
it("fails to find nonexistent row", async () => {
await expectQuery({
notEqual: { user: "us_none" },
}).toContainExactly([
{ user: { _id: user1._id } },
{ user: { _id: user2._id } },
{},
])
})
})
describe("oneOf", () => {
it("successfully finds a row", async () => {
await expectQuery({
oneOf: { user: [user1._id] },
}).toContainExactly([{ user: { _id: user1._id } }])
})
it("fails to find nonexistent row", async () => {
await expectQuery({
oneOf: { user: ["us_none"] },
}).toFindNothing()
})
})
describe("empty", () => {
it("finds empty rows", async () => {
await expectQuery({ empty: { user: null } }).toContainExactly(
[{}]
)
})
})
describe("notEmpty", () => {
it("finds non-empty rows", async () => {
await expectQuery({
notEmpty: { user: null },
}).toContainExactly([
{ user: { _id: user1._id } },
{ user: { _id: user2._id } },
])
})
})
})
describe("multi user", () => {
let user1: User
let user2: User
beforeAll(async () => {
user1 = await config.createUser({ _id: `us_${utils.newid()}` })
user2 = await config.createUser({ _id: `us_${utils.newid()}` })
tableOrViewId = await createTableOrView({
users: {
name: "users",
type: FieldType.BB_REFERENCE,
subtype: BBReferenceFieldSubType.USER,
constraints: { type: "array" },
},
number: {
name: "number",
type: FieldType.NUMBER,
},
})
await createRows([
{ number: 1, users: [user1] },
{ number: 2, users: [user2] },
{ number: 3, users: [user1, user2] },
{ number: 4, users: [] },
])
})
describe("contains", () => {
it("successfully finds a row", async () => {
await expectQuery({
contains: { users: [user1._id] },
}).toContainExactly([
{ users: [{ _id: user1._id }] },
{ users: [{ _id: user1._id }, { _id: user2._id }] },
])
})
it("successfully finds a row searching with a string", async () => {
await expectQuery({
// @ts-expect-error this test specifically goes against the type to
// test that we coerce the string to an array.
contains: { "1:users": user1._id },
}).toContainExactly([
{ users: [{ _id: user1._id }] },
{ users: [{ _id: user1._id }, { _id: user2._id }] },
])
})
it("fails to find nonexistent row", async () => {
await expectQuery({
contains: { users: ["us_none"] },
}).toFindNothing()
})
})
describe("notContains", () => {
it("successfully finds a row", async () => {
await expectQuery({
notContains: { users: [user1._id] },
}).toContainExactly([{ users: [{ _id: user2._id }] }, {}])
})
it("fails to find nonexistent row", async () => {
await expectQuery({
notContains: { users: ["us_none"] },
}).toContainExactly([
{ users: [{ _id: user1._id }] },
{ users: [{ _id: user2._id }] },
{ users: [{ _id: user1._id }, { _id: user2._id }] },
{},
])
})
})
describe("containsAny", () => {
it("successfully finds rows", async () => {
await expectQuery({
containsAny: { users: [user1._id, user2._id] },
}).toContainExactly([
{ users: [{ _id: user1._id }] },
{ users: [{ _id: user2._id }] },
{ users: [{ _id: user1._id }, { _id: user2._id }] },
])
})
it("fails to find nonexistent row", async () => {
await expectQuery({
containsAny: { users: ["us_none"] },
}).toFindNothing()
})
})
describe("multi-column equals", () => {
it("successfully finds a row", async () => {
await expectQuery({
equal: { number: 1 },
contains: { users: [user1._id] },
}).toContainExactly([
{ users: [{ _id: user1._id }], number: 1 },
])
})
it("fails to find nonexistent row", async () => {
await expectQuery({
equal: { number: 2 },
contains: { users: [user1._id] },
}).toFindNothing()
})
})
})
// It also can't work for in-memory searching because the related table name
// isn't available.
!isInMemory &&
describe.each([
RelationshipType.ONE_TO_MANY,
RelationshipType.MANY_TO_ONE,
RelationshipType.MANY_TO_MANY,
])("relations (%s)", relationshipType => {
let productCategoryTable: Table, productCatRows: Row[]
beforeAll(async () => {
const { relatedTable, tableId } =
await basicRelationshipTables(relationshipType)
tableOrViewId = tableId
productCategoryTable = relatedTable
productCatRows = await Promise.all([
config.api.row.save(productCategoryTable._id!, {
name: "foo",
}),
config.api.row.save(productCategoryTable._id!, {
name: "bar",
}),
])
await Promise.all([
config.api.row.save(tableOrViewId, {
name: "foo",
productCat: [productCatRows[0]._id],
}),
config.api.row.save(tableOrViewId, {
name: "bar",
productCat: [productCatRows[1]._id],
}),
config.api.row.save(tableOrViewId, {
name: "baz",
productCat: [],
}),
])
})
it("should be able to filter by relationship using column name", async () => {
await expectQuery({
equal: { ["productCat.name"]: "foo" },
}).toContainExactly([
{
name: "foo",
productCat: [{ _id: productCatRows[0]._id }],
},
])
})
it("should be able to filter by relationship using table name", async () => {
await expectQuery({
equal: { [`${productCategoryTable.name}.name`]: "foo" },
}).toContainExactly([
{
name: "foo",
productCat: [{ _id: productCatRows[0]._id }],
},
])
})
it("shouldn't return any relationship for last row", async () => {
await expectQuery({
equal: { ["name"]: "baz" },
}).toContainExactly([{ name: "baz", productCat: undefined }])
})
describe("logical filters", () => {
const logicalOperators = [
LogicalOperator.AND,
LogicalOperator.OR,
]
describe("$and", () => {
it("should allow single conditions", async () => {
await expectQuery({
$and: {
conditions: [
{
equal: { ["productCat.name"]: "foo" },
},
],
},
}).toContainExactly([
{
name: "foo",
productCat: [{ _id: productCatRows[0]._id }],
},
])
})
it("should allow exclusive conditions", async () => {
await expectQuery({
$and: {
conditions: [
{
equal: { ["productCat.name"]: "foo" },
notEqual: { ["productCat.name"]: "foo" },
},
],
},
}).toContainExactly([])
})
it.each([logicalOperators])(
"should allow nested ands with single conditions (with %s as root)",
async rootOperator => {
await expectQuery({
[rootOperator]: {
conditions: [
{
$and: {
conditions: [
{
equal: { ["productCat.name"]: "foo" },
},
],
},
},
],
},
}).toContainExactly([
{
name: "foo",
productCat: [{ _id: productCatRows[0]._id }],
},
])
}
)
it.each([logicalOperators])(
"should allow nested ands with exclusive conditions (with %s as root)",
async rootOperator => {
await expectQuery({
[rootOperator]: {
conditions: [
{
$and: {
conditions: [
{
equal: { ["productCat.name"]: "foo" },
notEqual: { ["productCat.name"]: "foo" },
},
],
},
},
],
},
}).toContainExactly([])
}
)
it.each([logicalOperators])(
"should allow nested ands with multiple conditions (with %s as root)",
async rootOperator => {
await expectQuery({
[rootOperator]: {
conditions: [
{
$and: {
conditions: [
{
equal: { ["productCat.name"]: "foo" },
},
],
},
notEqual: { ["productCat.name"]: "foo" },
},
],
},
}).toContainExactly([])
}
)
})
describe("$ors", () => {
it("should allow single conditions", async () => {
await expectQuery({
$or: {
conditions: [
{
equal: { ["productCat.name"]: "foo" },
},
],
},
}).toContainExactly([
{
name: "foo",
productCat: [{ _id: productCatRows[0]._id }],
},
])
})
it("should allow exclusive conditions", async () => {
await expectQuery({
$or: {
conditions: [
{
equal: { ["productCat.name"]: "foo" },
notEqual: { ["productCat.name"]: "foo" },
},
],
},
}).toContainExactly([
{
name: "foo",
productCat: [{ _id: productCatRows[0]._id }],
},
{
name: "bar",
productCat: [{ _id: productCatRows[1]._id }],
},
{ name: "baz", productCat: undefined },
])
})
it.each([logicalOperators])(
"should allow nested ors with single conditions (with %s as root)",
async rootOperator => {
await expectQuery({
[rootOperator]: {
conditions: [
{
$or: {
conditions: [
{
equal: { ["productCat.name"]: "foo" },
},
],
},
},
],
},
}).toContainExactly([
{
name: "foo",
productCat: [{ _id: productCatRows[0]._id }],
},
])
}
)
it.each([logicalOperators])(
"should allow nested ors with exclusive conditions (with %s as root)",
async rootOperator => {
await expectQuery({
[rootOperator]: {
conditions: [
{
$or: {
conditions: [
{
equal: { ["productCat.name"]: "foo" },
notEqual: { ["productCat.name"]: "foo" },
},
],
},
},
],
},
}).toContainExactly([
{
name: "foo",
productCat: [{ _id: productCatRows[0]._id }],
},
{
name: "bar",
productCat: [{ _id: productCatRows[1]._id }],
},
{ name: "baz", productCat: undefined },
])
}
)
it("should allow nested ors with multiple conditions", async () => {
await expectQuery({
$or: {
conditions: [
{
$or: {
conditions: [
{
equal: { ["productCat.name"]: "foo" },
},
],
},
notEqual: { ["productCat.name"]: "foo" },
},
],
},
}).toContainExactly([
{
name: "foo",
productCat: [{ _id: productCatRows[0]._id }],
},
{
name: "bar",
productCat: [{ _id: productCatRows[1]._id }],
},
{ name: "baz", productCat: undefined },
])
})
})
})
})
isSql &&
describe("relationship - table with spaces", () => {
let primaryTable: Table, row: Row
beforeAll(async () => {
const { relatedTable, tableId } =
await basicRelationshipTables(
RelationshipType.ONE_TO_MANY,
{
tableName: "table with spaces",
primaryColumn: "related",
otherColumn: "related",
}
)
tableOrViewId = tableId
primaryTable = relatedTable
row = await config.api.row.save(primaryTable._id!, {
name: "foo",
})
await config.api.row.save(tableOrViewId, {
name: "foo",
related: [row._id],
})
})
it("should be able to search by table name with spaces", async () => {
await expectQuery({
equal: {
["table with spaces.name"]: "foo",
},
}).toContain([{ name: "foo" }])
})
})
isSql &&
describe.each([
RelationshipType.MANY_TO_ONE,
RelationshipType.MANY_TO_MANY,
])("big relations (%s)", relationshipType => {
beforeAll(async () => {
const { relatedTable, tableId } =
await basicRelationshipTables(relationshipType)
tableOrViewId = tableId
const mainRow = await config.api.row.save(tableOrViewId, {
name: "foo",
})
for (let i = 0; i < 11; i++) {
await config.api.row.save(relatedTable._id!, {
name: i,
product: [mainRow._id!],
})
}
})
it("can only pull 10 related rows", async () => {
await withCoreEnv(
{ SQL_MAX_RELATED_ROWS: "10" },
async () => {
const response = await expectQuery({}).toContain([
{ name: "foo" },
])
expect(response.rows[0].productCat).toBeArrayOfSize(10)
}
)
})
it("can pull max rows when env not set (defaults to 500)", async () => {
const response = await expectQuery({}).toContain([
{ name: "foo" },
])
expect(response.rows[0].productCat).toBeArrayOfSize(11)
})
})
isSql &&
describe("relations to same table", () => {
let relatedTable: string, relatedRows: Row[]
beforeAll(async () => {
relatedTable = await createTable({
name: { name: "name", type: FieldType.STRING },
})
tableOrViewId = await createTableOrView({
name: { name: "name", type: FieldType.STRING },
related1: {
type: FieldType.LINK,
name: "related1",
fieldName: "main1",
tableId: relatedTable,
relationshipType: RelationshipType.MANY_TO_MANY,
},
related2: {
type: FieldType.LINK,
name: "related2",
fieldName: "main2",
tableId: relatedTable,
relationshipType: RelationshipType.MANY_TO_MANY,
},
})
relatedRows = await Promise.all([
config.api.row.save(relatedTable, { name: "foo" }),
config.api.row.save(relatedTable, { name: "bar" }),
config.api.row.save(relatedTable, { name: "baz" }),
config.api.row.save(relatedTable, { name: "boo" }),
])
await Promise.all([
config.api.row.save(tableOrViewId, {
name: "test",
related1: [relatedRows[0]._id!],
related2: [relatedRows[1]._id!],
}),
config.api.row.save(tableOrViewId, {
name: "test2",
related1: [relatedRows[2]._id!],
related2: [relatedRows[3]._id!],
}),
config.api.row.save(tableOrViewId, {
name: "test3",
related1: [relatedRows[1]._id],
related2: [relatedRows[2]._id!],
}),
])
})
it("should be able to relate to same table", async () => {
await expectSearch({
query: {},
}).toContainExactly([
{
name: "test",
related1: [{ _id: relatedRows[0]._id }],
related2: [{ _id: relatedRows[1]._id }],
},
{
name: "test2",
related1: [{ _id: relatedRows[2]._id }],
related2: [{ _id: relatedRows[3]._id }],
},
{
name: "test3",
related1: [{ _id: relatedRows[1]._id }],
related2: [{ _id: relatedRows[2]._id }],
},
])
})
it("should be able to filter via the first relation field with equal", async () => {
await expectSearch({
query: {
equal: {
["related1.name"]: "baz",
},
},
}).toContainExactly([
{
name: "test2",
related1: [{ _id: relatedRows[2]._id }],
},
])
})
it("should be able to filter via the second relation field with not equal", async () => {
await expectSearch({
query: {
notEqual: {
["1:related2.name"]: "foo",
["2:related2.name"]: "baz",
["3:related2.name"]: "boo",
},
},
}).toContainExactly([
{
name: "test",
},
])
})
it("should be able to filter on both fields", async () => {
await expectSearch({
query: {
notEqual: {
["related1.name"]: "foo",
["related2.name"]: "baz",
},
},
}).toContainExactly([
{
name: "test2",
},
])
})
})
isInternal &&
describe("no column error backwards compat", () => {
beforeAll(async () => {
tableOrViewId = await createTableOrView({
name: {
name: "name",
type: FieldType.STRING,
},
})
})
it("shouldn't error when column doesn't exist", async () => {
await expectSearch({
query: {
string: {
"1:something": "a",
},
},
}).toMatch({ rows: [] })
})
})
describe("row counting", () => {
beforeAll(async () => {
tableOrViewId = await createTableOrView({
name: {
name: "name",
type: FieldType.STRING,
},
})
await createRows([{ name: "a" }, { name: "b" }])
})
it("should be able to count rows when option set", async () => {
await expectSearch({
countRows: true,
query: {
notEmpty: {
name: true,
},
},
}).toMatch({ totalRows: 2, rows: expect.any(Array) })
})
it("shouldn't count rows when option is not set", async () => {
await expectSearch({
countRows: false,
query: {
notEmpty: {
name: true,
},
},
}).toNotHaveProperty(["totalRows"])
})
})
describe("Invalid column definitions", () => {
beforeAll(async () => {
// need to create an invalid table - means ignoring typescript
tableOrViewId = await createTableOrView({
// @ts-ignore
invalid: {
type: FieldType.STRING,
},
name: {
name: "name",
type: FieldType.STRING,
},
})
await createRows([
{ name: "foo", invalid: "id1" },
{ name: "bar", invalid: "id2" },
])
})
it("can get rows with all table data", async () => {
await expectSearch({
query: {},
}).toContain([
{ name: "foo", invalid: "id1" },
{ name: "bar", invalid: "id2" },
])
})
})
describe.each([
"data_name_test",
"name_data_test",
"name_test_data_",
])("special (%s) case", column => {
beforeAll(async () => {
tableOrViewId = await createTableOrView({
[column]: {
name: column,
type: FieldType.STRING,
},
})
await createRows([{ [column]: "a" }, { [column]: "b" }])
})
it("should be able to query a column with data_ in it", async () => {
await expectSearch({
query: {
equal: {
[`1:${column}`]: "a",
},
},
}).toContainExactly([{ [column]: "a" }])
})
})
isInternal &&
describe("sample data", () => {
beforeAll(async () => {
await config.api.application.addSampleData(config.appId!)
tableOrViewId = DEFAULT_EMPLOYEE_TABLE_SCHEMA._id!
rows = await config.api.row.fetch(tableOrViewId)
})
it("should be able to search sample data", async () => {
await expectSearch({
query: {},
}).toContain([
{
"First Name": "Mandy",
},
])
})
})
describe.each([
{
low: "2024-07-03T00:00:00.000Z",
high: "9999-00-00T00:00:00.000Z",
},
{
low: "2024-07-03T00:00:00.000Z",
high: "9998-00-00T00:00:00.000Z",
},
{
low: "0000-00-00T00:00:00.000Z",
high: "2024-07-04T00:00:00.000Z",
},
{
low: "0001-00-00T00:00:00.000Z",
high: "2024-07-04T00:00:00.000Z",
},
])("date special cases", ({ low, high }) => {
const earlyDate = "2024-07-03T10:00:00.000Z",
laterDate = "2024-07-03T11:00:00.000Z"
beforeAll(async () => {
tableOrViewId = await createTableOrView({
date: {
name: "date",
type: FieldType.DATETIME,
},
})
await createRows([{ date: earlyDate }, { date: laterDate }])
})
it("should be able to handle a date search", async () => {
await expectSearch({
query: {
range: {
"1:date": { low, high },
},
},
}).toContainExactly([{ date: earlyDate }, { date: laterDate }])
})
})
describe.each([
"名前", // Japanese for "name"
"Benutzer-ID", // German for "user ID", includes a hyphen
"numéro", // French for "number", includes an accent
"år", // Swedish for "year", includes a ring above
"naïve", // English word borrowed from French, includes an umlaut
"الاسم", // Arabic for "name"
"оплата", // Russian for "payment"
"पता", // Hindi for "address"
"用戶名", // Chinese for "username"
"çalışma_zamanı", // Turkish for "runtime", includes an underscore and a cedilla
"preço", // Portuguese for "price", includes a cedilla
"사용자명", // Korean for "username"
"usuario_ñoño", // Spanish, uses an underscore and includes "ñ"
"файл", // Bulgarian for "file"
"δεδομένα", // Greek for "data"
"geändert_am", // German for "modified on", includes an umlaut
"ব্যবহারকারীর_নাম", // Bengali for "user name", includes an underscore
"São_Paulo", // Portuguese, includes an underscore and a tilde
"età", // Italian for "age", includes an accent
"ชื่อผู้ใช้", // Thai for "username"
])("non-ascii column name: %s", name => {
beforeAll(async () => {
tableOrViewId = await createTableOrView({
[name]: {
name,
type: FieldType.STRING,
},
})
await createRows([{ [name]: "a" }, { [name]: "b" }])
})
it("should be able to query a column with non-ascii characters", async () => {
await expectSearch({
query: {
equal: {
[`1:${name}`]: "a",
},
},
}).toContainExactly([{ [name]: "a" }])
})
})
// This is currently not supported in external datasources, it produces SQL
// errors at time of writing. We supported it (potentially by accident) in
// Lucene, though, so we need to make sure it's supported in SQS as well. We
// found real cases in production of column names ending in a space.
isInternal &&
describe("space at end of column name", () => {
beforeAll(async () => {
tableOrViewId = await createTableOrView({
"name ": {
name: "name ",
type: FieldType.STRING,
},
})
await createRows([{ ["name "]: "foo" }, { ["name "]: "bar" }])
})
it("should be able to query a column that ends with a space", async () => {
await expectSearch({
query: {
string: {
"name ": "foo",
},
},
}).toContainExactly([{ ["name "]: "foo" }])
})
it("should be able to query a column that ends with a space using numeric notation", async () => {
await expectSearch({
query: {
string: {
"1:name ": "foo",
},
},
}).toContainExactly([{ ["name "]: "foo" }])
})
})
isInternal &&
describe("space at start of column name", () => {
beforeAll(async () => {
tableOrViewId = await createTableOrView({
" name": {
name: " name",
type: FieldType.STRING,
},
})
await createRows([{ [" name"]: "foo" }, { [" name"]: "bar" }])
})
it("should be able to query a column that starts with a space", async () => {
await expectSearch({
query: {
string: {
" name": "foo",
},
},
}).toContainExactly([{ [" name"]: "foo" }])
})
it("should be able to query a column that starts with a space using numeric notation", async () => {
await expectSearch({
query: {
string: {
"1: name": "foo",
},
},
}).toContainExactly([{ [" name"]: "foo" }])
})
})
isInternal &&
!isView &&
describe("duplicate columns", () => {
beforeAll(async () => {
tableOrViewId = await createTableOrView({
name: {
name: "name",
type: FieldType.STRING,
},
})
await context.doInAppContext(config.getAppId(), async () => {
const db = context.getAppDB()
const tableDoc = await db.get<Table>(tableOrViewId)
tableDoc.schema.Name = {
name: "Name",
type: FieldType.STRING,
}
try {
// remove the SQLite definitions so that they can be rebuilt as part of the search
const sqliteDoc = await db.get(SQLITE_DESIGN_DOC_ID)
await db.remove(sqliteDoc)
} catch (err) {
// no-op
}
})
await createRows([{ name: "foo", Name: "bar" }])
})
it("should handle invalid duplicate column names", async () => {
await expectSearch({
query: {},
}).toContainExactly([{ name: "foo" }])
})
})
!isInMemory &&
describe("search by _id", () => {
let row: Row
beforeAll(async () => {
const toRelateTable = await createTable({
name: {
name: "name",
type: FieldType.STRING,
},
})
tableOrViewId = await createTableOrView({
name: {
name: "name",
type: FieldType.STRING,
},
rel: {
name: "rel",
type: FieldType.LINK,
relationshipType: RelationshipType.MANY_TO_MANY,
tableId: toRelateTable,
fieldName: "rel",
},
})
const [row1, row2] = await Promise.all([
config.api.row.save(toRelateTable, { name: "tag 1" }),
config.api.row.save(toRelateTable, { name: "tag 2" }),
])
row = await config.api.row.save(tableOrViewId, {
name: "product 1",
rel: [row1._id, row2._id],
})
})
it("can filter by the row ID with limit 1", async () => {
await expectSearch({
query: {
equal: { _id: row._id },
},
limit: 1,
}).toContainExactly([row])
})
isInternal &&
describe("search by _id for relations", () => {
it("can filter by the related _id", async () => {
await expectSearch({
query: {
equal: { "rel._id": row.rel[0]._id },
},
}).toContainExactly([row])
await expectSearch({
query: {
equal: { "rel._id": row.rel[1]._id },
},
}).toContainExactly([row])
})
it("can filter by the related _id and find nothing", async () => {
await expectSearch({
query: {
equal: { "rel._id": "rel_none" },
},
}).toFindNothing()
})
})
})
!isInternal &&
describe("search by composite key", () => {
beforeAll(async () => {
const table = await config.api.table.save(
tableForDatasource(datasource, {
schema: {
idColumn1: {
name: "idColumn1",
type: FieldType.NUMBER,
},
idColumn2: {
name: "idColumn2",
type: FieldType.NUMBER,
},
},
primary: ["idColumn1", "idColumn2"],
})
)
tableOrViewId = table._id!
await createRows([{ idColumn1: 1, idColumn2: 2 }])
})
it("can filter by the row ID with limit 1", async () => {
await expectSearch({
query: {
equal: { _id: generateRowIdField([1, 2]) },
},
limit: 1,
}).toContain([
{
idColumn1: 1,
idColumn2: 2,
},
])
})
})
isSql &&
describe("primaryDisplay", () => {
beforeAll(async () => {
let toRelateTableId = await createTable({
name: {
name: "name",
type: FieldType.STRING,
},
})
tableOrViewId = await createTableOrView({
name: {
name: "name",
type: FieldType.STRING,
},
link: {
name: "link",
type: FieldType.LINK,
relationshipType: RelationshipType.MANY_TO_ONE,
tableId: toRelateTableId,
fieldName: "main",
},
})
const toRelateTable = await config.api.table.get(
toRelateTableId
)
await config.api.table.save({
...toRelateTable,
primaryDisplay: "name",
})
const relatedRows = await Promise.all([
config.api.row.save(toRelateTable._id!, {
name: "related",
}),
])
await config.api.row.save(tableOrViewId, {
name: "test",
link: relatedRows.map(row => row._id),
})
})
it("should be able to query, primary display on related table shouldn't be used", async () => {
// this test makes sure that if a relationship has been specified as the primary display on a table
// it is ignored and another column is used instead
await expectQuery({}).toContain([
{ name: "test", link: [{ primaryDisplay: "related" }] },
])
})
})
describe("$and", () => {
beforeAll(async () => {
tableOrViewId = await createTableOrView({
age: { name: "age", type: FieldType.NUMBER },
name: { name: "name", type: FieldType.STRING },
})
await createRows([
{ age: 1, name: "Jane" },
{ age: 10, name: "Jack" },
{ age: 7, name: "Hanna" },
{ age: 8, name: "Jan" },
])
})
it("successfully finds a row for one level condition", async () => {
await expectQuery({
$and: {
conditions: [
{ equal: { age: 10 } },
{ equal: { name: "Jack" } },
],
},
}).toContainExactly([{ age: 10, name: "Jack" }])
})
it("successfully finds a row for one level with multiple conditions", async () => {
await expectQuery({
$and: {
conditions: [
{ equal: { age: 10 } },
{ equal: { name: "Jack" } },
],
},
}).toContainExactly([{ age: 10, name: "Jack" }])
})
it("successfully finds multiple rows for one level with multiple conditions", async () => {
await expectQuery({
$and: {
conditions: [
{ range: { age: { low: 1, high: 9 } } },
{ string: { name: "Ja" } },
],
},
}).toContainExactly([
{ age: 1, name: "Jane" },
{ age: 8, name: "Jan" },
])
})
it("successfully finds rows for nested filters", async () => {
await expectQuery({
$and: {
conditions: [
{
$and: {
conditions: [
{
range: { age: { low: 1, high: 10 } },
},
{ string: { name: "Ja" } },
],
},
equal: { name: "Jane" },
},
],
},
}).toContainExactly([{ age: 1, name: "Jane" }])
})
it("returns nothing when filtering out all data", async () => {
await expectQuery({
$and: {
conditions: [
{ equal: { age: 7 } },
{ equal: { name: "Jack" } },
],
},
}).toFindNothing()
})
!isInMemory &&
it("validates conditions that are not objects", async () => {
await expect(
expectQuery({
$and: {
conditions: [
{ equal: { age: 10 } },
"invalidCondition" as any,
],
},
}).toFindNothing()
).rejects.toThrow(
'Invalid body - "query.$and.conditions[1]" must be of type object'
)
})
!isInMemory &&
it("validates $and without conditions", async () => {
await expect(
expectQuery({
$and: {
conditions: [
{ equal: { age: 10 } },
{
$and: {
conditions: undefined as any,
},
},
],
},
}).toFindNothing()
).rejects.toThrow(
'Invalid body - "query.$and.conditions[1].$and.conditions" is required'
)
})
// onEmptyFilter cannot be sent to view searches
!isView &&
it("returns no rows when onEmptyFilter set to none", async () => {
await expectSearch({
query: {
onEmptyFilter: EmptyFilterOption.RETURN_NONE,
$and: {
conditions: [{ equal: { name: "" } }],
},
},
}).toFindNothing()
})
it("returns all rows when onEmptyFilter set to all", async () => {
await expectSearch({
query: {
onEmptyFilter: EmptyFilterOption.RETURN_ALL,
$and: {
conditions: [{ equal: { name: "" } }],
},
},
}).toHaveLength(4)
})
})
describe("$or", () => {
beforeAll(async () => {
tableOrViewId = await createTableOrView({
age: { name: "age", type: FieldType.NUMBER },
name: { name: "name", type: FieldType.STRING },
})
await createRows([
{ age: 1, name: "Jane" },
{ age: 10, name: "Jack" },
{ age: 7, name: "Hanna" },
{ age: 8, name: "Jan" },
])
})
it("successfully finds a row for one level condition", async () => {
await expectQuery({
$or: {
conditions: [
{ equal: { age: 7 } },
{ equal: { name: "Jack" } },
],
},
}).toContainExactly([
{ age: 10, name: "Jack" },
{ age: 7, name: "Hanna" },
])
})
it("successfully finds a row for one level with multiple conditions", async () => {
await expectQuery({
$or: {
conditions: [
{ equal: { age: 7 } },
{ equal: { name: "Jack" } },
],
},
}).toContainExactly([
{ age: 10, name: "Jack" },
{ age: 7, name: "Hanna" },
])
})
it("successfully finds multiple rows for one level with multiple conditions", async () => {
await expectQuery({
$or: {
conditions: [
{ range: { age: { low: 1, high: 9 } } },
{ string: { name: "Jan" } },
],
},
}).toContainExactly([
{ age: 1, name: "Jane" },
{ age: 7, name: "Hanna" },
{ age: 8, name: "Jan" },
])
})
it("successfully finds rows for nested filters", async () => {
await expectQuery({
$or: {
conditions: [
{
$or: {
conditions: [
{
range: { age: { low: 1, high: 7 } },
},
{ string: { name: "Jan" } },
],
},
equal: { name: "Jane" },
},
],
},
}).toContainExactly([
{ age: 1, name: "Jane" },
{ age: 7, name: "Hanna" },
{ age: 8, name: "Jan" },
])
})
it("returns nothing when filtering out all data", async () => {
await expectQuery({
$or: {
conditions: [
{ equal: { age: 6 } },
{ equal: { name: "John" } },
],
},
}).toFindNothing()
})
it("can nest $and under $or filters", async () => {
await expectQuery({
$or: {
conditions: [
{
$and: {
conditions: [
{
range: { age: { low: 1, high: 8 } },
},
{ equal: { name: "Jan" } },
],
},
equal: { name: "Jane" },
},
],
},
}).toContainExactly([
{ age: 1, name: "Jane" },
{ age: 8, name: "Jan" },
])
})
it("can nest $or under $and filters", async () => {
await expectQuery({
$and: {
conditions: [
{
$or: {
conditions: [
{
range: { age: { low: 1, high: 8 } },
},
{ equal: { name: "Jan" } },
],
},
equal: { name: "Jane" },
},
],
},
}).toContainExactly([{ age: 1, name: "Jane" }])
})
// onEmptyFilter cannot be sent to view searches
!isView &&
it("returns no rows when onEmptyFilter set to none", async () => {
await expectSearch({
query: {
onEmptyFilter: EmptyFilterOption.RETURN_NONE,
$or: {
conditions: [{ equal: { name: "" } }],
},
},
}).toFindNothing()
})
it("returns all rows when onEmptyFilter set to all", async () => {
await expectSearch({
query: {
onEmptyFilter: EmptyFilterOption.RETURN_ALL,
$or: {
conditions: [{ equal: { name: "" } }],
},
},
}).toHaveLength(4)
})
})
isSql &&
describe("max related columns", () => {
let relatedRows: Row[]
beforeAll(async () => {
const relatedSchema: TableSchema = {}
const row: Row = {}
for (let i = 0; i < 100; i++) {
const name = `column${i}`
relatedSchema[name] = { name, type: FieldType.NUMBER }
row[name] = i
}
const relatedTable = await createTable(relatedSchema)
tableOrViewId = await createTableOrView({
name: { name: "name", type: FieldType.STRING },
related1: {
type: FieldType.LINK,
name: "related1",
fieldName: "main1",
tableId: relatedTable,
relationshipType: RelationshipType.MANY_TO_MANY,
},
})
relatedRows = await Promise.all([
config.api.row.save(relatedTable, row),
])
await config.api.row.save(tableOrViewId, {
name: "foo",
related1: [relatedRows[0]._id],
})
})
it("retrieve the row with relationships", async () => {
await expectQuery({}).toContainExactly([
{
name: "foo",
related1: [{ _id: relatedRows[0]._id }],
},
])
})
})
!isInternal &&
describe("SQL injection", () => {
const badStrings = [
"1; DROP TABLE %table_name%;",
"1; DELETE FROM %table_name%;",
"1; UPDATE %table_name% SET name = 'foo';",
"1; INSERT INTO %table_name% (name) VALUES ('foo');",
"' OR '1'='1' --",
"'; DROP TABLE %table_name%; --",
"' OR 1=1 --",
"' UNION SELECT null, null, null; --",
"' AND (SELECT COUNT(*) FROM %table_name%) > 0 --",
"\"; EXEC xp_cmdshell('dir'); --",
"\"' OR 'a'='a",
"OR 1=1;",
"'; SHUTDOWN --",
]
describe.each(badStrings)(
"bad string: %s",
badStringTemplate => {
// The SQL that knex generates when you try to use a double quote in a
// field name is always invalid and never works, so we skip it for these
// tests.
const skipFieldNameCheck =
isOracle && badStringTemplate.includes('"')
!skipFieldNameCheck &&
it("should not allow SQL injection as a field name", async () => {
const tableOrViewId = await createTableOrView()
const table = await getTable(tableOrViewId)
const badString = badStringTemplate.replace(
/%table_name%/g,
table.name
)
await config.api.table.save({
...table,
schema: {
...table.schema,
[badString]: {
name: badString,
type: FieldType.STRING,
},
},
})
if (docIds.isViewId(tableOrViewId)) {
const view = await config.api.viewV2.get(
tableOrViewId
)
await config.api.viewV2.update({
...view,
schema: {
[badString]: { visible: true },
},
})
}
await config.api.row.save(tableOrViewId, {
[badString]: "foo",
})
await assertTableExists(table)
await assertTableNumRows(table, 1)
const { rows } = await config.api.row.search(
tableOrViewId,
{ query: {} },
{ status: 200 }
)
expect(rows).toHaveLength(1)
await assertTableExists(table)
await assertTableNumRows(table, 1)
})
it("should not allow SQL injection as a field value", async () => {
const tableOrViewId = await createTableOrView({
foo: {
name: "foo",
type: FieldType.STRING,
},
})
const table = await getTable(tableOrViewId)
const badString = badStringTemplate.replace(
/%table_name%/g,
table.name
)
await config.api.row.save(tableOrViewId, { foo: "foo" })
await assertTableExists(table)
await assertTableNumRows(table, 1)
const { rows } = await config.api.row.search(
tableOrViewId,
{ query: { equal: { foo: badString } } },
{ status: 200 }
)
expect(rows).toBeEmpty()
await assertTableExists(table)
await assertTableNumRows(table, 1)
})
}
)
})
}
)
})
}
)
}