@milaboratories/pl-model-common 1.24.9 → 1.24.10

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.
@@ -0,0 +1,293 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { PObjectId } from "../../../pool";
3
+ import type { SpecQuery, SpecQueryJoinEntry } from "./query_spec";
4
+ import {
5
+ traverseQuerySpec,
6
+ mapSpecQueryColumns,
7
+ collectSpecQueryColumns,
8
+ sortSpecQuery,
9
+ isBooleanExpression,
10
+ } from "./utils";
11
+
12
+ type Q = SpecQuery<string>;
13
+ type JE = SpecQueryJoinEntry<string>;
14
+
15
+ const col = (c: string): Q => ({ type: "column", column: c });
16
+ const entry = (q: Q): JE => ({ entry: q, qualifications: [] });
17
+
18
+ /** Typed helpers for sortSpecQuery tests (PObjectId is branded). */
19
+ const pid = (c: string) => c as PObjectId;
20
+ const pcol = (c: string): SpecQuery => ({ type: "column", column: pid(c) });
21
+ const pentry = (q: SpecQuery): SpecQueryJoinEntry => ({ entry: q, qualifications: [] });
22
+
23
+ describe("traverseQuerySpec", () => {
24
+ it("transforms column leaf", () => {
25
+ const result = traverseQuerySpec(col("a"), { column: (c) => c.toUpperCase() });
26
+ expect(result).toEqual({ type: "column", column: "A" });
27
+ });
28
+
29
+ it("transforms sparseToDenseColumn leaf", () => {
30
+ const q: Q = { type: "sparseToDenseColumn", column: "a", axesIndices: [0, 1] } as Q;
31
+ const result = traverseQuerySpec(q, { column: (c) => `${c}!` });
32
+ expect(result).toEqual({ type: "sparseToDenseColumn", column: "a!", axesIndices: [0, 1] });
33
+ });
34
+
35
+ it("passes inlineColumn through unchanged", () => {
36
+ const q: Q = {
37
+ type: "inlineColumn",
38
+ spec: { id: "x", spec: {} },
39
+ dataInfo: {},
40
+ } as unknown as Q;
41
+ const result = traverseQuerySpec(q, { column: (c) => c.toUpperCase() });
42
+ expect(result).toBe(q);
43
+ });
44
+
45
+ it("transforms columns inside innerJoin", () => {
46
+ const q: Q = { type: "innerJoin", entries: [entry(col("a")), entry(col("b"))] };
47
+ const result = traverseQuerySpec(q, { column: (c) => c.toUpperCase() });
48
+ expect(result).toEqual({
49
+ type: "innerJoin",
50
+ entries: [entry({ type: "column", column: "A" }), entry({ type: "column", column: "B" })],
51
+ });
52
+ });
53
+
54
+ it("transforms columns inside fullJoin", () => {
55
+ const q: Q = { type: "fullJoin", entries: [entry(col("a"))] };
56
+ const result = traverseQuerySpec(q, { column: (c) => c.toUpperCase() });
57
+ expect(result).toEqual({
58
+ type: "fullJoin",
59
+ entries: [entry({ type: "column", column: "A" })],
60
+ });
61
+ });
62
+
63
+ it("transforms columns inside outerJoin", () => {
64
+ const q: Q = {
65
+ type: "outerJoin",
66
+ primary: entry(col("a")),
67
+ secondary: [entry(col("b"))],
68
+ };
69
+ const result = traverseQuerySpec(q, { column: (c) => c.toUpperCase() });
70
+ expect(result).toEqual({
71
+ type: "outerJoin",
72
+ primary: entry({ type: "column", column: "A" }),
73
+ secondary: [entry({ type: "column", column: "B" })],
74
+ });
75
+ });
76
+
77
+ it("transforms columns inside filter", () => {
78
+ const q: Q = {
79
+ type: "filter",
80
+ input: col("a"),
81
+ predicate: { type: "isNull", input: { type: "columnRef", value: pid("id1") } },
82
+ } as Q;
83
+ const result = traverseQuerySpec(q, { column: (c) => c.toUpperCase() });
84
+ expect(result).toEqual({
85
+ type: "filter",
86
+ input: { type: "column", column: "A" },
87
+ predicate: { type: "isNull", input: { type: "columnRef", value: "id1" } },
88
+ });
89
+ });
90
+
91
+ it("transforms columns inside sort", () => {
92
+ const q: Q = {
93
+ type: "sort",
94
+ input: col("a"),
95
+ sortBy: [
96
+ { expression: { type: "columnRef", value: pid("id1") }, ascending: true, nullsFirst: null },
97
+ ],
98
+ } as Q;
99
+ const result = traverseQuerySpec(q, { column: (c) => c.toUpperCase() });
100
+ expect(result).toEqual({
101
+ type: "sort",
102
+ input: { type: "column", column: "A" },
103
+ sortBy: [
104
+ { expression: { type: "columnRef", value: "id1" }, ascending: true, nullsFirst: null },
105
+ ],
106
+ });
107
+ });
108
+
109
+ it("transforms nested structure", () => {
110
+ const q: Q = {
111
+ type: "filter",
112
+ input: {
113
+ type: "outerJoin",
114
+ primary: entry(col("a")),
115
+ secondary: [entry(col("b")), entry(col("c"))],
116
+ },
117
+ predicate: { type: "isNull", input: { type: "columnRef", value: pid("id1") } },
118
+ } as Q;
119
+ const result = traverseQuerySpec(q, { column: (c) => c.toUpperCase() });
120
+ expect(collectSpecQueryColumns(result)).toEqual(["A", "B", "C"]);
121
+ });
122
+
123
+ it("calls node visitor after children are traversed", () => {
124
+ const q: Q = { type: "innerJoin", entries: [entry(col("a")), entry(col("b"))] };
125
+ const visited: string[] = [];
126
+ traverseQuerySpec(q, {
127
+ column: (c) => c,
128
+ node: (node) => {
129
+ visited.push(node.type);
130
+ return node;
131
+ },
132
+ });
133
+ expect(visited).toEqual(["column", "column", "innerJoin"]);
134
+ });
135
+
136
+ it("calls joinEntry visitor for each entry", () => {
137
+ const q: Q = { type: "innerJoin", entries: [entry(col("a")), entry(col("b"))] };
138
+ const visited: string[] = [];
139
+ traverseQuerySpec(q, {
140
+ column: (c) => c,
141
+ joinEntry: (e) => {
142
+ if (e.entry.type === "column") visited.push(e.entry.column);
143
+ return e;
144
+ },
145
+ });
146
+ expect(visited).toEqual(["a", "b"]);
147
+ });
148
+ });
149
+
150
+ describe("mapSpecQueryColumns", () => {
151
+ it("maps all column references", () => {
152
+ const q: Q = {
153
+ type: "outerJoin",
154
+ primary: entry(col("a")),
155
+ secondary: [entry(col("b"))],
156
+ };
157
+ const result = mapSpecQueryColumns(q, (c) => c.toUpperCase());
158
+ expect(collectSpecQueryColumns(result)).toEqual(["A", "B"]);
159
+ });
160
+ });
161
+
162
+ describe("collectSpecQueryColumns", () => {
163
+ it("collects from a single column", () => {
164
+ expect(collectSpecQueryColumns(col("a"))).toEqual(["a"]);
165
+ });
166
+
167
+ it("collects from join", () => {
168
+ const q: Q = { type: "innerJoin", entries: [entry(col("a")), entry(col("b"))] };
169
+ expect(collectSpecQueryColumns(q)).toEqual(["a", "b"]);
170
+ });
171
+
172
+ it("collects from nested structure", () => {
173
+ const q: Q = {
174
+ type: "filter",
175
+ input: {
176
+ type: "outerJoin",
177
+ primary: entry(col("x")),
178
+ secondary: [entry(col("y")), entry(col("z"))],
179
+ },
180
+ predicate: { type: "isNull", input: { type: "columnRef", value: pid("id1") } },
181
+ } as Q;
182
+ expect(collectSpecQueryColumns(q)).toEqual(["x", "y", "z"]);
183
+ });
184
+
185
+ it("collects from sparseToDenseColumn", () => {
186
+ const q: Q = { type: "sparseToDenseColumn", column: "s", axesIndices: [0] } as Q;
187
+ expect(collectSpecQueryColumns(q)).toEqual(["s"]);
188
+ });
189
+
190
+ it("returns empty for inlineColumn", () => {
191
+ const q: Q = {
192
+ type: "inlineColumn",
193
+ spec: { id: "x", spec: {} },
194
+ dataInfo: {},
195
+ } as unknown as Q;
196
+ expect(collectSpecQueryColumns(q)).toEqual([]);
197
+ });
198
+ });
199
+
200
+ describe("sortSpecQuery", () => {
201
+ it("sorts innerJoin entries by column id", () => {
202
+ const q: SpecQuery = { type: "innerJoin", entries: [pentry(pcol("b")), pentry(pcol("a"))] };
203
+ const result = sortSpecQuery(q);
204
+ expect(result).toEqual({
205
+ type: "innerJoin",
206
+ entries: [pentry(pcol("a")), pentry(pcol("b"))],
207
+ });
208
+ });
209
+
210
+ it("sorts outerJoin secondary entries", () => {
211
+ const q: SpecQuery = {
212
+ type: "outerJoin",
213
+ primary: pentry(pcol("p")),
214
+ secondary: [pentry(pcol("c")), pentry(pcol("a")), pentry(pcol("b"))],
215
+ };
216
+ const result = sortSpecQuery(q);
217
+ expect(result).toEqual({
218
+ type: "outerJoin",
219
+ primary: pentry(pcol("p")),
220
+ secondary: [pentry(pcol("a")), pentry(pcol("b")), pentry(pcol("c"))],
221
+ });
222
+ });
223
+
224
+ it("sorts sparseToDenseColumn axesIndices", () => {
225
+ const q: SpecQuery = {
226
+ type: "sparseToDenseColumn",
227
+ column: pid("a"),
228
+ axesIndices: [2, 0, 1],
229
+ } as SpecQuery;
230
+ const result = sortSpecQuery(q);
231
+ expect(result).toEqual({
232
+ type: "sparseToDenseColumn",
233
+ column: "a",
234
+ axesIndices: [0, 1, 2],
235
+ });
236
+ });
237
+
238
+ it("sorts join entry qualifications", () => {
239
+ const q: SpecQuery = {
240
+ type: "innerJoin",
241
+ entries: [
242
+ {
243
+ entry: pcol("a"),
244
+ qualifications: [
245
+ { axis: { name: "z" }, additionalDomains: {} },
246
+ { axis: { name: "a" }, additionalDomains: {} },
247
+ ],
248
+ },
249
+ ],
250
+ };
251
+ const result = sortSpecQuery(q);
252
+ const entries = (result as { type: "innerJoin"; entries: SpecQueryJoinEntry[] }).entries;
253
+ expect(entries[0].qualifications[0].axis).toEqual({ name: "a" });
254
+ expect(entries[0].qualifications[1].axis).toEqual({ name: "z" });
255
+ });
256
+
257
+ it("sorts nested structures recursively", () => {
258
+ const q: SpecQuery = {
259
+ type: "filter",
260
+ input: { type: "innerJoin", entries: [pentry(pcol("b")), pentry(pcol("a"))] },
261
+ predicate: { type: "isNull", input: { type: "columnRef", value: pid("id1") } },
262
+ };
263
+ const result = sortSpecQuery(q);
264
+ const inner = (result as { type: "filter"; input: SpecQuery }).input;
265
+ expect(collectSpecQueryColumns(inner)).toEqual(["a", "b"]);
266
+ });
267
+
268
+ it("leaves column leaf unchanged", () => {
269
+ const q: SpecQuery = pcol("a");
270
+ expect(sortSpecQuery(q)).toEqual(pcol("a"));
271
+ });
272
+ });
273
+
274
+ describe("isBooleanExpression", () => {
275
+ it("returns true for boolean expression types", () => {
276
+ expect(
277
+ isBooleanExpression({ type: "isNull", input: { type: "columnRef", value: pid("a") } }),
278
+ ).toBe(true);
279
+ expect(
280
+ isBooleanExpression({
281
+ type: "not",
282
+ input: { type: "isNull", input: { type: "columnRef", value: pid("a") } },
283
+ }),
284
+ ).toBe(true);
285
+ expect(isBooleanExpression({ type: "and", input: [] })).toBe(true);
286
+ });
287
+
288
+ it("returns false for non-boolean expression types", () => {
289
+ expect(isBooleanExpression({ type: "columnRef", value: pid("a") })).toBe(false);
290
+ expect(isBooleanExpression({ type: "axisRef", value: { name: "x" } })).toBe(false);
291
+ expect(isBooleanExpression({ type: "constant", value: 42 })).toBe(false);
292
+ });
293
+ });
@@ -27,108 +27,127 @@ export function isBooleanExpression(expr: SpecQueryExpression): expr is SpecQuer
27
27
  );
