@platforma-sdk/model 1.54.9 → 1.54.13

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.
Files changed (52) hide show
  1. package/dist/components/PlDataTable/state-migration.cjs.map +1 -1
  2. package/dist/components/PlDataTable/state-migration.js.map +1 -1
  3. package/dist/components/PlDataTable/table.cjs +27 -9
  4. package/dist/components/PlDataTable/table.cjs.map +1 -1
  5. package/dist/components/PlDataTable/table.d.ts.map +1 -1
  6. package/dist/components/PlDataTable/table.js +28 -10
  7. package/dist/components/PlDataTable/table.js.map +1 -1
  8. package/dist/components/PlDataTable/v5.d.ts +8 -5
  9. package/dist/components/PlDataTable/v5.d.ts.map +1 -1
  10. package/dist/filters/converters/filterToQuery.cjs +21 -15
  11. package/dist/filters/converters/filterToQuery.cjs.map +1 -1
  12. package/dist/filters/converters/filterToQuery.d.ts +1 -1
  13. package/dist/filters/converters/filterToQuery.d.ts.map +1 -1
  14. package/dist/filters/converters/filterToQuery.js +21 -15
  15. package/dist/filters/converters/filterToQuery.js.map +1 -1
  16. package/dist/filters/converters/filterUiToExpressionImpl.cjs +80 -100
  17. package/dist/filters/converters/filterUiToExpressionImpl.cjs.map +1 -1
  18. package/dist/filters/converters/filterUiToExpressionImpl.d.ts.map +1 -1
  19. package/dist/filters/converters/filterUiToExpressionImpl.js +81 -101
  20. package/dist/filters/converters/filterUiToExpressionImpl.js.map +1 -1
  21. package/dist/filters/distill.cjs +18 -18
  22. package/dist/filters/distill.cjs.map +1 -1
  23. package/dist/filters/distill.d.ts +3 -2
  24. package/dist/filters/distill.d.ts.map +1 -1
  25. package/dist/filters/distill.js +18 -18
  26. package/dist/filters/distill.js.map +1 -1
  27. package/dist/filters/traverse.cjs +53 -0
  28. package/dist/filters/traverse.cjs.map +1 -0
  29. package/dist/filters/traverse.d.ts +27 -0
  30. package/dist/filters/traverse.d.ts.map +1 -0
  31. package/dist/filters/traverse.js +50 -0
  32. package/dist/filters/traverse.js.map +1 -0
  33. package/dist/package.json.cjs +1 -1
  34. package/dist/package.json.js +1 -1
  35. package/dist/pframe_utils/querySpec.d.ts +1 -1
  36. package/dist/pframe_utils/querySpec.d.ts.map +1 -1
  37. package/dist/render/api.cjs +1 -1
  38. package/dist/render/api.cjs.map +1 -1
  39. package/dist/render/api.d.ts.map +1 -1
  40. package/dist/render/api.js +2 -2
  41. package/dist/render/api.js.map +1 -1
  42. package/package.json +6 -6
  43. package/src/components/PlDataTable/state-migration.ts +4 -4
  44. package/src/components/PlDataTable/table.ts +43 -12
  45. package/src/components/PlDataTable/v5.ts +10 -7
  46. package/src/filters/converters/filterToQuery.ts +25 -17
  47. package/src/filters/converters/filterUiToExpressionImpl.ts +81 -125
  48. package/src/filters/distill.ts +28 -24
  49. package/src/filters/traverse.test.ts +134 -0
  50. package/src/filters/traverse.ts +84 -0
  51. package/src/pframe_utils/querySpec.ts +1 -1
  52. package/src/render/api.ts +2 -2
@@ -15,6 +15,7 @@ import type {
15
15
  SingleAxisSelector,
16
16
  SpecQueryExpression,
17
17
  SpecQueryJoinEntry,
18
+ CanonicalizedJson,
18
19
  } from "@milaboratories/pl-model-common";
