Fix lucene filtering of all types by parsing values as expected types, and correctly wrapping non-numeric types while building queries

This commit is contained in:
Andrew Kingston 2021-07-22 15:53:20 +01:00
parent d8136d0704
commit 520651119b
2 changed files with 70 additions and 25 deletions

View File

@ -3,13 +3,35 @@ const env = require("../../../environment")
const fetch = require("node-fetch") const fetch = require("node-fetch")
/** /**
* Escapes any characters in a string which lucene searches require to be * Preprocesses a value before going into a lucene search.
* escaped. * Transforms strings to lowercase and wraps strings and bools in quotes.
* @param value The value to escape * @param value The value to process
* @returns {string} * @param options The preprocess options
* @returns {string|*}
*/ */
const luceneEscape = value => { const preprocess = (
return `${value}`.replace(/[ #+\-&|!(){}\]^"~*?:\\]/g, "\\$&") value,
options = { escape: true, lowercase: true, wrap: true }
) => {
// Determine if type needs wrapped
const originalType = typeof value
// Convert to lowercase
if (options.lowercase) {
value = value?.toLowerCase ? value.toLowerCase() : value
}
// Escape characters
if (options.escape) {
value = `${value}`.replace(/[ #+\-&|!(){}\]^"~*?:\\]/g, "\\$&")
}
// Wrap in quotes
if (options.wrap) {
value = originalType === "number" ? value : `"${value}"`
}
return value
} }
/** /**
@ -113,7 +135,10 @@ class QueryBuilder {
function build(structure, queryFn) { function build(structure, queryFn) {
for (let [key, value] of Object.entries(structure)) { for (let [key, value] of Object.entries(structure)) {
const expression = queryFn(luceneEscape(key.replace(/ /, "_")), value) key = preprocess(key.replace(/ /, "_"), {
escape: true,
})
const expression = queryFn(key, value)
if (expression == null) { if (expression == null) {
continue continue
} }
@ -124,7 +149,14 @@ class QueryBuilder {
// Construct the actual lucene search query string from JSON structure // Construct the actual lucene search query string from JSON structure
if (this.query.string) { if (this.query.string) {
build(this.query.string, (key, value) => { build(this.query.string, (key, value) => {
return value ? `${key}:${luceneEscape(value.toLowerCase())}*` : null if (!value) {
return null
}
value = preprocess(value, {
escape: true,
lowercase: true,
})
return `${key}:${value}*`
}) })
} }
if (this.query.range) { if (this.query.range) {
@ -138,30 +170,37 @@ class QueryBuilder {
if (value.high == null || value.high === "") { if (value.high == null || value.high === "") {
return null return null
} }
return `${key}:[${value.low} TO ${value.high}]` const low = preprocess(value.low)
const high = preprocess(value.high)
return `${key}:[${low} TO ${high}]`
}) })
} }
if (this.query.fuzzy) { if (this.query.fuzzy) {
build(this.query.fuzzy, (key, value) => { build(this.query.fuzzy, (key, value) => {
return value ? `${key}:${luceneEscape(value.toLowerCase())}~` : null if (!value) {
return null
}
value = preprocess(value, {
escape: true,
lowercase: true,
})
return `${key}:${value}~`
}) })
} }
if (this.query.equal) { if (this.query.equal) {
build(this.query.equal, (key, value) => { build(this.query.equal, (key, value) => {
const escapedValue = luceneEscape(value.toLowerCase()) if (!value) {
// have to do the or to manage straight values, or strings return null
return value }
? `(${key}:${escapedValue} OR ${key}:"${escapedValue}")` return `${key}:${preprocess(value)}`
: null
}) })
} }
if (this.query.notEqual) { if (this.query.notEqual) {
build(this.query.notEqual, (key, value) => { build(this.query.notEqual, (key, value) => {
const escapedValue = luceneEscape(value.toLowerCase()) if (!value) {
// have to do the or to manage straight values, or strings return null
return value }
? `(!${key}:${escapedValue} OR !${key}:"${escapedValue}")` return `!${key}:${preprocess(value)}`
: null
}) })
} }
if (this.query.empty) { if (this.query.empty) {

View File

@ -15,10 +15,16 @@ export const buildLuceneQuery = filter => {
if (Array.isArray(filter)) { if (Array.isArray(filter)) {
filter.forEach(expression => { filter.forEach(expression => {
let { operator, field, type, value } = expression let { operator, field, type, value } = expression
// Ensure date fields are transformed into ISO strings // Parse all values into correct types
if (type === "datetime" && value) { if (type === "datetime" && value) {
value = new Date(value).toISOString() value = new Date(value).toISOString()
} }
if (type === "number") {
value = parseFloat(value)
}
if (type === "boolean") {
value = value?.toLowerCase() === "true"
}
if (operator.startsWith("range")) { if (operator.startsWith("range")) {
if (!query.range[field]) { if (!query.range[field]) {
query.range[field] = { query.range[field] = {
@ -42,10 +48,10 @@ export const buildLuceneQuery = filter => {
// 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 (operator === "equal" && value === "false") { if (operator === "equal" && value === false) {
query.notEqual[field] = "true" query.notEqual[field] = true
} else if (operator === "notEqual" && value === "false") { } else if (operator === "notEqual" && value === false) {
query.equal[field] = "true" query.equal[field] = true
} else { } else {
query[operator][field] = value query[operator][field] = value
} }