Skip to content

Commit 279bd9f

Browse files
committed
feat: maxLevel and "is/is not" operators
1 parent a5bd159 commit 279bd9f

3 files changed

Lines changed: 173 additions & 56 deletions

File tree

README.md

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,8 @@ const engine = new SearchEngine({
5151
excludeKeys: ['name', 'tags'],
5252
allowNumericString: false,
5353
allowKeyValueMatching: true,
54-
matchChildKeysAsValues: false
54+
matchChildKeysAsValues: false,
55+
maxLevels: 5
5556
})
5657

5758
const results = engine.search(users, 'age~: 25-35')
@@ -68,6 +69,10 @@ The `SearchOptions` object allows you to customize the behavior of the search en
6869
| `allowNumericString` | `boolean` | `true` | Controls whether string values that can be parsed as numbers are used in range searches. |
6970
| `allowKeyValueMatching` | `boolean` | `true` | When enabled, unquoted terms without a field/value separator match both field names and values. |
7071
| `matchChildKeysAsValues` | `boolean` | `false` | When enabled, after finding a matching key, also looks for the value in child object keys. |
72+
| `maxLevels` | `number` | unlimited | Maximum levels of nested objects to search through. Useful to avoid infinite loops in deeply nested or circular data. |
73+
74+
### Notes
75+
- The `maxLevels` option can be set to limit how deep the search will go into nested objects. This is especially useful for large or circular data structures.
7176

7277
### Differences Between Options
7378

@@ -98,7 +103,17 @@ The `SearchOptions` object allows you to customize the behavior of the search en
98103
- `field~: 10-` - Numbers greater than or equal to 10.
99104
- `field~: -20` - Numbers less than or equal to 20.
100105

101-
Negative values are also supported.
106+
### "Is / Is Not" Operators
107+
108+
- `field is value` - Search for objects where `field` is exactly `value`.
109+
- `field is not value` - Search for objects where `field` is not `value`.
110+
111+
Where `value` must be one of the following:
112+
- A boolean (`true` or `false`)
113+
- `null`
114+
- `undefined` (or `undef`)
115+
- `blank` (empty strings)
116+
- `empty` (empty arrays)
102117

103118
### Boolean Operators
104119

@@ -143,6 +158,7 @@ To store the options, use the constructor below:
143158
- Set `matchChildKeysAsValues: false` (default) unless you specifically need to match object keys as values.
144159
- Use `excludeKeys` to skip searching in fields that are never relevant to your searches.
145160
- For repeated searches with the same options, create a `SearchEngine` instance instead of using the static method.
161+
- Limit `maxLevels` if you have deeply nested data to avoid performance issues.
146162

147163
## Examples and Advanced Usage
148164

lib/index.ts

