@milaboratories/pf-spec 1.0.0

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,265 @@
1
+ import type { PColumnSpec, PObjectId } from "@milaboratories/pl-model-common";
2
+ import { expect, test } from "vitest";
3
+ import { buildQuery, rewriteLegacyFilters } from "./index.ts";
4
+ import { PFrame } from "./p-frame.ts";
5
+
6
+ const column1Spec: PColumnSpec = {
7
+ kind: "PColumn",
8
+ name: "column1",
9
+ valueType: "Int",
10
+ axesSpec: [{ type: "Int", name: "a1" }],
11
+ };
12
+
13
+ const column2Spec: PColumnSpec = {
14
+ kind: "PColumn",
15
+ name: "column2",
16
+ valueType: "Int",
17
+ axesSpec: [{ type: "Int", name: "a1" }],
18
+ };
19
+
20
+ test("findColumns", () => {
21
+ using pframe = new PFrame({ column1: column1Spec });
22
+
23
+ const response = pframe.findColumns({
24
+ columnFilter: {},
25
+ compatibleWith: [],
26
+ strictlyCompatible: false,
27
+ });
28
+
29
+ expect(response).toEqual({
30
+ hits: [
31
+ {
32
+ hit: { columnId: "column1", spec: column1Spec },
33
+ mappingVariants: [],
34
+ },
35
+ ],
36
+ });
37
+ });
38
+
39
+ test("discoverColumns", () => {
40
+ using pframe = new PFrame({
41
+ column1: column1Spec,
42
+ column2: column2Spec,
43
+ });
44
+
45
+ const request = {
46
+ axes: [],
47
+ includeColumns: [],
48
+ constraints: {
49
+ allowFloatingSourceAxes: true,
50
+ allowFloatingHitAxes: true,
51
+ allowSourceQualifications: true,
52
+ allowHitQualifications: true,
53
+ },
54
+ };
55
+
56
+ const response = pframe.discoverColumns(request);
57
+
58
+ expect(response).toEqual({
59
+ hits: [
60
+ {
61
+ hit: { columnId: "column1", spec: column1Spec },
62
+ mappingVariants: [],
63
+ path: [],
64
+ },
65
+ {
66
+ hit: { columnId: "column2", spec: column2Spec },
67
+ mappingVariants: [],
68
+ path: [],
69
+ },
70
+ ],
71
+ });
72
+ });
73
+
74
+ test("deleteColumns", () => {
75
+ using pframe = new PFrame({});
76
+
77
+ const response = pframe.deleteColumns({
78
+ columns: [
79
+ { axesSpec: [{ type: "Int", name: "a1" }], qualifications: [] },
80
+ {
81
+ axesSpec: [
82
+ { type: "Int", name: "a1" },
83
+ { type: "Int", name: "a2" },
84
+ ],
85
+ qualifications: [],
86
+ },
87
+ ],
88
+ delete: 1,
89
+ });
90
+
91
+ expect(response).toEqual({
92
+ columns: [{ axesSpec: [{ type: "Int", name: "a1" }], qualifications: [] }],
93
+ });
94
+ });
95
+
96
+ test("evaluateQuery", () => {
97
+ using pframe = new PFrame({ column1: column1Spec });
98
+
99
+ const response = pframe.evaluateQuery({
100
+ type: "column",
101
+ column: "column1" as PObjectId,
102
+ });
103
+
104
+ expect(response.tableSpec).toEqual([
105
+ {
106
+ type: "axis",
107
+ id: { name: "a1", type: "Int" },
108
+ spec: { name: "a1", type: "Int" },
109
+ },
110
+ {
111
+ type: "column",
112
+ id: "column1",
113
+ spec: column1Spec,
114
+ },
115
+ ]);
116
+ expect(response.dataQuery).toEqual({
117
+ type: "column",
118
+ column: "column1",
119
+ });
120
+ });
121
+
122
+ test("listColumns", () => {
123
+ using pframe = new PFrame({
124
+ column1: column1Spec,
125
+ column2: column2Spec,
126
+ });
127
+
128
+ const columns = pframe.listColumns();
129
+
130
+ expect(columns).toEqual([
131
+ { columnId: "column1", spec: column1Spec },
132
+ { columnId: "column2", spec: column2Spec },
133
+ ]);
134
+ });
135
+
136
+ test("getColumn", () => {
137
+ using pframe = new PFrame({
138
+ column1: column1Spec,
139
+ column2: column2Spec,
140
+ });
141
+
142
+ expect(pframe.getColumn("column1" as PObjectId)).toEqual({
143
+ columnId: "column1",
144
+ spec: column1Spec,
145
+ });
146
+ expect(pframe.getColumn("missing" as PObjectId)).toBeNull();
147
+ });
148
+
149
+ test("buildQuery (top-level, no frame needed)", () => {
150
+ const columnId = "c1" as PObjectId;
151
+
152
+ const entry = buildQuery({ version: "v1", column: columnId });
153
+
154
+ expect(entry.entry).toEqual({ type: "column", column: columnId });
155
+ });
156
+
157
+ test("discoverColumns: parallel linkers with cd-disambiguated one-side axes yield 4 variants", () => {
158
+ // This test mirrors case 42 in pframes-rs-spec. Two sibling linkers each
159
+ // expose two `a2` axes on the one-side, and a context domain makes the two
160
+ // axes different. A hit with an `a2` axis that has no context domain
161
+ // matches both axes. Therefore there are 2 paths x 2 context domain
162
+ // choices, and the result has 4 variants.
163
+ const linkerAxes = [
164
+ { type: "Int", name: "a3" },
165
+ { type: "Int", name: "a2", parentAxes: [0], contextDomain: { d: "1" } },
166
+ { type: "Int", name: "a2", parentAxes: [0], contextDomain: { d: "2" } },
167
+ { type: "Int", name: "a1" },
168
+ ];
169
+ const lClosest: PColumnSpec = {
170
+ kind: "PColumn",
171
+ name: "linker",
172
+ valueType: "Int",
173
+ axesSpec: linkerAxes,
174
+ domain: { algo: "closest" },
175
+ annotations: { "pl7.app/isLinkerColumn": "true" },
176
+ } as PColumnSpec;
177
+ const lFuel: PColumnSpec = {
178
+ kind: "PColumn",
179
+ name: "linker",
180
+ valueType: "Int",
181
+ axesSpec: linkerAxes,
182
+ domain: { algo: "fuelOpt" },
183
+ annotations: { "pl7.app/isLinkerColumn": "true" },
184
+ } as PColumnSpec;
185
+ const hitSpec: PColumnSpec = {
186
+ kind: "PColumn",
187
+ name: "hit",
188
+ valueType: "Int",
189
+ axesSpec: [
190
+ { type: "Int", name: "a3" },
191
+ { type: "Int", name: "a2", parentAxes: [0] },
192
+ ],
193
+ };
194
+
195
+ using pframe = new PFrame({
196
+ l_closest: lClosest,
197
+ l_fuel: lFuel,
198
+ hit: hitSpec,
199
+ });
200
+
201
+ const response = pframe.discoverColumns({
202
+ axes: [{ axesSpec: [{ type: "Int", name: "a1" }], qualifications: [] }],
203
+ maxHops: 1,
204
+ constraints: {
205
+ allowFloatingSourceAxes: true,
206
+ allowFloatingHitAxes: false,
207
+ allowSourceQualifications: false,
208
+ allowHitQualifications: true,
209
+ },
210
+ });
211
+
212
+ expect(response.hits).toHaveLength(2);
213
+ const paths = response.hits
214
+ .map((h) => h.path.map((s) => (s.type === "linker" ? s.linker.columnId : s.filter.columnId)))
215
+ .sort();
216
+ expect(paths).toEqual([["l_closest"], ["l_fuel"]]);
217
+ for (const hit of response.hits) {
218
+ expect(hit.hit.columnId).toBe("hit");
219
+ expect(hit.mappingVariants).toHaveLength(2);
220
+ const cdValues = hit.mappingVariants
221
+ .map((v) => v.qualifications.forHit[0]?.contextDomain?.d)
222
+ .sort();
223
+ expect(cdValues).toEqual(["1", "2"]);
224
+ }
225
+ });
226
+
227
+ test("rewriteLegacyQuery", () => {
228
+ using pframe = new PFrame({ column1: column1Spec });
229
+
230
+ const response = pframe.rewriteLegacyQuery({
231
+ src: { type: "column", column: "column1" as PObjectId },
232
+ filters: [],
233
+ });
234
+
235
+ expect(response).toEqual({
236
+ type: "column",
237
+ column: "column1",
238
+ });
239
+ });
240
+
241
+ test("rewriteLegacyFilters", () => {
242
+ const response = rewriteLegacyFilters({
243
+ tableSpec: [
244
+ { type: "axis", id: { type: "Int", name: "a1" }, spec: { type: "Int", name: "a1" } },
245
+ { type: "column", id: "column1" as PObjectId, spec: column1Spec },
246
+ ],
247
+ filters: [
248
+ {
249
+ type: "bySingleColumnV2",
250
+ column: { type: "column", id: "column1" as PObjectId },
251
+ predicate: { operator: "Equal", reference: 30 },
252
+ },
253
+ ],
254
+ });
255
+
256
+ // Selectors resolve to indices. The column value is `columnRef` 0.
257
+ expect(response).toEqual([
258
+ {
259
+ type: "numericComparison",
260
+ operand: "eq",
261
+ left: { type: "columnRef", value: 0 },
262
+ right: { type: "constant", value: 30 },
263
+ },
264
+ ]);
265
+ });
package/src/p-frame.ts ADDED
@@ -0,0 +1,148 @@
1
+ import type {
2
+ EvaluateQueryResponse,
3
+ JoinEntry,
4
+ PColumnIdAndSpec,
5
+ PColumnSpec,
6
+ PColumnValue,
7
+ PObjectId,
8
+ PTableRecordFilter,
9
+ PTableSorting,
10
+ SpecQuery,
11
+ } from "@milaboratories/pl-model-common";
12
+ import type { PFrameInternal } from "@milaboratories/pl-model-middle-layer";
13
+ import { spec as bindings } from "./generated/pframes_rs_wasip2.js";
14
+
15
+ /** A legacy (V4) query. Code used this type before the unified `SpecQuery`. */
16
+ export type LegacyQuery = {
17
+ /** The source join entry. It defines the data sources and the join structure. */
18
+ src: JoinEntry<PObjectId>;
19
+ /** Optional record-level filters. The query applies these to its results. */
20
+ filters?: PTableRecordFilter[];
21
+ /** Optional sort specifications. They set the order of the results. */
22
+ sorting?: PTableSorting[];
23
+ };
24
+
25
+ /**
26
+ * A set of registered column specs, and the spec-plane operations on them.
27
+ *
28
+ * Each method uses the specs that this frame holds. No method reads data. The module
29
+ * surface holds the spec operations that do not need a frame.
30
+ *
31
+ * This class holds a WASM resource. Use a `using` declaration to release it. Garbage
32
+ * collection also releases the resource, but not at a known time.
33
+ */
34
+ export class PFrame implements Disposable {
35
+ #frame: bindings.Frame;
36
+
37
+ constructor(spec: Record<string, PColumnSpec>) {
38
+ this.#frame = bindings.Frame.fromJson(JSON.stringify(spec));
39
+ }
40
+
41
+ /** Deletes columns from a columns specification. */
42
+ deleteColumns(
43
+ request: PFrameInternal.DeleteColumnFromColumnsRequest,
44
+ ): PFrameInternal.DeleteColumnFromColumnsResponse {
45
+ return JSON.parse(bindings.Frame.deleteColumns(JSON.stringify(request)));
46
+ }
47
+
48
+ /** Returns `null` if this frame has no column with the id `columnId`. */
49
+ getColumn(columnId: PObjectId): PColumnIdAndSpec | null {
50
+ return JSON.parse(this.#frame.getColumn(JSON.stringify(columnId)));
51
+ }
52
+
53
+ /** Lists each column in this frame with its id and its spec. */
54
+ listColumns(): PColumnIdAndSpec[] {
55
+ const columns = JSON.parse(this.#frame.listColumns()) as Record<string, PColumnIdAndSpec>;
56
+ return Object.values(columns);
57
+ }
58
+
59
+ /**
60
+ * Discovers the columns that are compatible with a given axes integration.
61
+ *
62
+ * The include filters and the exclude filters apply in order. The exclude filters
63
+ * remove matches from the include set. Each hit has its own traversal `path`. To
64
+ * materialize a hit as a `SpecQueryJoinEntry`, give the hit's column id and `path`
65
+ * to {@link buildQuery}.
66
+ */
67
+ discoverColumns(
68
+ request: PFrameInternal.DiscoverColumnsRequestV2,
69
+ ): PFrameInternal.DiscoverColumnsResponse {
70
+ return JSON.parse(this.#frame.discoverColumns(JSON.stringify(request)));
71
+ }
72
+
73
+ /** Finds the columns in this frame that match the given filter criteria. */
74
+ findColumns(request: PFrameInternal.FindColumnsRequest): PFrameInternal.FindColumnsResponse {
75
+ return JSON.parse(this.#frame.findColumns(JSON.stringify(request)));
76
+ }
77
+
78
+ /** Resolves a query against this frame's specs. Returns a table spec and a data query. */
79
+ evaluateQuery(request: SpecQuery): EvaluateQueryResponse {
80
+ return JSON.parse(this.#frame.evaluateQuery(JSON.stringify(request)));
81
+ }
82
+
83
+ /** Upgrades a {@link LegacyQuery} to the current `SpecQuery` format. */
84
+ rewriteLegacyQuery(request: LegacyQuery): SpecQuery {
85
+ const src = joinEntryToInternal(request.src);
86
+ return JSON.parse(this.#frame.rewriteLegacyQuery(JSON.stringify({ ...request, src })));
87
+ }
88
+
89
+ [Symbol.dispose](): void {
90
+ this.#frame[Symbol.dispose]();
91
+ }
92
+ }
93
+
94
+ function joinEntryToInternal(entry: JoinEntry<PObjectId>): PFrameInternal.JoinEntryV4 {
95
+ const type = entry.type;
96
+ switch (type) {
97
+ case "column":
98
+ return {
99
+ type: "column",
100
+ columnId: entry.column,
101
+ };
102
+ case "slicedColumn":
103
+ return {
104
+ type: "slicedColumn",
105
+ columnId: entry.column,
106
+ newId: entry.newId,
107
+ axisFilters: entry.axisFilters,
108
+ };
109
+ case "artificialColumn":
110
+ return {
111
+ type: "artificialColumn",
112
+ columnId: entry.column,
113
+ newId: entry.newId,
114
+ axesIndices: entry.axesIndices,
115
+ };
116
+ case "inlineColumn":
117
+ return {
118
+ type: "inlineColumn",
119
+ newId: entry.column.id,
120
+ spec: entry.column.spec,
121
+ dataInfo: {
122
+ type: "Json",
123
+ keyLength: entry.column.spec.axesSpec.length,
124
+ data: entry.column.data.reduce(
125
+ (acc, row) => {
126
+ acc[JSON.stringify(row.key)] = row.val;
127
+ return acc;
128
+ },
129
+ {} as Record<string, PColumnValue>,
130
+ ),
131
+ },
132
+ };
133
+ case "inner":
134
+ case "full":
135
+ return {
136
+ type: entry.type,
137
+ entries: entry.entries.map((col) => joinEntryToInternal(col)),
138
+ };
139
+ case "outer":
140
+ return {
141
+ type: "outer",
142
+ primary: joinEntryToInternal(entry.primary),
143
+ secondary: entry.secondary.map((col) => joinEntryToInternal(col)),
144
+ };
145
+ default:
146
+ throw new Error(`unsupported PFrame join entry type: ${type satisfies never}`);
147
+ }
148
+ }
@@ -0,0 +1,23 @@
1
+ import type {
2
+ DataQueryBooleanExpression,
3
+ PTableColumnSpec,
4
+ PTableRecordFilter,
5
+ } from "@milaboratories/pl-model-common";
6
+ import { spec as bindings } from "./generated/pframes_rs_wasip2.js";
7
+
8
+ /**
9
+ * Upgrades the selector-based legacy record filters into index-based boolean
10
+ * expressions for the data layer. It resolves them against the given unified
11
+ * table spec, which holds the axes first and then the columns.
12
+ *
13
+ * The operation is stateless, because a filter does not change the spec of a
14
+ * table. Therefore the result is valid for `getUniqueValues`, which works on
15
+ * the single-column table of the source column. The result is also valid when
16
+ * you compose a `filter` over a `table` query node.
17
+ */
18
+ export function rewriteLegacyFilters(request: {
19
+ tableSpec: PTableColumnSpec[];
20
+ filters: PTableRecordFilter[];
21
+ }): DataQueryBooleanExpression[] {
22
+ return JSON.parse(bindings.Frame.rewriteLegacyFilters(JSON.stringify(request)));
23
+ }
@@ -0,0 +1,35 @@
1
+ import type { PColumnSpec, PObjectId, PTableColumnSpec } from "@milaboratories/pl-model-common";
2
+ import { expect, test } from "vitest";
3
+ import { findColumn } from "./table.ts";
4
+
5
+ const column1Spec: PColumnSpec = {
6
+ kind: "PColumn",
7
+ name: "column1",
8
+ valueType: "Int",
9
+ axesSpec: [{ type: "Int", name: "a1" }],
10
+ };
11
+
12
+ const tableSpec: PTableColumnSpec[] = [
13
+ {
14
+ type: "axis",
15
+ id: { name: "a1", type: "Int" },
16
+ spec: { name: "a1", type: "Int" },
17
+ },
18
+ {
19
+ type: "column",
20
+ id: "column1" as PObjectId,
21
+ spec: column1Spec,
22
+ },
23
+ ];
24
+
25
+ test("findColumn - axis", () => {
26
+ expect(findColumn(tableSpec, { type: "axis", id: { name: "a1", type: "Int" } })).toBe(0);
27
+ });
28
+
29
+ test("findColumn - column", () => {
30
+ expect(findColumn(tableSpec, { type: "column", id: "column1" as PObjectId })).toBe(1);
31
+ });
32
+
33
+ test("findColumn - not found", () => {
34
+ expect(findColumn(tableSpec, { type: "column", id: "nonexistent" as PObjectId })).toBe(-1);
35
+ });
package/src/table.ts ADDED
@@ -0,0 +1,11 @@
1
+ import type { PTableColumnId, PTableColumnSpec } from "@milaboratories/pl-model-common";
2
+ import { spec as bindings } from "./generated/pframes_rs_wasip2.js";
3
+
4
+ export function findColumn(tableSpec: PTableColumnSpec[], selector: PTableColumnId): number {
5
+ using table = bindings.Table.fromJson(JSON.stringify(tableSpec));
6
+ try {
7
+ return JSON.parse(table.findColumn(JSON.stringify(selector)));
8
+ } catch {
9
+ return -1;
10
+ }
11
+ }