19
20
  import {
20
21
  Annotation,
@@ -25,6 +26,7 @@ import {
25
26
  readAnnotation,
26
27
  uniqueBy,
27
28
  isBooleanExpression,
29
+ parseJson,
28
30
  } from "@milaboratories/pl-model-common";
29
31
  import { filterSpecToSpecQueryExpr } from "../../filters";
30
32
  import type { RenderCtxBase, TreeNodeAccessor, PColumnDataUniversal } from "../../render";
@@ -36,6 +38,7 @@ import { upgradePlDataTableStateV2 } from "./state-migration";
36
38
  import type { PlDataTableStateV2 } from "./state-migration";
37
39
  import type { PlDataTableSheet } from "./v5";
38
40
  import { getAllLabelColumns, getMatchingLabelColumns } from "./labels";
41
+ import { collectFilterSpecColumns } from "../../filters/traverse";
39
42
 
40
43
  /** Convert a PTableColumnId to a SpecQueryExpression reference. */
41
44
  function columnIdToExpr(col: PTableColumnId): SpecQueryExpression {
@@ -177,21 +180,37 @@ export function createPlDataTableV2<A, U>(
177
180
  ...fullColumns.map((c) => ({ type: "column", id: c.id }) satisfies PTableColumnIdColumn),
178
181
  ];
179
182
  const fullColumnsIdsSet = new Set(fullColumnsIds.map((c) => canonicalizeJson<PTableColumnId>(c)));
180
- const isValidColumnId = (id: PTableColumnId): boolean =>
181
- fullColumnsIdsSet.has(canonicalizeJson<PTableColumnId>(id));
183
+ const isValidColumnId = (id: string): boolean =>
184
+ fullColumnsIdsSet.has(id as CanonicalizedJson<PTableColumnId>);
185
+
186
+ // -- Filtering validation --
187
+ const stateFilters = tableStateNormalized.pTableParams.filters;
188
+ const opsFilters = ops?.filters ?? null;
189
+ const filters: null | PlDataTableFilters =
190
+ stateFilters != null && opsFilters != null
191
+ ? { type: "and", filters: [stateFilters, opsFilters] }
192
+ : (stateFilters ?? opsFilters);
193
+ const filterColumns = filters ? collectFilterSpecColumns(filters) : [];
194
+ const firstInvalidFilterColumn = filterColumns.find((col) => !isValidColumnId(col));
195
+ if (firstInvalidFilterColumn)
196
+ throw new Error(
197
+ `Invalid filter column ${firstInvalidFilterColumn}: column reference does not match the table columns`,
198
+ );
182
199
 
183
- const coreJoinType = ops?.coreJoinType ?? "full";
184
- const filters = tableStateNormalized.pTableParams.filters;
200
+ // -- Sorting validation --
185
201
  const sorting: PTableSorting[] = uniqueBy(
186
202
  [...tableStateNormalized.pTableParams.sorting, ...(ops?.sorting ?? [])],
187
203
  (s) => canonicalizeJson<PTableColumnId>(s.column),
188
- ).filter((s) => {
189
- const valid = isValidColumnId(s.column);
190
- if (!valid)
191
- ctx.logWarn(`Sorting ${JSON.stringify(s)} does not match provided columns, skipping`);
192
- return valid;
193
- });
204
+ );
205
+ const firstInvalidSortingColumn = sorting.find(
206
+ (s) => !isValidColumnId(canonicalizeJson<PTableColumnId>(s.column)),
207
+ );
208
+ if (firstInvalidSortingColumn)
209
+ throw new Error(
210
+ `Invalid sorting column ${JSON.stringify(firstInvalidSortingColumn.column)}: column reference does not match the table columns`,
211
+ );
194
212
 
213
+ const coreJoinType = ops?.coreJoinType ?? "full";
195
214
  const fullDef = createPTableDef({
196
215
  columns,
197
216
  labelColumns: fullLabelColumns,
@@ -200,6 +219,7 @@ export function createPlDataTableV2<A, U>(
200
219
  sorting,
201
220
  coreColumnPredicate: ops?.coreColumnPredicate,
202
221
  });
222
+
203
223
  const fullHandle = ctx.createPTableV2(fullDef);
204
224
  if (!fullHandle) return undefined;
205
225
 
@@ -227,12 +247,22 @@ export function createPlDataTableV2<A, U>(
227
247
  coreColumns.forEach((c) => hiddenColumns.delete(c));
228
248
  }
229
249
 
230
- // Sorting changes the order of result rows — preserve sorted columns from being hidden
250
+ // Preserve sorted columns from being hidden
231
251
  sorting
232
252
  .map((s) => s.column)
233
253
  .filter((c): c is PTableColumnIdColumn => c.type === "column")
234
254
  .forEach((c) => hiddenColumns.delete(c.id));
235
255
 
256
+ // Preserve filter columns from being hidden
257
+ if (filters) {
258
+ collectFilterSpecColumns(filters)
259
+ .flatMap((c) => {
260
+ const obj = parseJson(c);
261
+ return obj.type === "column" ? [obj.id] : [];
262
+ })
263
+ .forEach((c) => hiddenColumns.delete(c));
264
+ }
265
+
236
266
  const visibleColumns = columns.filter((c) => !hiddenColumns.has(c.id));
237
267
  const visibleLabelColumns = getMatchingLabelColumns(
238
268
  visibleColumns.map(getColumnIdAndSpec),
@@ -251,13 +281,14 @@ export function createPlDataTableV2<A, U>(
251
281
  coreColumnPredicate,
252
282
  });
253
283
  const visibleHandle = ctx.createPTableV2(visibleDef);
284
+
254
285
  if (!visibleHandle) return undefined;
255
286
 
256
287
  return {
257
288
  sourceId: tableStateNormalized.pTableParams.sourceId,
258
289
  fullTableHandle: fullHandle,
259
290
  visibleTableHandle: visibleHandle,
260
- } satisfies PlDataTableModel;
291
+ } as PlDataTableModel;
261
292
  }
