Skip to content

Commit 81c5891

Browse files
committed
Fix group negation
1 parent 4364a2a commit 81c5891

File tree

3 files changed

+115
-21
lines changed

3 files changed

+115
-21
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ const result4 = search(users, '("developer" or "designer") and not age~:40-50')
100100
### Negation and Grouping
101101

102102
- `not term` - Term must not match
103-
- `(term1 or term2) and term3` - Logical grouping with parentheses
103+
- `not (term1 or term2) and term3` - Logical grouping with parentheses
104104

105105
## API Reference
106106

lib/index.ts

Lines changed: 29 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,10 @@ const GROUP_START = '('
77
const GROUP_END = ')'
88
const EMPTY_QUOTES_STR = '""'
99
const KEY_SEPARATOR = '.'
10-
const NEGATED_PREFIX = 'not '
10+
const NEGATED_PREFIX = 'not'
1111
const RANGE_REGEXP = /^[-\D]*(-?\d+(\.\d+)?)?[-\D]*-[-\D]*(-?\d+(\.\d+)?)?[-\D]*$/
12-
const TOKENIZER = new RegExp(` *(\\${GROUP_START})| *(${NEGATED_PREFIX}+)?(?:((?:\\\\.|[^ ${GROUP_START}${GROUP_END}\\\\${REGEX_CHAR}${RANGE_CHAR}${TOKEN_SEPARATOR}])+) *([${REGEX_CHAR}${RANGE_CHAR}]?${TOKEN_SEPARATOR}))? *("((?:\\\\.|[^"\\\\])+)"|(?:\\\\.|[^ ${GROUP_START}${GROUP_END}\\\\])+)? *(\\${GROUP_END})? *(and|or|\\)|$)`, 'g')
13-
const TOKEN = { GROUP_START: 1, NEGATED: 2, KEY: 3, TYPE: 4, VALUE: 5, QUOTED_VALUE: 6, GROUP_END: 7, OPERATOR: 8 }
12+
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')
13+
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[] = []
1616
const EMPTY_STR = ''
@@ -34,6 +34,7 @@ interface Range {
3434

3535
class GroupQuery {
3636
conditions: (Query | GroupQuery)[]
37+
negated?: boolean
3738
operator?: Operator
3839

3940
constructor() {
@@ -71,14 +72,16 @@ function search<T extends Record<string, any>>(objList: T[], queryStr: string, e
7172

7273
if (!queryStr || queryStr.trim() === EMPTY_STR) { return objList.slice() }
7374

74-
return [...evaluateConditions(objList, extractConditionsFromQuery(queryStr.toLowerCase()), exclude)]
75+
return [...evaluateCondition(objList, extractConditionsFromQuery(queryStr.toLowerCase()), exclude)]
7576
}
7677

7778
function extractConditionsFromQuery(query: string, regex = new RegExp(TOKENIZER), group = new GroupQuery()): GroupQuery {
7879
let m: RegExpExecArray | null
7980
while ((m = regex.exec(query)) !== null && m[0] !== EMPTY_STR) {
8081
if (m[TOKEN.GROUP_START]) {
81-
group.conditions.push(extractConditionsFromQuery(query, regex))
82+
const subGroup = extractConditionsFromQuery(query, regex)
83+
subGroup.negated = !!m[TOKEN.GROUP_NEGATED]
84+
group.conditions.push(subGroup)
8285
continue
8386
}
8487

@@ -96,16 +99,7 @@ function extractConditionsFromQuery(query: string, regex = new RegExp(TOKENIZER)
9699
group.conditions.push(getQuery(!!m[TOKEN.NEGATED], type, key, value))
97100
}
98101

99-
if (m[TOKEN.OPERATOR] === GROUP_END) {
100-
m[TOKEN.GROUP_END] = GROUP_END
101-
m[TOKEN.OPERATOR] = void 0
102-
}
103-
104-
if (m[TOKEN.GROUP_END]) {
105-
if (m[TOKEN.OPERATOR]) { group.operator = Operator.from(m[TOKEN.OPERATOR]) }
106-
break
107-
}
108-
102+
if (m[TOKEN.OPERATOR] === GROUP_END) { break }
109103
if (m[TOKEN.OPERATOR]) { group.lastCondition()!.operator = Operator.from(m[TOKEN.OPERATOR]) }
110104
}
111105

@@ -151,7 +145,7 @@ function getQuery(negated: boolean, type?: string, key?: string, value?: string)
151145
return query
152146
}
153147

154-
function evaluateConditions<T>(objList: T[], group: GroupQuery, exclude?: string[]): Set<T> {
148+
function evaluateGroup<T>(objList: T[], group: GroupQuery, exclude?: string[]): Set<T> {
155149
if (group.conditions.length === 0) { return new Set(objList) }
156150

157151
let currentResults = evaluateCondition(objList, group.conditions[0], exclude)
@@ -172,7 +166,24 @@ function evaluateConditions<T>(objList: T[], group: GroupQuery, exclude?: string
172166
}
173167

174168
function evaluateCondition<T>(objList: T[], condition: Query | GroupQuery, exclude?: string[]): Set<T> {
175-
if ('conditions' in condition) { return evaluateConditions(objList, condition, exclude) }
169+
if ('conditions' in condition) {
170+
// Get the result of evaluating the group
171+
const groupResult = evaluateGroup(objList, condition, exclude)
172+
173+
// If the group is negated, return everything except the group results
174+
if (condition.negated) {
175+
const negatedResult = new Set<T>()
176+
objList.forEach(obj => {
177+
if (!groupResult.has(obj)) {
178+
negatedResult.add(obj)
179+
}
180+
})
181+
return negatedResult
182+
}
183+
184+
return groupResult
185+
}
186+
176187
const resultSet = new Set<T>()
177188
objList.forEach(obj => {
178189
if (condition.negated !== findQuery(obj, condition, EMPTY_STR, exclude)) {
@@ -182,7 +193,7 @@ function evaluateCondition<T>(objList: T[], condition: Query | GroupQuery, exclu
182193
return resultSet
183194
}
184195

185-
function findQuery(obj: Object, query: Query, nestedKeys: string, excludedKeys?: string[], keyFound?: boolean): boolean {
196+
function findQuery(obj: any, query: Query, nestedKeys: string, excludedKeys?: string[], keyFound?: boolean): boolean {
186197
return getObjectKeys(obj).some((key) => {
187198
const newNestedKeys = nestedKeys + KEY_SEPARATOR + key.toLowerCase()
188199

test/index.test.js

Lines changed: 85 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,14 @@ describe('Search Engine', () => {
191191
expect(results[0].id).toBe(2)
192192
expect(results[1].id).toBe(4)
193193
})
194+
195+
test('Negation of groups', () => {
196+
const query = "not(not(not((name:john or not active:true))))"
197+
const results = search(testData, query)
198+
expect(results.length).toBe(2)
199+
expect(results[0].id).toBe(2)
200+
expect(results[1].id).toBe(4)
201+
})
194202
})
195203

196204
// 6. Nested property searching
@@ -241,6 +249,14 @@ describe('Search Engine', () => {
241249
expect(results.some(r => r.id === 2)).toBe(true)
242250
})
243251

252+
test('Simple grouping De Morgan', () => {
253+
const query = "not (not age:25 and not age:30)"
254+
const results = search(testData, query)
255+
expect(results.length).toBe(2)
256+
expect(results.some(r => r.id === 1)).toBe(true)
257+
expect(results.some(r => r.id === 2)).toBe(true)
258+
})
259+
244260
test('Nested grouping', () => {
245261
const query = "active:true and (age~:25-30 or tags:python)"
246262
const results = search(testData, query)
@@ -251,7 +267,7 @@ describe('Search Engine', () => {
251267
})
252268

253269
test('Complex expression', () => {
254-
const query = '(active:"true" and ((age~:"25-30"))) or((((not active:true))) and ((((age:35)))))'
270+
const query = 'not (not active:"true" or not (not(not age~:"25-30"))) or((((not active:true))) and ((((age:35)))))'
255271
const results = search(testData, query)
256272
expect(results.length).toBe(4)
257273
expect(results.some(r => r.id === 1)).toBe(true)
@@ -348,4 +364,71 @@ describe('Search Engine', () => {
348364
expect(results[0].id).toBe(6)
349365
})
350366
})
351-
})
367+
368+
describe('All features together', () => {
369+
test('Combined search with all features', () => {
370+
// This complex query combines:
371+
// - Regex pattern matching (name*:^J)
372+
// - Range searches (age~:25-35)
373+
// - Negation (not tags:manager)
374+
// - Group negation (not (age:45 or tags:golang))
375+
// - Boolean operators (and/or)
376+
// - Multiple nested groups
377+
// - Field-only search (skill_level)
378+
// - Value-only search ("developer")
379+
380+
const query = `
381+
(name*:^J and age~:25-35 and not tags:manager)
382+
or
383+
("developer" and not (age:45 or tags:golang))
384+
or
385+
(skill_level and not (active:false))
386+
`.replace(/\n/g, ' ').trim()
387+
388+
const results = search(testData, query)
389+
390+
// Expected matches:
391+
// id:1 - John Smith: matches (name*:^J and age~:25-35)
392+
// id:2 - Jane Doe: matches (name*:^J and age~:25-35)
393+
// id:4 - Alice Williams: matches ("developer" and not (age:45 or tags:golang))
394+
395+
expect(results.length).toBe(3)
396+
expect(results.some(r => r.id === 1)).toBe(true)
397+
expect(results.some(r => r.id === 2)).toBe(true)
398+
expect(results.some(r => r.id === 4)).toBe(true)
399+
400+
// These should NOT match:
401+
expect(results.some(r => r.id === 3)).toBe(false) // Bob: age 45, tags:manager
402+
expect(results.some(r => r.id === 5)).toBe(false) // Charlie: active:false with skill_level
403+
expect(results.some(r => r.id === 6)).toBe(false) // Eve: tags:golang
404+
})
405+
406+
test('Advanced De Morgan negation with all features', () => {
407+
// This query tests complex negation logic with De Morgan's laws
408+
// not(A and B) is equivalent to (not A or not B)
409+
const query = `
410+
not (
411+
not (name*:^[JB] or age~:32-45)
412+
and
413+
not ("developer" or active:true)
414+
)
415+
`.replace(/\n/g, ' ').trim()
416+
417+
const results = search(testData, query)
418+
419+
// This complex query resolves to:
420+
// (name*:^[JB] or age~:32-45) or ("developer" or active:true)
421+
// Which should match all records except id:6 (Eve)
422+
423+
expect(results.length).toBe(5)
424+
expect(results.some(r => r.id === 6)).toBe(false)
425+
426+
// Verify the breakdown of matching conditions:
427+
const exactQuery = "(name*:^[JB] or age~:32-45) or (\"developer\" or active:true)"
428+
const exactResults = search(testData, exactQuery)
429+
expect(exactResults.length).toBe(5)
430+
expect(JSON.stringify(results.map(r => r.id).sort()))
431+
.toBe(JSON.stringify(exactResults.map(r => r.id).sort()))
432+
})
433+
})
434+
})

0 commit comments

Comments
 (0)