Making progress toward testing buildCondition and friends.

This commit is contained in:
Sam Rose 2024-10-17 11:54:34 +01:00
parent 31c0ed69f1
commit cb41861d13
No known key found for this signature in database
8 changed files with 156 additions and 83 deletions

View File

@ -27,6 +27,8 @@ import {
ViewV2Schema, ViewV2Schema,
ViewV2Type, ViewV2Type,
JsonTypes, JsonTypes,
FilterGroupLogicalOperator,
EmptyFilterOption,
} from "@budibase/types" } from "@budibase/types"
import { generator, mocks } from "@budibase/backend-core/tests" import { generator, mocks } from "@budibase/backend-core/tests"
import { DatabaseName, getDatasource } from "../../../integrations/tests/utils" import { DatabaseName, getDatasource } from "../../../integrations/tests/utils"
@ -145,17 +147,26 @@ describe.each([
}) })
it.only("can persist views with all fields", async () => { it.only("can persist views with all fields", async () => {
const newView: Required<Omit<CreateViewRequest, "queryUI" | "type">> = { const newView: Required<Omit<CreateViewRequest, "query" | "type">> = {
name: generator.name(), name: generator.name(),
tableId: table._id!, tableId: table._id!,
primaryDisplay: "id", primaryDisplay: "id",
query: [ queryUI: {
logicalOperator: FilterGroupLogicalOperator.ALL,
onEmptyFilter: EmptyFilterOption.RETURN_ALL,
groups: [
{
logicalOperator: FilterGroupLogicalOperator.ALL,
filters: [
{ {
operator: BasicOperator.EQUAL, operator: BasicOperator.EQUAL,
field: "field", field: "field",
value: "value", value: "value",
}, },
], ],
},
],
},
sort: { sort: {
field: "fieldToSort", field: "fieldToSort",
order: SortOrder.DESCENDING, order: SortOrder.DESCENDING,
@ -170,7 +181,7 @@ describe.each([
} }
const res = await config.api.viewV2.create(newView) const res = await config.api.viewV2.create(newView)
expect(res).toEqual({ const expected: ViewV2 = {
...newView, ...newView,
schema: { schema: {
id: { visible: true }, id: { visible: true },
@ -178,10 +189,29 @@ describe.each([
visible: true, visible: true,
}, },
}, },
queryUI: {}, query: {
onEmptyFilter: EmptyFilterOption.RETURN_ALL,
$and: {
conditions: [
{
$and: {
conditions: [
{
equal: {
field: "value",
},
},
],
},
},
],
},
},
id: expect.any(String), id: expect.any(String),
version: 2, version: 2,
}) }
expect(res).toEqual(expected)
}) })
it("persist only UI schema overrides", async () => { it("persist only UI schema overrides", async () => {

View File

@ -5,6 +5,7 @@ import sdk from "../../../sdk"
import * as utils from "../../../db/utils" import * as utils from "../../../db/utils"
import { enrichSchema, isV2 } from "." import { enrichSchema, isV2 } from "."
import { breakExternalTableId } from "../../../integrations/utils" import { breakExternalTableId } from "../../../integrations/utils"
import { ensureQuerySet, ensureQueryUISet } from "./utils"
export async function get(viewId: string): Promise<ViewV2> { export async function get(viewId: string): Promise<ViewV2> {
const { tableId } = utils.extractViewInfoFromID(viewId) const { tableId } = utils.extractViewInfoFromID(viewId)
@ -18,6 +19,7 @@ export async function get(viewId: string): Promise<ViewV2> {
if (!found) { if (!found) {
throw new Error("No view found") throw new Error("No view found")
} }
ensureQueryUISet(found)
return found return found
} }
@ -33,6 +35,7 @@ export async function getEnriched(viewId: string): Promise<ViewV2Enriched> {
if (!found) { if (!found) {
throw new Error("No view found") throw new Error("No view found")
} }
ensureQueryUISet(found)
return await enrichSchema(found, table.schema) return await enrichSchema(found, table.schema)
} }
@ -46,6 +49,8 @@ export async function create(
version: 2, version: 2,
} }
ensureQuerySet(view)
const db = context.getAppDB() const db = context.getAppDB()
const { datasourceId, tableName } = breakExternalTableId(tableId) const { datasourceId, tableName } = breakExternalTableId(tableId)
@ -74,6 +79,8 @@ export async function update(tableId: string, view: ViewV2): Promise<ViewV2> {
throw new HTTPError(`Cannot update view type after creation`, 400) throw new HTTPError(`Cannot update view type after creation`, 400)
} }
ensureQuerySet(view)
delete views[existingView.name] delete views[existingView.name]
views[view.name] = view views[view.name] = view
await db.put(ds) await db.put(ds)

View File

@ -4,19 +4,7 @@ import { context, HTTPError } from "@budibase/backend-core"
import sdk from "../../../sdk" import sdk from "../../../sdk"
import * as utils from "../../../db/utils" import * as utils from "../../../db/utils"
import { enrichSchema, isV2 } from "." import { enrichSchema, isV2 } from "."
import { utils as sharedUtils, dataFilters } from "@budibase/shared-core" import { ensureQuerySet, ensureQueryUISet } from "./utils"
function ensureQueryUISet(view: ViewV2) {
if (!view.queryUI && view.query) {
view.queryUI = sharedUtils.processSearchFilters(view.query)
}
}
function ensureQuerySet(view: ViewV2) {
if (!view.query && view.queryUI) {
view.query = dataFilters.buildQuery(view.queryUI)
}
}
export async function get(viewId: string): Promise<ViewV2> { export async function get(viewId: string): Promise<ViewV2> {
const { tableId } = utils.extractViewInfoFromID(viewId) const { tableId } = utils.extractViewInfoFromID(viewId)
@ -52,6 +40,8 @@ export async function create(
version: 2, version: 2,
} }
ensureQuerySet(view)
const db = context.getAppDB() const db = context.getAppDB()
const table = await sdk.tables.getTable(tableId) const table = await sdk.tables.getTable(tableId)
@ -78,6 +68,8 @@ export async function update(tableId: string, view: ViewV2): Promise<ViewV2> {
throw new HTTPError(`Cannot update view type after creation`, 400) throw new HTTPError(`Cannot update view type after creation`, 400)
} }
ensureQuerySet(view)
delete table.views[existingView.name] delete table.views[existingView.name]
table.views[view.name] = view table.views[view.name] = view
await db.put(table) await db.put(table)

View File

@ -0,0 +1,30 @@
import { ViewV2 } from "@budibase/types"
import { utils, dataFilters } from "@budibase/shared-core"
export function ensureQueryUISet(view: ViewV2) {
if (!view.queryUI && view.query) {
if (!Array.isArray(view.query)) {
// In practice this should not happen. `view.query`, at the time this code
// goes into the codebase, only contains LegacyFilter[] in production.
// We're changing it in the change that this comment is part of to also
// include SearchFilters objects. These are created when we receive an
// update to a ViewV2 that contains a queryUI and not a query field. We
// can convert SearchFilterGroup (the type of queryUI) to SearchFilters,
// but not LegacyFilter[], they are incompatible due to SearchFilterGroup
// and SearchFilters being recursive types.
//
// So despite the type saying that `view.query` is a LegacyFilter[] |
// SearchFilters, it will never be a SearchFilters when a `view.queryUI`
// is specified, making it "safe" to throw an error here.
throw new Error("view is missing queryUI field")
}
view.queryUI = utils.processSearchFilters(view.query)
}
}
export function ensureQuerySet(view: ViewV2) {
if (!view.query && view.queryUI) {
view.query = dataFilters.buildQuery(view.queryUI)
}
}

View File

@ -21,6 +21,9 @@ import {
isLogicalSearchOperator, isLogicalSearchOperator,
SearchFilterGroup, SearchFilterGroup,
FilterGroupLogicalOperator, FilterGroupLogicalOperator,
isBasicSearchOperator,
isArraySearchOperator,
isRangeSearchOperator,
} from "@budibase/types" } from "@budibase/types"
import dayjs from "dayjs" import dayjs from "dayjs"
import { OperatorOptions, SqlNumberTypeRangeMap } from "./constants" import { OperatorOptions, SqlNumberTypeRangeMap } from "./constants"
@ -307,36 +310,23 @@ export class ColumnSplitter {
* Builds a JSON query from the filter a SearchFilter definition * Builds a JSON query from the filter a SearchFilter definition
* @param filter the builder filter structure * @param filter the builder filter structure
*/ */
const buildCondition = (expression: LegacyFilter) => { const buildCondition = (expression: LegacyFilter) => {
// Filter body const query: SearchFilters = {}
let query: SearchFilters = { const { operator, field, type, externalType, onEmptyFilter } = expression
string: {}, let { value } = expression
fuzzy: {},
range: {},
equal: {},
notEqual: {},
empty: {},
notEmpty: {},
contains: {},
notContains: {},
oneOf: {},
containsAny: {},
}
let { operator, field, type, value, externalType, onEmptyFilter } = expression
if (!operator || !field) { if (!operator || !field) {
return return
} }
const queryOperator = operator as SearchFilterOperator
const isHbs = const isHbs =
typeof value === "string" && (value.match(HBS_REGEX) || []).length > 0 typeof value === "string" && (value.match(HBS_REGEX) || []).length > 0
// Parse all values into correct types
if (operator === "allOr") { if (operator === "allOr") {
query.allOr = true query.allOr = true
return return
} }
if (onEmptyFilter) { if (onEmptyFilter) {
query.onEmptyFilter = onEmptyFilter query.onEmptyFilter = onEmptyFilter
return return
@ -344,15 +334,15 @@ const buildCondition = (expression: LegacyFilter) => {
// Default the value for noValue fields to ensure they are correctly added // Default the value for noValue fields to ensure they are correctly added
// to the final query // to the final query
if (queryOperator === "empty" || queryOperator === "notEmpty") { if (operator === "empty" || operator === "notEmpty") {
value = null value = null
} }
if ( if (
type === "datetime" && type === "datetime" &&
!isHbs && !isHbs &&
queryOperator !== "empty" && operator !== "empty" &&
queryOperator !== "notEmpty" operator !== "notEmpty"
) { ) {
// Ensure date value is a valid date and parse into correct format // Ensure date value is a valid date and parse into correct format
if (!value) { if (!value) {
@ -365,7 +355,7 @@ const buildCondition = (expression: LegacyFilter) => {
} }
} }
if (type === "number" && typeof value === "string" && !isHbs) { if (type === "number" && typeof value === "string" && !isHbs) {
if (queryOperator === "oneOf") { if (operator === "oneOf") {
value = value.split(",").map(item => parseFloat(item)) value = value.split(",").map(item => parseFloat(item))
} else { } else {
value = parseFloat(value) value = parseFloat(value)
@ -383,50 +373,55 @@ const buildCondition = (expression: LegacyFilter) => {
) { ) {
value = value.split(",") value = value.split(",")
} }
if (operator.toLocaleString().startsWith("range") && query.range) {
const minint = if (isRangeSearchOperator(operator)) {
SqlNumberTypeRangeMap[externalType as keyof typeof SqlNumberTypeRangeMap] const key = externalType as keyof typeof SqlNumberTypeRangeMap
?.min || Number.MIN_SAFE_INTEGER const limits = SqlNumberTypeRangeMap[key] || {
const maxint = min: Number.MIN_SAFE_INTEGER,
SqlNumberTypeRangeMap[externalType as keyof typeof SqlNumberTypeRangeMap] max: Number.MAX_SAFE_INTEGER,
?.max || Number.MAX_SAFE_INTEGER
if (!query.range[field]) {
query.range[field] = {
low: type === "number" ? minint : "0000-00-00T00:00:00.000Z",
high: type === "number" ? maxint : "9999-00-00T00:00:00.000Z",
} }
}
if (operator === "rangeLow" && value != null && value !== "") { query[operator] ??= {}
query.range[field] = { query[operator][field] = {
...query.range[field], low: type === "number" ? limits.min : "0000-00-00T00:00:00.000Z",
low: value, high: type === "number" ? limits.max : "9999-00-00T00:00:00.000Z",
} }
} else if (operator === "rangeHigh" && value != null && value !== "") { } else if (operator === "rangeHigh" && value != null && value !== "") {
query.range ??= {}
query.range[field] = { query.range[field] = {
...query.range[field], ...query.range[field],
high: value, high: value,
} }
} else if (operator === "rangeLow" && value != null && value !== "") {
query.range ??= {}
query.range[field] = {
...query.range[field],
low: value,
} }
} else if (isLogicalSearchOperator(queryOperator)) { } else if (isLogicalSearchOperator(operator)) {
// TODO // TODO
} else if (query[queryOperator] && operator !== "onEmptyFilter") { } else if (
isBasicSearchOperator(operator) ||
isArraySearchOperator(operator) ||
isRangeSearchOperator(operator)
) {
if (type === "boolean") { if (type === "boolean") {
// Transform boolean filters to cope with null. // Transform boolean filters to cope with null.
// "equals false" needs to be "not equals true" // "equals false" needs to be "not equals true"
// "not equals false" needs to be "equals true" // "not equals false" needs to be "equals true"
if (queryOperator === "equal" && value === false) { if (operator === "equal" && value === false) {
query.notEqual = query.notEqual || {} query.notEqual = query.notEqual || {}
query.notEqual[field] = true query.notEqual[field] = true
} else if (queryOperator === "notEqual" && value === false) { } else if (operator === "notEqual" && value === false) {
query.equal = query.equal || {} query.equal = query.equal || {}
query.equal[field] = true query.equal[field] = true
} else { } else {
query[queryOperator] ??= {} query[operator] ??= {}
query[queryOperator]![field] = value query[operator][field] = value
} }
} else { } else {
query[queryOperator] ??= {} query[operator] ??= {}
query[queryOperator]![field] = value query[operator][field] = value
} }
} }
@ -604,7 +599,7 @@ export function buildQuery(
return { return {
...(globalOnEmpty ? { onEmptyFilter: globalOnEmpty } : {}), ...(globalOnEmpty ? { onEmptyFilter: globalOnEmpty } : {}),
[globalOperator]: { [globalOperator]: {
conditions: parsedFilter.groups?.map((group: SearchFilterGroup) => { conditions: parsedFilter.groups?.map(group => {
return { return {
[operatorMap[group.logicalOperator]]: { [operatorMap[group.logicalOperator]]: {
conditions: group.filters conditions: group.filters

View File

@ -6,6 +6,8 @@ import {
BasicOperator, BasicOperator,
ArrayOperator, ArrayOperator,
isLogicalSearchOperator, isLogicalSearchOperator,
EmptyFilterOption,
SearchFilterChild,
} from "@budibase/types" } from "@budibase/types"
import * as Constants from "./constants" import * as Constants from "./constants"
import { removeKeyNumbering } from "./filters" import { removeKeyNumbering } from "./filters"
@ -142,6 +144,7 @@ export const processSearchFilters = (
// Base search config. // Base search config.
const defaultCfg: SearchFilterGroup = { const defaultCfg: SearchFilterGroup = {
logicalOperator: FilterGroupLogicalOperator.ALL, logicalOperator: FilterGroupLogicalOperator.ALL,
onEmptyFilter: EmptyFilterOption.RETURN_ALL,
groups: [], groups: [],
} }
@ -156,8 +159,7 @@ export const processSearchFilters = (
"formulaType", "formulaType",
] ]
let baseGroup: SearchFilterGroup = { let baseGroup: SearchFilterChild = {
filters: [],
logicalOperator: FilterGroupLogicalOperator.ALL, logicalOperator: FilterGroupLogicalOperator.ALL,
} }

View File

@ -14,10 +14,15 @@ export type LegacyFilter = {
externalType?: string externalType?: string
} }
export type SearchFilterChild = {
logicalOperator: FilterGroupLogicalOperator
groups?: SearchFilterChild[]
filters?: LegacyFilter[]
}
// this is a type purely used by the UI // this is a type purely used by the UI
export type SearchFilterGroup = { export type SearchFilterGroup = {
logicalOperator: FilterGroupLogicalOperator logicalOperator: FilterGroupLogicalOperator
onEmptyFilter?: EmptyFilterOption onEmptyFilter: EmptyFilterOption
groups?: SearchFilterGroup[] groups: SearchFilterChild[]
filters?: LegacyFilter[]
} }

View File

@ -32,7 +32,19 @@ export enum LogicalOperator {
export function isLogicalSearchOperator( export function isLogicalSearchOperator(
value: string value: string
): value is LogicalOperator { ): value is LogicalOperator {
return value === LogicalOperator.AND || value === LogicalOperator.OR return Object.values(LogicalOperator).includes(value as LogicalOperator)
}
export function isBasicSearchOperator(value: string): value is BasicOperator {
return Object.values(BasicOperator).includes(value as BasicOperator)
}
export function isArraySearchOperator(value: string): value is ArrayOperator {
return Object.values(ArrayOperator).includes(value as ArrayOperator)
}
export function isRangeSearchOperator(value: string): value is RangeOperator {
return Object.values(RangeOperator).includes(value as RangeOperator)
} }
export type SearchFilterOperator = export type SearchFilterOperator =