262
293
 
263
294
  /** Create sheet entries for PlDataTable */
@@ -5,12 +5,13 @@ import type {
5
5
  ListOptionBase,
6
6
  PObjectId,
7
7
  PTableColumnSpec,
8
- PTableRecordFilter,
9
8
  PTableSorting,
10
9
  PColumnIdAndSpec,
11
10
  PTableHandle,
11
+ RootFilterSpec,
12
+ PTableColumnId,
12
13
  } from "@milaboratories/pl-model-common";
13
- import type { FilterSpec, FilterSpecLeaf } from "../../filters";
14
+ import type { FilterSpecLeaf } from "../../filters";
14
15
 
15
16
  export type PlTableColumnId = {
16
17
  /** Original column spec */
@@ -61,10 +62,10 @@ export type PlDataTableSheetState = {
61
62
  };
62
63
 
63
64
  /** Tree-based filter state compatible with PlAdvancedFilter's RootFilter */
64
- export type PlDataTableFilters = FilterSpec<FilterSpecLeaf<string>>;
65
- export type PlDataTableFiltersWithMeta = FilterSpec<
66
- FilterSpecLeaf<string>,
67
- { id: number; isExpanded?: boolean }
65
+ export type PlDataTableFilters = RootFilterSpec<FilterSpecLeaf<CanonicalizedJson<PTableColumnId>>>;
66
+ export type PlDataTableFiltersWithMeta = RootFilterSpec<
67
+ FilterSpecLeaf<CanonicalizedJson<PTableColumnId>>,
68
+ { id: number; isExpanded?: boolean; source?: "table-filter" | "table-search" }
68
69
  >;
69
70
 
70
71
  export type PlDataTableStateV2CacheEntry = {
@@ -76,6 +77,8 @@ export type PlDataTableStateV2CacheEntry = {
76
77
  sheetsState: PlDataTableSheetState[];
77
78
  /** Filters state (tree-based, compatible with PlAdvancedFilter) */
78
79
  filtersState: null | PlDataTableFiltersWithMeta;
80
+ /** Fast search string */
81
+ searchString?: string;
79
82
  };
80
83
 
81
84
  export type PTableParamsV2 =
@@ -113,7 +116,7 @@ export type PlDataTableModel = {
113
116
 
114
117
  export type CreatePlDataTableOps = {
115
118
  /** Filters for columns and non-partitioned axes */
116
- filters?: PTableRecordFilter[];
119
+ filters?: PlDataTableFilters;
117
120
 
118
121
  /** Sorting to columns hidden from user */
119
122
  sorting?: PTableSorting[];
@@ -6,34 +6,42 @@ import type {
6
6
  SingleAxisSelector,
7
7
  SpecQueryExpression,
8
8
  } from "@milaboratories/pl-model-common";
9
+ import { traverseFilterSpec } from "../traverse";
9
10
 
10
11
  /** Parses a CanonicalizedJson<PTableColumnId> string into a SpecQueryExpression reference. */
11
12
  function resolveColumnRef(columnStr: string): SpecQueryExpression {
12
13
  const parsed = JSON.parse(columnStr) as PTableColumnId;
13
- if (parsed.type === "axis") {
14
- return { type: "axisRef", value: parsed.id as SingleAxisSelector };
15
- }
16
- return { type: "columnRef", value: parsed.id };
14
+ return parsed.type === "axis"
15
+ ? { type: "axisRef", value: parsed.id as SingleAxisSelector }
16
+ : { type: "columnRef", value: parsed.id };
17
17
  }
18
18
 
19
19
  /** Converts a FilterSpec tree into a SpecQueryExpression. */
20
- export function filterSpecToSpecQueryExpr(
21
- filter: FilterSpec<FilterSpecLeaf<string>>,
20
+ export function filterSpecToSpecQueryExpr<Leaf extends FilterSpecLeaf<string>>(
21
+ filter: FilterSpec<Leaf>,
22
22
  ): SpecQueryExpression {
23
- switch (filter.type) {
24
- case "and":
25
- case "or": {
26
- const inputs = filter.filters
27
- .filter((f) => f.type !== undefined)
28
- .map(filterSpecToSpecQueryExpr);
23
+ return traverseFilterSpec(filter, {
24
+ leaf: leafToSpecQueryExpr,
25
+ and: (inputs) => {
29
26
  if (inputs.length === 0) {
30
- throw new Error(`${filter.type.toUpperCase()} filter requires at least one operand`);
27
+ throw new Error("AND filter requires at least one operand");
31
28
  }
32
- return { type: filter.type, input: inputs };
33
- }
34
- case "not":
35
- return { type: "not", input: filterSpecToSpecQueryExpr(filter.filter) };
29
+ return { type: "and", input: inputs };
30
+ },
31
+ or: (inputs) => {
32
+ if (inputs.length === 0) {
33
+ throw new Error("OR filter requires at least one operand");
34
+ }
35
+ return { type: "or", input: inputs };
36
+ },
37
+ not: (input) => ({ type: "not", input }),
38
+ });
39
+ }
36
40
 
41
+ function leafToSpecQueryExpr<Leaf extends FilterSpecLeaf<string>>(
42
+ filter: Leaf,
43
+ ): SpecQueryExpression {
44
+ switch (filter.type) {
37
45
  case "patternEquals":
38
46
  return {
39
47
  type: "stringEquals",
@@ -1,4 +1,5 @@
1
1
  import { assertNever } from "@milaboratories/pl-model-common";
2
+ import type { FilterSpecLeaf } from "@milaboratories/pl-model-common";
2
3
  import {
3
4
  and,
4
5
  col,
@@ -9,135 +10,90 @@ import {
9
10
  type ExpressionImpl,
10
11
  } from "@milaboratories/ptabler-expression-js";
11
12
  import type { FilterSpec } from "../types";
13
+ import { traverseFilterSpec } from "../traverse";
12
14
 
13
15
  export function convertFilterUiToExpressionImpl(value: FilterSpec): ExpressionImpl {
14
- if (value.type === "or") {
15
- const expressions = value.filters
16
- .filter((f) => f.type !== undefined)
17
- .map(convertFilterUiToExpressionImpl);
18
- if (expressions.length === 0) {
19
- throw new Error("OR filter requires at least one operand");
20
- }
21
- return or(...expressions);
22
- }
23
-
24
- if (value.type === "and") {
25
- const expressions = value.filters
26
- .filter((f) => f.type !== undefined)
27
- .map(convertFilterUiToExpressionImpl);
28
- if (expressions.length === 0) {
29
- throw new Error("AND filter requires at least one operand");
30
- }
31
- return and(...expressions);
32
- }
33
-
34
- if (value.type === "not") {
35
- return convertFilterUiToExpressionImpl(value.filter).not();
36
- }
37
-
38
- if (value.type === "isNA") {
39
- return col(value.column).isNull();
40
- }
41
-
42
- if (value.type === "isNotNA") {
43
- return col(value.column).isNotNull();
44
- }
45
-
46
- if (value.type === "patternEquals") {
47
- return col(value.column).eq(lit(value.value));
48
- }
49
-
50
- if (value.type === "patternNotEquals") {
51
- return col(value.column).neq(lit(value.value));
52
- }
53
-
54
- if (value.type === "patternContainSubsequence") {
55
- return col(value.column).strContains(value.value, false, true);
56
- }
57
-
58
- if (value.type === "patternNotContainSubsequence") {
59
- return col(value.column).strContains(value.value, false, true).not();
60
- }
61
-
62
- if (value.type === "equal") {
63
- return col(value.column).eq(lit(value.x));
64
- }
65
-
66
- if (value.type === "notEqual") {
67
- return col(value.column).neq(lit(value.x));
68
- }
69
-
70
- if (value.type === "lessThan") {
71
- return col(value.column).lt(lit(value.x));
72
- }
73
-
74
- if (value.type === "greaterThan") {
75
- return col(value.column).gt(lit(value.x));
76
- }
77
-
78
- if (value.type === "lessThanOrEqual") {
79
- return col(value.column).le(lit(value.x));
80
- }
81
-
82
- if (value.type === "greaterThanOrEqual") {
83
- return col(value.column).ge(lit(value.x));
84
- }
85
-
86
- if (value.type === "equalToColumn") {
87
- return col(value.column).eq(col(value.rhs));
88
- }
89
-
90
- if (value.type === "greaterThanColumn") {
91
- if (value.minDiff !== undefined && value.minDiff !== 0) {
92
- return col(value.column).plus(lit(value.minDiff)).gt(col(value.rhs));
93
- }
94
- return col(value.column).gt(col(value.rhs));
95
- }
96
-
97
- if (value.type === "lessThanColumn") {
98
- if (value.minDiff !== undefined && value.minDiff !== 0) {
99
- return col(value.column).plus(lit(value.minDiff)).lt(col(value.rhs));
100
- }
101
- return col(value.column).lt(col(value.rhs));
102
- }
103
-
104
- if (value.type === "greaterThanColumnOrEqual") {
105
- if (value.minDiff !== undefined && value.minDiff !== 0) {
106
- return col(value.column).plus(lit(value.minDiff)).ge(col(value.rhs));
107
- }
108
- return col(value.column).ge(col(value.rhs));
109
- }
110
-
111
- if (value.type === "lessThanColumnOrEqual") {
112
- if (value.minDiff !== undefined && value.minDiff !== 0) {
113
- return col(value.column).plus(lit(value.minDiff)).le(col(value.rhs));
114
- }
115
- return col(value.column).le(col(value.rhs));
116
- }
117
-
118
- if (value.type === "topN") {
119
- return rank(col(value.column), true).over([]).le(lit(value.n));
120
- }
121
-
122
- if (value.type === "bottomN") {
123
- return rank(col(value.column), false).over([]).le(lit(value.n));
124
- }
125
-
126
- if (
127
- value.type === "patternMatchesRegularExpression" ||
128
- value.type === "patternFuzzyContainSubsequence" ||
129
- value.type === "inSet" ||
130
- value.type === "notInSet" ||
131
- value.type === "ifNa"
132
- ) {
133
- throw new Error("Not implemented filter type: " + value.type);
134
- }
16
+ return traverseFilterSpec(value, {
17
+ leaf: leafToExpressionImpl,
18
+ and: (expressions) => {
19
+ if (expressions.length === 0) {
20
+ throw new Error("AND filter requires at least one operand");
21
+ }
22
+ return and(...expressions);
23
+ },
24
+ or: (expressions) => {
25
+ if (expressions.length === 0) {
26
+ throw new Error("OR filter requires at least one operand");
27
+ }
28
+ return or(...expressions);
29
+ },
30
+ not: (result) => result.not(),
31
+ });
32
+ }
135
33
 
136
- if (value.type === undefined) {
137
- throw new Error("Filter type is undefined, this should not happen");
34
+ function leafToExpressionImpl(value: FilterSpecLeaf): ExpressionImpl {
35
+ switch (value.type) {
36
+ case "isNA":
37
+ return col(value.column).isNull();
38
+ case "isNotNA":
39
+ return col(value.column).isNotNull();
40
+ case "patternEquals":
41
+ return col(value.column).eq(lit(value.value));
42
+ case "patternNotEquals":
43
+ return col(value.column).neq(lit(value.value));
44
+ case "patternContainSubsequence":
45
+ return col(value.column).strContains(value.value, false, true);
46
+ case "patternNotContainSubsequence":
47
+ return col(value.column).strContains(value.value, false, true).not();
48
+ case "equal":
49
+ return col(value.column).eq(lit(value.x));
50
+ case "notEqual":
51
+ return col(value.column).neq(lit(value.x));
52
+ case "lessThan":
53
+ return col(value.column).lt(lit(value.x));
54
+ case "greaterThan":
55
+ return col(value.column).gt(lit(value.x));
56
+ case "lessThanOrEqual":
57
+ return col(value.column).le(lit(value.x));
58
+ case "greaterThanOrEqual":
59
+ return col(value.column).ge(lit(value.x));
60
+ case "equalToColumn":
61
+ return col(value.column).eq(col(value.rhs));
62
+ case "greaterThanColumn":
63
+ if (value.minDiff !== undefined && value.minDiff !== 0) {
64
+ return col(value.column).plus(lit(value.minDiff)).gt(col(value.rhs));
65
+ }
66
+ return col(value.column).gt(col(value.rhs));
67
+ case "lessThanColumn":
68
+ if (value.minDiff !== undefined && value.minDiff !== 0) {
69
+ return col(value.column).plus(lit(value.minDiff)).lt(col(value.rhs));
70
+ }
71
+ return col(value.column).lt(col(value.rhs));
72
+ case "greaterThanColumnOrEqual":
73
+ if (value.minDiff !== undefined && value.minDiff !== 0) {
74
+ return col(value.column).plus(lit(value.minDiff)).ge(col(value.rhs));
75
+ }
76
+ return col(value.column).ge(col(value.rhs));
77
+ case "lessThanColumnOrEqual":
78
+ if (value.minDiff !== undefined && value.minDiff !== 0) {
79
+ return col(value.column).plus(lit(value.minDiff)).le(col(value.rhs));
80
+ }
81
+ return col(value.column).le(col(value.rhs));
82
+ case "topN":
83
+ return rank(col(value.column), true).over([]).le(lit(value.n));
84
+ case "bottomN":
85
+ return rank(col(value.column), false).over([]).le(lit(value.n));
86
+ case "patternMatchesRegularExpression":
87
+ case "patternFuzzyContainSubsequence":
88
+ case "inSet":
89
+ case "notInSet":
90
+ case "ifNa":
91
+ throw new Error("Not implemented filter type: " + value.type);
92
+ case undefined:
93
+ throw new Error("Filter type is undefined, this should not happen");
94
+ default:
95
+ assertNever(value);
138
96
  }
139
-
140
- assertNever(value);
141
97
  }
142
98
 
143
99
  export function convertFilterUiToExpressions(value: FilterSpec): Expression {
@@ -1,5 +1,11 @@
1
1
  import { DistributiveKeys, UnionToTuples } from "@milaboratories/helpers";
2
- import { type FilterSpec, type FilterSpecLeaf } from "@milaboratories/pl-model-common";
2
+ import {
3
+ RootFilterSpec,
4
+ type FilterSpec,
5
+ type FilterSpecLeaf,
6
+ } from "@milaboratories/pl-model-common";
7
+ import { traverseFilterSpec } from "./traverse";
8
+ import { InferFilterSpecLeaf } from "@milaboratories/pl-model-common";
3
9
 
4
10
  /** All possible field names that can appear in any FilterSpecLeaf variant. */
5
11
  type FilterSpecLeafKey = DistributiveKeys<FilterSpecLeaf<string>>;
@@ -36,32 +42,30 @@ function distillLeaf(node: Record<string, unknown>): FilterSpecLeaf<string> {
36
42
  return result as FilterSpecLeaf<string>;
37
43
  }
38
44
 
39
- function distillNode<T extends FilterSpecLeaf<unknown>>(
40
- node: FilterSpec<T, unknown, unknown>,
41
- ): FilterSpec<T> | null {
42
- switch (node.type) {
43
- case "and":
44
- case "or": {
45
- const filtered = node.filters.map(distillNode).filter((f): f is FilterSpec<T> => f !== null);
46
- return filtered.length === 0 ? null : { type: node.type, filters: filtered };
47
- }
48
- case "not": {
49
- const inner = distillNode(node.filter);
50
- return inner === null ? null : { type: "not", filter: inner };
51
- }
52
- default:
53
- if (!isFilledLeaf(node)) return null;
54
- return distillLeaf(node);
55
- }
56
- }
57
-
58
45
  /**
59
46
  * Strips non-FilterSpec metadata (whitelist approach) and removes
60
47
  * unfilled leaves (type is undefined or any required field is undefined).
61
48
  */
62
- export function distillFilterSpec<T extends FilterSpecLeaf<unknown>>(
63
- filter: null | undefined | FilterSpec<T, unknown, unknown>,
64
- ): null | FilterSpec<T> {
49
+ export function distillFilterSpec<
50
+ FS extends FilterSpec<FilterSpecLeaf<unknown>, unknown, unknown>,
51
+ R extends FS extends RootFilterSpec<FilterSpecLeaf<unknown>, unknown, unknown>
52
+ ? RootFilterSpec<InferFilterSpecLeaf<FS>>
53
+ : FilterSpec<InferFilterSpecLeaf<FS>>,
54
+ >(filter: null | undefined | FS): null | R {
65
55
  if (filter == null) return null;
66
- return distillNode(filter);
56
+ return traverseFilterSpec<FS, null | R>(filter, {
57
+ leaf: (leaf) => {
58
+ if (!isFilledLeaf(leaf as Record<string, unknown>)) return null;
59
+ return distillLeaf(leaf as Record<string, unknown>) as R;
60
+ },
61
+ and: (results) => {
62
+ const filtered = results.filter((f): f is NonNullable<typeof f> => f !== null);
63
+ return filtered.length === 0 ? null : ({ type: "and", filters: filtered } as R);
64
+ },
65
+ or: (results) => {
66
+ const filtered = results.filter((f): f is NonNullable<typeof f> => f !== null);
67
+ return filtered.length === 0 ? null : ({ type: "or", filters: filtered } as R);
68
+ },
69
+ not: (result) => (result === null ? null : ({ type: "not", filter: result } as R)),
70
+ });
67
71
  }
@@ -0,0 +1,134 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { FilterSpec, FilterSpecLeaf } from "@milaboratories/pl-model-common";
3
+ import { traverseFilterSpec, collectFilterSpecColumns } from "./traverse";
4
+
5
+ type F = FilterSpec<FilterSpecLeaf<string>>;
6
+
7
+ const eq1: F = { type: "equal", column: "c1", x: 1 };
8
+ const eq2: F = { type: "equal", column: "c2", x: 2 };
9
+ const gt: F = { type: "greaterThan", column: "c3", x: 10 };
10
+ const empty: F = { type: undefined };
11
+
12
+ /** Visitor that collects leaf types into a string expression. */
13
+ const toStringVisitor = {
14
+ leaf: (l: FilterSpecLeaf<unknown>) => l.type ?? "empty",
15
+ and: (r: string[]) => `(${r.join(" & ")})`,
16
+ or: (r: string[]) => `(${r.join(" | ")})`,
17
+ not: (r: string) => `!${r}`,
18
+ };
19
+
20
+ describe("traverseFilterSpec", () => {
21
+ it("visits a leaf node", () => {
22
+ expect(traverseFilterSpec(eq1, toStringVisitor)).toBe("equal");
23
+ });
24
+
25
+ it("visits an and node", () => {
26
+ const filter: F = { type: "and", filters: [eq1, gt] };
27
+ expect(traverseFilterSpec(filter, toStringVisitor)).toBe("(equal & greaterThan)");
28
+ });
29
+
30
+ it("visits an or node", () => {
31
+ const filter: F = { type: "or", filters: [eq1, eq2] };
32
+ expect(traverseFilterSpec(filter, toStringVisitor)).toBe("(equal | equal)");
33
+ });
34
+
35
+ it("visits a not node", () => {
36
+ const filter: F = { type: "not", filter: eq1 };
37
+ expect(traverseFilterSpec(filter, toStringVisitor)).toBe("!equal");
38
+ });
39
+
40
+ it("handles nested structure", () => {
41
+ const filter: F = {
42
+ type: "and",
43
+ filters: [
44
+ { type: "not", filter: eq1 },
45
+ { type: "or", filters: [eq2, gt] },
46
+ ],
47
+ };
48
+ expect(traverseFilterSpec(filter, toStringVisitor)).toBe("(!equal & (equal | greaterThan))");
49
+ });
50
+
51
+ it("skips { type: undefined } entries in and", () => {
52
+ const filter: F = { type: "and", filters: [empty, eq1, empty] };
53
+ expect(traverseFilterSpec(filter, toStringVisitor)).toBe("(equal)");
54
+ });
55
+
56
+ it("skips { type: undefined } entries in or", () => {
57
+ const filter: F = { type: "or", filters: [empty, eq1, empty, eq2] };
58
+ expect(traverseFilterSpec(filter, toStringVisitor)).toBe("(equal | equal)");
59
+ });
60
+
61
+ it("returns empty and when all children are undefined", () => {
62
+ const filter: F = { type: "and", filters: [empty, empty] };
63
+ expect(traverseFilterSpec(filter, toStringVisitor)).toBe("()");
64
+ });
65
+
66
+ it("returns empty or when all children are undefined", () => {
67
+ const filter: F = { type: "or", filters: [empty] };
68
+ expect(traverseFilterSpec(filter, toStringVisitor)).toBe("()");
69
+ });
70
+
71
+ it("works with CommonNode/CommonLeaf metadata", () => {
72
+ type MetaLeaf = FilterSpecLeaf<string>;
73
+ type MetaFilter = FilterSpec<MetaLeaf, { _id: number }, { _id: number }>;
74
+
75
+ const filter: MetaFilter = {
76
+ type: "and",
77
+ _id: 1,
78
+ filters: [
79
+ { type: "equal", column: "c1", x: 1, _id: 2 },
80
+ { type: "greaterThan", column: "c2", x: 5, _id: 3 },
81
+ ],
82
+ };
83
+
84
+ const result = traverseFilterSpec(filter, {
85
+ leaf: (l) => [l._id],
86
+ and: (r) => r.flat(),
87
+ or: (r) => r.flat(),
88
+ not: (r) => r,
89
+ });
90
+
91
+ expect(result).toEqual([2, 3]);
92
+ });
93
+ });
94
+
95
+ describe("collectFilterSpecColumns", () => {
96
+ it("collects column from a single leaf", () => {
97
+ expect(collectFilterSpecColumns(eq1)).toEqual(["c1"]);
98
+ });
99
+
100
+ it("collects columns from and", () => {
101
+ const filter: F = { type: "and", filters: [eq1, eq2, gt] };
102
+ expect(collectFilterSpecColumns(filter)).toEqual(["c1", "c2", "c3"]);
103
+ });
104
+
105
+ it("collects columns from nested structure", () => {
106
+ const filter: F = {
107
+ type: "or",
108
+ filters: [
109
+ { type: "not", filter: eq1 },
110
+ { type: "and", filters: [eq2, gt] },
111
+ ],
112
+ };
113
+ expect(collectFilterSpecColumns(filter)).toEqual(["c1", "c2", "c3"]);
114
+ });
115
+
116
+ it("collects rhs fields", () => {
117
+ const filter: F = { type: "greaterThanColumn", column: "c1", rhs: "c2" };
118
+ expect(collectFilterSpecColumns(filter)).toEqual(["c1", "c2"]);
119
+ });
120
+
121
+ it("skips { type: undefined } entries", () => {
122
+ const filter: F = { type: "and", filters: [empty, eq1, empty] };
123
+ expect(collectFilterSpecColumns(filter)).toEqual(["c1"]);
124
+ });
125
+
126
+ it("returns empty array for all-empty and", () => {
127
+ const filter: F = { type: "and", filters: [empty, empty] };
128
+ expect(collectFilterSpecColumns(filter)).toEqual([]);
129
+ });
130
+
131
+ it("returns empty array for empty leaf", () => {
132
+ expect(collectFilterSpecColumns(empty)).toEqual([]);
133
+ });
134
+ });