Lines changed: 81 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -3,27 +3,36 @@
33
const TOKEN_SEPARATOR = ':'
44
const REGEX_CHAR = '*'
55
const RANGE_CHAR = '~'
6+
const NEGATED_PREFIX = 'not'
7+
const SPECIAL_MATCH_PREFIX = 'is'
8+
const SPECIAL_NOT_MATCH_PREFIX = `${SPECIAL_MATCH_PREFIX} ${NEGATED_PREFIX}`
9+
const SPECIAL_MATCH_VALUES = ['true', 'false', 'undef', 'undefined', 'null', 'blank', 'empty']
610
const GROUP_START = '('
711
const GROUP_END = ')'
812
const VAL_TOKEN = '"'
913
const EMPTY_VAL_GROUP = `${VAL_TOKEN}${VAL_TOKEN}`
1014
const KEY_SEPARATOR = '.'
11-
const NEGATED_PREFIX = 'not'
1215
const RANGE_REGEXP = /^[^-\d]*(-?\d+(\.\d+)?)?[^-\d]*-[^-\d]*(-?\d+(\.\d+)?)?[^-\d]*$/
13-
const TOKENIZER = new RegExp(` *(${NEGATED_PREFIX})? *(\\${GROUP_START})| *(${NEGATED_PREFIX} +)?(?:((?:\\\\.|[^ ${GROUP_START}${GROUP_END}\\\\${REGEX_CHAR}${RANGE_CHAR}${VAL_TOKEN}${TOKEN_SEPARATOR}])+) *([${REGEX_CHAR}${RANGE_CHAR}]?${TOKEN_SEPARATOR}))? *(${VAL_TOKEN}((?:\\\\.|[^${VAL_TOKEN}\\\\])+)${VAL_TOKEN}?|(?:\\\\.|[^ ${GROUP_START}${GROUP_END}\\\\])+)? *(and|or|\\${GROUP_END}|$)`, 'g')
16+
const TOKENIZER = new RegExp(` *(${NEGATED_PREFIX})? *(\\${GROUP_START})| *(${NEGATED_PREFIX} +)?(?:((?:\\\\.|[^ ${GROUP_START}${GROUP_END}\\\\${REGEX_CHAR}${RANGE_CHAR}${VAL_TOKEN}${TOKEN_SEPARATOR}])+) *(${SPECIAL_MATCH_PREFIX}|${SPECIAL_NOT_MATCH_PREFIX}|[${REGEX_CHAR}${RANGE_CHAR}]?${TOKEN_SEPARATOR}))? *(${VAL_TOKEN}((?:\\\\.|[^${VAL_TOKEN}\\\\])+)${VAL_TOKEN}?|(?:\\\\.|[^ ${GROUP_START}${GROUP_END}\\\\])+)? *(and|or|\\${GROUP_END}|$)`, 'g')
1417
const TOKEN = { GROUP_NEGATED: 1, GROUP_START: 2, NEGATED: 3, KEY: 4, TYPE: 5, VALUE: 6, QUOTED_VALUE: 7, OPERATOR: 8 }
1518
const UNKNOWN = -1
1619
const EMPTY_STR = ''
1720
const STRING = 'string'
1821
const NUMBER = 'number'
19-
const BOOLEAN = 'boolean'
2022
const BIGINT = 'bigint'
2123
const OBJECT = 'object'
2224

25+
enum QueryType {
26+
PARTIAL = 1,
27+
RANGE = 2,
28+
REGEX = 3,
29+
IS = 4,
30+
}
31+
2332
interface Query {
2433
key: any
2534
value: any
26-
type?: string
35+
type: QueryType
2736
operator?: Operator
2837
negated?: boolean
2938
}
@@ -84,22 +93,27 @@ interface SearchOptions {
8493
* If true, "foo:bar" => [{ foo: 'bar' }, { foo: { bar: 'dummy' } }]
8594
* Else, "foo:bar" => [{ foo: 'bar' }]
8695
*/
87-
matchChildKeysAsValues?: boolean
96+
matchChildKeysAsValues?: boolean,
97+
/** Maximum levels of nested objects to search through.
98+
* Disabling allows searching through all levels but may impact performance and cause infinite loop in case of circular references.
99+
* Default is unlimited levels.
100+
*/
101+
maxLevels?: number
88102
}
89103

