@open-mercato/shared 0.6.1-develop.3276.1.1fcd145078 → 0.6.1-develop.3278.1.a4d26475e4

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.
@@ -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 out: NormalizedFilter[] = []
20
- const obj = filters as Record<string, unknown>
21
- const push = (field: string, op: FilterOp, value?: unknown, orGroup?: string) => {
22
- out.push({ field, op, value, orGroup })
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
- // Handle $or at top level — one group id per disjunct so fields inside a clause are ANDed by the engine.
25
- if (Array.isArray(obj.$or)) {
26
- const clauses = obj.$or as Record<string, unknown>[]
27
- for (let clauseIndex = 0; clauseIndex < clauses.length; clauseIndex++) {
28
- const clause = clauses[clauseIndex]
29
- const orGroupId = `or_${clauseIndex}`
30
- if (clause && typeof clause === 'object') {
31
- for (const [rawKey, rawVal] of Object.entries(clause)) {
32
- const field = normalizeField(rawKey)
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 (const [rawKey, rawVal] of Object.entries(obj)) {
48
- if (rawKey === '$or') continue
49
- const field = normalizeField(rawKey)
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