diff --git a/packages/server/src/api/controllers/row/views.ts b/packages/server/src/api/controllers/row/views.ts index 8a1e5e08ce..5957531857 100644 --- a/packages/server/src/api/controllers/row/views.ts +++ b/packages/server/src/api/controllers/row/views.ts @@ -33,23 +33,26 @@ export async function searchView( .map(([key]) => key) const { body } = ctx.request + const sqsEnabled = await features.flags.isEnabled("SQS") + const supportsLogicalOperators = isExternalTableID(view.tableId) || sqsEnabled + // Enrich saved query with ephemeral query params. // We prevent searching on any fields that are saved as part of the query, as // that could let users find rows they should not be allowed to access. - let query: any = dataFilters.buildQuery(view.query) + let query: any = supportsLogicalOperators + ? dataFilters.buildQuery(view.query) + : dataFilters.buildQueryLegacy(view.query as SearchFilter[]) + + delete query?.onEmptyFilter + if (body.query) { // Delete extraneous search params that cannot be overridden delete body.query.onEmptyFilter - if ( - !isExternalTableID(view.tableId) && - !(await features.flags.isEnabled("SQS")) - ) { + if (!supportsLogicalOperators) { // In the unlikely event that a Grouped Filter is in a non-SQS environment // It needs to be ignored. Entirely - let queryFilters: SearchFilter[] = Array.isArray(view.query) - ? view.query - : [] + let queryFilters: SearchFilter[] = Array.isArray(query) ? query : [] // Extract existing fields const existingFields = diff --git a/packages/server/src/sdk/app/rows/queryUtils.ts b/packages/server/src/sdk/app/rows/queryUtils.ts index 65f400a1d9..a73992bcee 100644 --- a/packages/server/src/sdk/app/rows/queryUtils.ts +++ b/packages/server/src/sdk/app/rows/queryUtils.ts @@ -15,7 +15,9 @@ export const removeInvalidFilters = ( const result = cloneDeep(filters) validFields = validFields.map(f => f.toLowerCase()) - for (const filterKey of Object.keys(result) as (keyof SearchFilters)[]) { + for (const filterKey of Object.keys( + result || {} + ) as (keyof SearchFilters)[]) { const filter = result[filterKey] if (!filter || typeof filter !== "object") { continue @@ -24,7 +26,7 @@ export const removeInvalidFilters = ( const resultingConditions: SearchFilters[] = [] for (const condition of filter.conditions) { const resultingCondition = removeInvalidFilters(condition, validFields) - if (Object.keys(resultingCondition).length) { + if (Object.keys(resultingCondition || {}).length) { resultingConditions.push(resultingCondition) } } diff --git a/packages/shared-core/src/filters.ts b/packages/shared-core/src/filters.ts index 97e1212401..63d3044da9 100644 --- a/packages/shared-core/src/filters.ts +++ b/packages/shared-core/src/filters.ts @@ -428,6 +428,126 @@ const builderFilter = (expression: SearchFilter) => { return query } +export const buildQueryLegacy = (filter: SearchFilter[]) => { + // + let query: SearchFilters = { + string: {}, + fuzzy: {}, + range: {}, + equal: {}, + notEqual: {}, + empty: {}, + notEmpty: {}, + contains: {}, + notContains: {}, + oneOf: {}, + containsAny: {}, + } + + if (!Array.isArray(filter)) { + return query + } + + filter.forEach(expression => { + let { operator, field, type, value, externalType, onEmptyFilter } = + expression + const queryOperator = operator as SearchFilterOperator + const isHbs = + typeof value === "string" && (value.match(HBS_REGEX) || []).length > 0 + // Parse all values into correct types + if (operator === "allOr") { + query.allOr = true + return + } + if (onEmptyFilter) { + query.onEmptyFilter = onEmptyFilter + return + } + if ( + type === "datetime" && + !isHbs && + queryOperator !== "empty" && + queryOperator !== "notEmpty" + ) { + // Ensure date value is a valid date and parse into correct format + if (!value) { + return + } + try { + value = new Date(value).toISOString() + } catch (error) { + return + } + } + if (type === "number" && typeof value === "string" && !isHbs) { + if (queryOperator === "oneOf") { + value = value.split(",").map(item => parseFloat(item)) + } else { + value = parseFloat(value) + } + } + if (type === "boolean") { + value = `${value}`?.toLowerCase() === "true" + } + if ( + ["contains", "notContains", "containsAny"].includes(operator) && + type === "array" && + typeof value === "string" + ) { + value = value.split(",") + } + if (operator.startsWith("range") && query.range) { + const minint = + SqlNumberTypeRangeMap[ + externalType as keyof typeof SqlNumberTypeRangeMap + ]?.min || Number.MIN_SAFE_INTEGER + const maxint = + SqlNumberTypeRangeMap[ + externalType as keyof typeof SqlNumberTypeRangeMap + ]?.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.range[field] = { + ...query.range[field], + low: value, + } + } else if (operator === "rangeHigh" && value != null && value !== "") { + query.range[field] = { + ...query.range[field], + high: value, + } + } + } else if (isLogicalSearchOperator(queryOperator)) { + // TODO + } else if (query[queryOperator] && operator !== "onEmptyFilter") { + if (type === "boolean") { + // Transform boolean filters to cope with null. + // "equals false" needs to be "not equals true" + // "not equals false" needs to be "equals true" + if (queryOperator === "equal" && value === false) { + query.notEqual = query.notEqual || {} + query.notEqual[field] = true + } else if (queryOperator === "notEqual" && value === false) { + query.equal = query.equal || {} + query.equal[field] = true + } else { + query[queryOperator] ??= {} + query[queryOperator]![field] = value + } + } else { + query[queryOperator] ??= {} + query[queryOperator]![field] = value + } + } + }) + return query +} + export const buildQuery = (filter?: SearchFilterGroup | SearchFilter[]) => { if (!filter) { return @@ -891,9 +1011,23 @@ export const hasFilters = (query?: SearchFilters) => { } const queryRoot = query[LogicalOperator.AND] ?? query[LogicalOperator.OR] + if (!queryRoot) { + const skipped = ["allOr", "onEmptyFilter"] + for (let [key, value] of Object.entries(query)) { + if (skipped.includes(key) || typeof value !== "object") { + continue + } + if (Object.keys(value || {}).length !== 0) { + return true + } + } + return false + } + return ( (queryRoot?.conditions || []).reduce((acc, group) => { - const groupRoot = group[LogicalOperator.AND] ?? group[LogicalOperator.OR] + const groupRoot = + group?.[LogicalOperator.AND] ?? group?.[LogicalOperator.OR] acc += groupRoot?.conditions?.length || 0 return acc }, 0) > 0