mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-10-02 06:10:31 +00:00
Basic advanced search query foundations
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
// Fields and forms documented in docs/usage.md > "Document searches"
|
||||
|
||||
export enum AdvancedSearchField {
|
||||
Any = '',
|
||||
Title = 'title',
|
||||
Content = 'content',
|
||||
OriginalFilename = 'original_filename',
|
||||
NoteText = 'notes.note',
|
||||
NoteAuthor = 'notes.user',
|
||||
CustomFieldName = 'custom_fields.name',
|
||||
CustomFieldValue = 'custom_fields.value',
|
||||
Correspondent = 'correspondent',
|
||||
DocumentType = 'document_type',
|
||||
StoragePath = 'storage_path',
|
||||
Tag = 'tag',
|
||||
ASN = 'asn',
|
||||
PageCount = 'page_count',
|
||||
NumNotes = 'num_notes',
|
||||
Created = 'created',
|
||||
Added = 'added',
|
||||
Modified = 'modified',
|
||||
Checksum = 'checksum',
|
||||
}
|
||||
|
||||
export enum AdvancedSearchFieldKind {
|
||||
Text = 'text',
|
||||
Number = 'number',
|
||||
Date = 'date',
|
||||
Checksum = 'checksum',
|
||||
}
|
||||
|
||||
export const ADVANCED_SEARCH_FIELD_KINDS: Record<
|
||||
AdvancedSearchField,
|
||||
AdvancedSearchFieldKind
|
||||
> = {
|
||||
[AdvancedSearchField.Any]: AdvancedSearchFieldKind.Text,
|
||||
[AdvancedSearchField.Title]: AdvancedSearchFieldKind.Text,
|
||||
[AdvancedSearchField.Content]: AdvancedSearchFieldKind.Text,
|
||||
[AdvancedSearchField.OriginalFilename]: AdvancedSearchFieldKind.Text,
|
||||
[AdvancedSearchField.NoteText]: AdvancedSearchFieldKind.Text,
|
||||
[AdvancedSearchField.NoteAuthor]: AdvancedSearchFieldKind.Text,
|
||||
[AdvancedSearchField.CustomFieldName]: AdvancedSearchFieldKind.Text,
|
||||
[AdvancedSearchField.CustomFieldValue]: AdvancedSearchFieldKind.Text,
|
||||
[AdvancedSearchField.Correspondent]: AdvancedSearchFieldKind.Text,
|
||||
[AdvancedSearchField.DocumentType]: AdvancedSearchFieldKind.Text,
|
||||
[AdvancedSearchField.StoragePath]: AdvancedSearchFieldKind.Text,
|
||||
[AdvancedSearchField.Tag]: AdvancedSearchFieldKind.Text,
|
||||
[AdvancedSearchField.ASN]: AdvancedSearchFieldKind.Number,
|
||||
[AdvancedSearchField.PageCount]: AdvancedSearchFieldKind.Number,
|
||||
[AdvancedSearchField.NumNotes]: AdvancedSearchFieldKind.Number,
|
||||
[AdvancedSearchField.Created]: AdvancedSearchFieldKind.Date,
|
||||
[AdvancedSearchField.Added]: AdvancedSearchFieldKind.Date,
|
||||
[AdvancedSearchField.Modified]: AdvancedSearchFieldKind.Date,
|
||||
[AdvancedSearchField.Checksum]: AdvancedSearchFieldKind.Checksum,
|
||||
}
|
||||
|
||||
export enum AdvancedSearchOperator {
|
||||
AllWords = 'all',
|
||||
AnyWord = 'any',
|
||||
Phrase = 'phrase',
|
||||
StartsWith = 'prefix',
|
||||
Equals = 'eq',
|
||||
AtLeast = 'gte',
|
||||
AtMost = 'lte',
|
||||
Between = 'between',
|
||||
DateKeyword = 'keyword',
|
||||
WithinLast = 'within',
|
||||
}
|
||||
|
||||
export const ADVANCED_SEARCH_OPERATORS_BY_KIND: Record<
|
||||
AdvancedSearchFieldKind,
|
||||
AdvancedSearchOperator[]
|
||||
> = {
|
||||
[AdvancedSearchFieldKind.Text]: [
|
||||
AdvancedSearchOperator.AllWords,
|
||||
AdvancedSearchOperator.AnyWord,
|
||||
AdvancedSearchOperator.Phrase,
|
||||
AdvancedSearchOperator.StartsWith,
|
||||
],
|
||||
[AdvancedSearchFieldKind.Number]: [
|
||||
AdvancedSearchOperator.Equals,
|
||||
AdvancedSearchOperator.AtLeast,
|
||||
AdvancedSearchOperator.AtMost,
|
||||
AdvancedSearchOperator.Between,
|
||||
],
|
||||
[AdvancedSearchFieldKind.Date]: [
|
||||
AdvancedSearchOperator.DateKeyword,
|
||||
AdvancedSearchOperator.WithinLast,
|
||||
AdvancedSearchOperator.AtLeast,
|
||||
AdvancedSearchOperator.AtMost,
|
||||
AdvancedSearchOperator.Between,
|
||||
],
|
||||
[AdvancedSearchFieldKind.Checksum]: [AdvancedSearchOperator.StartsWith],
|
||||
}
|
||||
|
||||
export const ADVANCED_SEARCH_DATE_KEYWORDS = [
|
||||
'today',
|
||||
'yesterday',
|
||||
'tomorrow',
|
||||
'previous week',
|
||||
'this month',
|
||||
'previous month',
|
||||
'previous quarter',
|
||||
'this year',
|
||||
'previous year',
|
||||
] as const
|
||||
|
||||
export type AdvancedSearchDateKeyword =
|
||||
(typeof ADVANCED_SEARCH_DATE_KEYWORDS)[number]
|
||||
|
||||
export enum AdvancedSearchDateUnit {
|
||||
Day = 'day',
|
||||
Week = 'week',
|
||||
Month = 'month',
|
||||
Year = 'year',
|
||||
}
|
||||
|
||||
export enum AdvancedSearchLogicalOperator {
|
||||
And = 'AND',
|
||||
Or = 'OR',
|
||||
Not = 'NOT',
|
||||
}
|
||||
|
||||
export enum AdvancedSearchQueryElementType {
|
||||
Atom = 'atom',
|
||||
Group = 'group',
|
||||
}
|
||||
|
||||
export interface AdvancedSearchQueryAtom {
|
||||
type: AdvancedSearchQueryElementType.Atom
|
||||
field: AdvancedSearchField
|
||||
operator: AdvancedSearchOperator
|
||||
// words, phrase, number or yyyy-mm-dd date; the lower bound for Between;
|
||||
// the amount for WithinLast
|
||||
value?: string
|
||||
// upper bound for Between
|
||||
valueTo?: string
|
||||
// unit for WithinLast
|
||||
unit?: AdvancedSearchDateUnit
|
||||
}
|
||||
|
||||
export interface AdvancedSearchQueryGroup {
|
||||
type: AdvancedSearchQueryElementType.Group
|
||||
operator: AdvancedSearchLogicalOperator
|
||||
children: AdvancedSearchQueryElement[]
|
||||
}
|
||||
|
||||
export type AdvancedSearchQueryElement =
|
||||
AdvancedSearchQueryAtom | AdvancedSearchQueryGroup
|
||||
@@ -0,0 +1,398 @@
|
||||
import {
|
||||
AdvancedSearchDateUnit,
|
||||
AdvancedSearchField,
|
||||
AdvancedSearchLogicalOperator,
|
||||
AdvancedSearchOperator,
|
||||
AdvancedSearchQueryAtom,
|
||||
AdvancedSearchQueryElement,
|
||||
AdvancedSearchQueryElementType,
|
||||
AdvancedSearchQueryGroup,
|
||||
} from '../data/advanced-search-query'
|
||||
import { serializeAdvancedSearchQuery } from './advanced-search-query'
|
||||
|
||||
const atom = (
|
||||
field: AdvancedSearchField,
|
||||
operator: AdvancedSearchOperator,
|
||||
value?: string,
|
||||
extra: Partial<AdvancedSearchQueryAtom> = {}
|
||||
): AdvancedSearchQueryAtom => ({
|
||||
type: AdvancedSearchQueryElementType.Atom,
|
||||
field,
|
||||
operator,
|
||||
value,
|
||||
...extra,
|
||||
})
|
||||
|
||||
const group = (
|
||||
operator: AdvancedSearchLogicalOperator,
|
||||
...children: AdvancedSearchQueryElement[]
|
||||
): AdvancedSearchQueryGroup => ({
|
||||
type: AdvancedSearchQueryElementType.Group,
|
||||
operator,
|
||||
children,
|
||||
})
|
||||
|
||||
const { And, Or, Not } = AdvancedSearchLogicalOperator
|
||||
|
||||
describe('serializeAdvancedSearchQuery', () => {
|
||||
describe('text fields', () => {
|
||||
it.each([
|
||||
[AdvancedSearchOperator.AllWords, 'invoice', 'title:invoice'],
|
||||
[
|
||||
AdvancedSearchOperator.AllWords,
|
||||
' invoice unpaid ',
|
||||
'title:invoice AND title:unpaid',
|
||||
],
|
||||
[
|
||||
AdvancedSearchOperator.AnyWord,
|
||||
'invoice unpaid',
|
||||
'title:invoice OR title:unpaid',
|
||||
],
|
||||
[
|
||||
AdvancedSearchOperator.Phrase,
|
||||
'quick brown fox',
|
||||
'title:"quick brown fox"',
|
||||
],
|
||||
[AdvancedSearchOperator.Phrase, 'say "hi"', 'title:"say hi"'],
|
||||
[AdvancedSearchOperator.StartsWith, 'invoi', 'title:invoi*'],
|
||||
[AdvancedSearchOperator.StartsWith, 'in*v?oi', 'title:invoi*'],
|
||||
])('%s %j writes %s', (operator, value, expected) => {
|
||||
expect(
|
||||
serializeAdvancedSearchQuery(
|
||||
atom(AdvancedSearchField.Title, operator, value)
|
||||
)
|
||||
).toBe(expected)
|
||||
})
|
||||
|
||||
it('writes bare words for the Any field', () => {
|
||||
expect(
|
||||
serializeAdvancedSearchQuery(
|
||||
atom(AdvancedSearchField.Any, AdvancedSearchOperator.AllWords, 'a b')
|
||||
)
|
||||
).toBe('a AND b')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['A-1312/99', 'custom_fields.value:A-1312/99'],
|
||||
["O'Brien", "custom_fields.value:O'Brien"],
|
||||
["'quoted'", `custom_fields.value:"'quoted'"`],
|
||||
['foo:bar', 'custom_fields.value:"foo:bar"'],
|
||||
['(x)', 'custom_fields.value:"(x)"'],
|
||||
['2024*', 'custom_fields.value:"2024*"'],
|
||||
['a,b', 'custom_fields.value:"a,b"'],
|
||||
['OR', 'custom_fields.value:"OR"'],
|
||||
['or', 'custom_fields.value:or'],
|
||||
])(
|
||||
'quotes %j only when the grammar would read it as syntax',
|
||||
(value, expected) => {
|
||||
expect(
|
||||
serializeAdvancedSearchQuery(
|
||||
atom(
|
||||
AdvancedSearchField.CustomFieldValue,
|
||||
AdvancedSearchOperator.AllWords,
|
||||
value
|
||||
)
|
||||
)
|
||||
).toBe(expected)
|
||||
}
|
||||
)
|
||||
|
||||
it('uses the dotted names for custom fields', () => {
|
||||
expect(
|
||||
serializeAdvancedSearchQuery(
|
||||
group(
|
||||
And,
|
||||
atom(
|
||||
AdvancedSearchField.CustomFieldName,
|
||||
AdvancedSearchOperator.Phrase,
|
||||
'status'
|
||||
),
|
||||
atom(
|
||||
AdvancedSearchField.CustomFieldValue,
|
||||
AdvancedSearchOperator.AllWords,
|
||||
'paid'
|
||||
)
|
||||
)
|
||||
)
|
||||
).toBe('custom_fields.name:"status" AND custom_fields.value:paid')
|
||||
})
|
||||
|
||||
it('uses the dotted names for notes', () => {
|
||||
expect(
|
||||
serializeAdvancedSearchQuery(
|
||||
group(
|
||||
And,
|
||||
atom(
|
||||
AdvancedSearchField.NoteText,
|
||||
AdvancedSearchOperator.AllWords,
|
||||
'call'
|
||||
),
|
||||
atom(
|
||||
AdvancedSearchField.NoteAuthor,
|
||||
AdvancedSearchOperator.AllWords,
|
||||
'alice'
|
||||
)
|
||||
)
|
||||
)
|
||||
).toBe('notes.note:call AND notes.user:alice')
|
||||
})
|
||||
|
||||
it.each([
|
||||
[AdvancedSearchOperator.AllWords, ''],
|
||||
[AdvancedSearchOperator.AllWords, ' '],
|
||||
[AdvancedSearchOperator.AllWords, '!! --'],
|
||||
[AdvancedSearchOperator.Phrase, '""'],
|
||||
[AdvancedSearchOperator.StartsWith, 'two words'],
|
||||
[AdvancedSearchOperator.StartsWith, '***'],
|
||||
[AdvancedSearchOperator.StartsWith, undefined],
|
||||
])('leaves out %s %j', (operator, value) => {
|
||||
expect(
|
||||
serializeAdvancedSearchQuery(
|
||||
atom(AdvancedSearchField.Title, operator, value)
|
||||
)
|
||||
).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('checksum', () => {
|
||||
it('lowercases the prefix', () => {
|
||||
expect(
|
||||
serializeAdvancedSearchQuery(
|
||||
atom(
|
||||
AdvancedSearchField.Checksum,
|
||||
AdvancedSearchOperator.StartsWith,
|
||||
'9F86D081'
|
||||
)
|
||||
)
|
||||
).toBe('checksum:9f86d081*')
|
||||
})
|
||||
})
|
||||
|
||||
describe('number fields', () => {
|
||||
it.each([
|
||||
[AdvancedSearchOperator.Equals, '42', undefined, 'asn:42'],
|
||||
[AdvancedSearchOperator.AtLeast, '50', undefined, 'asn:[50 to]'],
|
||||
[AdvancedSearchOperator.AtMost, '50', undefined, 'asn:[to 50]'],
|
||||
[AdvancedSearchOperator.Between, '50', '150', 'asn:[50 to 150]'],
|
||||
[AdvancedSearchOperator.Equals, '4.2', undefined, ''],
|
||||
[AdvancedSearchOperator.Equals, '-1', undefined, ''],
|
||||
[AdvancedSearchOperator.Equals, '2024-01-01', undefined, ''],
|
||||
[AdvancedSearchOperator.Between, '50', '', ''],
|
||||
])('%s %j %j writes %j', (operator, value, valueTo, expected) => {
|
||||
expect(
|
||||
serializeAdvancedSearchQuery(
|
||||
atom(AdvancedSearchField.ASN, operator, value, { valueTo })
|
||||
)
|
||||
).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('date fields', () => {
|
||||
it.each([
|
||||
['today', 'added:today'],
|
||||
['previous month', 'added:"previous month"'],
|
||||
['last tuesday', ''],
|
||||
['', ''],
|
||||
])('keyword %j writes %j', (value, expected) => {
|
||||
expect(
|
||||
serializeAdvancedSearchQuery(
|
||||
atom(
|
||||
AdvancedSearchField.Added,
|
||||
AdvancedSearchOperator.DateKeyword,
|
||||
value
|
||||
)
|
||||
)
|
||||
).toBe(expected)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['1', AdvancedSearchDateUnit.Day, 'added:[-1 day to now]'],
|
||||
['3', AdvancedSearchDateUnit.Month, 'added:[-3 months to now]'],
|
||||
['2', AdvancedSearchDateUnit.Week, 'added:[-2 weeks to now]'],
|
||||
['0', AdvancedSearchDateUnit.Year, ''],
|
||||
['1.5', AdvancedSearchDateUnit.Year, ''],
|
||||
['3', undefined, ''],
|
||||
['3', 'fortnight' as AdvancedSearchDateUnit, ''],
|
||||
])('within the last %j %j writes %j', (value, unit, expected) => {
|
||||
expect(
|
||||
serializeAdvancedSearchQuery(
|
||||
atom(
|
||||
AdvancedSearchField.Added,
|
||||
AdvancedSearchOperator.WithinLast,
|
||||
value,
|
||||
{
|
||||
unit,
|
||||
}
|
||||
)
|
||||
)
|
||||
).toBe(expected)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[
|
||||
AdvancedSearchOperator.AtLeast,
|
||||
'2024-01-01',
|
||||
undefined,
|
||||
'created:[2024-01-01 to]',
|
||||
],
|
||||
[
|
||||
AdvancedSearchOperator.AtMost,
|
||||
'2024-01-01',
|
||||
undefined,
|
||||
'created:[to 2024-01-01]',
|
||||
],
|
||||
[
|
||||
AdvancedSearchOperator.Between,
|
||||
'2024-01-01',
|
||||
'2024-03-31',
|
||||
'created:[2024-01-01 to 2024-03-31]',
|
||||
],
|
||||
[AdvancedSearchOperator.AtLeast, '2024', undefined, ''],
|
||||
[AdvancedSearchOperator.Between, '2024-01-01', 'now', ''],
|
||||
])('%s %j %j writes %j', (operator, value, valueTo, expected) => {
|
||||
expect(
|
||||
serializeAdvancedSearchQuery(
|
||||
atom(AdvancedSearchField.Created, operator, value, { valueTo })
|
||||
)
|
||||
).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('groups', () => {
|
||||
const invoice = atom(
|
||||
AdvancedSearchField.Content,
|
||||
AdvancedSearchOperator.AllWords,
|
||||
'invoice'
|
||||
)
|
||||
const letter = atom(
|
||||
AdvancedSearchField.Title,
|
||||
AdvancedSearchOperator.AllWords,
|
||||
'letter'
|
||||
)
|
||||
const paid = atom(
|
||||
AdvancedSearchField.Tag,
|
||||
AdvancedSearchOperator.AllWords,
|
||||
'paid'
|
||||
)
|
||||
const twoWords = atom(
|
||||
AdvancedSearchField.Title,
|
||||
AdvancedSearchOperator.AllWords,
|
||||
'a b'
|
||||
)
|
||||
const anyWords = atom(
|
||||
AdvancedSearchField.Title,
|
||||
AdvancedSearchOperator.AnyWord,
|
||||
'a b'
|
||||
)
|
||||
const empty = atom(
|
||||
AdvancedSearchField.Title,
|
||||
AdvancedSearchOperator.AllWords,
|
||||
''
|
||||
)
|
||||
|
||||
it.each([
|
||||
['an empty group', group(And), ''],
|
||||
['a group of empty atoms', group(Or, empty, group(And, empty)), ''],
|
||||
[
|
||||
'a single child without parentheses',
|
||||
group(Or, invoice),
|
||||
'content:invoice',
|
||||
],
|
||||
['All', group(And, invoice, letter), 'content:invoice AND title:letter'],
|
||||
['Any', group(Or, invoice, letter), 'content:invoice OR title:letter'],
|
||||
[
|
||||
'skipped empty atoms',
|
||||
group(And, empty, invoice, empty),
|
||||
'content:invoice',
|
||||
],
|
||||
['Not with one child', group(Not, paid), 'NOT tag:paid'],
|
||||
[
|
||||
'Not as none of',
|
||||
group(Not, paid, letter),
|
||||
'NOT (tag:paid OR title:letter)',
|
||||
],
|
||||
[
|
||||
'Not with a compound child',
|
||||
group(Not, twoWords),
|
||||
'NOT (title:a AND title:b)',
|
||||
],
|
||||
[
|
||||
'Not inside All',
|
||||
group(And, invoice, group(Not, paid)),
|
||||
'content:invoice AND NOT tag:paid',
|
||||
],
|
||||
[
|
||||
'Not inside Any',
|
||||
group(Or, invoice, group(Not, paid)),
|
||||
'content:invoice OR NOT tag:paid',
|
||||
],
|
||||
[
|
||||
'Any inside All',
|
||||
group(And, invoice, group(Or, letter, paid)),
|
||||
'content:invoice AND (title:letter OR tag:paid)',
|
||||
],
|
||||
[
|
||||
'All inside Any',
|
||||
group(Or, invoice, group(And, letter, paid)),
|
||||
'content:invoice OR (title:letter AND tag:paid)',
|
||||
],
|
||||
[
|
||||
'All inside All flattened',
|
||||
group(And, invoice, group(And, letter, paid)),
|
||||
'content:invoice AND title:letter AND tag:paid',
|
||||
],
|
||||
[
|
||||
'an all-words atom inside Any',
|
||||
group(Or, invoice, twoWords),
|
||||
'content:invoice OR (title:a AND title:b)',
|
||||
],
|
||||
[
|
||||
'an any-word atom inside All',
|
||||
group(And, invoice, anyWords),
|
||||
'content:invoice AND (title:a OR title:b)',
|
||||
],
|
||||
[
|
||||
'an all-words atom inside Not with siblings',
|
||||
group(Not, paid, twoWords),
|
||||
'NOT (tag:paid OR (title:a AND title:b))',
|
||||
],
|
||||
])('writes %s', (_, tree, expected) => {
|
||||
expect(serializeAdvancedSearchQuery(tree)).toBe(expected)
|
||||
})
|
||||
|
||||
it('writes the mockup example', () => {
|
||||
expect(
|
||||
serializeAdvancedSearchQuery(
|
||||
group(
|
||||
And,
|
||||
invoice,
|
||||
group(
|
||||
Or,
|
||||
atom(
|
||||
AdvancedSearchField.Correspondent,
|
||||
AdvancedSearchOperator.Phrase,
|
||||
'acme corp'
|
||||
),
|
||||
atom(
|
||||
AdvancedSearchField.Content,
|
||||
AdvancedSearchOperator.Phrase,
|
||||
'acme corporation'
|
||||
)
|
||||
),
|
||||
atom(
|
||||
AdvancedSearchField.Added,
|
||||
AdvancedSearchOperator.WithinLast,
|
||||
'3',
|
||||
{
|
||||
unit: AdvancedSearchDateUnit.Month,
|
||||
}
|
||||
),
|
||||
group(Not, paid)
|
||||
)
|
||||
)
|
||||
).toBe(
|
||||
'content:invoice AND (correspondent:"acme corp" OR content:"acme corporation") AND added:[-3 months to now] AND NOT tag:paid'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,185 @@
|
||||
import {
|
||||
ADVANCED_SEARCH_DATE_KEYWORDS,
|
||||
ADVANCED_SEARCH_FIELD_KINDS,
|
||||
AdvancedSearchDateUnit,
|
||||
AdvancedSearchField,
|
||||
AdvancedSearchFieldKind,
|
||||
AdvancedSearchLogicalOperator,
|
||||
AdvancedSearchOperator,
|
||||
AdvancedSearchQueryAtom,
|
||||
AdvancedSearchQueryElement,
|
||||
AdvancedSearchQueryElementType,
|
||||
AdvancedSearchQueryGroup,
|
||||
} from '../data/advanced-search-query'
|
||||
|
||||
// Anything the query grammar would read as syntax rather than as a word
|
||||
const SYNTAX_CHARS = /[\s():"[\]*?,{}^~\\]/
|
||||
const SYNTAX_CHARS_GLOBAL = new RegExp(SYNTAX_CHARS, 'g')
|
||||
// A word wrapped in single quotes is also syntax, an apostrophe inside one is not
|
||||
const EDGE_SINGLE_QUOTE = /^'|'$/
|
||||
const RESERVED_WORDS = /^(AND|OR|NOT|TO)$/
|
||||
const HAS_WORD_CHAR = /[\p{L}\p{N}]/u
|
||||
const WHOLE_NUMBER = /^\d+$/
|
||||
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/
|
||||
|
||||
interface Serialized {
|
||||
text: string
|
||||
// The operator joining the top level of `text`, null when it is self-delimiting
|
||||
join:
|
||||
AdvancedSearchLogicalOperator.And | AdvancedSearchLogicalOperator.Or | null
|
||||
}
|
||||
|
||||
const prefix = (field: AdvancedSearchField) => (field ? `${field}:` : '')
|
||||
|
||||
const quote = (text: string) => `"${text.replace(/"/g, '')}"`
|
||||
|
||||
const words = (value: string) =>
|
||||
value
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter((word) => HAS_WORD_CHAR.test(word))
|
||||
|
||||
const word = (w: string) =>
|
||||
SYNTAX_CHARS.test(w) || EDGE_SINGLE_QUOTE.test(w) || RESERVED_WORDS.test(w)
|
||||
? quote(w)
|
||||
: w
|
||||
|
||||
const atomic = (text: string): Serialized => ({ text, join: null })
|
||||
|
||||
function serializeWords(
|
||||
atom: AdvancedSearchQueryAtom,
|
||||
join: AdvancedSearchLogicalOperator.And | AdvancedSearchLogicalOperator.Or
|
||||
): Serialized {
|
||||
// A field applies only to the word right after it, so repeat it per word
|
||||
const terms = words(atom.value ?? '').map(
|
||||
(w) => `${prefix(atom.field)}${word(w)}`
|
||||
)
|
||||
if (terms.length === 0) return null
|
||||
if (terms.length === 1) return atomic(terms[0])
|
||||
return { text: terms.join(` ${join} `), join }
|
||||
}
|
||||
|
||||
function serializeRange(
|
||||
atom: AdvancedSearchQueryAtom,
|
||||
isValid: (v: string) => boolean
|
||||
): Serialized {
|
||||
const lo = atom.value?.trim() ?? ''
|
||||
const hi = atom.valueTo?.trim() ?? ''
|
||||
const field = prefix(atom.field)
|
||||
switch (atom.operator) {
|
||||
case AdvancedSearchOperator.Equals:
|
||||
return isValid(lo) ? atomic(`${field}${lo}`) : null
|
||||
case AdvancedSearchOperator.AtLeast:
|
||||
return isValid(lo) ? atomic(`${field}[${lo} to]`) : null
|
||||
case AdvancedSearchOperator.AtMost:
|
||||
return isValid(lo) ? atomic(`${field}[to ${lo}]`) : null
|
||||
case AdvancedSearchOperator.Between:
|
||||
return isValid(lo) && isValid(hi)
|
||||
? atomic(`${field}[${lo} to ${hi}]`)
|
||||
: null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function serializeAtom(atom: AdvancedSearchQueryAtom): Serialized {
|
||||
const field = prefix(atom.field)
|
||||
const value = atom.value?.trim() ?? ''
|
||||
|
||||
switch (atom.operator) {
|
||||
case AdvancedSearchOperator.AllWords:
|
||||
return serializeWords(atom, AdvancedSearchLogicalOperator.And)
|
||||
case AdvancedSearchOperator.AnyWord:
|
||||
return serializeWords(atom, AdvancedSearchLogicalOperator.Or)
|
||||
case AdvancedSearchOperator.Phrase:
|
||||
return HAS_WORD_CHAR.test(value)
|
||||
? atomic(`${field}${quote(value)}`)
|
||||
: null
|
||||
case AdvancedSearchOperator.StartsWith: {
|
||||
if (/\s/.test(value)) return null
|
||||
let stem = value.replace(SYNTAX_CHARS_GLOBAL, '')
|
||||
if (!HAS_WORD_CHAR.test(stem)) return null
|
||||
// checksum is indexed as-is, in lowercase
|
||||
if (atom.field === AdvancedSearchField.Checksum) {
|
||||
stem = stem.toLowerCase()
|
||||
}
|
||||
return atomic(`${field}${stem}*`)
|
||||
}
|
||||
case AdvancedSearchOperator.DateKeyword:
|
||||
return (ADVANCED_SEARCH_DATE_KEYWORDS as readonly string[]).includes(
|
||||
value
|
||||
)
|
||||
? atomic(`${field}${word(value)}`)
|
||||
: null
|
||||
case AdvancedSearchOperator.WithinLast: {
|
||||
const amount = parseInt(value, 10)
|
||||
const units = Object.values(AdvancedSearchDateUnit) as string[]
|
||||
if (!WHOLE_NUMBER.test(value) || amount < 1) return null
|
||||
if (!units.includes(atom.unit)) return null
|
||||
const unit = amount === 1 ? atom.unit : `${atom.unit}s`
|
||||
return atomic(`${field}[-${amount} ${unit} to now]`)
|
||||
}
|
||||
case AdvancedSearchOperator.Equals:
|
||||
case AdvancedSearchOperator.AtLeast:
|
||||
case AdvancedSearchOperator.AtMost:
|
||||
case AdvancedSearchOperator.Between:
|
||||
return serializeRange(
|
||||
atom,
|
||||
ADVANCED_SEARCH_FIELD_KINDS[atom.field] === AdvancedSearchFieldKind.Date
|
||||
? (v) => ISO_DATE.test(v)
|
||||
: (v) => WHOLE_NUMBER.test(v)
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function wrap(
|
||||
child: Serialized,
|
||||
parentJoin: AdvancedSearchLogicalOperator
|
||||
): string {
|
||||
return child.join === null || child.join === parentJoin
|
||||
? child.text
|
||||
: `(${child.text})`
|
||||
}
|
||||
|
||||
function serializeGroup(group: AdvancedSearchQueryGroup): Serialized {
|
||||
const children = group.children.map(serializeElement).filter(Boolean)
|
||||
if (children.length === 0) return null
|
||||
|
||||
if (group.operator === AdvancedSearchLogicalOperator.Not) {
|
||||
// A Not group matches documents matching none of its children
|
||||
if (children.length === 1 && children[0].join === null) {
|
||||
return atomic(`NOT ${children[0].text}`)
|
||||
}
|
||||
const inner =
|
||||
children.length === 1
|
||||
? children[0].text
|
||||
: children
|
||||
.map((c) => wrap(c, AdvancedSearchLogicalOperator.Or))
|
||||
.join(' OR ')
|
||||
return atomic(`NOT (${inner})`)
|
||||
}
|
||||
|
||||
if (children.length === 1) return children[0]
|
||||
return {
|
||||
text: children
|
||||
.map((c) => wrap(c, group.operator))
|
||||
.join(` ${group.operator} `),
|
||||
join: group.operator,
|
||||
}
|
||||
}
|
||||
|
||||
function serializeElement(element: AdvancedSearchQueryElement): Serialized {
|
||||
return element.type === AdvancedSearchQueryElementType.Group
|
||||
? serializeGroup(element)
|
||||
: serializeAtom(element)
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes an editor tree as a full-text query. Atoms that are not filled in
|
||||
* (or not valid) are left out, and empty groups with them.
|
||||
*/
|
||||
export function serializeAdvancedSearchQuery(
|
||||
element: AdvancedSearchQueryElement
|
||||
): string {
|
||||
return serializeElement(element)?.text ?? ''
|
||||
}
|
||||
Reference in New Issue
Block a user