Skip to content

Commit c510a66

Browse files
committed
Prototype of options
1 parent d6455ae commit c510a66

2 files changed

Lines changed: 123 additions & 80 deletions

File tree

lib/index.ts

Lines changed: 122 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,12 @@ const GROUP_END = ')'
88
const EMPTY_QUOTES_STR = '""'
99
const KEY_SEPARATOR = '.'
1010
const NEGATED_PREFIX = 'not'
11-
const RANGE_REGEXP = /^[-\D]*(-?\d+(\.\d+)?)?[-\D]*-[-\D]*(-?\d+(\.\d+)?)?[-\D]*$/
11+
const RANGE_REGEXP = /^[^-\d]*(-?\d+(\.\d+)?)?[^-\d]*-[^-\d]*(-?\d+(\.\d+)?)?[^-\d]*$/
1212
const TOKENIZER = new RegExp(` *(${NEGATED_PREFIX})? *(\\${GROUP_START})| *(${NEGATED_PREFIX} +)?(?:((?:\\\\.|[^ ${GROUP_START}${GROUP_END}\\\\${REGEX_CHAR}${RANGE_CHAR}${TOKEN_SEPARATOR}])+) *([${REGEX_CHAR}${RANGE_CHAR}]?${TOKEN_SEPARATOR}))? *("((?:\\\\.|[^"\\\\])+)"|(?:\\\\.|[^ ${GROUP_START}${GROUP_END}\\\\])+)? *(and|or|\\${GROUP_END}|$)`, 'g')
1313
const TOKEN = { GROUP_NEGATED: 1, GROUP_START: 2, NEGATED: 3, KEY: 4, TYPE: 5, VALUE: 6, QUOTED_VALUE: 7, OPERATOR: 8 }
1414
const UNKNOWN = -1
1515
const EMPTY_ARR: any[] = []
16+
const EMPTY_OBJ: any = {}
1617
const EMPTY_STR = ''
1718
const STRING = 'string'
1819
const NUMBER = 'number'
@@ -59,19 +60,122 @@ namespace Operator {
5960
}
6061
}
6162

63+
interface SearchOptions {
64+
/** Array of keys to exclude from search. */
65+
excludeKeys?: string[],
66+
/** Whether to consider numeric strings in the range search. Disabling improves range search performance. Default is true. */
67+
allowNumericString?: boolean
68+
/** Whether to match keys and values when no quotes are used in the query and no value is provided. Disabling improves key search performance. Default is true.
69+
* @example
70+
* The query "foo" (no quotes), will match "foo: anyValue" and "anyField: foo".
71+
* The query "foo:bar" has a value and will not be affected by this option.
72+
*/
73+
allowKeyValueMatching?: boolean
74+
}
6275

