2022-01-18 10:39:19 +01:00
|
|
|
import { deepGet } from "./helpers"
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Operator options for lucene queries
|
|
|
|
*/
|
|
|
|
export const OperatorOptions = {
|
|
|
|
Equals: {
|
|
|
|
value: "equal",
|
|
|
|
label: "Equals",
|
|
|
|
},
|
|
|
|
NotEquals: {
|
|
|
|
value: "notEqual",
|
|
|
|
label: "Not equals",
|
|
|
|
},
|
|
|
|
Empty: {
|
|
|
|
value: "empty",
|
|
|
|
label: "Is empty",
|
|
|
|
},
|
|
|
|
NotEmpty: {
|
|
|
|
value: "notEmpty",
|
|
|
|
label: "Is not empty",
|
|
|
|
},
|
|
|
|
StartsWith: {
|
|
|
|
value: "string",
|
|
|
|
label: "Starts with",
|
|
|
|
},
|
|
|
|
Like: {
|
|
|
|
value: "fuzzy",
|
|
|
|
label: "Like",
|
|
|
|
},
|
|
|
|
MoreThan: {
|
|
|
|
value: "rangeLow",
|
|
|
|
label: "More than",
|
|
|
|
},
|
|
|
|
LessThan: {
|
|
|
|
value: "rangeHigh",
|
|
|
|
label: "Less than",
|
|
|
|
},
|
|
|
|
Contains: {
|
|
|
|
value: "equal",
|
|
|
|
label: "Contains",
|
|
|
|
},
|
|
|
|
NotContains: {
|
|
|
|
value: "notEqual",
|
|
|
|
label: "Does Not Contain",
|
|
|
|
},
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Operators which do not support empty strings as values
|
|
|
|
*/
|
|
|
|
export const NoEmptyFilterStrings = [
|
|
|
|
OperatorOptions.StartsWith.value,
|
|
|
|
OperatorOptions.Like.value,
|
|
|
|
OperatorOptions.Equals.value,
|
|
|
|
OperatorOptions.NotEquals.value,
|
|
|
|
OperatorOptions.Contains.value,
|
|
|
|
OperatorOptions.NotContains.value,
|
|
|
|
]
|
2021-10-06 18:38:32 +02:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Removes any fields that contain empty strings that would cause inconsistent
|
|
|
|
* behaviour with how backend tables are filtered (no value means no filter).
|
|
|
|
*/
|
2022-01-18 10:39:19 +01:00
|
|
|
const cleanupQuery = query => {
|
2021-10-06 18:38:32 +02:00
|
|
|
if (!query) {
|
|
|
|
return query
|
|
|
|
}
|
|
|
|
for (let filterField of NoEmptyFilterStrings) {
|
|
|
|
if (!query[filterField]) {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
for (let [key, value] of Object.entries(query[filterField])) {
|
|
|
|
if (!value || value === "") {
|
|
|
|
delete query[filterField][key]
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return query
|
|
|
|
}
|
|
|
|
|
2021-09-27 13:59:49 +02:00
|
|
|
/**
|
|
|
|
* Builds a lucene JSON query from the filter structure generated in the builder
|
|
|
|
* @param filter the builder filter structure
|
|
|
|
*/
|
|
|
|
export const buildLuceneQuery = filter => {
|
|
|
|
let query = {
|
|
|
|
string: {},
|
|
|
|
fuzzy: {},
|
|
|
|
range: {},
|
|
|
|
equal: {},
|
|
|
|
notEqual: {},
|
|
|
|
empty: {},
|
|
|
|
notEmpty: {},
|
|
|
|
contains: {},
|
|
|
|
notContains: {},
|
|
|
|
}
|
|
|
|
if (Array.isArray(filter)) {
|
|
|
|
filter.forEach(expression => {
|
|
|
|
let { operator, field, type, value } = expression
|
|
|
|
// Parse all values into correct types
|
|
|
|
if (type === "datetime" && value) {
|
|
|
|
value = new Date(value).toISOString()
|
|
|
|
}
|
|
|
|
if (type === "number") {
|
|
|
|
value = parseFloat(value)
|
|
|
|
}
|
|
|
|
if (type === "boolean") {
|
|
|
|
value = `${value}`?.toLowerCase() === "true"
|
|
|
|
}
|
|
|
|
if (operator.startsWith("range")) {
|
|
|
|
if (!query.range[field]) {
|
|
|
|
query.range[field] = {
|
|
|
|
low:
|
|
|
|
type === "number"
|
|
|
|
? Number.MIN_SAFE_INTEGER
|
|
|
|
: "0000-00-00T00:00:00.000Z",
|
|
|
|
high:
|
|
|
|
type === "number"
|
|
|
|
? Number.MAX_SAFE_INTEGER
|
|
|
|
: "9999-00-00T00:00:00.000Z",
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if (operator === "rangeLow" && value != null && value !== "") {
|
|
|
|
query.range[field].low = value
|
|
|
|
} else if (operator === "rangeHigh" && value != null && value !== "") {
|
|
|
|
query.range[field].high = value
|
|
|
|
}
|
|
|
|
} else if (query[operator]) {
|
|
|
|
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 (operator === "equal" && value === false) {
|
|
|
|
query.notEqual[field] = true
|
|
|
|
} else if (operator === "notEqual" && value === false) {
|
|
|
|
query.equal[field] = true
|
|
|
|
} else {
|
|
|
|
query[operator][field] = value
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
query[operator][field] = value
|
|
|
|
}
|
|
|
|
}
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
return query
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Performs a client-side lucene search on an array of data
|
|
|
|
* @param docs the data
|
|
|
|
* @param query the JSON lucene query
|
|
|
|
*/
|
2022-01-18 10:39:19 +01:00
|
|
|
export const runLuceneQuery = (docs, query) => {
|
2021-12-07 14:58:59 +01:00
|
|
|
if (!docs || !Array.isArray(docs)) {
|
|
|
|
return []
|
|
|
|
}
|
2021-09-27 13:59:49 +02:00
|
|
|
if (!query) {
|
|
|
|
return docs
|
|
|
|
}
|
2021-12-06 13:04:22 +01:00
|
|
|
|
2021-10-06 18:38:32 +02:00
|
|
|
// make query consistent first
|
|
|
|
query = cleanupQuery(query)
|
2021-09-27 13:59:49 +02:00
|
|
|
|
|
|
|
// Iterates over a set of filters and evaluates a fail function against a doc
|
|
|
|
const match = (type, failFn) => doc => {
|
|
|
|
const filters = Object.entries(query[type] || {})
|
|
|
|
for (let i = 0; i < filters.length; i++) {
|
2021-12-06 13:04:22 +01:00
|
|
|
const [key, testValue] = filters[i]
|
|
|
|
const docValue = deepGet(doc, key)
|
|
|
|
if (failFn(docValue, testValue)) {
|
2021-09-27 13:59:49 +02:00
|
|
|
return false
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return true
|
|
|
|
}
|
|
|
|
|
|
|
|
// Process a string match (fails if the value does not start with the string)
|
2021-12-06 13:04:22 +01:00
|
|
|
const stringMatch = match("string", (docValue, testValue) => {
|
2022-01-04 15:34:09 +01:00
|
|
|
return (
|
|
|
|
!docValue || !docValue?.toLowerCase().startsWith(testValue?.toLowerCase())
|
|
|
|
)
|
2021-09-27 13:59:49 +02:00
|
|
|
})
|
|
|
|
|
|
|
|
// Process a fuzzy match (treat the same as starts with when running locally)
|
2021-12-06 13:04:22 +01:00
|
|
|
const fuzzyMatch = match("fuzzy", (docValue, testValue) => {
|
2022-01-04 15:34:09 +01:00
|
|
|
return (
|
|
|
|
!docValue || !docValue?.toLowerCase().startsWith(testValue?.toLowerCase())
|
|
|
|
)
|
2021-09-27 13:59:49 +02:00
|
|
|
})
|
|
|
|
|
|
|
|
// Process a range match
|
2021-12-06 13:04:22 +01:00
|
|
|
const rangeMatch = match("range", (docValue, testValue) => {
|
|
|
|
return !docValue || docValue < testValue.low || docValue > testValue.high
|
2021-09-27 13:59:49 +02:00
|
|
|
})
|
|
|
|
|
|
|
|
// Process an equal match (fails if the value is different)
|
2021-12-06 13:04:22 +01:00
|
|
|
const equalMatch = match("equal", (docValue, testValue) => {
|
|
|
|
return testValue != null && testValue !== "" && docValue !== testValue
|
2021-09-27 13:59:49 +02:00
|
|
|
})
|
|
|
|
|
|
|
|
// Process a not-equal match (fails if the value is the same)
|
2021-12-06 13:04:22 +01:00
|
|
|
const notEqualMatch = match("notEqual", (docValue, testValue) => {
|
|
|
|
return testValue != null && testValue !== "" && docValue === testValue
|
2021-09-27 13:59:49 +02:00
|
|
|
})
|
|
|
|
|
|
|
|
// Process an empty match (fails if the value is not empty)
|
2021-12-06 13:04:22 +01:00
|
|
|
const emptyMatch = match("empty", docValue => {
|
|
|
|
return docValue != null && docValue !== ""
|
2021-09-27 13:59:49 +02:00
|
|
|
})
|
|
|
|
|
|
|
|
// Process a not-empty match (fails is the value is empty)
|
2021-12-06 13:04:22 +01:00
|
|
|
const notEmptyMatch = match("notEmpty", docValue => {
|
|
|
|
return docValue == null || docValue === ""
|
2021-09-27 13:59:49 +02:00
|
|
|
})
|
|
|
|
|
|
|
|
// Match a document against all criteria
|
|
|
|
const docMatch = doc => {
|
|
|
|
return (
|
|
|
|
stringMatch(doc) &&
|
|
|
|
fuzzyMatch(doc) &&
|
|
|
|
rangeMatch(doc) &&
|
|
|
|
equalMatch(doc) &&
|
|
|
|
notEqualMatch(doc) &&
|
|
|
|
emptyMatch(doc) &&
|
|
|
|
notEmptyMatch(doc)
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
|
|
|
// Process all docs
|
|
|
|
return docs.filter(docMatch)
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Performs a client-side sort from the equivalent server-side lucene sort
|
|
|
|
* parameters.
|
|
|
|
* @param docs the data
|
|
|
|
* @param sort the sort column
|
|
|
|
* @param sortOrder the sort order ("ascending" or "descending")
|
|
|
|
* @param sortType the type of sort ("string" or "number")
|
|
|
|
*/
|
|
|
|
export const luceneSort = (docs, sort, sortOrder, sortType = "string") => {
|
|
|
|
if (!sort || !sortOrder || !sortType) {
|
|
|
|
return docs
|
|
|
|
}
|
|
|
|
const parse = sortType === "string" ? x => `${x}` : x => parseFloat(x)
|
|
|
|
return docs.slice().sort((a, b) => {
|
|
|
|
const colA = parse(a[sort])
|
|
|
|
const colB = parse(b[sort])
|
|
|
|
if (sortOrder === "Descending") {
|
|
|
|
return colA > colB ? -1 : 1
|
|
|
|
} else {
|
|
|
|
return colA > colB ? 1 : -1
|
|
|
|
}
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Limits the specified docs to the specified number of rows from the equivalent
|
|
|
|
* server-side lucene limit parameters.
|
|
|
|
* @param docs the data
|
|
|
|
* @param limit the number of docs to limit to
|
|
|
|
*/
|
|
|
|
export const luceneLimit = (docs, limit) => {
|
|
|
|
const numLimit = parseFloat(limit)
|
|
|
|
if (isNaN(numLimit)) {
|
|
|
|
return docs
|
|
|
|
}
|
|
|
|
return docs.slice(0, numLimit)
|
|
|
|
}
|