advanced-search-query
Version:
Another parser for advanced search query syntax.
398 lines (347 loc) • 9.92 kB
text/typescript
import groupBy from 'lodash.groupby'
import { getQuotePairMap } from './utils'
const RESET = 'RESET'
const IN_OPERAND = 'IN_OPERAND'
const IN_TEXT = 'IN_TEXT'
const SINGLE_QUOTE = 'SINGLE_QUOTE'
const DOUBLE_QUOTE = 'DOUBLE_QUOTE'
type State = typeof RESET | typeof IN_OPERAND | typeof IN_TEXT
type QuoteState = typeof RESET | typeof SINGLE_QUOTE | typeof DOUBLE_QUOTE
type Value = string
type Keyword = { name: string; value: Value; isNegated: boolean }
type Text = { value: string; isNegated: boolean }
type TransformTextToKeyword = (text: string) => Keyword | null
type ParsedQuery = {
text: {
include: Value[]
exclude: Value[]
}
keywords: Record<
string,
{
include: Value[]
exclude: Value[]
}
>
}
export class AdvancedSearchQuery {
keywords: Keyword[]
texts: Text[]
input: string
output: string | undefined
isDirty: boolean
/**
* Not intended for public use. API could change.
*/
constructor(keywords: Keyword[], texts: Text[]) {
this.keywords = keywords
this.texts = texts
this.input = ''
this.isDirty = true
}
getText() {
return this.texts
? this.texts
.map(({ value, isNegated }) => (isNegated ? `-${value}` : value))
.join(' ')
: ''
}
getTexts() {
return this.texts
}
getKeywords(): Record<string, Omit<Keyword, 'name'>[]> {
const keywords: Record<string, Keyword[]> = groupBy(this.keywords, 'name')
return Object.entries(keywords)
.map(([name, keywords]): [string, Omit<Keyword, 'name'>[]] => {
return [
name,
keywords.map((keyword) => {
return {
...keyword,
name: undefined,
}
}),
]
})
.reduce((cumulatedKeywords, [name, keywords]) => {
return {
...cumulatedKeywords,
[name]: keywords,
}
}, {})
}
getKeyword(name: string) {
const keyword = this.getKeywords()[name]
if (!keyword) {
return []
}
return keyword
}
toObject() {
const parsedQuery: ParsedQuery = {
text: {
include: this.getTexts()
.filter((text) => {
return text.isNegated === false
})
.map((text) => {
return text.value
}),
exclude: this.getTexts()
.filter((text) => {
return text.isNegated === true
})
.map((text) => {
return text.value
}),
},
keywords: {},
}
this.keywords.forEach((keyword) => {
parsedQuery.keywords[keyword.name] = parsedQuery.keywords[
keyword.name
] || {
include: [],
exclude: [],
}
if (keyword.isNegated) {
parsedQuery.keywords[keyword.name].exclude.push(keyword.value)
} else {
parsedQuery.keywords[keyword.name].include.push(keyword.value)
}
})
return parsedQuery
}
addKeyword(name: string, value: Value, isNegated: boolean = false) {
this.keywords.push({
name,
value,
isNegated,
})
this.isDirty = true
return this
}
removeKeywords() {
this.keywords = []
this.isDirty = true
return this
}
removeKeyword(name: string, value?: Value, isNegated?: boolean) {
const keywords = this.keywords.filter((keyword) => {
if (name !== keyword.name) {
return true
}
if (typeof value !== 'undefined' && value !== keyword.value) {
return true
}
if (typeof isNegated !== 'undefined' && isNegated !== keyword.isNegated) {
return true
}
return false
})
this.isDirty = keywords.length !== this.keywords.length
this.keywords = keywords
return this
}
addText(value: Value, isNegated: boolean = false) {
this.texts.push({
value,
isNegated,
})
this.isDirty = true
return this
}
removeText(value: Value, isNegated?: boolean) {
const texts = this.texts.filter((text) => {
if (value !== text.value) {
return true
}
if (typeof isNegated !== 'undefined' && isNegated !== text.isNegated) {
return true
}
return false
})
this.isDirty = texts.length !== this.texts.length
this.texts = texts
return this
}
removeTexts() {
this.texts = []
this.isDirty = true
return this
}
clone() {
return new AdvancedSearchQuery(this.keywords.slice(0), this.texts.slice(0))
}
toString() {
if (!this.isDirty && this.output) {
return this.output
}
// Group keyword, isNegated pairs as keys
const conditionGroups: Record<string, string[]> = {}
this.keywords.forEach(({ name, value, isNegated }) => {
const isNegatedPrefix = isNegated ? '-' : ''
const key = `${isNegatedPrefix}${name}`
conditionGroups[key] = conditionGroups[key] || []
conditionGroups[key].push(value)
})
// Build condition
let condition = ''
Object.keys(conditionGroups).forEach((conditionGroupKey) => {
const values = conditionGroups[conditionGroupKey]
const safeValues = values.filter(Boolean).map((value) => {
let newV = ''
let shouldQuote = false
for (let i = 0; i < value.length; i++) {
const char = value[i]
if (char === '"') {
newV += '\\"'
} else {
if (char === ' ' || char === ',') {
shouldQuote = true
}
newV += char
}
}
return shouldQuote ? `"${newV}"` : newV
})
if (safeValues.length > 0) {
condition += ` ${conditionGroupKey}:${safeValues.join(',')}`
}
})
this.output = `${condition} ${this.getText()}`.trim()
this.isDirty = false
return this.output
}
}
/**
* @param {String} string to parse e.g. 'to:me -from:joe@acme.com foobar'.
* @param {Array} transformTextToConditions Array of functions to transform text into conditions
* @returns {AdvancedSearchQuery} An instance of class AdvancedSearchQuery.
*/
export default function (
input: string = '',
transformTextToKeywords: TransformTextToKeyword[] = []
) {
const keywords: Keyword[] = []
const texts: Text[] = []
const addCondition = (name: string, value: Value, isNegated: boolean) => {
keywords.push({
name,
value,
isNegated,
})
}
const addTextSegment = (value: string, isNegated: boolean) => {
let hasTransform = false
transformTextToKeywords.forEach((transform) => {
const transformed = transform(value)
if (transformed) {
const { name, value } = transformed
if (name && value) {
addCondition(name, value, isNegated)
hasTransform = true
}
}
})
if (!hasTransform) {
texts.push({ value, isNegated })
}
}
let state: State = RESET
let currentOperand = ''
let isNegated = false
let currentText = ''
let quoteState: QuoteState
let prevChar = ''
const performReset = () => {
state = RESET
quoteState = RESET
currentOperand = ''
currentText = ''
isNegated = false
prevChar = ''
}
// Terminology, in this example: 'to:joe@acme.com'
// 'to' is the operator
// 'joe@acme.com' is the operand
// 'to:joe@acme.com' is the condition
// Possible states:
const inText = () => state === IN_TEXT // could be inside raw text or operator
const inOperand = () => state === IN_OPERAND
const inSingleQuote = () => quoteState === SINGLE_QUOTE
const inDoubleQuote = () => quoteState === DOUBLE_QUOTE
const inQuote = () => inSingleQuote() || inDoubleQuote()
performReset()
const quotePairMap = getQuotePairMap(input)
for (let i = 0; i < input.length; i++) {
const char = input[i]
if (char === ' ') {
if (inOperand()) {
if (inQuote()) {
currentOperand += char
} else {
addCondition(currentText, currentOperand, isNegated)
performReset()
}
} else if (inText()) {
if (inQuote()) {
currentText += char
} else {
addTextSegment(currentText, isNegated)
performReset()
}
}
} else if (char === ',' && inOperand() && !inQuote()) {
addCondition(currentText, currentOperand, isNegated)
// No reset here because we are still evaluating operands for the same operator
currentOperand = ''
} else if (char === '-' && !inOperand() && !inText()) {
isNegated = true
} else if (char === ':' && !inQuote()) {
if (inOperand()) {
// If we're in an operand, just push the string on.
currentOperand += char
} else if (inText()) {
// Skip this char, move states into IN_OPERAND,
state = IN_OPERAND
}
} else if (char === '"' && prevChar !== '\\' && !inSingleQuote()) {
if (inDoubleQuote()) {
quoteState = RESET
} else if (quotePairMap.double[i]) {
quoteState = DOUBLE_QUOTE
} else if (inOperand()) {
currentOperand += char
} else {
currentText += char
}
} else if (char === "'" && prevChar !== '\\' && !inDoubleQuote()) {
if (inSingleQuote()) {
quoteState = RESET
} else if (quotePairMap.single[i]) {
quoteState = SINGLE_QUOTE
} else if (inOperand()) {
currentOperand += char
} else {
currentText += char
}
} else if (char !== '\\') {
// Regular character..
if (inOperand()) {
currentOperand += char
} else {
currentText += char
state = IN_TEXT
}
}
prevChar = char
}
// End of string, add any last entries
if (inText()) {
addTextSegment(currentText, isNegated)
} else if (inOperand()) {
addCondition(currentText, currentOperand, isNegated)
}
return new AdvancedSearchQuery(keywords, texts)
}