1
0
Fork 0
mirror of synced 2024-06-28 02:50:50 +12:00
budibase/packages/server/src/api/controllers/row/internalSearch.js

508 lines
13 KiB
JavaScript
Raw Normal View History

const { SearchIndexes } = require("../../../db/utils")
const { removeKeyNumbering } = require("./utils")
const fetch = require("node-fetch")
2022-04-28 03:56:30 +12:00
const { getCouchInfo } = require("@budibase/backend-core/db")
const { getAppId } = require("@budibase/backend-core/context")
2021-05-14 00:11:16 +12:00
/**
* Class to build lucene query URLs.
* Optionally takes a base lucene query object.
*/
class QueryBuilder {
constructor(base) {
this.query = {
allOr: false,
string: {},
fuzzy: {},
range: {},
equal: {},
notEqual: {},
empty: {},
notEmpty: {},
2022-06-25 08:15:41 +12:00
oneOf: {},
2022-07-20 04:28:56 +12:00
contains: {},
2022-07-27 04:21:58 +12:00
notContains: {},
2022-07-28 00:02:46 +12:00
containsAny: {},
...base,
}
this.limit = 50
this.sortOrder = "ascending"
this.sortType = "string"
this.includeDocs = true
2021-07-24 02:29:14 +12:00
this.version = null
}
setVersion(version) {
this.version = version
return this
}
setTable(tableId) {
this.query.equal.tableId = tableId
return this
}
setLimit(limit) {
if (limit != null) {
this.limit = limit
}
return this
}
setSort(sort) {
if (sort != null) {
this.sort = sort
}
return this
}
setSortOrder(sortOrder) {
if (sortOrder != null) {
this.sortOrder = sortOrder
}
return this
}
setSortType(sortType) {
if (sortType != null) {
this.sortType = sortType
}
return this
}
setBookmark(bookmark) {
this.bookmark = bookmark
return this
}
excludeDocs() {
this.includeDocs = false
return this
}
addString(key, partial) {
this.query.string[key] = partial
return this
}
addFuzzy(key, fuzzy) {
this.query.fuzzy[key] = fuzzy
return this
}
addRange(key, low, high) {
this.query.range = {
low,
high,
}
return this
}
addEqual(key, value) {
this.query.equal[key] = value
return this
}
addNotEqual(key, value) {
this.query.notEqual[key] = value
return this
}
addEmpty(key, value) {
this.query.empty[key] = value
return this
}
addNotEmpty(key, value) {
this.query.notEmpty[key] = value
return this
}
2022-06-25 08:15:41 +12:00
addOneOf(key, value) {
this.query.oneOf[key] = value
return this
}
addContains(key, value) {
this.query.contains[key] = value
return this
}
2022-07-27 04:21:58 +12:00
addNotContains(key, value) {
this.query.notContains[key] = value
return this
}
2022-07-28 00:02:46 +12:00
addContainsAny(key, value) {
this.query.containsAny[key] = value
return this
}
2021-07-24 02:29:14 +12:00
/**
* Preprocesses a value before going into a lucene search.
* Transforms strings to lowercase and wraps strings and bools in quotes.
* @param value The value to process
* @param options The preprocess options
* @returns {string|*}
*/
2022-10-05 04:28:29 +13:00
preprocess(value, { escape, lowercase, wrap, type } = {}) {
2021-07-24 02:29:14 +12:00
const hasVersion = !!this.version
// Determine if type needs wrapped
const originalType = typeof value
// Convert to lowercase
if (value && lowercase) {
value = value.toLowerCase ? value.toLowerCase() : value
}
// Escape characters
if (escape && originalType === "string") {
2021-08-26 22:39:42 +12:00
value = `${value}`.replace(/[ #+\-&|!(){}\]^"~*?:\\]/g, "\\$&")
2021-07-24 02:29:14 +12:00
}
2022-10-03 23:30:57 +13:00
2021-07-24 02:29:14 +12:00
// Wrap in quotes
2022-10-05 04:28:29 +13:00
if (originalType === "string" && !isNaN(value) && !type) {
2022-10-03 23:30:57 +13:00
value = `"${value}"`
} else if (hasVersion && wrap) {
2021-07-24 02:29:14 +12:00
value = originalType === "number" ? value : `"${value}"`
}
return value
}
buildSearchQuery() {
2021-07-24 02:29:14 +12:00
const builder = this
let allOr = this.query && this.query.allOr
let query = allOr ? "" : "*:*"
const allPreProcessingOpts = { escape: true, lowercase: true, wrap: true }
let tableId
if (this.query.equal.tableId) {
tableId = this.query.equal.tableId
delete this.query.equal.tableId
}
const equal = (key, value) => {
// 0 evaluates to false, which means we would return all rows if we don't check it
if (!value && value !== 0) {
return null
}
return `${key}:${builder.preprocess(value, allPreProcessingOpts)}`
}
2022-07-28 00:02:46 +12:00
const contains = (key, value, mode = "AND") => {
2022-08-16 01:11:15 +12:00
if (Array.isArray(value) && value.length === 0) {
return null
}
2022-08-16 01:11:15 +12:00
if (!Array.isArray(value)) {
2022-08-16 01:11:42 +12:00
return `${key}:${value}`
2022-08-16 01:11:15 +12:00
}
2022-07-28 00:02:46 +12:00
let statement = `${builder.preprocess(value[0], { escape: true })}`
2022-07-27 01:01:45 +12:00
for (let i = 1; i < value.length; i++) {
2022-07-28 19:56:52 +12:00
statement += ` ${mode} ${builder.preprocess(value[i], {
escape: true,
})}`
2022-07-27 01:01:45 +12:00
}
2022-07-28 00:02:46 +12:00
return `${key}:(${statement})`
}
2022-07-27 04:21:58 +12:00
const notContains = (key, value) => {
2022-08-16 01:11:15 +12:00
const allPrefix = allOr === "" ? "*:* AND" : ""
return allPrefix + "NOT " + contains(key, value)
2022-07-27 04:21:58 +12:00
}
2022-07-28 00:02:46 +12:00
const containsAny = (key, value) => {
return contains(key, value, "OR")
}
const oneOf = (key, value) => {
if (!Array.isArray(value)) {
if (typeof value === "string") {
value = value.split(",")
} else {
return ""
}
}
let orStatement = `${builder.preprocess(value[0], allPreProcessingOpts)}`
for (let i = 1; i < value.length; i++) {
orStatement += ` OR ${builder.preprocess(
value[i],
allPreProcessingOpts
)}`
}
return `${key}:(${orStatement})`
}
function build(structure, queryFn) {
for (let [key, value] of Object.entries(structure)) {
// check for new format - remove numbering if needed
key = removeKeyNumbering(key)
key = builder.preprocess(key.replace(/ /g, "_"), {
escape: true,
})
const expression = queryFn(key, value)
if (expression == null) {
continue
}
if (query.length > 0) {
query += ` ${allOr ? "OR" : "AND"} `
}
query += expression
}
}
2021-05-14 00:11:16 +12:00
// Construct the actual lucene search query string from JSON structure
if (this.query.string) {
build(this.query.string, (key, value) => {
if (!value) {
return null
}
2021-07-24 02:29:14 +12:00
value = builder.preprocess(value, {
escape: true,
lowercase: true,
2022-10-05 04:28:29 +13:00
type: "string",
})
return `${key}:${value}*`
})
}
if (this.query.range) {
build(this.query.range, (key, value) => {
if (!value) {
return null
}
if (value.low == null || value.low === "") {
return null
}
if (value.high == null || value.high === "") {
return null
}
2021-07-24 02:29:14 +12:00
const low = builder.preprocess(value.low, allPreProcessingOpts)
const high = builder.preprocess(value.high, allPreProcessingOpts)
return `${key}:[${low} TO ${high}]`
})
}
if (this.query.fuzzy) {
build(this.query.fuzzy, (key, value) => {
if (!value) {
return null
}
2021-07-24 02:29:14 +12:00
value = builder.preprocess(value, {
escape: true,
lowercase: true,
2022-10-05 04:28:29 +13:00
type: "fuzzy",
})
return `${key}:${value}~`
})
}
if (this.query.equal) {
build(this.query.equal, equal)
}
if (this.query.notEqual) {
build(this.query.notEqual, (key, value) => {
if (!value) {
return null
}
2021-07-24 02:29:14 +12:00
return `!${key}:${builder.preprocess(value, allPreProcessingOpts)}`
})
}
if (this.query.empty) {
2021-05-11 22:24:16 +12:00
build(this.query.empty, key => `!${key}:["" TO *]`)
}
if (this.query.notEmpty) {
2021-05-11 22:24:16 +12:00
build(this.query.notEmpty, key => `${key}:["" TO *]`)
}
2022-06-25 08:15:41 +12:00
if (this.query.oneOf) {
build(this.query.oneOf, oneOf)
}
if (this.query.contains) {
build(this.query.contains, contains)
2022-06-25 08:15:41 +12:00
}
2022-07-27 04:21:58 +12:00
if (this.query.notContains) {
build(this.query.notContains, notContains)
}
2022-07-28 00:02:46 +12:00
if (this.query.containsAny) {
build(this.query.containsAny, containsAny)
}
// make sure table ID is always added as an AND
if (tableId) {
query = `(${query})`
allOr = false
build({ tableId }, equal)
}
return query
}
buildSearchBody() {
let body = {
q: this.buildSearchQuery(),
limit: Math.min(this.limit, 200),
include_docs: this.includeDocs,
}
if (this.bookmark) {
body.bookmark = this.bookmark
}
if (this.sort) {
const order = this.sortOrder === "descending" ? "-" : ""
const type = `<${this.sortType}>`
body.sort = `${order}${this.sort.replace(/ /g, "_")}${type}`
}
return body
}
2021-05-14 00:11:16 +12:00
async run() {
const appId = getAppId()
2022-04-28 03:56:30 +12:00
const { url, cookie } = getCouchInfo()
const fullPath = `${url}/${appId}/_design/database/_search/${SearchIndexes.ROWS}`
const body = this.buildSearchBody()
2022-04-28 03:56:30 +12:00
return await runQuery(fullPath, body, cookie)
}
}
// exported for unit testing
exports.QueryBuilder = QueryBuilder
2021-05-14 00:11:16 +12:00
/**
* Executes a lucene search query.
* @param url The query URL
* @param body The request body defining search criteria
2022-04-28 03:56:30 +12:00
* @param cookie The auth cookie for CouchDB
2021-05-14 00:11:16 +12:00
* @returns {Promise<{rows: []}>}
*/
2022-04-28 03:56:30 +12:00
const runQuery = async (url, body, cookie) => {
const response = await fetch(url, {
body: JSON.stringify(body),
method: "POST",
2022-04-28 03:56:30 +12:00
headers: {
Authorization: cookie,
},
})
const json = await response.json()
let output = {
rows: [],
}
if (json.rows != null && json.rows.length > 0) {
2021-05-11 22:24:16 +12:00
output.rows = json.rows.map(row => row.doc)
}
if (json.bookmark) {
output.bookmark = json.bookmark
}
return output
}
2021-05-14 00:11:16 +12:00
/**
* Gets round the fixed limit of 200 results from a query by fetching as many
* pages as required and concatenating the results. This recursively operates
* until enough results have been found.
* @param query {object} The JSON query structure
* @param params {object} The search params including:
* tableId {string} The table ID to search
* sort {string} The sort column
* sortOrder {string} The sort order ("ascending" or "descending")
* sortType {string} Whether to treat sortable values as strings or
* numbers. ("string" or "number")
* limit {number} The number of results to fetch
* bookmark {string|null} Current bookmark in the recursive search
* rows {array|null} Current results in the recursive search
2021-05-14 00:11:16 +12:00
* @returns {Promise<*[]|*>}
*/
const recursiveSearch = async (query, params) => {
const bookmark = params.bookmark
const rows = params.rows || []
if (rows.length >= params.limit) {
return rows
}
let pageSize = 200
if (rows.length > params.limit - 200) {
pageSize = params.limit - rows.length
}
const page = await new QueryBuilder(query)
2021-07-24 02:29:14 +12:00
.setVersion(params.version)
.setTable(params.tableId)
.setBookmark(bookmark)
.setLimit(pageSize)
.setSort(params.sort)
.setSortOrder(params.sortOrder)
.setSortType(params.sortType)
.run()
if (!page.rows.length) {
return rows
}
if (page.rows.length < 200) {
return [...rows, ...page.rows]
}
const newParams = {
...params,
bookmark: page.bookmark,
rows: [...rows, ...page.rows],
}
return await recursiveSearch(query, newParams)
}
2021-05-14 00:11:16 +12:00
/**
* Performs a paginated search. A bookmark will be returned to allow the next
* page to be fetched. There is a max limit off 200 results per page in a
* paginated search.
* @param query {object} The JSON query structure
* @param params {object} The search params including:
* tableId {string} The table ID to search
* sort {string} The sort column
* sortOrder {string} The sort order ("ascending" or "descending")
* sortType {string} Whether to treat sortable values as strings or
* numbers. ("string" or "number")
* limit {number} The desired page size
* bookmark {string} The bookmark to resume from
2021-05-14 00:11:16 +12:00
* @returns {Promise<{hasNextPage: boolean, rows: *[]}>}
*/
exports.paginatedSearch = async (query, params) => {
let limit = params.limit
if (limit == null || isNaN(limit) || limit < 0) {
limit = 50
}
2021-05-14 00:11:16 +12:00
limit = Math.min(limit, 200)
const search = new QueryBuilder(query)
.setVersion(params.version)
.setTable(params.tableId)
.setSort(params.sort)
.setSortOrder(params.sortOrder)
.setSortType(params.sortType)
const searchResults = await search
.setBookmark(params.bookmark)
.setLimit(limit)
.run()
2021-05-14 00:11:16 +12:00
// Try fetching 1 row in the next page to see if another page of results
// exists or not
const nextResults = await search
.setTable(params.tableId)
.setBookmark(searchResults.bookmark)
.setLimit(1)
.run()
2021-05-14 00:11:16 +12:00
return {
...searchResults,
hasNextPage: nextResults.rows && nextResults.rows.length > 0,
}
}
2021-05-14 00:11:16 +12:00
/**
* Performs a full search, fetching multiple pages if required to return the
* desired amount of results. There is a limit of 1000 results to avoid
* heavy performance hits, and to avoid client components breaking from
* handling too much data.
* @param query {object} The JSON query structure
* @param params {object} The search params including:
* tableId {string} The table ID to search
* sort {string} The sort column
* sortOrder {string} The sort order ("ascending" or "descending")
* sortType {string} Whether to treat sortable values as strings or
* numbers. ("string" or "number")
* limit {number} The desired number of results
2021-05-14 00:11:16 +12:00
* @returns {Promise<{rows: *}>}
*/
exports.fullSearch = async (query, params) => {
let limit = params.limit
if (limit == null || isNaN(limit) || limit < 0) {
limit = 1000
}
params.limit = Math.min(limit, 1000)
const rows = await recursiveSearch(query, params)
return { rows }
}