@open-mercato/shared 0.6.1-develop.3276.1.1fcd145078 → 0.6.1-develop.3287.1.450f4ffb56
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/dist/lib/crud/advanced-filter-integration.js +48 -2
- package/dist/lib/crud/advanced-filter-integration.js.map +2 -2
- package/dist/lib/query/advanced-filter-tree.js +280 -0
- package/dist/lib/query/advanced-filter-tree.js.map +7 -0
- package/dist/lib/query/advanced-filter.js +168 -1
- package/dist/lib/query/advanced-filter.js.map +2 -2
- package/dist/lib/query/engine.js +4 -3
- package/dist/lib/query/engine.js.map +2 -2
- package/dist/lib/query/join-utils.js +117 -69
- package/dist/lib/query/join-utils.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/package.json +2 -2
- package/src/lib/crud/__tests__/advanced-filter-integration.test.ts +113 -0
- package/src/lib/crud/advanced-filter-integration.ts +85 -1
- package/src/lib/query/__tests__/advanced-filter-end-to-end.test.ts +367 -0
- package/src/lib/query/__tests__/advanced-filter-serialization.test.ts +218 -0
- package/src/lib/query/__tests__/advanced-filter-tree.test.ts +326 -0
- package/src/lib/query/__tests__/engine.scope-and-or.test.ts +9 -7
- package/src/lib/query/__tests__/join-utils.complexity.test.ts +43 -0
- package/src/lib/query/advanced-filter-tree.ts +328 -0
- package/src/lib/query/advanced-filter.ts +217 -0
- package/src/lib/query/engine.ts +4 -3
- package/src/lib/query/join-utils.ts +156 -70
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* End-to-end Phase 1-5 verification of the user's reported AND/OR scenarios.
|
|
3
|
+
*
|
|
4
|
+
* Pipeline exercised:
|
|
5
|
+
* AdvancedFilterTree
|
|
6
|
+
* -> compileTreeToWhere (advanced-filter-tree.ts)
|
|
7
|
+
* -> mergeAdvancedFilterTree (advanced-filter-integration.ts)
|
|
8
|
+
* -> normalizeFilters with DNF expansion + common-clause lift (join-utils.ts)
|
|
9
|
+
*
|
|
10
|
+
* The user's bug: with seed people Daniel Cho & Naomi Harris,
|
|
11
|
+
* - "Name contains Daniel OR Name contains Naomi" should return both;
|
|
12
|
+
* - "Name contains Daniel AND Name contains Harris" should return Daniel Harris (or none if absent),
|
|
13
|
+
* NOT Naomi Harris alone.
|
|
14
|
+
*
|
|
15
|
+
* We verify the math by inspecting the normalized filter shape that the engine
|
|
16
|
+
* would issue. Each test asserts on which row(s) match.
|
|
17
|
+
*/
|
|
18
|
+
/** @jest-environment node */
|
|
19
|
+
import { compileTreeToWhere, type AdvancedFilterTree, type FilterRule, type FilterGroup } from '../advanced-filter-tree'
|
|
20
|
+
import { mergeAdvancedFilterTree } from '../../crud/advanced-filter-integration'
|
|
21
|
+
import { normalizeFilters } from '../join-utils'
|
|
22
|
+
import type { FilterOp } from '../types'
|
|
23
|
+
|
|
24
|
+
type Row = { id: string; display_name: string; status?: string; kind: 'person' }
|
|
25
|
+
const people: Row[] = [
|
|
26
|
+
{ id: '1', display_name: 'Daniel Cho', kind: 'person' },
|
|
27
|
+
{ id: '2', display_name: 'Naomi Harris', kind: 'person', status: 'Lead' },
|
|
28
|
+
{ id: '3', display_name: 'Daniel Harris', kind: 'person' },
|
|
29
|
+
{ id: '4', display_name: 'Naomi Cho', kind: 'person' },
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
type Predicate = (row: Row) => boolean
|
|
33
|
+
|
|
34
|
+
function leafToPredicate(field: string, op: FilterOp, value: unknown): Predicate {
|
|
35
|
+
return (row: Row) => {
|
|
36
|
+
const cell = (row as Record<string, unknown>)[field]
|
|
37
|
+
if (op === 'eq') return cell === value
|
|
38
|
+
if (op === 'ne') return cell !== value
|
|
39
|
+
if (op === 'ilike' && typeof cell === 'string' && typeof value === 'string') {
|
|
40
|
+
// Convert SQL ilike pattern (% wildcards) to regex
|
|
41
|
+
const pattern = value.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/%/g, '.*')
|
|
42
|
+
return new RegExp(`^${pattern}$`, 'i').test(cell)
|
|
43
|
+
}
|
|
44
|
+
if (op === 'in' && Array.isArray(value)) return value.includes(cell)
|
|
45
|
+
if (op === 'exists') return value === true ? cell != null : cell == null
|
|
46
|
+
return false
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Take normalized filter output (regular base filters + orGroup-tagged disjuncts)
|
|
52
|
+
* and produce a single predicate. Mirrors the engine's eb.and([...]) and eb.or([...]).
|
|
53
|
+
*/
|
|
54
|
+
function buildPredicate(filters: ReturnType<typeof normalizeFilters>): Predicate {
|
|
55
|
+
const regular: Predicate[] = []
|
|
56
|
+
const groups = new Map<string, Predicate[]>()
|
|
57
|
+
for (const f of filters) {
|
|
58
|
+
const pred = leafToPredicate(String(f.field), f.op, f.value)
|
|
59
|
+
if (f.orGroup) {
|
|
60
|
+
const list = groups.get(f.orGroup) ?? []
|
|
61
|
+
list.push(pred)
|
|
62
|
+
groups.set(f.orGroup, list)
|
|
63
|
+
} else {
|
|
64
|
+
regular.push(pred)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return (row: Row) => {
|
|
68
|
+
for (const p of regular) if (!p(row)) return false
|
|
69
|
+
if (groups.size === 0) return true
|
|
70
|
+
for (const [, preds] of groups) {
|
|
71
|
+
if (preds.every((p) => p(row))) return true
|
|
72
|
+
}
|
|
73
|
+
return false
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function evaluate(routeFilters: Record<string, unknown>, tree: AdvancedFilterTree): Row[] {
|
|
78
|
+
const merged = mergeAdvancedFilterTree(routeFilters, tree)
|
|
79
|
+
const normalized = normalizeFilters(merged)
|
|
80
|
+
const pred = buildPredicate(normalized)
|
|
81
|
+
return people.filter(pred)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const rule = (field: string, op: FilterRule['operator'], value: string): FilterRule => ({
|
|
85
|
+
id: `${field}:${value}`, type: 'rule', field, operator: op, value,
|
|
86
|
+
})
|
|
87
|
+
const group = (combinator: 'and' | 'or', children: Array<FilterRule | FilterGroup>): FilterGroup => ({
|
|
88
|
+
id: combinator, type: 'group', combinator, children,
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
describe('Advanced filter — end-to-end user bug scenarios', () => {
|
|
92
|
+
const baseRouteFilters: Record<string, unknown> = { kind: { $eq: 'person' } }
|
|
93
|
+
|
|
94
|
+
test('"Name contains Daniel" -> 2 rows', () => {
|
|
95
|
+
const tree: AdvancedFilterTree = { root: group('and', [rule('display_name', 'contains', 'Daniel')]) }
|
|
96
|
+
expect(evaluate(baseRouteFilters, tree).map((r) => r.display_name).sort()).toEqual(['Daniel Cho', 'Daniel Harris'])
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
test('"Name contains Daniel AND Name contains Harris" -> 1 row (Daniel Harris) — Bug A fixed', () => {
|
|
100
|
+
const tree: AdvancedFilterTree = {
|
|
101
|
+
root: group('and', [
|
|
102
|
+
rule('display_name', 'contains', 'Daniel'),
|
|
103
|
+
rule('display_name', 'contains', 'Harris'),
|
|
104
|
+
]),
|
|
105
|
+
}
|
|
106
|
+
expect(evaluate(baseRouteFilters, tree).map((r) => r.display_name)).toEqual(['Daniel Harris'])
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('"Name contains Daniel OR Name contains Naomi" -> 4 rows', () => {
|
|
110
|
+
const tree: AdvancedFilterTree = {
|
|
111
|
+
root: group('or', [
|
|
112
|
+
rule('display_name', 'contains', 'Daniel'),
|
|
113
|
+
rule('display_name', 'contains', 'Naomi'),
|
|
114
|
+
]),
|
|
115
|
+
}
|
|
116
|
+
expect(evaluate(baseRouteFilters, tree).map((r) => r.display_name).sort()).toEqual([
|
|
117
|
+
'Daniel Cho', 'Daniel Harris', 'Naomi Cho', 'Naomi Harris',
|
|
118
|
+
])
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('"(Daniel OR Naomi) AND Harris" -> 2 rows', () => {
|
|
122
|
+
const tree: AdvancedFilterTree = {
|
|
123
|
+
root: group('and', [
|
|
124
|
+
group('or', [
|
|
125
|
+
rule('display_name', 'contains', 'Daniel'),
|
|
126
|
+
rule('display_name', 'contains', 'Naomi'),
|
|
127
|
+
]),
|
|
128
|
+
rule('display_name', 'contains', 'Harris'),
|
|
129
|
+
]),
|
|
130
|
+
}
|
|
131
|
+
expect(evaluate(baseRouteFilters, tree).map((r) => r.display_name).sort()).toEqual([
|
|
132
|
+
'Daniel Harris', 'Naomi Harris',
|
|
133
|
+
])
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
test('"Daniel OR (Naomi AND Harris)" -> 3 rows (precedence-distinct from above)', () => {
|
|
137
|
+
const tree: AdvancedFilterTree = {
|
|
138
|
+
root: group('or', [
|
|
139
|
+
rule('display_name', 'contains', 'Daniel'),
|
|
140
|
+
group('and', [
|
|
141
|
+
rule('display_name', 'contains', 'Naomi'),
|
|
142
|
+
rule('display_name', 'contains', 'Harris'),
|
|
143
|
+
]),
|
|
144
|
+
]),
|
|
145
|
+
}
|
|
146
|
+
expect(evaluate(baseRouteFilters, tree).map((r) => r.display_name).sort()).toEqual([
|
|
147
|
+
'Daniel Cho', 'Daniel Harris', 'Naomi Harris',
|
|
148
|
+
])
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
test('mixed-field: "(status is Lead) OR (Name contains Cho)" -> 3 rows (Naomi Harris by status, Daniel/Naomi Cho by name)', () => {
|
|
152
|
+
const tree: AdvancedFilterTree = {
|
|
153
|
+
root: group('or', [
|
|
154
|
+
rule('status', 'is', 'Lead'),
|
|
155
|
+
rule('display_name', 'contains', 'Cho'),
|
|
156
|
+
]),
|
|
157
|
+
}
|
|
158
|
+
expect(evaluate(baseRouteFilters, tree).map((r) => r.display_name).sort()).toEqual([
|
|
159
|
+
'Daniel Cho', 'Naomi Cho', 'Naomi Harris',
|
|
160
|
+
])
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
test('empty tree returns route filters only (all persons)', () => {
|
|
164
|
+
const tree: AdvancedFilterTree = { root: group('and', []) }
|
|
165
|
+
expect(evaluate(baseRouteFilters, tree)).toHaveLength(4)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
test('depth-3 tree: "(Daniel AND (Cho OR Harris)) OR (Naomi AND Cho)" -> 3 rows', () => {
|
|
169
|
+
const tree: AdvancedFilterTree = {
|
|
170
|
+
root: group('or', [
|
|
171
|
+
group('and', [
|
|
172
|
+
rule('display_name', 'contains', 'Daniel'),
|
|
173
|
+
group('or', [
|
|
174
|
+
rule('display_name', 'contains', 'Cho'),
|
|
175
|
+
rule('display_name', 'contains', 'Harris'),
|
|
176
|
+
]),
|
|
177
|
+
]),
|
|
178
|
+
group('and', [
|
|
179
|
+
rule('display_name', 'contains', 'Naomi'),
|
|
180
|
+
rule('display_name', 'contains', 'Cho'),
|
|
181
|
+
]),
|
|
182
|
+
]),
|
|
183
|
+
}
|
|
184
|
+
expect(evaluate(baseRouteFilters, tree).map((r) => r.display_name).sort()).toEqual([
|
|
185
|
+
'Daniel Cho', 'Daniel Harris', 'Naomi Cho',
|
|
186
|
+
])
|
|
187
|
+
})
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
describe('Advanced filter — long chains and high-cardinality combinations', () => {
|
|
191
|
+
// Add 6 more rows to exercise long ORs.
|
|
192
|
+
const more: Row[] = [
|
|
193
|
+
{ id: '5', display_name: 'Lena Ortiz', kind: 'person' },
|
|
194
|
+
{ id: '6', display_name: 'Mia Johnson', kind: 'person' },
|
|
195
|
+
{ id: '7', display_name: 'Taylor Brooks', kind: 'person' },
|
|
196
|
+
{ id: '8', display_name: 'Arjun Patel', kind: 'person' },
|
|
197
|
+
]
|
|
198
|
+
const everyone = [...people, ...more]
|
|
199
|
+
function evalAll(routeFilters: Record<string, unknown>, tree: AdvancedFilterTree): Row[] {
|
|
200
|
+
const merged = mergeAdvancedFilterTree(routeFilters, tree)
|
|
201
|
+
const normalized = normalizeFilters(merged)
|
|
202
|
+
const pred = buildPredicate(normalized)
|
|
203
|
+
return everyone.filter(pred)
|
|
204
|
+
}
|
|
205
|
+
const c = (v: string): FilterRule => rule('display_name', 'contains', v)
|
|
206
|
+
|
|
207
|
+
test('6-way OR matches all six people', () => {
|
|
208
|
+
const t: AdvancedFilterTree = { root: group('or', ['Daniel', 'Naomi', 'Lena', 'Mia', 'Taylor', 'Arjun'].map(c)) }
|
|
209
|
+
expect(evalAll({}, t).map((r) => r.display_name).sort()).toEqual([
|
|
210
|
+
'Arjun Patel', 'Daniel Cho', 'Daniel Harris', 'Lena Ortiz', 'Mia Johnson', 'Naomi Cho', 'Naomi Harris', 'Taylor Brooks',
|
|
211
|
+
])
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
test('5-way AND impossible for distinct names returns empty', () => {
|
|
215
|
+
const t: AdvancedFilterTree = { root: group('and', ['Daniel', 'Naomi', 'Lena', 'Mia', 'Taylor'].map(c)) }
|
|
216
|
+
expect(evalAll({}, t)).toHaveLength(0)
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
test('4-way AND that one row satisfies', () => {
|
|
220
|
+
const t: AdvancedFilterTree = { root: group('and', ['Daniel', 'Cho', 'Dan', 'Daniel'].map(c)) }
|
|
221
|
+
expect(evalAll({}, t).map((r) => r.display_name)).toEqual(['Daniel Cho'])
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
test('Two OR groups ANDed: (A OR B) AND (C OR D)', () => {
|
|
225
|
+
const t: AdvancedFilterTree = {
|
|
226
|
+
root: group('and', [
|
|
227
|
+
group('or', [c('Daniel'), c('Naomi')]),
|
|
228
|
+
group('or', [c('Cho'), c('Harris')]),
|
|
229
|
+
]),
|
|
230
|
+
}
|
|
231
|
+
expect(evalAll({}, t).map((r) => r.display_name).sort()).toEqual(['Daniel Cho', 'Daniel Harris', 'Naomi Cho', 'Naomi Harris'])
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
test('Three OR groups ANDed: (A OR B) AND (C OR D) AND (E OR F)', () => {
|
|
235
|
+
const t: AdvancedFilterTree = {
|
|
236
|
+
root: group('and', [
|
|
237
|
+
group('or', [c('Daniel'), c('Naomi')]),
|
|
238
|
+
group('or', [c('Cho'), c('Harris')]),
|
|
239
|
+
group('or', [c('Lena'), c('Mia')]),
|
|
240
|
+
]),
|
|
241
|
+
}
|
|
242
|
+
expect(evalAll({}, t)).toHaveLength(0)
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
test('Depth-3 mixed (Daniel OR (Naomi AND Harris)) OR Lena', () => {
|
|
246
|
+
const t: AdvancedFilterTree = {
|
|
247
|
+
root: group('or', [
|
|
248
|
+
c('Daniel'),
|
|
249
|
+
group('and', [c('Naomi'), c('Harris')]),
|
|
250
|
+
c('Lena'),
|
|
251
|
+
]),
|
|
252
|
+
}
|
|
253
|
+
expect(evalAll({}, t).map((r) => r.display_name).sort()).toEqual(['Daniel Cho', 'Daniel Harris', 'Lena Ortiz', 'Naomi Harris'])
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
test('Depth-3 deeper: ((Daniel AND Cho) OR Naomi) AND (Cho OR Harris)', () => {
|
|
257
|
+
const t: AdvancedFilterTree = {
|
|
258
|
+
root: group('and', [
|
|
259
|
+
group('or', [
|
|
260
|
+
group('and', [c('Daniel'), c('Cho')]),
|
|
261
|
+
c('Naomi'),
|
|
262
|
+
]),
|
|
263
|
+
group('or', [c('Cho'), c('Harris')]),
|
|
264
|
+
]),
|
|
265
|
+
}
|
|
266
|
+
expect(evalAll({}, t).map((r) => r.display_name).sort()).toEqual(['Daniel Cho', 'Naomi Cho', 'Naomi Harris'])
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
test('10-way OR with mixed hits and misses (de-dup verified)', () => {
|
|
270
|
+
const t: AdvancedFilterTree = {
|
|
271
|
+
root: group('or', ['Daniel', 'Naomi', 'Lena', 'XXX', 'YYY', 'Mia', 'Taylor', 'Arjun', 'ZZZ', 'Patel'].map(c)),
|
|
272
|
+
}
|
|
273
|
+
expect(evalAll({}, t).map((r) => r.display_name).sort()).toEqual([
|
|
274
|
+
'Arjun Patel', 'Daniel Cho', 'Daniel Harris', 'Lena Ortiz', 'Mia Johnson', 'Naomi Cho', 'Naomi Harris', 'Taylor Brooks',
|
|
275
|
+
])
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
test('Wide AND clause: (10-way OR over names) AND a single rule', () => {
|
|
279
|
+
const t: AdvancedFilterTree = {
|
|
280
|
+
root: group('and', [
|
|
281
|
+
group('or', ['Daniel', 'Naomi', 'Lena', 'Mia', 'Taylor', 'Arjun', 'Sara', 'John', 'Bob', 'Tom'].map(c)),
|
|
282
|
+
c('Cho'),
|
|
283
|
+
]),
|
|
284
|
+
}
|
|
285
|
+
expect(evalAll({}, t).map((r) => r.display_name).sort()).toEqual(['Daniel Cho', 'Naomi Cho'])
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
test('4-level nested: ((Daniel OR Naomi) AND Cho) OR ((Lena OR Mia) AND Ortiz)', () => {
|
|
289
|
+
const t: AdvancedFilterTree = {
|
|
290
|
+
root: group('or', [
|
|
291
|
+
group('and', [group('or', [c('Daniel'), c('Naomi')]), c('Cho')]),
|
|
292
|
+
group('and', [group('or', [c('Lena'), c('Mia')]), c('Ortiz')]),
|
|
293
|
+
]),
|
|
294
|
+
}
|
|
295
|
+
expect(evalAll({}, t).map((r) => r.display_name).sort()).toEqual(['Daniel Cho', 'Lena Ortiz', 'Naomi Cho'])
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
test('AND of 5 OR-pairs produces a large DNF (32 disjuncts) and still resolves correctly', () => {
|
|
299
|
+
const t: AdvancedFilterTree = {
|
|
300
|
+
root: group('and', [
|
|
301
|
+
group('or', [c('Daniel'), c('Naomi')]),
|
|
302
|
+
group('or', [c('Cho'), c('Harris')]),
|
|
303
|
+
group('or', [c('Cho'), c('Harris')]),
|
|
304
|
+
group('or', [c('Daniel'), c('Naomi')]),
|
|
305
|
+
group('or', [c('Cho'), c('Harris')]),
|
|
306
|
+
]),
|
|
307
|
+
}
|
|
308
|
+
// logically reduces to (Daniel OR Naomi) AND (Cho OR Harris) - 4 rows
|
|
309
|
+
expect(evalAll({}, t).map((r) => r.display_name).sort()).toEqual(['Daniel Cho', 'Daniel Harris', 'Naomi Cho', 'Naomi Harris'])
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
test('Same value repeated in OR — collapses to a single match', () => {
|
|
313
|
+
const t: AdvancedFilterTree = { root: group('or', [c('Daniel'), c('Daniel')]) }
|
|
314
|
+
expect(evalAll({}, t).map((r) => r.display_name).sort()).toEqual(['Daniel Cho', 'Daniel Harris'])
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
test('Width cap: 15 OR alternatives — accepted', () => {
|
|
318
|
+
const t: AdvancedFilterTree = {
|
|
319
|
+
root: group('or', ['Daniel','Naomi','Lena','Mia','Taylor','Arjun','Cho','Harris','Ortiz','Johnson','Brooks','Patel','Dan','Nao','Len'].map(c)),
|
|
320
|
+
}
|
|
321
|
+
expect(evalAll({}, t).map((r) => r.display_name).length).toBe(8) // all 8 distinct rows
|
|
322
|
+
})
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
describe('Advanced filter — math vs old buggy algorithm', () => {
|
|
326
|
+
// Compare current algorithm output to what the buggy old algorithm would produce
|
|
327
|
+
// for the user's specific bug cases. This documents the regression contract.
|
|
328
|
+
test('Old AND collision would return Naomi Harris; new tree returns Daniel Harris', () => {
|
|
329
|
+
const tree: AdvancedFilterTree = {
|
|
330
|
+
root: group('and', [
|
|
331
|
+
rule('display_name', 'contains', 'Daniel'),
|
|
332
|
+
rule('display_name', 'contains', 'Harris'),
|
|
333
|
+
]),
|
|
334
|
+
}
|
|
335
|
+
const got = evaluate({}, tree).map((r) => r.display_name)
|
|
336
|
+
expect(got).toEqual(['Daniel Harris'])
|
|
337
|
+
expect(got).not.toContain('Naomi Harris')
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
test('Standard SQL precedence: A OR (B AND C) is NOT (A OR B) AND C', () => {
|
|
341
|
+
// Demonstrate the design's claim about precedence: these two trees give
|
|
342
|
+
// different result sets, so the model preserves the user's intent.
|
|
343
|
+
const A_or_BC: AdvancedFilterTree = {
|
|
344
|
+
root: group('or', [
|
|
345
|
+
rule('display_name', 'contains', 'Daniel'),
|
|
346
|
+
group('and', [
|
|
347
|
+
rule('display_name', 'contains', 'Naomi'),
|
|
348
|
+
rule('display_name', 'contains', 'Harris'),
|
|
349
|
+
]),
|
|
350
|
+
]),
|
|
351
|
+
}
|
|
352
|
+
const AB_or_then_C: AdvancedFilterTree = {
|
|
353
|
+
root: group('and', [
|
|
354
|
+
group('or', [
|
|
355
|
+
rule('display_name', 'contains', 'Daniel'),
|
|
356
|
+
rule('display_name', 'contains', 'Naomi'),
|
|
357
|
+
]),
|
|
358
|
+
rule('display_name', 'contains', 'Harris'),
|
|
359
|
+
]),
|
|
360
|
+
}
|
|
361
|
+
const a = evaluate({}, A_or_BC).map((r) => r.display_name).sort()
|
|
362
|
+
const b = evaluate({}, AB_or_then_C).map((r) => r.display_name).sort()
|
|
363
|
+
expect(a).not.toEqual(b)
|
|
364
|
+
expect(a).toEqual(['Daniel Cho', 'Daniel Harris', 'Naomi Harris'])
|
|
365
|
+
expect(b).toEqual(['Daniel Harris', 'Naomi Harris'])
|
|
366
|
+
})
|
|
367
|
+
})
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/** @jest-environment node */
|
|
2
|
+
import {
|
|
3
|
+
serializeTree,
|
|
4
|
+
deserializeTree,
|
|
5
|
+
flatToTree,
|
|
6
|
+
type AdvancedFilterState,
|
|
7
|
+
type FilterCondition,
|
|
8
|
+
} from '../advanced-filter'
|
|
9
|
+
import type { AdvancedFilterTree, FilterCombinator } from '../advanced-filter-tree'
|
|
10
|
+
|
|
11
|
+
describe('serializeTree', () => {
|
|
12
|
+
it('omits params for an empty tree', () => {
|
|
13
|
+
expect(serializeTree({
|
|
14
|
+
root: { id: 'r', type: 'group', combinator: 'and', children: [] },
|
|
15
|
+
})).toEqual({})
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('emits v2 bracketed params for a single rule', () => {
|
|
19
|
+
const tree: AdvancedFilterTree = {
|
|
20
|
+
root: {
|
|
21
|
+
id: 'r', type: 'group', combinator: 'and',
|
|
22
|
+
children: [{ id: 'c', type: 'rule', field: 'display_name', operator: 'contains', value: 'Daniel' }],
|
|
23
|
+
},
|
|
24
|
+
}
|
|
25
|
+
expect(serializeTree(tree)).toEqual({
|
|
26
|
+
'filter[v]': '2',
|
|
27
|
+
'filter[root][combinator]': 'and',
|
|
28
|
+
'filter[root][children][0][type]': 'rule',
|
|
29
|
+
'filter[root][children][0][field]': 'display_name',
|
|
30
|
+
'filter[root][children][0][op]': 'contains',
|
|
31
|
+
'filter[root][children][0][value]': 'Daniel',
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('emits nested groups recursively', () => {
|
|
36
|
+
const tree: AdvancedFilterTree = {
|
|
37
|
+
root: {
|
|
38
|
+
id: 'r', type: 'group', combinator: 'and',
|
|
39
|
+
children: [
|
|
40
|
+
{
|
|
41
|
+
id: 'g', type: 'group', combinator: 'or',
|
|
42
|
+
children: [
|
|
43
|
+
{ id: 'a', type: 'rule', field: 'display_name', operator: 'contains', value: 'Daniel' },
|
|
44
|
+
{ id: 'b', type: 'rule', field: 'display_name', operator: 'contains', value: 'Naomi' },
|
|
45
|
+
],
|
|
46
|
+
},
|
|
47
|
+
{ id: 'c', type: 'rule', field: 'status', operator: 'is', value: 'Lead' },
|
|
48
|
+
],
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
expect(serializeTree(tree)).toMatchObject({
|
|
52
|
+
'filter[v]': '2',
|
|
53
|
+
'filter[root][combinator]': 'and',
|
|
54
|
+
'filter[root][children][0][type]': 'group',
|
|
55
|
+
'filter[root][children][0][combinator]': 'or',
|
|
56
|
+
'filter[root][children][0][children][0][type]': 'rule',
|
|
57
|
+
'filter[root][children][0][children][0][field]': 'display_name',
|
|
58
|
+
'filter[root][children][0][children][0][op]': 'contains',
|
|
59
|
+
'filter[root][children][0][children][0][value]': 'Daniel',
|
|
60
|
+
'filter[root][children][0][children][1][value]': 'Naomi',
|
|
61
|
+
'filter[root][children][1][type]': 'rule',
|
|
62
|
+
'filter[root][children][1][field]': 'status',
|
|
63
|
+
'filter[root][children][1][op]': 'is',
|
|
64
|
+
'filter[root][children][1][value]': 'Lead',
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('omits value param for valueless operators', () => {
|
|
69
|
+
const tree: AdvancedFilterTree = {
|
|
70
|
+
root: {
|
|
71
|
+
id: 'r', type: 'group', combinator: 'and',
|
|
72
|
+
children: [{ id: 'a', type: 'rule', field: 'primary_email', operator: 'is_empty', value: '' }],
|
|
73
|
+
},
|
|
74
|
+
}
|
|
75
|
+
const out = serializeTree(tree)
|
|
76
|
+
expect(out['filter[root][children][0][op]']).toBe('is_empty')
|
|
77
|
+
expect(out['filter[root][children][0][value]']).toBeUndefined()
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
describe('deserializeTree (v2)', () => {
|
|
82
|
+
it('round-trips a nested tree', () => {
|
|
83
|
+
const tree: AdvancedFilterTree = {
|
|
84
|
+
root: { id: 'r', type: 'group', combinator: 'and', children: [
|
|
85
|
+
{ id: 'g', type: 'group', combinator: 'or', children: [
|
|
86
|
+
{ id: 'a', type: 'rule', field: 'display_name', operator: 'contains', value: 'Daniel' },
|
|
87
|
+
{ id: 'b', type: 'rule', field: 'display_name', operator: 'contains', value: 'Naomi' },
|
|
88
|
+
]},
|
|
89
|
+
{ id: 'c', type: 'rule', field: 'status', operator: 'is', value: 'Lead' },
|
|
90
|
+
]},
|
|
91
|
+
}
|
|
92
|
+
const params = serializeTree(tree)
|
|
93
|
+
const round = deserializeTree(params)
|
|
94
|
+
// ids are regenerated; compare structure only.
|
|
95
|
+
type StripNode =
|
|
96
|
+
| { type: 'rule'; field: string; operator: string; value: unknown }
|
|
97
|
+
| { type: 'group'; combinator: FilterCombinator; children: StripNode[] }
|
|
98
|
+
const stripIds = (node: any): StripNode =>
|
|
99
|
+
node.type === 'rule'
|
|
100
|
+
? { type: 'rule', field: node.field, operator: node.operator, value: node.value }
|
|
101
|
+
: { type: 'group', combinator: node.combinator, children: node.children.map(stripIds) }
|
|
102
|
+
expect(round).not.toBeNull()
|
|
103
|
+
expect(stripIds(round!.root)).toEqual(stripIds(tree.root))
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('returns null when filter[v] is not 2', () => {
|
|
107
|
+
expect(deserializeTree({ 'filter[v]': '1' })).toBeNull()
|
|
108
|
+
expect(deserializeTree({})).toBeNull()
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('returns null when root combinator is missing', () => {
|
|
112
|
+
expect(deserializeTree({ 'filter[v]': '2' })).toBeNull()
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('parses a valueless operator with no value param', () => {
|
|
116
|
+
const out = deserializeTree({
|
|
117
|
+
'filter[v]': '2',
|
|
118
|
+
'filter[root][combinator]': 'and',
|
|
119
|
+
'filter[root][children][0][type]': 'rule',
|
|
120
|
+
'filter[root][children][0][field]': 'primary_email',
|
|
121
|
+
'filter[root][children][0][op]': 'is_empty',
|
|
122
|
+
})
|
|
123
|
+
expect(out).not.toBeNull()
|
|
124
|
+
expect(out!.root.children[0]).toMatchObject({ type: 'rule', operator: 'is_empty' })
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
describe('flatToTree — legacy v1 -> tree (SQL precedence)', () => {
|
|
129
|
+
const cond = (field: string, value: string, join: 'and' | 'or' = 'and'): FilterCondition => ({
|
|
130
|
+
id: field, field, operator: 'contains', value, join,
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('A AND B AND C -> single AND group', () => {
|
|
134
|
+
const flat: AdvancedFilterState = {
|
|
135
|
+
logic: 'and',
|
|
136
|
+
conditions: [cond('a', 'x', 'and'), cond('b', 'y', 'and'), cond('c', 'z', 'and')],
|
|
137
|
+
}
|
|
138
|
+
const tree = flatToTree(flat)
|
|
139
|
+
expect(tree.root.combinator).toBe('and')
|
|
140
|
+
expect(tree.root.children).toHaveLength(3)
|
|
141
|
+
expect(tree.root.children.every((c) => c.type === 'rule')).toBe(true)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('A OR B OR C -> single OR group', () => {
|
|
145
|
+
const flat: AdvancedFilterState = {
|
|
146
|
+
logic: 'and',
|
|
147
|
+
conditions: [cond('a', 'x', 'and'), cond('b', 'y', 'or'), cond('c', 'z', 'or')],
|
|
148
|
+
}
|
|
149
|
+
const tree = flatToTree(flat)
|
|
150
|
+
expect(tree.root.combinator).toBe('or')
|
|
151
|
+
expect(tree.root.children).toHaveLength(3)
|
|
152
|
+
expect(tree.root.children.every((c) => c.type === 'rule')).toBe(true)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('A AND B OR C AND D -> OR(AND(A,B), AND(C,D))', () => {
|
|
156
|
+
const flat: AdvancedFilterState = {
|
|
157
|
+
logic: 'and',
|
|
158
|
+
conditions: [cond('a', 'x', 'and'), cond('b', 'y', 'and'), cond('c', 'z', 'or'), cond('d', 'w', 'and')],
|
|
159
|
+
}
|
|
160
|
+
const tree = flatToTree(flat)
|
|
161
|
+
expect(tree.root.combinator).toBe('or')
|
|
162
|
+
expect(tree.root.children).toHaveLength(2)
|
|
163
|
+
const [first, second] = tree.root.children
|
|
164
|
+
expect(first.type).toBe('group')
|
|
165
|
+
expect(second.type).toBe('group')
|
|
166
|
+
if (first.type === 'group') {
|
|
167
|
+
expect(first.combinator).toBe('and')
|
|
168
|
+
expect(first.children).toHaveLength(2)
|
|
169
|
+
}
|
|
170
|
+
if (second.type === 'group') {
|
|
171
|
+
expect(second.combinator).toBe('and')
|
|
172
|
+
expect(second.children).toHaveLength(2)
|
|
173
|
+
}
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('A OR B AND C -> OR(A, AND(B,C))', () => {
|
|
177
|
+
const flat: AdvancedFilterState = {
|
|
178
|
+
logic: 'and',
|
|
179
|
+
conditions: [cond('a', 'x', 'and'), cond('b', 'y', 'or'), cond('c', 'z', 'and')],
|
|
180
|
+
}
|
|
181
|
+
const tree = flatToTree(flat)
|
|
182
|
+
expect(tree.root.combinator).toBe('or')
|
|
183
|
+
expect(tree.root.children).toHaveLength(2)
|
|
184
|
+
expect(tree.root.children[0].type).toBe('rule')
|
|
185
|
+
expect(tree.root.children[1].type).toBe('group')
|
|
186
|
+
if (tree.root.children[1].type === 'group') {
|
|
187
|
+
expect(tree.root.children[1].combinator).toBe('and')
|
|
188
|
+
expect(tree.root.children[1].children).toHaveLength(2)
|
|
189
|
+
}
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('first row join is forced to AND even if user set "or"', () => {
|
|
193
|
+
const flat: AdvancedFilterState = {
|
|
194
|
+
logic: 'and',
|
|
195
|
+
conditions: [cond('a', 'x', 'or'), cond('b', 'y', 'and')],
|
|
196
|
+
}
|
|
197
|
+
const tree = flatToTree(flat)
|
|
198
|
+
expect(tree.root.combinator).toBe('and')
|
|
199
|
+
expect(tree.root.children).toHaveLength(2)
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it('single rule -> root group with one rule', () => {
|
|
203
|
+
const flat: AdvancedFilterState = {
|
|
204
|
+
logic: 'and',
|
|
205
|
+
conditions: [cond('a', 'x', 'and')],
|
|
206
|
+
}
|
|
207
|
+
const tree = flatToTree(flat)
|
|
208
|
+
expect(tree.root.combinator).toBe('and')
|
|
209
|
+
expect(tree.root.children).toHaveLength(1)
|
|
210
|
+
expect(tree.root.children[0].type).toBe('rule')
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('empty conditions -> empty root', () => {
|
|
214
|
+
const flat: AdvancedFilterState = { logic: 'and', conditions: [] }
|
|
215
|
+
const tree = flatToTree(flat)
|
|
216
|
+
expect(tree.root.children).toHaveLength(0)
|
|
217
|
+
})
|
|
218
|
+
})
|