@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
|
@@ -7,86 +7,172 @@ type AnyBuilder = any
|
|
|
7
7
|
|
|
8
8
|
export type NormalizedFilter = { field: string; op: FilterOp; value?: unknown; orGroup?: string; qualified?: string | null }
|
|
9
9
|
|
|
10
|
+
const VALID_OPS = ['eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'in', 'nin', 'like', 'ilike', 'exists'] as const
|
|
11
|
+
|
|
12
|
+
const normalizeField = (key: string) => (key.startsWith('cf_') ? `cf:${key.slice(3)}` : key)
|
|
13
|
+
|
|
14
|
+
type LeafClause = { field: string; op: FilterOp; value?: unknown }
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Cap on DNF disjunct count to prevent adversarial / pathological filters from
|
|
18
|
+
* exploding the in-memory normalization. `validateTreeLimits` already caps
|
|
19
|
+
* editor input at 50 rules / 15 children per group / 3 levels of nesting, but
|
|
20
|
+
* a hand-crafted URL or buggy preset could still bypass that. The cap mirrors
|
|
21
|
+
* the editor's posture: fail loud, refuse to compile.
|
|
22
|
+
*/
|
|
23
|
+
export const MAX_DNF_DISJUNCTS = 1000
|
|
24
|
+
|
|
25
|
+
export class AdvancedFilterComplexityError extends Error {
|
|
26
|
+
readonly code = 'ADVANCED_FILTER_TOO_COMPLEX'
|
|
27
|
+
readonly disjunctCount: number
|
|
28
|
+
constructor(disjunctCount: number) {
|
|
29
|
+
super(
|
|
30
|
+
`Advanced filter expanded to ${disjunctCount} disjuncts, exceeding the limit of ${MAX_DNF_DISJUNCTS}. ` +
|
|
31
|
+
`Reduce the number of OR branches or simplify nested groups.`,
|
|
32
|
+
)
|
|
33
|
+
this.name = 'AdvancedFilterComplexityError'
|
|
34
|
+
this.disjunctCount = disjunctCount
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Expand a Mongo-style filter object (with arbitrary nesting of `$and`/`$or` and
|
|
40
|
+
* implicit AND between sibling keys) into Disjunctive Normal Form: `Clause[][]`,
|
|
41
|
+
* where the outer array is OR'd alternatives and each inner array is the AND'd
|
|
42
|
+
* leaves of one alternative.
|
|
43
|
+
*
|
|
44
|
+
* Empty filter -> `[[]]` (one empty disjunct, treated as "no constraint").
|
|
45
|
+
* Throws `AdvancedFilterComplexityError` if any intermediate cartesian product
|
|
46
|
+
* exceeds `MAX_DNF_DISJUNCTS`.
|
|
47
|
+
*/
|
|
48
|
+
function compileToDnf(filter: unknown): LeafClause[][] {
|
|
49
|
+
if (filter === null || filter === undefined) return [[]]
|
|
50
|
+
if (Array.isArray(filter) || typeof filter !== 'object') return [[]]
|
|
51
|
+
const obj = filter as Record<string, unknown>
|
|
52
|
+
const partialDnfs: LeafClause[][][] = []
|
|
53
|
+
|
|
54
|
+
for (const [rawKey, rawVal] of Object.entries(obj)) {
|
|
55
|
+
if (rawKey === '$and' && Array.isArray(rawVal)) {
|
|
56
|
+
partialDnfs.push(cartesianAnd((rawVal as unknown[]).map(compileToDnf)))
|
|
57
|
+
continue
|
|
58
|
+
}
|
|
59
|
+
if (rawKey === '$or' && Array.isArray(rawVal)) {
|
|
60
|
+
const expanded = (rawVal as unknown[]).flatMap(compileToDnf)
|
|
61
|
+
if (expanded.length > MAX_DNF_DISJUNCTS) {
|
|
62
|
+
throw new AdvancedFilterComplexityError(expanded.length)
|
|
63
|
+
}
|
|
64
|
+
partialDnfs.push(expanded)
|
|
65
|
+
continue
|
|
66
|
+
}
|
|
67
|
+
const field = normalizeField(rawKey)
|
|
68
|
+
if (rawVal !== null && typeof rawVal === 'object' && !Array.isArray(rawVal)) {
|
|
69
|
+
const clauses: LeafClause[] = []
|
|
70
|
+
for (const [opKey, opVal] of Object.entries(rawVal as Record<string, unknown>)) {
|
|
71
|
+
const op = (opKey.startsWith('$') ? opKey.slice(1) : opKey) as FilterOp
|
|
72
|
+
if (VALID_OPS.includes(op as (typeof VALID_OPS)[number])) {
|
|
73
|
+
clauses.push({ field, op, value: opVal })
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (clauses.length > 0) partialDnfs.push([clauses])
|
|
77
|
+
} else {
|
|
78
|
+
partialDnfs.push([[{ field, op: 'eq' as FilterOp, value: rawVal }]])
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return cartesianAnd(partialDnfs)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function cartesianAnd(dnfs: LeafClause[][][]): LeafClause[][] {
|
|
86
|
+
if (dnfs.length === 0) return [[]]
|
|
87
|
+
let result: LeafClause[][] = [[]]
|
|
88
|
+
for (const dnf of dnfs) {
|
|
89
|
+
if (dnf.length === 0) {
|
|
90
|
+
// An empty DNF means an unsatisfiable subexpression; the AND becomes unsatisfiable
|
|
91
|
+
// too. Represent that as an empty outer array (no disjuncts).
|
|
92
|
+
return []
|
|
93
|
+
}
|
|
94
|
+
// Predict the next size to fail loud before allocating millions of arrays.
|
|
95
|
+
const projected = result.length * dnf.length
|
|
96
|
+
if (projected > MAX_DNF_DISJUNCTS) {
|
|
97
|
+
throw new AdvancedFilterComplexityError(projected)
|
|
98
|
+
}
|
|
99
|
+
const next: LeafClause[][] = []
|
|
100
|
+
for (const left of result) {
|
|
101
|
+
for (const right of dnf) {
|
|
102
|
+
next.push([...left, ...right])
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
result = next
|
|
106
|
+
}
|
|
107
|
+
return result
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function clauseKey(c: LeafClause): string {
|
|
111
|
+
return JSON.stringify([c.field, c.op, c.value])
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Lift clauses that appear in every disjunct into a "common" set so the engine
|
|
116
|
+
* can treat them as regular ANDed base filters (preserving the search-tokens
|
|
117
|
+
* optimization for like/ilike clauses) instead of duplicating them across OR
|
|
118
|
+
* groups.
|
|
119
|
+
*/
|
|
120
|
+
function liftCommonClauses(dnf: LeafClause[][]): { common: LeafClause[]; remaining: LeafClause[][] } {
|
|
121
|
+
if (dnf.length <= 1) return { common: [], remaining: dnf }
|
|
122
|
+
const counts = new Map<string, { clause: LeafClause; count: number }>()
|
|
123
|
+
for (const disjunct of dnf) {
|
|
124
|
+
const seen = new Set<string>()
|
|
125
|
+
for (const c of disjunct) {
|
|
126
|
+
const key = clauseKey(c)
|
|
127
|
+
if (seen.has(key)) continue // count once per disjunct even if duplicated
|
|
128
|
+
seen.add(key)
|
|
129
|
+
const entry = counts.get(key)
|
|
130
|
+
if (entry) entry.count += 1
|
|
131
|
+
else counts.set(key, { clause: c, count: 1 })
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const total = dnf.length
|
|
135
|
+
const commonKeys = new Set<string>()
|
|
136
|
+
const common: LeafClause[] = []
|
|
137
|
+
for (const [key, { clause, count }] of counts) {
|
|
138
|
+
if (count === total) {
|
|
139
|
+
commonKeys.add(key)
|
|
140
|
+
common.push(clause)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (common.length === 0) return { common: [], remaining: dnf }
|
|
144
|
+
const remaining = dnf.map((d) => d.filter((c) => !commonKeys.has(clauseKey(c))))
|
|
145
|
+
return { common, remaining }
|
|
146
|
+
}
|
|
147
|
+
|
|
10
148
|
export function normalizeFilters(filters?: QueryOptions['filters']): NormalizedFilter[] {
|
|
11
149
|
if (!filters) return []
|
|
12
|
-
const normalizeField = (key: string) => (key.startsWith('cf_') ? `cf:${key.slice(3)}` : key)
|
|
13
150
|
if (Array.isArray(filters)) {
|
|
14
151
|
return (filters as any[]).map((f) => ({
|
|
15
152
|
...f,
|
|
16
153
|
field: normalizeField(String((f as any).field)),
|
|
17
154
|
}))
|
|
18
155
|
}
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
const
|
|
22
|
-
|
|
156
|
+
const dnf = compileToDnf(filters)
|
|
157
|
+
if (dnf.length === 0) return []
|
|
158
|
+
const nonEmpty = dnf.filter((disjunct) => disjunct.length > 0)
|
|
159
|
+
if (nonEmpty.length === 0) return []
|
|
160
|
+
if (nonEmpty.length === 1) {
|
|
161
|
+
return nonEmpty[0].map((c) => ({ field: c.field, op: c.op, value: c.value }))
|
|
23
162
|
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
if (rawVal !== null && typeof rawVal === 'object' && !Array.isArray(rawVal)) {
|
|
34
|
-
for (const [opKey, opVal] of Object.entries(rawVal as Record<string, unknown>)) {
|
|
35
|
-
const op = opKey.replace('$', '') as FilterOp
|
|
36
|
-
if (['eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'in', 'nin', 'like', 'ilike', 'exists'].includes(op)) {
|
|
37
|
-
push(field, op, opVal, orGroupId)
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
} else {
|
|
41
|
-
push(field, 'eq', rawVal, orGroupId)
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
}
|
|
163
|
+
const { common, remaining } = liftCommonClauses(nonEmpty)
|
|
164
|
+
const out: NormalizedFilter[] = []
|
|
165
|
+
for (const c of common) out.push({ field: c.field, op: c.op, value: c.value })
|
|
166
|
+
// Drop disjuncts that became empty after lifting (they're already represented in `common`).
|
|
167
|
+
const remainingNonEmpty = remaining.filter((d) => d.length > 0)
|
|
168
|
+
if (remainingNonEmpty.length === 0) return out
|
|
169
|
+
if (remainingNonEmpty.length === 1) {
|
|
170
|
+
for (const c of remainingNonEmpty[0]) out.push({ field: c.field, op: c.op, value: c.value })
|
|
171
|
+
return out
|
|
46
172
|
}
|
|
47
|
-
for (
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
if (rawVal !== null && typeof rawVal === 'object' && !Array.isArray(rawVal)) {
|
|
51
|
-
for (const [opKey, opVal] of Object.entries(rawVal as Record<string, unknown>)) {
|
|
52
|
-
switch (opKey) {
|
|
53
|
-
case '$eq':
|
|
54
|
-
push(field, 'eq', opVal)
|
|
55
|
-
break
|
|
56
|
-
case '$ne':
|
|
57
|
-
push(field, 'ne', opVal)
|
|
58
|
-
break
|
|
59
|
-
case '$gt':
|
|
60
|
-
push(field, 'gt', opVal)
|
|
61
|
-
break
|
|
62
|
-
case '$gte':
|
|
63
|
-
push(field, 'gte', opVal)
|
|
64
|
-
break
|
|
65
|
-
case '$lt':
|
|
66
|
-
push(field, 'lt', opVal)
|
|
67
|
-
break
|
|
68
|
-
case '$lte':
|
|
69
|
-
push(field, 'lte', opVal)
|
|
70
|
-
break
|
|
71
|
-
case '$in':
|
|
72
|
-
push(field, 'in', opVal)
|
|
73
|
-
break
|
|
74
|
-
case '$nin':
|
|
75
|
-
push(field, 'nin', opVal)
|
|
76
|
-
break
|
|
77
|
-
case '$like':
|
|
78
|
-
push(field, 'like', opVal)
|
|
79
|
-
break
|
|
80
|
-
case '$ilike':
|
|
81
|
-
push(field, 'ilike', opVal)
|
|
82
|
-
break
|
|
83
|
-
case '$exists':
|
|
84
|
-
push(field, 'exists', opVal)
|
|
85
|
-
break
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
} else {
|
|
89
|
-
push(field, 'eq', rawVal)
|
|
173
|
+
for (let i = 0; i < remainingNonEmpty.length; i++) {
|
|
174
|
+
for (const c of remainingNonEmpty[i]) {
|
|
175
|
+
out.push({ field: c.field, op: c.op, value: c.value, orGroup: `or_${i}` })
|
|
90
176
|
}
|
|
91
177
|
}
|
|
92
178
|
return out
|