@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,326 @@
|
|
|
1
|
+
/** @jest-environment node */
|
|
2
|
+
import {
|
|
3
|
+
validateTreeLimits,
|
|
4
|
+
createEmptyTree,
|
|
5
|
+
compileTreeToWhere,
|
|
6
|
+
serializeTreeForPersist,
|
|
7
|
+
deserializeTreeFromPersist,
|
|
8
|
+
isPersistedFilterTree,
|
|
9
|
+
type AdvancedFilterTree,
|
|
10
|
+
type FilterRule,
|
|
11
|
+
type FilterGroup,
|
|
12
|
+
} from '../advanced-filter-tree'
|
|
13
|
+
import type { FilterOperator } from '../advanced-filter'
|
|
14
|
+
|
|
15
|
+
const rule = (id: string, field = 'name'): FilterRule => ({
|
|
16
|
+
id, type: 'rule', field, operator: 'contains', value: 'x',
|
|
17
|
+
})
|
|
18
|
+
const group = (id: string, children: Array<FilterRule | FilterGroup>, combinator: 'and' | 'or' = 'and'): FilterGroup => ({
|
|
19
|
+
id, type: 'group', combinator, children,
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
describe('validateTreeLimits', () => {
|
|
23
|
+
it('accepts an empty tree', () => {
|
|
24
|
+
expect(validateTreeLimits(createEmptyTree())).toEqual({ ok: true })
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('rejects a group at level 4', () => {
|
|
28
|
+
const tree: AdvancedFilterTree = {
|
|
29
|
+
root: group('1', [
|
|
30
|
+
group('2', [
|
|
31
|
+
group('3', [
|
|
32
|
+
group('4', [rule('r')]), // level 4: too deep
|
|
33
|
+
]),
|
|
34
|
+
]),
|
|
35
|
+
]),
|
|
36
|
+
}
|
|
37
|
+
expect(validateTreeLimits(tree)).toEqual({ ok: false, reason: 'depth' })
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('accepts the deepest legal tree (rule at level 3 inside groups at levels 1, 2, 3)', () => {
|
|
41
|
+
const tree: AdvancedFilterTree = {
|
|
42
|
+
root: group('1', [
|
|
43
|
+
group('2', [
|
|
44
|
+
group('3', [rule('r')]),
|
|
45
|
+
]),
|
|
46
|
+
]),
|
|
47
|
+
}
|
|
48
|
+
expect(validateTreeLimits(tree)).toEqual({ ok: true })
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('rejects a group with 16 children', () => {
|
|
52
|
+
const children = Array.from({ length: 16 }, (_, i) => rule(`r${i}`))
|
|
53
|
+
const tree: AdvancedFilterTree = { root: group('1', children) }
|
|
54
|
+
expect(validateTreeLimits(tree)).toEqual({ ok: false, reason: 'width' })
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('rejects 51 total rules without tripping the width cap', () => {
|
|
58
|
+
// Spread across four sub-groups so no group exceeds 15 children:
|
|
59
|
+
// 15 + 15 + 15 + 6 = 51 total rules; root has 4 children (under width cap).
|
|
60
|
+
const mk = (prefix: string, n: number) =>
|
|
61
|
+
Array.from({ length: n }, (_, i) => rule(`${prefix}${i}`))
|
|
62
|
+
const tree: AdvancedFilterTree = {
|
|
63
|
+
root: group('1', [
|
|
64
|
+
group('a', mk('a', 15)),
|
|
65
|
+
group('b', mk('b', 15)),
|
|
66
|
+
group('c', mk('c', 15)),
|
|
67
|
+
group('d', mk('d', 6)),
|
|
68
|
+
]),
|
|
69
|
+
}
|
|
70
|
+
expect(validateTreeLimits(tree)).toEqual({ ok: false, reason: 'total' })
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
describe('compileTreeToWhere — leaf rules', () => {
|
|
75
|
+
it('compiles a single contains rule', () => {
|
|
76
|
+
const tree: AdvancedFilterTree = {
|
|
77
|
+
root: group('1', [{ id: 'a', type: 'rule', field: 'display_name', operator: 'contains', value: 'Daniel' }]),
|
|
78
|
+
}
|
|
79
|
+
expect(compileTreeToWhere(tree)).toEqual({ display_name: { $ilike: '%Daniel%' } })
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('compiles "is" rule to $eq', () => {
|
|
83
|
+
const tree: AdvancedFilterTree = {
|
|
84
|
+
root: group('1', [{ id: 'a', type: 'rule', field: 'status', operator: 'is', value: 'Lead' }]),
|
|
85
|
+
}
|
|
86
|
+
expect(compileTreeToWhere(tree)).toEqual({ status: { $eq: 'Lead' } })
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('returns null for an empty root group', () => {
|
|
90
|
+
expect(compileTreeToWhere(createEmptyTree())).toBeNull()
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('returns null when the only rule has an empty value (and the operator requires one)', () => {
|
|
94
|
+
const tree: AdvancedFilterTree = {
|
|
95
|
+
root: group('1', [{ id: 'a', type: 'rule', field: 'display_name', operator: 'contains', value: ' ' }]),
|
|
96
|
+
}
|
|
97
|
+
expect(compileTreeToWhere(tree)).toBeNull()
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('keeps a valueless operator (is_empty) without value', () => {
|
|
101
|
+
const tree: AdvancedFilterTree = {
|
|
102
|
+
root: group('1', [{ id: 'a', type: 'rule', field: 'primary_email', operator: 'is_empty', value: '' }]),
|
|
103
|
+
}
|
|
104
|
+
expect(compileTreeToWhere(tree)).toEqual({ primary_email: { $exists: false } })
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
describe('compileTreeToWhere — group compilation', () => {
|
|
109
|
+
it('AND of two rules same field — no key collision (Bug A)', () => {
|
|
110
|
+
const tree: AdvancedFilterTree = {
|
|
111
|
+
root: group('1', [
|
|
112
|
+
{ id: 'a', type: 'rule', field: 'display_name', operator: 'contains', value: 'Daniel' },
|
|
113
|
+
{ id: 'b', type: 'rule', field: 'display_name', operator: 'contains', value: 'Harris' },
|
|
114
|
+
], 'and'),
|
|
115
|
+
}
|
|
116
|
+
expect(compileTreeToWhere(tree)).toEqual({
|
|
117
|
+
$and: [
|
|
118
|
+
{ display_name: { $ilike: '%Daniel%' } },
|
|
119
|
+
{ display_name: { $ilike: '%Harris%' } },
|
|
120
|
+
],
|
|
121
|
+
})
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('OR of two rules same field', () => {
|
|
125
|
+
const tree: AdvancedFilterTree = {
|
|
126
|
+
root: group('1', [
|
|
127
|
+
{ id: 'a', type: 'rule', field: 'display_name', operator: 'contains', value: 'Daniel' },
|
|
128
|
+
{ id: 'b', type: 'rule', field: 'display_name', operator: 'contains', value: 'Naomi' },
|
|
129
|
+
], 'or'),
|
|
130
|
+
}
|
|
131
|
+
expect(compileTreeToWhere(tree)).toEqual({
|
|
132
|
+
$or: [
|
|
133
|
+
{ display_name: { $ilike: '%Daniel%' } },
|
|
134
|
+
{ display_name: { $ilike: '%Naomi%' } },
|
|
135
|
+
],
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('(A OR B) AND C — distinct from A OR (B AND C)', () => {
|
|
140
|
+
const A: FilterRule = { id: 'a', type: 'rule', field: 'display_name', operator: 'contains', value: 'A' }
|
|
141
|
+
const B: FilterRule = { id: 'b', type: 'rule', field: 'display_name', operator: 'contains', value: 'B' }
|
|
142
|
+
const C: FilterRule = { id: 'c', type: 'rule', field: 'status', operator: 'is', value: 'Lead' }
|
|
143
|
+
|
|
144
|
+
const treeAB_or_then_AND_C: AdvancedFilterTree = {
|
|
145
|
+
root: group('r', [group('g', [A, B], 'or'), C], 'and'),
|
|
146
|
+
}
|
|
147
|
+
expect(compileTreeToWhere(treeAB_or_then_AND_C)).toEqual({
|
|
148
|
+
$and: [
|
|
149
|
+
{ $or: [{ display_name: { $ilike: '%A%' } }, { display_name: { $ilike: '%B%' } }] },
|
|
150
|
+
{ status: { $eq: 'Lead' } },
|
|
151
|
+
],
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
const treeA_or_BC: AdvancedFilterTree = {
|
|
155
|
+
root: group('r', [A, group('g', [B, C], 'and')], 'or'),
|
|
156
|
+
}
|
|
157
|
+
expect(compileTreeToWhere(treeA_or_BC)).toEqual({
|
|
158
|
+
$or: [
|
|
159
|
+
{ display_name: { $ilike: '%A%' } },
|
|
160
|
+
{ $and: [{ display_name: { $ilike: '%B%' } }, { status: { $eq: 'Lead' } }] },
|
|
161
|
+
],
|
|
162
|
+
})
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('group with 1 child after pruning unwraps to that child', () => {
|
|
166
|
+
const tree: AdvancedFilterTree = {
|
|
167
|
+
root: group('r', [
|
|
168
|
+
{ id: 'a', type: 'rule', field: 'display_name', operator: 'contains', value: 'Daniel' },
|
|
169
|
+
{ id: 'b', type: 'rule', field: 'display_name', operator: 'contains', value: ' ' }, // pruned
|
|
170
|
+
], 'or'),
|
|
171
|
+
}
|
|
172
|
+
expect(compileTreeToWhere(tree)).toEqual({ display_name: { $ilike: '%Daniel%' } })
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('nested group with all empty rules is omitted', () => {
|
|
176
|
+
const tree: AdvancedFilterTree = {
|
|
177
|
+
root: group('r', [
|
|
178
|
+
{ id: 'a', type: 'rule', field: 'display_name', operator: 'contains', value: 'Daniel' },
|
|
179
|
+
group('inner', [
|
|
180
|
+
{ id: 'b', type: 'rule', field: 'display_name', operator: 'contains', value: '' },
|
|
181
|
+
], 'and'),
|
|
182
|
+
], 'and'),
|
|
183
|
+
}
|
|
184
|
+
expect(compileTreeToWhere(tree)).toEqual({ display_name: { $ilike: '%Daniel%' } })
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('depth-3 nesting (A AND (B OR (C AND D)))', () => {
|
|
188
|
+
const A: FilterRule = { id: 'a', type: 'rule', field: 'f1', operator: 'is', value: 'a' }
|
|
189
|
+
const B: FilterRule = { id: 'b', type: 'rule', field: 'f2', operator: 'is', value: 'b' }
|
|
190
|
+
const C: FilterRule = { id: 'c', type: 'rule', field: 'f3', operator: 'is', value: 'c' }
|
|
191
|
+
const D: FilterRule = { id: 'd', type: 'rule', field: 'f4', operator: 'is', value: 'd' }
|
|
192
|
+
const tree: AdvancedFilterTree = {
|
|
193
|
+
root: group('r', [A, group('o', [B, group('cd', [C, D], 'and')], 'or')], 'and'),
|
|
194
|
+
}
|
|
195
|
+
expect(compileTreeToWhere(tree)).toEqual({
|
|
196
|
+
$and: [
|
|
197
|
+
{ f1: { $eq: 'a' } },
|
|
198
|
+
{ $or: [
|
|
199
|
+
{ f2: { $eq: 'b' } },
|
|
200
|
+
{ $and: [{ f3: { $eq: 'c' } }, { f4: { $eq: 'd' } }] },
|
|
201
|
+
]},
|
|
202
|
+
],
|
|
203
|
+
})
|
|
204
|
+
})
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
describe('compileTreeToWhere — every operator', () => {
|
|
208
|
+
const cases: Array<[FilterOperator, unknown, Record<string, unknown>]> = [
|
|
209
|
+
['is', 'X', { f: { $eq: 'X' } }],
|
|
210
|
+
['is_not', 'X', { f: { $ne: 'X' } }],
|
|
211
|
+
['contains', 'X', { f: { $ilike: '%X%' } }],
|
|
212
|
+
['does_not_contain', 'X', { f: { $not: { $ilike: '%X%' } } }],
|
|
213
|
+
['starts_with', 'X', { f: { $ilike: 'X%' } }],
|
|
214
|
+
['ends_with', 'X', { f: { $ilike: '%X' } }],
|
|
215
|
+
['is_empty', '', { f: { $exists: false } }],
|
|
216
|
+
['is_not_empty', '', { f: { $exists: true } }],
|
|
217
|
+
['equals', 'X', { f: { $eq: 'X' } }],
|
|
218
|
+
['not_equals', 'X', { f: { $ne: 'X' } }],
|
|
219
|
+
['greater_than', '5', { f: { $gt: '5' } }],
|
|
220
|
+
['less_than', '5', { f: { $lt: '5' } }],
|
|
221
|
+
['greater_or_equal', '5', { f: { $gte: '5' } }],
|
|
222
|
+
['less_or_equal', '5', { f: { $lte: '5' } }],
|
|
223
|
+
['between', ['1', '9'], { f: { $gte: '1', $lte: '9' } }],
|
|
224
|
+
['is_before', '2026-01-01', { f: { $lt: '2026-01-01' } }],
|
|
225
|
+
['is_after', '2026-01-01', { f: { $gt: '2026-01-01' } }],
|
|
226
|
+
['is_any_of', ['a', 'b'], { f: { $in: ['a', 'b'] } }],
|
|
227
|
+
['is_none_of', ['a'], { f: { $nin: ['a'] } }],
|
|
228
|
+
['is_true', '', { f: { $eq: true } }],
|
|
229
|
+
['is_false', '', { f: { $eq: false } }],
|
|
230
|
+
['has_any_of', ['t1'], { f: { $in: ['t1'] } }],
|
|
231
|
+
['has_all_of', ['t1', 't2'], { f: { $contains: ['t1', 't2'] } }],
|
|
232
|
+
['has_none_of', ['t1'], { f: { $nin: ['t1'] } }],
|
|
233
|
+
]
|
|
234
|
+
|
|
235
|
+
it.each(cases)('compiles %s to expected output', (operator, value, expected) => {
|
|
236
|
+
const tree: AdvancedFilterTree = {
|
|
237
|
+
root: group('1', [{ id: 'a', type: 'rule', field: 'f', operator, value }]),
|
|
238
|
+
}
|
|
239
|
+
expect(compileTreeToWhere(tree)).toEqual(expected)
|
|
240
|
+
})
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
describe('serializeTreeForPersist / deserializeTreeFromPersist', () => {
|
|
244
|
+
// Build a tree with runtime metadata (`addedAt`) on every node, then
|
|
245
|
+
// verify it is stripped from the persisted shape and restored cleanly.
|
|
246
|
+
const tree: AdvancedFilterTree = {
|
|
247
|
+
root: {
|
|
248
|
+
id: 'root',
|
|
249
|
+
type: 'group',
|
|
250
|
+
combinator: 'and',
|
|
251
|
+
addedAt: 11,
|
|
252
|
+
children: [
|
|
253
|
+
{ id: 'a', type: 'rule', field: 'name', operator: 'contains', value: 'X', addedAt: 12 } as FilterRule,
|
|
254
|
+
{
|
|
255
|
+
id: 'sub',
|
|
256
|
+
type: 'group',
|
|
257
|
+
combinator: 'or',
|
|
258
|
+
addedAt: 13,
|
|
259
|
+
children: [
|
|
260
|
+
{ id: 'b', type: 'rule', field: 'status', operator: 'is', value: 'active', addedAt: 14 } as FilterRule,
|
|
261
|
+
{ id: 'c', type: 'rule', field: 'status', operator: 'is', value: 'lead', addedAt: 15 } as FilterRule,
|
|
262
|
+
],
|
|
263
|
+
} as FilterGroup,
|
|
264
|
+
],
|
|
265
|
+
},
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
it('serializeTreeForPersist tags v:2 and strips addedAt from every node', () => {
|
|
269
|
+
const persisted = serializeTreeForPersist(tree)
|
|
270
|
+
expect(persisted.v).toBe(2)
|
|
271
|
+
// Walk every node and assert no addedAt key.
|
|
272
|
+
function assertNoAddedAt(node: any): void {
|
|
273
|
+
expect('addedAt' in node).toBe(false)
|
|
274
|
+
if (node.children) for (const c of node.children) assertNoAddedAt(c)
|
|
275
|
+
}
|
|
276
|
+
assertNoAddedAt(persisted.root)
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
it('serializeTreeForPersist preserves ids, combinators, and child order', () => {
|
|
280
|
+
const persisted = serializeTreeForPersist(tree)
|
|
281
|
+
expect(persisted.root.id).toBe('root')
|
|
282
|
+
expect(persisted.root.combinator).toBe('and')
|
|
283
|
+
expect(persisted.root.children.map((c) => c.id)).toEqual(['a', 'sub'])
|
|
284
|
+
const sub = persisted.root.children[1] as FilterGroup
|
|
285
|
+
expect(sub.combinator).toBe('or')
|
|
286
|
+
expect(sub.children.map((c) => c.id)).toEqual(['b', 'c'])
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
it('isPersistedFilterTree distinguishes persisted shape from legacy filterValues', () => {
|
|
290
|
+
expect(isPersistedFilterTree(serializeTreeForPersist(tree))).toBe(true)
|
|
291
|
+
expect(isPersistedFilterTree({ status: 'active', source: 'event' })).toBe(false) // legacy
|
|
292
|
+
expect(isPersistedFilterTree(null)).toBe(false)
|
|
293
|
+
expect(isPersistedFilterTree(undefined)).toBe(false)
|
|
294
|
+
expect(isPersistedFilterTree({ v: 1, root: {} })).toBe(false) // wrong version
|
|
295
|
+
expect(isPersistedFilterTree({ v: 2 })).toBe(false) // missing root
|
|
296
|
+
expect(isPersistedFilterTree({ v: 2, root: { type: 'rule' } })).toBe(false) // root not a group
|
|
297
|
+
expect(isPersistedFilterTree({ v: 2, root: { type: 'group', combinator: 'xor', children: [] } })).toBe(false)
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
it('round-trips through serialize → deserialize without losing structure', () => {
|
|
301
|
+
const persisted = serializeTreeForPersist(tree)
|
|
302
|
+
const restored = deserializeTreeFromPersist(persisted as unknown)
|
|
303
|
+
expect(restored).not.toBeNull()
|
|
304
|
+
if (!restored) return
|
|
305
|
+
expect(restored.root.id).toBe('root')
|
|
306
|
+
expect(restored.root.children.map((c) => c.id)).toEqual(['a', 'sub'])
|
|
307
|
+
const sub = restored.root.children[1] as FilterGroup
|
|
308
|
+
expect(sub.combinator).toBe('or')
|
|
309
|
+
expect(sub.children.map((c) => c.id)).toEqual(['b', 'c'])
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
it('deserializeTreeFromPersist returns null for non-tree shapes', () => {
|
|
313
|
+
expect(deserializeTreeFromPersist({ status: 'active' })).toBeNull()
|
|
314
|
+
expect(deserializeTreeFromPersist(null)).toBeNull()
|
|
315
|
+
expect(deserializeTreeFromPersist(undefined)).toBeNull()
|
|
316
|
+
expect(deserializeTreeFromPersist({ v: 1, root: {} })).toBeNull()
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
it('compiled WHERE matches before and after a persist round-trip', () => {
|
|
320
|
+
const before = compileTreeToWhere(tree)
|
|
321
|
+
const restored = deserializeTreeFromPersist(serializeTreeForPersist(tree) as unknown)
|
|
322
|
+
expect(restored).not.toBeNull()
|
|
323
|
+
if (!restored) return
|
|
324
|
+
expect(compileTreeToWhere(restored)).toEqual(before)
|
|
325
|
+
})
|
|
326
|
+
})
|
|
@@ -188,7 +188,7 @@ function flattenCmps(node: Node | unknown, out: Cmp[] = []): Cmp[] {
|
|
|
188
188
|
}
|
|
189
189
|
|
|
190
190
|
describe('normalizeFilters $or clause grouping', () => {
|
|
191
|
-
test('assigns
|
|
191
|
+
test('assigns distinct orGroup ids per disjunct; lifts clauses common to every disjunct out of the OR', () => {
|
|
192
192
|
const normalized = normalizeFilters({
|
|
193
193
|
$or: [
|
|
194
194
|
{ organization_id: { $eq: 'org-1' }, tenant_id: { $eq: 't1' } },
|
|
@@ -196,22 +196,24 @@ describe('normalizeFilters $or clause grouping', () => {
|
|
|
196
196
|
],
|
|
197
197
|
})
|
|
198
198
|
|
|
199
|
-
|
|
200
|
-
|
|
199
|
+
// tenant_id appears in every disjunct, so it's lifted out for SQL efficiency
|
|
200
|
+
// (preserves the search-tokens optimization on common ANDed predicates).
|
|
201
|
+
const ungrouped = normalized.filter((f) => !f.orGroup).map((f) => f.field).sort()
|
|
202
|
+
expect(ungrouped).toEqual(['tenant_id'])
|
|
201
203
|
|
|
202
204
|
const byGroup = new Map<string, typeof normalized>()
|
|
203
|
-
for (const f of normalized) {
|
|
205
|
+
for (const f of normalized.filter((f) => f.orGroup)) {
|
|
204
206
|
const key = f.orGroup!
|
|
205
207
|
const list = byGroup.get(key) ?? []
|
|
206
208
|
list.push(f)
|
|
207
209
|
byGroup.set(key, list)
|
|
208
210
|
}
|
|
209
|
-
|
|
211
|
+
expect(byGroup.size).toBe(2)
|
|
210
212
|
const groupFields = Array.from(byGroup.values()).map((group) => group.map((f) => f.field).sort())
|
|
211
213
|
expect(groupFields).toEqual(
|
|
212
214
|
expect.arrayContaining([
|
|
213
|
-
['organization_id'
|
|
214
|
-
['organization_id', 'scope_type'
|
|
215
|
+
['organization_id'],
|
|
216
|
+
['organization_id', 'scope_type'].sort(),
|
|
215
217
|
]),
|
|
216
218
|
)
|
|
217
219
|
})
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** @jest-environment node */
|
|
2
|
+
import { normalizeFilters } from '../join-utils'
|
|
3
|
+
import { AdvancedFilterComplexityError, MAX_DNF_DISJUNCTS } from '../join-utils'
|
|
4
|
+
|
|
5
|
+
describe('compileToDnf complexity cap', () => {
|
|
6
|
+
test('accepts a filter that fits within the disjunct cap', () => {
|
|
7
|
+
const filter = {
|
|
8
|
+
$or: Array.from({ length: 10 }, (_v, i) => ({ name: { $ilike: `%v${i}%` } })),
|
|
9
|
+
}
|
|
10
|
+
expect(() => normalizeFilters(filter)).not.toThrow()
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
test('throws AdvancedFilterComplexityError when DNF expansion would exceed the cap', () => {
|
|
14
|
+
// 4 ANDed disjunctions of 8 alternatives each = 8^4 = 4096 disjuncts.
|
|
15
|
+
const eightOptions = (field: string) => ({
|
|
16
|
+
$or: Array.from({ length: 8 }, (_v, i) => ({ [field]: { $ilike: `%${i}%` } })),
|
|
17
|
+
})
|
|
18
|
+
const filter = {
|
|
19
|
+
$and: [
|
|
20
|
+
eightOptions('a'),
|
|
21
|
+
eightOptions('b'),
|
|
22
|
+
eightOptions('c'),
|
|
23
|
+
eightOptions('d'),
|
|
24
|
+
],
|
|
25
|
+
}
|
|
26
|
+
expect(() => normalizeFilters(filter)).toThrow(AdvancedFilterComplexityError)
|
|
27
|
+
try {
|
|
28
|
+
normalizeFilters(filter)
|
|
29
|
+
} catch (err) {
|
|
30
|
+
expect(err).toBeInstanceOf(AdvancedFilterComplexityError)
|
|
31
|
+
const e = err as AdvancedFilterComplexityError
|
|
32
|
+
expect(e.code).toBe('ADVANCED_FILTER_TOO_COMPLEX')
|
|
33
|
+
expect(e.disjunctCount).toBeGreaterThan(MAX_DNF_DISJUNCTS)
|
|
34
|
+
}
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('throws when a single $or has more than the cap of alternatives', () => {
|
|
38
|
+
const filter = {
|
|
39
|
+
$or: Array.from({ length: MAX_DNF_DISJUNCTS + 5 }, (_v, i) => ({ name: { $ilike: `%v${i}%` } })),
|
|
40
|
+
}
|
|
41
|
+
expect(() => normalizeFilters(filter)).toThrow(AdvancedFilterComplexityError)
|
|
42
|
+
})
|
|
43
|
+
})
|