6376
/**
64-
* Search through an array of objects using a powerful query syntax
65-
*
66-
* @param objList - Array of objects to search
67-
* @param queryStr - Query string (e.g. "name:john and age~:20-30")
68-
* @param exclude - Array of keys to exclude from search
69-
* @returns Array of matched objects
77+
* SearchEngine class provides methods to search through an array of objects using a query syntax.
78+
* The query syntax allows for complex searches including conditions, negations, and grouping.
7079
*/
71-
function search<T extends Record<string, any>>(objList: T[], queryStr: string, exclude?: string[]): T[] {
72-
if (!objList) { return [] }
73-
if (!queryStr || queryStr.trim() === EMPTY_STR) { return objList.slice() }
74-
return [...evaluateGroup(new Set(objList), extractConditionsFromQuery(queryStr.toLowerCase()), exclude)]
80+
class SearchEngine {
81+
#options: SearchOptions
82+
83+
/**
84+
* Creates a new instance of SearchEngine with the specified options.
85+
* @param options - Search options
86+
*/
87+
constructor(options: SearchOptions = {}) {
88+
options.allowNumericString === void 0 && (options.allowNumericString = true)
89+
options.allowKeyValueMatching === void 0 && (options.allowKeyValueMatching = true)
90+
this.#options = options
91+
}
92+
93+
/**
94+
* Search through an array of objects using the query syntax.
95+
*
96+
* @param objList - Array of objects to search
97+
* @param queryStr - Query string (e.g. "name:john and age~:20-30")
98+
* @returns Array of matched objects
99+
*/
100+
search<T extends Record<string, any>>(objList: T[], queryStr: string): T[] {
101+
if (!objList) { return [] }
102+
if (!queryStr || queryStr.trim() === EMPTY_STR) { return objList.slice() }
103+
return [...this.#evaluateGroup(new Set(objList), extractConditionsFromQuery(queryStr.toLowerCase()))]
104+
}
105+
106+
/**
107+
* Search through an array of objects using the query syntax.
108+
*
109+
* @param objList - Array of objects to search
110+
* @param queryStr - Query string (e.g. "name:john and age~:20-30")
111+
* @param options - Search options
112+
* @returns Array of matched objects
113+
*/
114+
static search<T extends Record<string, any>>(objList: T[], queryStr: string, options: SearchOptions = EMPTY_OBJ): T[] {
115+
return (new SearchEngine(options)).search(objList, queryStr)
116+
}
117+
118+
#evaluateGroup<T>(objList: Set<T>, group: GroupQuery): Set<T> {
119+
if (group.conditions.length === 0) { return group.negated ? new Set() : objList }
120+
121+
let currentResults = this.#evaluateCondition(objList, group.conditions[0])
122+
123+
for (let i = 1; i < group.conditions.length; i++) {
124+
const condition = group.conditions[i]
125+
const previousOperator = group.conditions[i - 1].operator
126+
127+
if (previousOperator && previousOperator === Operator.OR) {
128+
const nextResults = this.#evaluateCondition(objList, condition)
129+
nextResults.forEach(item => currentResults.add(item))
130+
} else {
131+
currentResults = this.#evaluateCondition(currentResults, condition)
132+
}
133+
}
134+
135+
if (!group.negated) { return currentResults }
136+
137+
// If the group is negated, return everything except the group results
138+
const negatedResult = new Set<T>()
139+
for (const obj of objList) { currentResults.has(obj) || negatedResult.add(obj) }
140+
return negatedResult
141+
}
142+
143+
#evaluateCondition<T>(objList: Set<T>, condition: Query | GroupQuery): Set<T> {
144+
if ('conditions' in condition) { return this.#evaluateGroup(objList, condition) }
145+
146+
const resultSet = new Set<T>()
147+
objList.forEach(obj => {
148+
if (condition.negated !== this.#findQuery(obj, condition, EMPTY_STR)) {
149+
resultSet.add(obj)
150+
}
151+
})
152+
return resultSet
153+
}
154+
155+
#findQuery(obj: any, query: Query, nestedKeys: string, keyFound?: boolean): boolean {
156+
const keys = getObjectKeys(obj)
157+
nestedKeys += KEY_SEPARATOR
158+
for (const key of keys) {
159+
const newNestedKeys = nestedKeys + key.toLowerCase()
160+
161+
if (isExcluded(newNestedKeys, this.#options.excludeKeys)) { continue }
162+
163+
if (keyFound === void 0) {
164+
if (newNestedKeys.indexOf(query.key) === UNKNOWN) {
165+
if (this.#findQuery(obj[key], query, newNestedKeys)) { return true }
166+
if (this.#options.allowKeyValueMatching && query.value === void 0 && match(query.key, obj[key], query.type, this.#options)) { return true }
167+
continue
168+
}
169+
170+
if (query.value === void 0) { return true }
171+
}
172+
173+
if (match(query.value, obj[key], query.type, this.#options) || this.#findQuery(obj[key], query, newNestedKeys, true)) {
174+
return true
175+
}
176+
}
177+
return false
178+
}
75179
}
76180

77181
function extractConditionsFromQuery(query: string, regex = new RegExp(TOKENIZER), group = new GroupQuery()): GroupQuery {
@@ -134,7 +238,9 @@ function getQuery(negated: boolean, type?: string, key?: string, value?: string)
134238
return query
135239
}
136240

137-
query.value = { min: parseFloat(matches[1]) || void 0, max: parseFloat(matches[3]) || void 0 }
241+
query.value = { min: parseFloat(matches[1]), max: parseFloat(matches[3]) }
242+
!query.value.min && query.value.min !== 0 && delete query.value.min
243+
!query.value.max && query.value.max !== 0 && delete query.value.max
138244

139245
if (query.value.min === void 0 && query.value.max === void 0) {
140246
delete query.type
@@ -144,77 +250,14 @@ function getQuery(negated: boolean, type?: string, key?: string, value?: string)
144250
return query
145251
}
146252

147-
function evaluateGroup<T>(objList: Set<T>, group: GroupQuery, exclude?: string[]): Set<T> {
148-
if (group.conditions.length === 0) { return group.negated ? new Set() : objList }
149-
150-
let currentResults = evaluateCondition(objList, group.conditions[0], exclude)
151-
152-
for (let i = 1; i < group.conditions.length; i++) {
153-
const condition = group.conditions[i]
154-
const previousOperator = group.conditions[i - 1].operator
155-
156-
if (previousOperator && previousOperator === Operator.OR) {
157-
const nextResults = evaluateCondition(objList, condition, exclude)
158-
nextResults.forEach(item => currentResults.add(item))
159-
} else {
160-
currentResults = evaluateCondition(currentResults, condition, exclude)
161-
}
162-
}
163-
164-
if (!group.negated) { return currentResults }
165-
166-
// If the group is negated, return everything except the group results
167-
const negatedResult = new Set<T>()
168-
for (const obj of objList) { currentResults.has(obj) || negatedResult.add(obj) }
169-
return negatedResult
170-
}
171-
172-
function evaluateCondition<T>(objList: Set<T>, condition: Query | GroupQuery, exclude?: string[]): Set<T> {
173-
if ('conditions' in condition) { return evaluateGroup(objList, condition, exclude) }
174-
175-
const resultSet = new Set<T>()
176-
objList.forEach(obj => {
177-
if (condition.negated !== findQuery(obj, condition, EMPTY_STR, exclude)) {
178-
resultSet.add(obj)
179-
}
180-
})
181-
return resultSet
182-
}
183-
184-
function findQuery(obj: any, query: Query, nestedKeys: string, excludedKeys?: string[], keyFound?: boolean): boolean {
185-
const keys = getObjectKeys(obj)
186-
nestedKeys += KEY_SEPARATOR
187-
for (const key of keys) {
188-
const newNestedKeys = nestedKeys + key.toLowerCase()
189-
190-
if (isExcluded(newNestedKeys, excludedKeys)) { continue }
191-
192-
if (keyFound === void 0) {
193-
if (newNestedKeys.indexOf(query.key!) === UNKNOWN) {
194-
if (findQuery(obj[key], query, newNestedKeys, excludedKeys)) {
195-
return true
196-
}
197-
continue
198-
}
199-
200-
if (query.value === void 0) { return true }
201-
}
202-
203-
if (match(query.value, obj[key], query.type) || findQuery(obj[key], query, newNestedKeys, excludedKeys, true)) {
204-
return true
205-
}
206-
}
207-
return false
208-
}
209-
210-
function match(expectedValue: any, value: any, type?: string): boolean {
253+
function match(expectedValue: any, value: any, type: string, options: SearchOptions): boolean {
211254
if (value === null || value === void 0) { return false }
212255

213256
const typeOf = typeof value
214257

215258
if (type === RANGE_CHAR) {
216-
if (typeOf !== NUMBER && typeOf !== BIGINT) { return false }
217-
return matchRange(expectedValue as Range, Number(value))
259+
if (typeOf !== NUMBER && typeOf !== BIGINT && !(options.allowNumericString && typeOf === STRING && !isNaN(value = +value))) { return false }
260+
return matchRange(expectedValue as Range, value)
218261
}
219262

220263
if (type === REGEX_CHAR) { return (expectedValue as RegExp).test(value) }
@@ -250,4 +293,4 @@ function removeEscapeChar(str?: string): string | void {
250293
return str ? str.replace(/\\(.)/g, '$1') : str
251294
}
252295

253-
export { search }
296+
export default SearchEngine

test/index.bench.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -263,7 +263,7 @@ function runSearchBenchmark() {
263263
// Implementations to benchmark
264264
const implementations = {
265265
"Static": SearchEngine.search,
266-
"Constructor": instance.search.bind(instance)
266+
"Constructor": (...args) => instance.search(...args)
267267

268268
// Add alternative implementations to compare:
269269
// "Alternative": alternativeSearch,

0 commit comments

Comments
 (0)