@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,328 @@
|
|
|
1
|
+
// packages/shared/src/lib/query/advanced-filter-tree.ts
|
|
2
|
+
import type { FilterOperator } from './advanced-filter'
|
|
3
|
+
import { isValuelessOperator } from './advanced-filter'
|
|
4
|
+
import { escapeLikePattern } from '../db/escapeLikePattern'
|
|
5
|
+
|
|
6
|
+
export type FilterCombinator = 'and' | 'or'
|
|
7
|
+
|
|
8
|
+
export type FilterRule = {
|
|
9
|
+
id: string
|
|
10
|
+
type: 'rule'
|
|
11
|
+
field: string
|
|
12
|
+
operator: FilterOperator
|
|
13
|
+
value: unknown
|
|
14
|
+
// Runtime-only metadata used by the editor reducer (e.g. removeLast) to find
|
|
15
|
+
// the most recently inserted node. MUST NOT be serialized into URL or persisted state.
|
|
16
|
+
addedAt?: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type FilterGroup = {
|
|
20
|
+
id: string
|
|
21
|
+
type: 'group'
|
|
22
|
+
combinator: FilterCombinator
|
|
23
|
+
children: Array<FilterRule | FilterGroup>
|
|
24
|
+
// Runtime-only metadata used by the editor reducer (e.g. removeLast) to find
|
|
25
|
+
// the most recently inserted node. MUST NOT be serialized into URL or persisted state.
|
|
26
|
+
addedAt?: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type AdvancedFilterTree = {
|
|
30
|
+
root: FilterGroup
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const TREE_LIMITS = {
|
|
34
|
+
maxGroupLevel: 3, // root = level 1; deepest nested group = level 3
|
|
35
|
+
maxChildrenPerGroup: 15, // counts both rules and nested groups
|
|
36
|
+
maxTotalRules: 50,
|
|
37
|
+
} as const
|
|
38
|
+
|
|
39
|
+
export function createEmptyTree(): AdvancedFilterTree {
|
|
40
|
+
return {
|
|
41
|
+
root: {
|
|
42
|
+
id: crypto.randomUUID(),
|
|
43
|
+
type: 'group',
|
|
44
|
+
combinator: 'and',
|
|
45
|
+
children: [],
|
|
46
|
+
},
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type ValidationResult =
|
|
51
|
+
| { ok: true }
|
|
52
|
+
| { ok: false; reason: 'depth' | 'width' | 'total' }
|
|
53
|
+
|
|
54
|
+
export function validateTreeLimits(tree: AdvancedFilterTree): ValidationResult {
|
|
55
|
+
let totalRules = 0
|
|
56
|
+
function walk(node: FilterRule | FilterGroup, level: number): ValidationResult {
|
|
57
|
+
if (node.type === 'rule') {
|
|
58
|
+
totalRules += 1
|
|
59
|
+
return { ok: true }
|
|
60
|
+
}
|
|
61
|
+
if (level > TREE_LIMITS.maxGroupLevel) return { ok: false, reason: 'depth' }
|
|
62
|
+
if (node.children.length > TREE_LIMITS.maxChildrenPerGroup) return { ok: false, reason: 'width' }
|
|
63
|
+
for (const child of node.children) {
|
|
64
|
+
const r = walk(child, level + 1)
|
|
65
|
+
if (!r.ok) return r
|
|
66
|
+
}
|
|
67
|
+
return { ok: true }
|
|
68
|
+
}
|
|
69
|
+
const r = walk(tree.root, 1)
|
|
70
|
+
if (!r.ok) return r
|
|
71
|
+
if (totalRules > TREE_LIMITS.maxTotalRules) return { ok: false, reason: 'total' }
|
|
72
|
+
return { ok: true }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function normalizeSingleValue(value: unknown): unknown {
|
|
76
|
+
if (typeof value !== 'string') return value
|
|
77
|
+
const trimmed = value.trim()
|
|
78
|
+
return trimmed.length > 0 ? trimmed : null
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function normalizeListValue(value: unknown): unknown[] {
|
|
82
|
+
const list = Array.isArray(value) ? value : [value]
|
|
83
|
+
return list.map(normalizeSingleValue).filter((v) => v !== null)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function compileRule(rule: FilterRule): Record<string, unknown> | null {
|
|
87
|
+
if (!rule.field || !rule.operator) return null
|
|
88
|
+
const filter: Record<string, unknown> = {}
|
|
89
|
+
switch (rule.operator) {
|
|
90
|
+
case 'is':
|
|
91
|
+
case 'equals': {
|
|
92
|
+
const v = normalizeSingleValue(rule.value)
|
|
93
|
+
if (v === null) return null
|
|
94
|
+
filter[rule.field] = { $eq: v }; break
|
|
95
|
+
}
|
|
96
|
+
case 'is_not':
|
|
97
|
+
case 'not_equals': {
|
|
98
|
+
const v = normalizeSingleValue(rule.value)
|
|
99
|
+
if (v === null) return null
|
|
100
|
+
filter[rule.field] = { $ne: v }; break
|
|
101
|
+
}
|
|
102
|
+
case 'contains': {
|
|
103
|
+
const v = normalizeSingleValue(rule.value)
|
|
104
|
+
if (v === null) return null
|
|
105
|
+
filter[rule.field] = { $ilike: `%${escapeLikePattern(String(v))}%` }; break
|
|
106
|
+
}
|
|
107
|
+
case 'does_not_contain': {
|
|
108
|
+
const v = normalizeSingleValue(rule.value)
|
|
109
|
+
if (v === null) return null
|
|
110
|
+
filter[rule.field] = { $not: { $ilike: `%${escapeLikePattern(String(v))}%` } }; break
|
|
111
|
+
}
|
|
112
|
+
case 'starts_with': {
|
|
113
|
+
const v = normalizeSingleValue(rule.value)
|
|
114
|
+
if (v === null) return null
|
|
115
|
+
filter[rule.field] = { $ilike: `${escapeLikePattern(String(v))}%` }; break
|
|
116
|
+
}
|
|
117
|
+
case 'ends_with': {
|
|
118
|
+
const v = normalizeSingleValue(rule.value)
|
|
119
|
+
if (v === null) return null
|
|
120
|
+
filter[rule.field] = { $ilike: `%${escapeLikePattern(String(v))}` }; break
|
|
121
|
+
}
|
|
122
|
+
case 'is_empty': filter[rule.field] = { $exists: false }; break
|
|
123
|
+
case 'is_not_empty': filter[rule.field] = { $exists: true }; break
|
|
124
|
+
case 'greater_than': { const v = normalizeSingleValue(rule.value); if (v === null) return null; filter[rule.field] = { $gt: v }; break }
|
|
125
|
+
case 'less_than': { const v = normalizeSingleValue(rule.value); if (v === null) return null; filter[rule.field] = { $lt: v }; break }
|
|
126
|
+
case 'greater_or_equal': { const v = normalizeSingleValue(rule.value); if (v === null) return null; filter[rule.field] = { $gte: v }; break }
|
|
127
|
+
case 'less_or_equal': { const v = normalizeSingleValue(rule.value); if (v === null) return null; filter[rule.field] = { $lte: v }; break }
|
|
128
|
+
case 'between': {
|
|
129
|
+
if (Array.isArray(rule.value) && rule.value.length === 2) {
|
|
130
|
+
const start = normalizeSingleValue(rule.value[0])
|
|
131
|
+
const end = normalizeSingleValue(rule.value[1])
|
|
132
|
+
if (start === null && end === null) return null
|
|
133
|
+
if (start !== null && end !== null) filter[rule.field] = { $gte: start, $lte: end }
|
|
134
|
+
else if (start !== null) filter[rule.field] = { $gte: start }
|
|
135
|
+
else filter[rule.field] = { $lte: end }
|
|
136
|
+
} else {
|
|
137
|
+
return null
|
|
138
|
+
}
|
|
139
|
+
break
|
|
140
|
+
}
|
|
141
|
+
case 'is_before': { const v = normalizeSingleValue(rule.value); if (v === null) return null; filter[rule.field] = { $lt: v }; break }
|
|
142
|
+
case 'is_after': { const v = normalizeSingleValue(rule.value); if (v === null) return null; filter[rule.field] = { $gt: v }; break }
|
|
143
|
+
case 'is_any_of':
|
|
144
|
+
case 'has_any_of': {
|
|
145
|
+
const list = normalizeListValue(rule.value)
|
|
146
|
+
if (list.length === 0) return null
|
|
147
|
+
filter[rule.field] = { $in: list }; break
|
|
148
|
+
}
|
|
149
|
+
case 'is_none_of':
|
|
150
|
+
case 'has_none_of': {
|
|
151
|
+
const list = normalizeListValue(rule.value)
|
|
152
|
+
if (list.length === 0) return null
|
|
153
|
+
filter[rule.field] = { $nin: list }; break
|
|
154
|
+
}
|
|
155
|
+
case 'has_all_of': {
|
|
156
|
+
const list = normalizeListValue(rule.value)
|
|
157
|
+
if (list.length === 0) return null
|
|
158
|
+
filter[rule.field] = { $contains: list }; break
|
|
159
|
+
}
|
|
160
|
+
case 'is_true': filter[rule.field] = { $eq: true }; break
|
|
161
|
+
case 'is_false': filter[rule.field] = { $eq: false }; break
|
|
162
|
+
}
|
|
163
|
+
return Object.keys(filter).length > 0 ? filter : null
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function compileTreeToWhere(tree: AdvancedFilterTree): Record<string, unknown> | null {
|
|
167
|
+
return compileGroup(tree.root)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function compileGroup(node: FilterGroup): Record<string, unknown> | null {
|
|
171
|
+
const compiled = node.children
|
|
172
|
+
.map((child) => (child.type === 'rule' ? compileRule(child) : compileGroup(child)))
|
|
173
|
+
.filter((c): c is Record<string, unknown> => c !== null)
|
|
174
|
+
if (compiled.length === 0) return null
|
|
175
|
+
if (compiled.length === 1) return compiled[0]
|
|
176
|
+
return node.combinator === 'or' ? { $or: compiled } : { $and: compiled }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Re-export isValuelessOperator so consumers of this module can access it
|
|
180
|
+
export { isValuelessOperator }
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Build a single-rule tree wrapped in an AND root group.
|
|
184
|
+
* Useful for quick-filter presets that apply one rule.
|
|
185
|
+
*/
|
|
186
|
+
export function makeRuleTree(rule: { field: string; operator: FilterOperator; value: unknown }): AdvancedFilterTree {
|
|
187
|
+
const ruleNode: FilterRule = {
|
|
188
|
+
id: crypto.randomUUID(),
|
|
189
|
+
type: 'rule',
|
|
190
|
+
field: rule.field,
|
|
191
|
+
operator: rule.operator,
|
|
192
|
+
value: rule.value,
|
|
193
|
+
}
|
|
194
|
+
return {
|
|
195
|
+
root: {
|
|
196
|
+
id: crypto.randomUUID(),
|
|
197
|
+
type: 'group',
|
|
198
|
+
combinator: 'and',
|
|
199
|
+
children: [ruleNode],
|
|
200
|
+
},
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Persisted shape of an `AdvancedFilterTree` inside `PerspectiveSettings.filters`.
|
|
206
|
+
* The `v: 2` marker disambiguates the tree from legacy flat `FilterValues` and
|
|
207
|
+
* from the plain `{root: ...}` shape. Runtime-only `addedAt` is stripped before
|
|
208
|
+
* persisting so saved perspectives are deterministic across reloads.
|
|
209
|
+
*/
|
|
210
|
+
export type PersistedFilterTree = {
|
|
211
|
+
v: 2
|
|
212
|
+
root: FilterGroup
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function stripRuntimeMetadataDeep<T extends FilterRule | FilterGroup>(node: T): T {
|
|
216
|
+
if (node.type === 'rule') {
|
|
217
|
+
const { addedAt: _ignored, ...rest } = node as FilterRule & { addedAt?: number }
|
|
218
|
+
return rest as T
|
|
219
|
+
}
|
|
220
|
+
const { addedAt: _ignored, children, ...rest } = node as FilterGroup & { addedAt?: number }
|
|
221
|
+
const cleanChildren = children.map((c) => stripRuntimeMetadataDeep(c))
|
|
222
|
+
return { ...rest, children: cleanChildren } as T
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Serialize a tree for persistence (perspective settings, exports, etc.). */
|
|
226
|
+
export function serializeTreeForPersist(tree: AdvancedFilterTree): PersistedFilterTree {
|
|
227
|
+
return { v: 2, root: stripRuntimeMetadataDeep(tree.root) }
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Type guard: does `value` look like a persisted filter tree? */
|
|
231
|
+
export function isPersistedFilterTree(value: unknown): value is PersistedFilterTree {
|
|
232
|
+
if (!value || typeof value !== 'object') return false
|
|
233
|
+
const record = value as Record<string, unknown>
|
|
234
|
+
if (record.v !== 2) return false
|
|
235
|
+
const root = record.root
|
|
236
|
+
if (!root || typeof root !== 'object') return false
|
|
237
|
+
const r = root as Record<string, unknown>
|
|
238
|
+
return r.type === 'group' && (r.combinator === 'and' || r.combinator === 'or') && Array.isArray(r.children)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Restore a tree from its persisted shape. Returns `null` when the input is
|
|
243
|
+
* not a recognizable persisted tree — callers should fall back to legacy
|
|
244
|
+
* filter handling in that case.
|
|
245
|
+
*/
|
|
246
|
+
export function deserializeTreeFromPersist(value: unknown): AdvancedFilterTree | null {
|
|
247
|
+
if (!isPersistedFilterTree(value)) return null
|
|
248
|
+
return { root: value.root }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Best-effort back-conversion of a tree to the legacy flat `AdvancedFilterState`
|
|
253
|
+
* shape (`{logic, conditions[]}`). The flat shape only supports a single level
|
|
254
|
+
* of AND/OR joining between sibling conditions; nested groups are flattened by
|
|
255
|
+
* walking all leaf rules into the top level. Use this only for the legacy
|
|
256
|
+
* `DataTable.advancedFilter` BC bridge — new code should keep the tree shape.
|
|
257
|
+
*
|
|
258
|
+
* The function picks `logic` from the root group's combinator and uses each
|
|
259
|
+
* rule's parent-group combinator as its `join`. The first rule's `join` is
|
|
260
|
+
* always `'and'` (it has no left neighbor).
|
|
261
|
+
*/
|
|
262
|
+
export function treeToFlat(tree: AdvancedFilterTree): {
|
|
263
|
+
logic: 'and' | 'or'
|
|
264
|
+
conditions: Array<{
|
|
265
|
+
id: string
|
|
266
|
+
field: string
|
|
267
|
+
operator: import('./advanced-filter').FilterOperator
|
|
268
|
+
value: unknown
|
|
269
|
+
join: 'and' | 'or'
|
|
270
|
+
}>
|
|
271
|
+
} {
|
|
272
|
+
const conditions: Array<{
|
|
273
|
+
id: string
|
|
274
|
+
field: string
|
|
275
|
+
operator: import('./advanced-filter').FilterOperator
|
|
276
|
+
value: unknown
|
|
277
|
+
join: 'and' | 'or'
|
|
278
|
+
}> = []
|
|
279
|
+
|
|
280
|
+
function walk(group: FilterGroup, parentJoin: 'and' | 'or') {
|
|
281
|
+
for (const child of group.children) {
|
|
282
|
+
if (child.type === 'rule') {
|
|
283
|
+
conditions.push({
|
|
284
|
+
id: child.id,
|
|
285
|
+
field: child.field,
|
|
286
|
+
operator: child.operator,
|
|
287
|
+
value: child.value,
|
|
288
|
+
// First rule overall gets 'and' (it has no left neighbor); the rest
|
|
289
|
+
// inherit the parent group's combinator.
|
|
290
|
+
join: conditions.length === 0 ? 'and' : parentJoin,
|
|
291
|
+
})
|
|
292
|
+
} else {
|
|
293
|
+
walk(child, child.combinator)
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
walk(tree.root, tree.root.combinator)
|
|
298
|
+
|
|
299
|
+
return { logic: tree.root.combinator, conditions }
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Build a multi-rule tree wrapped in a root group with the given combinator.
|
|
304
|
+
* Useful for quick-filter presets that combine multiple rules (e.g.
|
|
305
|
+
* `status is win AND close_date is_after start_of_quarter`).
|
|
306
|
+
*/
|
|
307
|
+
export function makeMultiRuleTree(
|
|
308
|
+
rules: Array<{ field: string; operator: FilterOperator; value: unknown }>,
|
|
309
|
+
combinator: FilterCombinator = 'and',
|
|
310
|
+
): AdvancedFilterTree {
|
|
311
|
+
return {
|
|
312
|
+
root: {
|
|
313
|
+
id: crypto.randomUUID(),
|
|
314
|
+
type: 'group',
|
|
315
|
+
combinator,
|
|
316
|
+
children: rules.map((rule) => {
|
|
317
|
+
const node: FilterRule = {
|
|
318
|
+
id: crypto.randomUUID(),
|
|
319
|
+
type: 'rule',
|
|
320
|
+
field: rule.field,
|
|
321
|
+
operator: rule.operator,
|
|
322
|
+
value: rule.value,
|
|
323
|
+
}
|
|
324
|
+
return node
|
|
325
|
+
}),
|
|
326
|
+
},
|
|
327
|
+
}
|
|
328
|
+
}
|
|
@@ -10,9 +10,12 @@ export type FilterOperator =
|
|
|
10
10
|
|
|
11
11
|
export type FilterFieldType = 'text' | 'number' | 'date' | 'select' | 'boolean' | 'tags'
|
|
12
12
|
|
|
13
|
+
export type FilterOptionTone = 'success' | 'error' | 'warning' | 'info' | 'neutral' | 'brand' | 'pink'
|
|
14
|
+
|
|
13
15
|
export type FilterOption = {
|
|
14
16
|
value: string
|
|
15
17
|
label: string
|
|
18
|
+
tone?: FilterOptionTone
|
|
16
19
|
}
|
|
17
20
|
|
|
18
21
|
export type FilterFieldDef = {
|
|
@@ -20,6 +23,7 @@ export type FilterFieldDef = {
|
|
|
20
23
|
label: string
|
|
21
24
|
type: FilterFieldType
|
|
22
25
|
group?: string
|
|
26
|
+
iconName?: string
|
|
23
27
|
loadOptions?: (query?: string) => Promise<FilterOption[]>
|
|
24
28
|
options?: FilterOption[]
|
|
25
29
|
}
|
|
@@ -302,3 +306,216 @@ export function convertAdvancedFilterToWhere(state: AdvancedFilterState): Record
|
|
|
302
306
|
|
|
303
307
|
return clauses.length > 1 ? { $or: clauses } : clauses[0]
|
|
304
308
|
}
|
|
309
|
+
|
|
310
|
+
// -----------------------------------------------------------------------------
|
|
311
|
+
// v2 tree serialization (advanced-filter-tree)
|
|
312
|
+
// -----------------------------------------------------------------------------
|
|
313
|
+
|
|
314
|
+
import type {
|
|
315
|
+
AdvancedFilterTree,
|
|
316
|
+
FilterRule as TreeFilterRule,
|
|
317
|
+
FilterGroup as TreeFilterGroup,
|
|
318
|
+
FilterCombinator as TreeFilterCombinator,
|
|
319
|
+
} from './advanced-filter-tree'
|
|
320
|
+
|
|
321
|
+
export function serializeTree(tree: AdvancedFilterTree): Record<string, string> {
|
|
322
|
+
if (!treeHasRules(tree.root)) return {}
|
|
323
|
+
const out: Record<string, string> = { 'filter[v]': '2' }
|
|
324
|
+
serializeTreeGroup(tree.root, 'filter[root]', out)
|
|
325
|
+
return out
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function treeHasRules(group: TreeFilterGroup): boolean {
|
|
329
|
+
return group.children.some((child) => child.type === 'rule' || treeHasRules(child))
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function serializeTreeGroup(group: TreeFilterGroup, prefix: string, out: Record<string, string>): void {
|
|
333
|
+
out[`${prefix}[combinator]`] = group.combinator
|
|
334
|
+
group.children.forEach((child, idx) => {
|
|
335
|
+
const childPrefix = `${prefix}[children][${idx}]`
|
|
336
|
+
if (child.type === 'rule') serializeTreeRule(child, childPrefix, out)
|
|
337
|
+
else { out[`${childPrefix}[type]`] = 'group'; serializeTreeGroup(child, childPrefix, out) }
|
|
338
|
+
})
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function serializeTreeRule(rule: TreeFilterRule, prefix: string, out: Record<string, string>): void {
|
|
342
|
+
out[`${prefix}[type]`] = 'rule'
|
|
343
|
+
out[`${prefix}[field]`] = rule.field
|
|
344
|
+
out[`${prefix}[op]`] = rule.operator
|
|
345
|
+
if (!isValuelessOperator(rule.operator) && rule.value != null) {
|
|
346
|
+
out[`${prefix}[value]`] = typeof rule.value === 'object'
|
|
347
|
+
? JSON.stringify(rule.value)
|
|
348
|
+
: String(rule.value)
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
export function deserializeTree(query: Record<string, unknown>): AdvancedFilterTree | null {
|
|
353
|
+
if (query['filter[v]'] !== '2') return null
|
|
354
|
+
const root = readTreeGroup('filter[root]', query)
|
|
355
|
+
if (!root) return null
|
|
356
|
+
return { root }
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function readTreeGroup(prefix: string, query: Record<string, unknown>): TreeFilterGroup | null {
|
|
360
|
+
const combRaw = query[`${prefix}[combinator]`]
|
|
361
|
+
if (combRaw !== 'and' && combRaw !== 'or') return null
|
|
362
|
+
const children: Array<TreeFilterRule | TreeFilterGroup> = []
|
|
363
|
+
for (let i = 0; i < 64; i++) {
|
|
364
|
+
const childPrefix = `${prefix}[children][${i}]`
|
|
365
|
+
const type = query[`${childPrefix}[type]`]
|
|
366
|
+
if (type === 'rule') {
|
|
367
|
+
const field = query[`${childPrefix}[field]`]
|
|
368
|
+
const op = query[`${childPrefix}[op]`]
|
|
369
|
+
if (typeof field !== 'string' || typeof op !== 'string' || !isValidOperator(op)) continue
|
|
370
|
+
const rawVal = query[`${childPrefix}[value]`]
|
|
371
|
+
children.push({
|
|
372
|
+
id: crypto.randomUUID(),
|
|
373
|
+
type: 'rule',
|
|
374
|
+
field,
|
|
375
|
+
operator: op as FilterOperator,
|
|
376
|
+
value: parseSerializedFilterValue(rawVal),
|
|
377
|
+
})
|
|
378
|
+
} else if (type === 'group') {
|
|
379
|
+
const sub = readTreeGroup(childPrefix, query)
|
|
380
|
+
if (sub) children.push(sub)
|
|
381
|
+
} else {
|
|
382
|
+
break
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
return {
|
|
386
|
+
id: crypto.randomUUID(),
|
|
387
|
+
type: 'group',
|
|
388
|
+
combinator: combRaw as TreeFilterCombinator,
|
|
389
|
+
children,
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Runtime discriminator: is this value an `AdvancedFilterState` (legacy flat
|
|
395
|
+
* shape with `logic` + `conditions`) rather than an `AdvancedFilterTree`
|
|
396
|
+
* (`{root: FilterGroup}`)?
|
|
397
|
+
*
|
|
398
|
+
* Used at the DataTable boundary to bridge legacy callers onto the new tree
|
|
399
|
+
* model without forcing third-party module developers to migrate immediately.
|
|
400
|
+
* See `BACKWARD_COMPATIBILITY.md` §3 and the spec's "Migration & Backward
|
|
401
|
+
* Compatibility" section.
|
|
402
|
+
*/
|
|
403
|
+
export function isAdvancedFilterState(value: unknown): value is AdvancedFilterState {
|
|
404
|
+
if (!value || typeof value !== 'object') return false
|
|
405
|
+
const record = value as Record<string, unknown>
|
|
406
|
+
return Array.isArray(record.conditions) && (record.logic === 'and' || record.logic === 'or')
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Convert legacy flat AdvancedFilterState into a tree under standard SQL precedence
|
|
411
|
+
* (AND binds tighter than OR). Runs of consecutive AND-joined conditions become
|
|
412
|
+
* AND-subgroups, and the OR connectors join those subgroups in a root OR-group.
|
|
413
|
+
*/
|
|
414
|
+
export function flatToTree(flat: AdvancedFilterState): AdvancedFilterTree {
|
|
415
|
+
if (flat.conditions.length === 0) {
|
|
416
|
+
return { root: { id: crypto.randomUUID(), type: 'group', combinator: 'and', children: [] } }
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// Step A: split into AND-runs separated by OR connectors. The first row's `join`
|
|
420
|
+
// is logically "and" (no left neighbor); we never use it as a separator.
|
|
421
|
+
const andRuns: FilterCondition[][] = [[flat.conditions[0]]]
|
|
422
|
+
for (let i = 1; i < flat.conditions.length; i++) {
|
|
423
|
+
const c = flat.conditions[i]
|
|
424
|
+
if (c.join === 'or') andRuns.push([c])
|
|
425
|
+
else andRuns[andRuns.length - 1].push(c)
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const ruleFromCondition = (c: FilterCondition): TreeFilterRule => ({
|
|
429
|
+
id: crypto.randomUUID(),
|
|
430
|
+
type: 'rule',
|
|
431
|
+
field: c.field,
|
|
432
|
+
operator: c.operator,
|
|
433
|
+
value: c.value,
|
|
434
|
+
})
|
|
435
|
+
|
|
436
|
+
// Step B: each AND-run becomes either a rule (length 1) or an AND-group.
|
|
437
|
+
const orChildren: Array<TreeFilterRule | TreeFilterGroup> = andRuns.map((run) => {
|
|
438
|
+
if (run.length === 1) return ruleFromCondition(run[0])
|
|
439
|
+
return {
|
|
440
|
+
id: crypto.randomUUID(),
|
|
441
|
+
type: 'group',
|
|
442
|
+
combinator: 'and',
|
|
443
|
+
children: run.map(ruleFromCondition),
|
|
444
|
+
}
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
// Step C: zero or one OR-disjunct -> root combinator stays "and"; otherwise OR.
|
|
448
|
+
if (orChildren.length === 1) {
|
|
449
|
+
const only = orChildren[0]
|
|
450
|
+
if (only.type === 'group') return { root: only }
|
|
451
|
+
return { root: { id: crypto.randomUUID(), type: 'group', combinator: 'and', children: [only] } }
|
|
452
|
+
}
|
|
453
|
+
return {
|
|
454
|
+
root: { id: crypto.randomUUID(), type: 'group', combinator: 'or', children: orChildren },
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Map a dictionary entry's display color (named like 'red'/'green' or a hex like '#ef4444')
|
|
460
|
+
* to a `FilterOptionTone`, so dictionary-backed select options can render with the
|
|
461
|
+
* correct status dot in chips and value pills. Unknown / undefined inputs return
|
|
462
|
+
* `undefined` so the consumer falls back to plain (no tone) rendering.
|
|
463
|
+
*/
|
|
464
|
+
export function mapDictionaryColorToTone(color: string | null | undefined): FilterOptionTone | undefined {
|
|
465
|
+
if (!color || typeof color !== 'string') return undefined
|
|
466
|
+
const trimmed = color.trim().toLowerCase()
|
|
467
|
+
if (!trimmed) return undefined
|
|
468
|
+
const named: Record<string, FilterOptionTone> = {
|
|
469
|
+
red: 'error',
|
|
470
|
+
crimson: 'error',
|
|
471
|
+
pink: 'pink',
|
|
472
|
+
rose: 'pink',
|
|
473
|
+
fuchsia: 'pink',
|
|
474
|
+
magenta: 'pink',
|
|
475
|
+
green: 'success',
|
|
476
|
+
emerald: 'success',
|
|
477
|
+
lime: 'success',
|
|
478
|
+
teal: 'success',
|
|
479
|
+
amber: 'warning',
|
|
480
|
+
yellow: 'warning',
|
|
481
|
+
orange: 'warning',
|
|
482
|
+
blue: 'info',
|
|
483
|
+
cyan: 'info',
|
|
484
|
+
sky: 'info',
|
|
485
|
+
indigo: 'info',
|
|
486
|
+
gray: 'neutral',
|
|
487
|
+
grey: 'neutral',
|
|
488
|
+
slate: 'neutral',
|
|
489
|
+
zinc: 'neutral',
|
|
490
|
+
stone: 'neutral',
|
|
491
|
+
violet: 'brand',
|
|
492
|
+
purple: 'brand',
|
|
493
|
+
}
|
|
494
|
+
if (named[trimmed]) return named[trimmed]
|
|
495
|
+
const hexMatch = /^#?([0-9a-f]{6})$/i.exec(trimmed)
|
|
496
|
+
if (!hexMatch) return undefined
|
|
497
|
+
const hex = hexMatch[1]
|
|
498
|
+
const r = parseInt(hex.slice(0, 2), 16)
|
|
499
|
+
const g = parseInt(hex.slice(2, 4), 16)
|
|
500
|
+
const b = parseInt(hex.slice(4, 6), 16)
|
|
501
|
+
const max = Math.max(r, g, b)
|
|
502
|
+
const min = Math.min(r, g, b)
|
|
503
|
+
const delta = max - min
|
|
504
|
+
if (delta < 24) return 'neutral'
|
|
505
|
+
let hue = 0
|
|
506
|
+
if (max === r) hue = ((g - b) / delta) % 6
|
|
507
|
+
else if (max === g) hue = (b - r) / delta + 2
|
|
508
|
+
else hue = (r - g) / delta + 4
|
|
509
|
+
hue = (hue * 60 + 360) % 360
|
|
510
|
+
// Hue ranges (degrees): 0-20 red(error), 20-50 orange(warning), 50-80 yellow(warning),
|
|
511
|
+
// 80-170 green(success), 170-260 blue/indigo(info), 260-320 violet/purple(brand),
|
|
512
|
+
// 320-340 pink/magenta(pink), 340-360 red(error).
|
|
513
|
+
if (hue < 20) return 'error'
|
|
514
|
+
if (hue < 50) return 'warning'
|
|
515
|
+
if (hue < 80) return 'warning'
|
|
516
|
+
if (hue < 170) return 'success'
|
|
517
|
+
if (hue < 260) return 'info'
|
|
518
|
+
if (hue < 320) return 'brand'
|
|
519
|
+
if (hue < 340) return 'pink'
|
|
520
|
+
return 'error'
|
|
521
|
+
}
|
package/src/lib/query/engine.ts
CHANGED
|
@@ -442,9 +442,10 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
442
442
|
}
|
|
443
443
|
if (resolvedGroupFilters.length > 0) {
|
|
444
444
|
q = q.where((eb: any) => eb.or(
|
|
445
|
-
resolvedGroupFilters.map((group) =>
|
|
446
|
-
group.map((rf) => this.buildColumnOpExpression(eb, rf.qualified, rf.op, rf.value))
|
|
447
|
-
|
|
445
|
+
resolvedGroupFilters.map((group) => {
|
|
446
|
+
const parts = group.map((rf) => this.buildColumnOpExpression(eb, rf.qualified, rf.op, rf.value))
|
|
447
|
+
return parts.length === 1 ? parts[0] : eb.and(parts)
|
|
448
|
+
})
|
|
448
449
|
))
|
|
449
450
|
}
|
|
450
451
|
}
|