28
28
  }
29
29
 
30
- /** Collects all column references from a SpecQuery tree. */
31
- export function collectQueryColumns<C>(query: SpecQuery<C>): C[] {
32
- const result: C[] = [];
33
- collectQueryColumnsImpl(query, result);
34
- return result;
35
- }
30
+ /**
31
+ * Recursively traverses a SpecQuery tree bottom-up, applying visitor callbacks.
32
+ *
33
+ * Traversal order:
34
+ * 1. Recurse into child queries
35
+ * 2. Apply `column` to transform column references in leaf nodes
36
+ * 3. Apply `joinEntry` to each join entry (with inner query already traversed)
37
+ * 4. Assemble node with transformed children
38
+ * 5. Apply `node` to the assembled node
39
+ */
40
+ export function traverseQuerySpec<C1, C2>(
41
+ query: SpecQuery<C1>,
42
+ visitor: {
43
+ /** Transform column references in leaf nodes (column, sparseToDenseColumn). */
44
+ column: (c: C1) => C2;
45
+ /** Visit a node after its children have been traversed. */
46
+ node?: (node: SpecQuery<C2>) => SpecQuery<C2>;
47
+ /** Visit a join entry after its inner query has been traversed. */
48
+ joinEntry?: (entry: SpecQueryJoinEntry<C2>) => SpecQueryJoinEntry<C2>;
49
+ },
50
+ ): SpecQuery<C2> {
51
+ const traverseEntry = (entry: SpecQueryJoinEntry<C1>): SpecQueryJoinEntry<C2> => {
52
+ const traversed: SpecQueryJoinEntry<C2> = {
53
+ ...entry,
54
+ entry: traverseQuerySpec(entry.entry, visitor),
55
+ };
56
+ return visitor.joinEntry ? visitor.joinEntry(traversed) : traversed;
57
+ };
36
58
 
37
- function collectQueryColumnsImpl<C>(query: SpecQuery<C>, result: C[]): void {
59
+ let result: SpecQuery<C2>;
38
60
  switch (query.type) {
39
61
  case "column":
40
- result.push(query.column);
62
+ result = { type: "column", column: visitor.column(query.column) };
41
63
  break;
42
64
  case "sparseToDenseColumn":
43
- result.push(query.column);
65
+ result = { ...query, column: visitor.column(query.column) };
44
66
  break;
45
67
  case "inlineColumn":
68
+ result = query;
46
69
  break;
47
70
  case "innerJoin":
48
71
  case "fullJoin":
49
- for (const e of query.entries) collectQueryColumnsImpl(e.entry, result);
72
+ result = { ...query, entries: query.entries.map(traverseEntry) };
50
73
  break;
51
74
  case "outerJoin":
52
- collectQueryColumnsImpl(query.primary.entry, result);
53
- for (const e of query.secondary) collectQueryColumnsImpl(e.entry, result);
75
+ result = {
76
+ ...query,
77
+ primary: traverseEntry(query.primary),
78
+ secondary: query.secondary.map(traverseEntry),
79
+ };
54
80
  break;
55
81
  case "filter":
56
82
  case "sort":
57
83
  case "sliceAxes":
58
- collectQueryColumnsImpl(query.input, result);
84
+ result = { ...query, input: traverseQuerySpec(query.input, visitor) };
59
85
  break;
60
86
  default:
61
87
  assertNever(query);
62
88
  }
89
+
90
+ return visitor.node ? visitor.node(result) : result;
63
91
  }
64
92
 
65
- export function sortQuerySpec(query: SpecQuery): SpecQuery {
66
- switch (query.type) {
67
- case "column":
68
- case "inlineColumn":
69
- return query;
70
- case "sparseToDenseColumn": {
71
- const sortedAxesIndices = query.axesIndices.toSorted((lhs, rhs) => lhs - rhs);
72
- return {
73
- ...query,
74
- axesIndices: sortedAxesIndices,
75
- };
76
- }
77
- case "innerJoin":
78
- case "fullJoin": {
79
- const sortedEntries = query.entries.map(sortQueryJoinEntrySpec);
80
- sortedEntries.sort(cmpQueryJoinEntrySpec);
81
- return {
82
- ...query,
83
- entries: sortedEntries,
84
- };
85
- }
86
- case "outerJoin": {
87
- const sortedSecondary = query.secondary.map(sortQueryJoinEntrySpec);
88
- sortedSecondary.sort(cmpQueryJoinEntrySpec);
89
- return {
90
- ...query,
91
- primary: sortQueryJoinEntrySpec(query.primary),
92
- secondary: sortedSecondary,
93
- };
94
- }
95
- case "sliceAxes": {
96
- const sortedAxisFilters = query.axisFilters.toSorted((lhs, rhs) => {
97
- const lhsKey = canonicalizeJson(lhs.axisSelector);
98
- const rhsKey = canonicalizeJson(rhs.axisSelector);
99
- return lhsKey < rhsKey ? -1 : lhsKey === rhsKey ? 0 : 1;
100
- });
101
- return {
102
- ...query,
103
- input: sortQuerySpec(query.input),
104
- axisFilters: sortedAxisFilters,
105
- };
106
- }
107
- case "sort":
108
- return {
109
- ...query,
110
- input: sortQuerySpec(query.input),
111
- };
112
- case "filter":
113
- return {
114
- ...query,
115
- input: sortQuerySpec(query.input),
116
- };
117
- default:
118
- assertNever(query);
119
- }
93
+ /** Recursively maps all column references in a SpecQuery tree. */
94
+ export function mapSpecQueryColumns<C1, C2>(
95
+ query: SpecQuery<C1>,
96
+ cb: (c: C1) => C2,
97
+ ): SpecQuery<C2> {
98
+ return traverseQuerySpec(query, { column: cb });
120
99
  }
121
100
 
122
- function sortQueryJoinEntrySpec(entry: SpecQueryJoinEntry): SpecQueryJoinEntry {
123
- const sortedQualifications = entry.qualifications.toSorted((lhs, rhs) => {
124
- const lhsKey = canonicalizeJson(lhs.axis);
125
- const rhsKey = canonicalizeJson(rhs.axis);
126
- return lhsKey < rhsKey ? -1 : lhsKey === rhsKey ? 0 : 1;
101
+ /** Collects all column references from a SpecQuery tree. */
102
+ export function collectSpecQueryColumns<C>(query: SpecQuery<C>): C[] {
103
+ const result: C[] = [];
104
+ traverseQuerySpec(query, {
105
+ column: (c: C) => {
106
+ result.push(c);
107
+ return c;
108
+ },
109
+ });
110
+ return result;
111
+ }
112
+
113
+ export function sortSpecQuery(query: SpecQuery): SpecQuery {
114
+ return traverseQuerySpec(query, {
115
+ column: (c) => c,
116
+ node: (node) => {
117
+ switch (node.type) {
118
+ case "sparseToDenseColumn":
119
+ return { ...node, axesIndices: node.axesIndices.toSorted((a, b) => a - b) };
120
+ case "innerJoin":
121
+ case "fullJoin": {
122
+ const sorted = [...node.entries].sort(cmpQueryJoinEntrySpec);
123
+ return { ...node, entries: sorted };
124
+ }
125
+ case "outerJoin": {
126
+ const sorted = [...node.secondary].sort(cmpQueryJoinEntrySpec);
127
+ return { ...node, secondary: sorted };
128
+ }
129
+ case "sliceAxes":
130
+ return {
131
+ ...node,
132
+ axisFilters: node.axisFilters.toSorted((a, b) => {
133
+ const ak = canonicalizeJson(a.axisSelector);
134
+ const bk = canonicalizeJson(b.axisSelector);
135
+ return ak < bk ? -1 : ak === bk ? 0 : 1;
136
+ }),
137
+ };
138
+ default:
139
+ return node;
140
+ }
141
+ },
142
+ joinEntry: (entry) => ({
143
+ ...entry,
144
+ qualifications: entry.qualifications.toSorted((a, b) => {
145
+ const ak = canonicalizeJson(a.axis);
146
+ const bk = canonicalizeJson(b.axis);
147
+ return ak < bk ? -1 : ak === bk ? 0 : 1;
148
+ }),
149
+ }),
127
150
  });
128
- return {
129
- entry: sortQuerySpec(entry.entry),
130
- qualifications: sortedQualifications,
131
- };
132
151
  }
133
152
 
134
153
  function cmpQuerySpec(lhs: SpecQuery, rhs: SpecQuery): number {
@@ -4,8 +4,9 @@ import type { PObjectId } from "../../pool";
4
4
  import { assertNever } from "../../util";
5
5
  import { getAxisId, type PColumn } from "./spec/spec";
6
6
  import type { PColumnValues } from "./data_info";
7
- import type { SpecQuery, SpecQueryJoinEntry } from "./query/query_spec";
7
+ import type { SpecQuery } from "./query/query_spec";
8
8
  import { canonicalizeJson } from "../../json";
9
+ import { mapSpecQueryColumns } from "./query";
9
10
 
10
11
  /** Defines a terminal column node in the join request tree */
11
12
  export interface ColumnJoinEntry<Col> {
@@ -423,49 +424,7 @@ export function sortPTableDef(def: PTableDef<PObjectId>): PTableDef<PObjectId> {
423
424
  }
424
425
 
425
426
  export function mapPTableDefV2<C1, C2>(def: PTableDefV2<C1>, cb: (c: C1) => C2): PTableDefV2<C2> {
426
- return { query: mapQuerySpec(def.query, cb) };
427
- }
428
-
429
- /** Recursively maps all column references in a SpecQuery tree. */
430
- export function mapQuerySpec<C1, C2>(query: SpecQuery<C1>, cb: (c: C1) => C2): SpecQuery<C2> {
431
- switch (query.type) {
432
- case "column":
433
- return { type: "column", column: cb(query.column) };
434
- case "sparseToDenseColumn":
435
- return { ...query, column: cb(query.column) };
436
- case "inlineColumn":
437
- return query;
438
- case "innerJoin":
439
- case "fullJoin":
440
- return {
441
- ...query,
442
- entries: query.entries.map((e) => mapQueryJoinEntrySpec(e, cb)),
443
- };
444
- case "outerJoin":
445
- return {
446
- ...query,
447
- primary: mapQueryJoinEntrySpec(query.primary, cb),
448
- secondary: query.secondary.map((e) => mapQueryJoinEntrySpec(e, cb)),
449
- };
450
- case "filter":
451
- return { ...query, input: mapQuerySpec(query.input, cb) };
452
- case "sort":
453
- return { ...query, input: mapQuerySpec(query.input, cb) };
454
- case "sliceAxes":
455
- return { ...query, input: mapQuerySpec(query.input, cb) };
456
- default:
457
- assertNever(query);
458
- }
459
- }
460
-
461
- function mapQueryJoinEntrySpec<C1, C2>(
462
- entry: SpecQueryJoinEntry<C1>,
463
- cb: (c: C1) => C2,
464
- ): SpecQueryJoinEntry<C2> {
465
- return {
466
- ...entry,
467
- entry: mapQuerySpec(entry.entry, cb),
468
- };
427
+ return { query: mapSpecQueryColumns(def.query, cb) };
469
428
  }
470
429
 
471
430
  export function mapJoinEntry<C1, C2>(entry: JoinEntry<C1>, cb: (c: C1) => C2): JoinEntry<C2> {