90104
/**
91105
* SearchEngine class provides methods to search through an array of objects using a query syntax.
92106
* The query syntax allows for complex searches including conditions, negations, and grouping.
93107
*/
94108
class SearchEngine {
95-
#options: SearchOptions
109+
private options: SearchOptions
96110

97111
/**
98112
* Creates a new instance of SearchEngine with the specified options.
99113
* @param options - Search options
100114
*/
101115
constructor(options: SearchOptions = {}) {
102-
this.#options = options
116+
this.options = options
103117
}
104118

105119
/**
@@ -110,7 +124,7 @@ class SearchEngine {
110124
* @returns Array of matched objects
111125
*/
112126
search<T extends Record<string, any>>(objList: T[], queryStr: string): T[] {
113-
return SearchEngine.search(objList, queryStr, this.#options)
127+
return SearchEngine.search(objList, queryStr, this.options)
114128
}
115129

116130
/**
@@ -160,17 +174,19 @@ function evaluateCondition<T>(objList: Set<T>, condition: Query | GroupQuery, op
160174

161175
const resultSet = new Set<T>()
162176
for (const obj of objList) {
163-
if (condition.negated !== findQuery(obj, condition, EMPTY_STR, options)) {
177+
if (condition.negated !== findQuery(obj, condition, EMPTY_STR, options, 1)) {
164178
resultSet.add(obj)
165179
}
166180
}
167181
return resultSet
168182
}
169183

170-
function findQuery(obj: any, query: Query, nestedKeys: string, options: SearchOptions, keyFound?: boolean): boolean {
184+
function findQuery(obj: any, query: Query, nestedKeys: string, options: SearchOptions, level: number, keyFound?: boolean): boolean {
171185
if (obj === null || obj === void 0 || typeof obj !== OBJECT) { return false }
172186
const keys = Object.keys(obj)
173187

188+
obj.length !== void 0 && keys.push('length')
189+
174190
nestedKeys += KEY_SEPARATOR
175191
for (const key of keys) {
176192
const newNestedKeys = nestedKeys + key.toLowerCase()
@@ -179,15 +195,15 @@ function findQuery(obj: any, query: Query, nestedKeys: string, options: SearchOp
179195

180196
if (keyFound === void 0) {
181197
if (newNestedKeys.indexOf(query.key) === UNKNOWN) {
182-
if (findQuery(obj[key], query, newNestedKeys, options)) { return true }
198+
if (findQuery(obj[key], query, newNestedKeys, options, level + 1)) { return true }
183199
if (options.allowKeyValueMatching && query.value === void 0 && match(query.key, obj[key], query.type, options)) { return true }
184200
continue
185201
}
186202

187203
if (query.value === void 0) { return true }
188204
}
189205

190-
if (match(query.value, obj[key], query.type, options) || findQuery(obj[key], query, newNestedKeys, options, true)) {
206+
if (match(query.value, obj[key], query.type, options) || findQuery(obj[key], query, newNestedKeys, options, level + 1, true)) {
191207
return true
192208
}
193209
}
@@ -204,8 +220,8 @@ function extractConditionsFromQuery(query: string, regex = new RegExp(TOKENIZER)
204220
continue
205221
}
206222

207-
let key = m[TOKEN.KEY]
208-
let value = m[TOKEN.QUOTED_VALUE] || void 0
223+
let key: string | undefined = m[TOKEN.KEY]
224+
let value: string | undefined = m[TOKEN.QUOTED_VALUE] || void 0
209225

210226
if (key === void 0) {
211227
key = value !== void 0 ? KEY_SEPARATOR : getUnquotedValue(m[TOKEN.VALUE])
@@ -214,8 +230,7 @@ function extractConditionsFromQuery(query: string, regex = new RegExp(TOKENIZER)
214230
}
215231

216232
if (key || value) {
217-
const type = m[TOKEN.TYPE] && m[TOKEN.TYPE] !== TOKEN_SEPARATOR ? m[TOKEN.TYPE].charAt(0) : void 0
218-
group.conditions.push(getQuery(!!m[TOKEN.NEGATED], type, key, value))
233+
group.conditions.push(getQuery(!!m[TOKEN.NEGATED], m[TOKEN.TYPE], key, value))
219234
}
220235

221236
if (m[TOKEN.OPERATOR] === GROUP_END) { break }
@@ -227,51 +242,61 @@ function extractConditionsFromQuery(query: string, regex = new RegExp(TOKENIZER)
227242

228243
function getQuery(negated: boolean, type?: string, key?: string, value?: string): Query {
229244

230-
const query: Query = { negated, key: removeEscapeChar(key), type, value: removeEscapeChar(value) }
245+
const query: Query = { negated, key: removeEscapeChar(key), type: QueryType.PARTIAL, value: removeEscapeChar(value) }
231246

232-
if (!query.type) { return query }
247+
if (!type || type === TOKEN_SEPARATOR) { return query }
233248

234249
if (!query.value || query.value.trim() === EMPTY_STR) {
235-
delete query.type
236250
delete query.value
237251
return query
238252
}
239253

240-
if (query.type === REGEX_CHAR) {
254+
if (type[0] === REGEX_CHAR) {
241255
try {
242256
query.value = new RegExp(query.value, 'i')
257+
query.type = QueryType.REGEX
243258
} catch (_e) {
244-
delete query.type
245259
delete query.value
246260
}
247261
return query
248-
}
262+
}
249263

250-
const matches = query.value.match(RANGE_REGEXP)
251-
if (!matches) {
252-
delete query.type
253-
delete query.value
264+
if (type[0] === RANGE_CHAR) {
265+
const matches = query.value.match(RANGE_REGEXP)
266+
if (!matches) {
267+
delete query.value
268+
return query
269+
}
270+
271+
query.value = { min: parseFloat(matches[1]), max: parseFloat(matches[3]) }
272+
!query.value.min && query.value.min !== 0 && delete query.value.min
273+
!query.value.max && query.value.max !== 0 && delete query.value.max
274+
275+
if (query.value.min === void 0 && query.value.max === void 0) {
276+
delete query.value
277+
} else {
278+
query.type = QueryType.RANGE
279+
}
254280
return query
255281
}
256282

257-
query.value = { min: parseFloat(matches[1]), max: parseFloat(matches[3]) }
258-
!query.value.min && query.value.min !== 0 && delete query.value.min
259-
!query.value.max && query.value.max !== 0 && delete query.value.max
260-
261-
if (query.value.min === void 0 && query.value.max === void 0) {
262-
delete query.type
263-
delete query.value
283+
if (type.toLowerCase() === SPECIAL_NOT_MATCH_PREFIX) { query.negated = !query.negated }
284+
if (SPECIAL_MATCH_VALUES.includes(query.value)) {
285+
query.type = QueryType.IS
264286
}
265287

266288
return query
267289
}
268290

269-
function match(expectedValue: any, value: any, type: string, options: SearchOptions): boolean {
270-
if (value === null || value === void 0) { return false }
271-
272-
const typeOf = typeof value
291+
function match(expectedValue: any, value: any, type: QueryType, options: SearchOptions): boolean {
292+
const typeOf = value === null || value === void 0 ? STRING : typeof value
273293

274294
if (typeOf === OBJECT) {
295+
if (Array.isArray(value)) {
296+
if (type === QueryType.IS && expectedValue === 'empty' && value.length === 0) { return true }
297+
return false
298+
}
299+
275300
if (options.matchChildKeysAsValues) {
276301
for (const v of Object.keys(value)) {
277302
if (match(expectedValue, v, type, options)) { return true }
@@ -280,18 +305,30 @@ function match(expectedValue: any, value: any, type: string, options: SearchOpti
280305
return false
281306
}
282307

283-
if (type === RANGE_CHAR) {
308+
if (type === QueryType.RANGE) {
284309
if (typeOf !== NUMBER && typeOf !== BIGINT && !(options.allowNumericString && typeOf === STRING && !isNaN(value = +value))) { return false }
285310
return matchRange(expectedValue as Range, value)
286311
}
287312

288-
if (type === REGEX_CHAR) { return (expectedValue as RegExp).test(value) }
313+
if (type === QueryType.REGEX) { return (expectedValue as RegExp).test(value) }
314+
315+
if (type === QueryType.IS) {
316+
switch (expectedValue) {
317+
case 'true': return value === true
318+
case 'false': return value === false
319+
case 'undef':
320+
case 'undefined': return value === void 0
321+
case 'null': return value === null
322+
case 'blank': return value === EMPTY_STR
323+
default: return false
324+
}
325+
}
289326

290327
if (typeOf === STRING) {
291328
return `${value}`.toLowerCase().indexOf(expectedValue) !== UNKNOWN
292329
}
293330

294-
if (typeOf === NUMBER || typeOf === BIGINT || typeOf === BOOLEAN) {
331+
if (typeOf === NUMBER || typeOf === BIGINT) {
295332
return `${value}`.indexOf(expectedValue) !== UNKNOWN
296333
}
297334

@@ -303,7 +340,7 @@ function matchRange(expectedRange: Range, numValue: number): boolean {
303340
return numValue >= expectedRange.min && numValue <= expectedRange.max
304341
}
305342
if (expectedRange.min !== void 0) { return numValue >= expectedRange.min }
306-
return numValue <= expectedRange.max
343+
return numValue <= expectedRange.max!
307344
}
308345

309346
function isExcluded(nestedKeys: string, excludedKeys?: string[]): boolean {
@@ -313,14 +350,15 @@ function isExcluded(nestedKeys: string, excludedKeys?: string[]): boolean {
313350
return false
314351
}
315352

316-
function removeEscapeChar(str?: string): string | void {
353+
function removeEscapeChar(str?: string): string | undefined {
317354
return str ? str.replace(/\\(.)/g, '$1') : str
318355
}
319356

320-
function getUnquotedValue(value: string): string {
357+
function getUnquotedValue(value: string): string | undefined {
321358
return value !== EMPTY_VAL_GROUP && value !== VAL_TOKEN ? value : void 0
322359
}
323360

324361
export default SearchEngine
325362

363+
// @ts-ignore module exists in CommonJS environments
326364
module && (module.exports = SearchEngine)

0 commit comments

Comments
 (0)