@platforma-sdk/model 1.59.3 → 1.60.2

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 (140) hide show
  1. package/dist/block_storage.cjs.map +1 -1
  2. package/dist/block_storage.d.ts +1 -11
  3. package/dist/block_storage.js.map +1 -1
  4. package/dist/block_storage_callbacks.cjs.map +1 -1
  5. package/dist/block_storage_callbacks.js.map +1 -1
  6. package/dist/columns/column_collection_builder.cjs +215 -0
  7. package/dist/columns/column_collection_builder.cjs.map +1 -0
  8. package/dist/columns/column_collection_builder.d.ts +112 -0
  9. package/dist/columns/column_collection_builder.js +214 -0
  10. package/dist/columns/column_collection_builder.js.map +1 -0
  11. package/dist/columns/column_selector.cjs +122 -0
  12. package/dist/columns/column_selector.cjs.map +1 -0
  13. package/dist/columns/column_selector.d.ts +41 -0
  14. package/dist/columns/column_selector.js +118 -0
  15. package/dist/columns/column_selector.js.map +1 -0
  16. package/dist/columns/column_snapshot.cjs +20 -0
  17. package/dist/columns/column_snapshot.cjs.map +1 -0
  18. package/dist/columns/column_snapshot.d.ts +39 -0
  19. package/dist/columns/column_snapshot.js +18 -0
  20. package/dist/columns/column_snapshot.js.map +1 -0
  21. package/dist/columns/column_snapshot_provider.cjs +112 -0
  22. package/dist/columns/column_snapshot_provider.cjs.map +1 -0
  23. package/dist/columns/column_snapshot_provider.d.ts +73 -0
  24. package/dist/columns/column_snapshot_provider.js +107 -0
  25. package/dist/columns/column_snapshot_provider.js.map +1 -0
  26. package/dist/columns/ctx_column_sources.cjs +84 -0
  27. package/dist/columns/ctx_column_sources.cjs.map +1 -0
  28. package/dist/columns/ctx_column_sources.d.ts +33 -0
  29. package/dist/columns/ctx_column_sources.js +82 -0
  30. package/dist/columns/ctx_column_sources.js.map +1 -0
  31. package/dist/columns/index.cjs +5 -0
  32. package/dist/columns/index.d.ts +5 -0
  33. package/dist/columns/index.js +5 -0
  34. package/dist/components/PlDataTable/createPlDataTable/createPlDataTableV2.cjs +111 -0
  35. package/dist/components/PlDataTable/createPlDataTable/createPlDataTableV2.cjs.map +1 -0
  36. package/dist/components/PlDataTable/createPlDataTable/createPlDataTableV2.d.ts +25 -0
  37. package/dist/components/PlDataTable/createPlDataTable/createPlDataTableV2.js +110 -0
  38. package/dist/components/PlDataTable/createPlDataTable/createPlDataTableV2.js.map +1 -0
  39. package/dist/components/PlDataTable/{table.cjs → createPlDataTable/createPlDataTableV3.cjs} +54 -54
  40. package/dist/components/PlDataTable/createPlDataTable/createPlDataTableV3.cjs.map +1 -0
  41. package/dist/components/PlDataTable/createPlDataTable/createPlDataTableV3.d.ts +39 -0
  42. package/dist/components/PlDataTable/{table.js → createPlDataTable/createPlDataTableV3.js} +53 -53
  43. package/dist/components/PlDataTable/createPlDataTable/createPlDataTableV3.js.map +1 -0
  44. package/dist/components/PlDataTable/createPlDataTable/index.cjs +12 -0
  45. package/dist/components/PlDataTable/createPlDataTable/index.cjs.map +1 -0
  46. package/dist/components/PlDataTable/createPlDataTable/index.d.ts +15 -0
  47. package/dist/components/PlDataTable/createPlDataTable/index.js +12 -0
  48. package/dist/components/PlDataTable/createPlDataTable/index.js.map +1 -0
  49. package/dist/components/PlDataTable/createPlDataTableSheet.cjs +18 -0
  50. package/dist/components/PlDataTable/createPlDataTableSheet.cjs.map +1 -0
  51. package/dist/components/PlDataTable/createPlDataTableSheet.d.ts +11 -0
  52. package/dist/components/PlDataTable/createPlDataTableSheet.js +17 -0
  53. package/dist/components/PlDataTable/createPlDataTableSheet.js.map +1 -0
  54. package/dist/components/PlDataTable/index.cjs +4 -1
  55. package/dist/components/PlDataTable/index.d.ts +5 -2
  56. package/dist/components/PlDataTable/index.js +4 -1
  57. package/dist/components/PlDataTable/state-migration.cjs.map +1 -1
  58. package/dist/components/PlDataTable/state-migration.d.ts +2 -2
  59. package/dist/components/PlDataTable/state-migration.js.map +1 -1
  60. package/dist/components/PlDataTable/{v4.d.ts → typesV4.d.ts} +2 -2
  61. package/dist/components/PlDataTable/{v5.d.ts → typesV5.d.ts} +2 -2
  62. package/dist/components/index.cjs +4 -1
  63. package/dist/components/index.d.ts +5 -2
  64. package/dist/components/index.js +4 -1
  65. package/dist/index.cjs +44 -16
  66. package/dist/index.d.ts +17 -5
  67. package/dist/index.js +15 -3
  68. package/dist/labels/derive_distinct_labels.cjs +156 -0
  69. package/dist/labels/derive_distinct_labels.cjs.map +1 -0
  70. package/dist/labels/derive_distinct_labels.d.ts +29 -0
  71. package/dist/labels/derive_distinct_labels.js +155 -0
  72. package/dist/labels/derive_distinct_labels.js.map +1 -0
  73. package/dist/labels/index.cjs +2 -0
  74. package/dist/labels/index.d.ts +2 -0
  75. package/dist/labels/index.js +2 -0
  76. package/dist/labels/write_labels_to_specs.cjs +15 -0
  77. package/dist/labels/write_labels_to_specs.cjs.map +1 -0
  78. package/dist/labels/write_labels_to_specs.d.ts +9 -0
  79. package/dist/labels/write_labels_to_specs.js +14 -0
  80. package/dist/labels/write_labels_to_specs.js.map +1 -0
  81. package/dist/package.cjs +1 -1
  82. package/dist/package.js +1 -1
  83. package/dist/render/api.cjs +11 -2
  84. package/dist/render/api.cjs.map +1 -1
  85. package/dist/render/api.d.ts +9 -5
  86. package/dist/render/api.js +12 -3
  87. package/dist/render/api.js.map +1 -1
  88. package/dist/render/index.d.ts +2 -1
  89. package/dist/render/index.js +1 -1
  90. package/dist/render/internal.cjs.map +1 -1
  91. package/dist/render/internal.d.ts +5 -2
  92. package/dist/render/internal.js.map +1 -1
  93. package/dist/render/util/column_collection.cjs +3 -3
  94. package/dist/render/util/column_collection.cjs.map +1 -1
  95. package/dist/render/util/column_collection.d.ts +3 -2
  96. package/dist/render/util/column_collection.js +4 -4
  97. package/dist/render/util/column_collection.js.map +1 -1
  98. package/dist/render/util/index.d.ts +2 -1
  99. package/dist/render/util/index.js +1 -1
  100. package/dist/render/util/label.cjs +7 -134
  101. package/dist/render/util/label.cjs.map +1 -1
  102. package/dist/render/util/label.d.ts +5 -50
  103. package/dist/render/util/label.js +8 -132
  104. package/dist/render/util/label.js.map +1 -1
  105. package/dist/render/util/split_selectors.d.ts +2 -2
  106. package/package.json +9 -7
  107. package/src/block_storage.ts +0 -11
  108. package/src/block_storage_callbacks.ts +1 -1
  109. package/src/columns/column_collection_builder.test.ts +427 -0
  110. package/src/columns/column_collection_builder.ts +455 -0
  111. package/src/columns/column_selector.test.ts +472 -0
  112. package/src/columns/column_selector.ts +212 -0
  113. package/src/columns/column_snapshot.ts +55 -0
  114. package/src/columns/column_snapshot_provider.ts +177 -0
  115. package/src/columns/ctx_column_sources.ts +107 -0
  116. package/src/columns/expand_by_partition.test.ts +289 -0
  117. package/src/columns/expand_by_partition.ts +187 -0
  118. package/src/columns/index.ts +5 -0
  119. package/src/components/PlDataTable/createPlDataTable/createPlDataTableV2.ts +193 -0
  120. package/src/components/PlDataTable/{table.ts → createPlDataTable/createPlDataTableV3.ts} +134 -70
  121. package/src/components/PlDataTable/createPlDataTable/index.ts +27 -0
  122. package/src/components/PlDataTable/createPlDataTableSheet.ts +20 -0
  123. package/src/components/PlDataTable/index.ts +6 -4
  124. package/src/components/PlDataTable/state-migration.ts +2 -2
  125. package/src/index.ts +2 -1
  126. package/src/labels/derive_distinct_labels.test.ts +461 -0
  127. package/src/labels/derive_distinct_labels.ts +289 -0
  128. package/src/labels/index.ts +2 -0
  129. package/src/labels/write_labels_to_specs.ts +12 -0
  130. package/src/render/api.ts +25 -3
  131. package/src/render/internal.ts +20 -1
  132. package/src/render/util/column_collection.ts +9 -6
  133. package/src/render/util/label.test.ts +1 -1
  134. package/src/render/util/label.ts +19 -235
  135. package/src/render/util/split_selectors.ts +3 -3
  136. package/dist/components/PlDataTable/table.cjs.map +0 -1
  137. package/dist/components/PlDataTable/table.d.ts +0 -30
  138. package/dist/components/PlDataTable/table.js.map +0 -1
  139. /package/src/components/PlDataTable/{v4.ts → typesV4.ts} +0 -0
  140. /package/src/components/PlDataTable/{v5.ts → typesV5.ts} +0 -0
@@ -0,0 +1,472 @@
1
+ import type { MultiColumnSelector, PColumnSpec } from "@milaboratories/pl-model-common";
2
+ import { describe, expect, test } from "vitest";
3
+ import type { RelaxedColumnSelector } from "./column_selector";
4
+ import {
5
+ matchColumn,
6
+ matchColumnSelectors,
7
+ normalizeSelectors,
8
+ columnSelectorsToPredicate,
9
+ } from "./column_selector";
10
+ import type { RegExpString } from "@milaboratories/helpers";
11
+
12
+ // --- Helpers ---
13
+
14
+ function spec(overrides: Partial<PColumnSpec> & { name: string }): PColumnSpec {
15
+ return {
16
+ kind: "PColumn",
17
+ valueType: "Int",
18
+ axesSpec: [],
19
+ annotations: {},
20
+ ...overrides,
21
+ } as PColumnSpec;
22
+ }
23
+
24
+ // --- normalizeSelectors ---
25
+
26
+ describe("normalizeSelectors", () => {
27
+ test("wraps single selector in array", () => {
28
+ const result = normalizeSelectors({ name: "foo" });
29
+ expect(result).toHaveLength(1);
30
+ expect(result[0].name).toEqual([{ type: "regex", value: "foo" }]);
31
+ });
32
+
33
+ test("passes through array of selectors", () => {
34
+ const result = normalizeSelectors([{ name: "a" }, { name: "b" }]);
35
+ expect(result).toHaveLength(2);
36
+ });
37
+
38
+ test("normalizes plain string name to RegexMatcher[]", () => {
39
+ const [sel] = normalizeSelectors({ name: "foo" });
40
+ expect(sel.name).toEqual([{ type: "regex", value: "foo" }]);
41
+ });
42
+
43
+ test("normalizes array of mixed strings and matchers", () => {
44
+ const [sel] = normalizeSelectors({
45
+ name: ["foo", { type: "regex", value: "bar.*" as RegExpString }],
46
+ });
47
+ expect(sel.name).toEqual([
48
+ { type: "regex", value: "foo" },
49
+ { type: "regex", value: "bar.*" },
50
+ ]);
51
+ });
52
+
53
+ test("normalizes single StringMatcher object", () => {
54
+ const [sel] = normalizeSelectors({ name: { type: "exact", value: "foo" } });
55
+ expect(sel.name).toEqual([{ type: "exact", value: "foo" }]);
56
+ });
57
+
58
+ test("normalizes single ValueType to array", () => {
59
+ const [sel] = normalizeSelectors({ type: "Int" });
60
+ expect(sel.type).toEqual(["Int"]);
61
+ });
62
+
63
+ test("passes through ValueType array", () => {
64
+ const [sel] = normalizeSelectors({ type: ["Int", "Long"] });
65
+ expect(sel.type).toEqual(["Int", "Long"]);
66
+ });
67
+
68
+ test("normalizes record with plain string values", () => {
69
+ const [sel] = normalizeSelectors({
70
+ domain: { "pl7.app/chain": "IGHeavy" },
71
+ });
72
+ expect(sel.domain).toEqual({
73
+ "pl7.app/chain": [{ type: "regex", value: "IGHeavy" }],
74
+ });
75
+ });
76
+
77
+ test("normalizes record with mixed array values", () => {
78
+ const [sel] = normalizeSelectors({
79
+ annotations: { label: ["a", { type: "regex", value: "b.*" as RegExpString }] },
80
+ });
81
+ expect(sel.annotations).toEqual({
82
+ label: [
83
+ { type: "regex", value: "a" },
84
+ { type: "regex", value: "b.*" },
85
+ ],
86
+ });
87
+ });
88
+
89
+ test("normalizes axes", () => {
90
+ const [sel] = normalizeSelectors({
91
+ axes: [{ name: "axisName", type: "String" }],
92
+ });
93
+ expect(sel.axes).toEqual([{ name: [{ type: "regex", value: "axisName" }], type: ["String"] }]);
94
+ });
95
+
96
+ test("preserves partialAxesMatch", () => {
97
+ const [sel] = normalizeSelectors({ partialAxesMatch: false });
98
+ expect(sel.partialAxesMatch).toBe(false);
99
+ });
100
+
101
+ test("omits undefined fields", () => {
102
+ const [sel] = normalizeSelectors({ name: "foo" });
103
+ expect(sel.type).toBeUndefined();
104
+ expect(sel.domain).toBeUndefined();
105
+ expect(sel.axes).toBeUndefined();
106
+ });
107
+ });
108
+
109
+ // --- matchColumn ---
110
+
111
+ describe("matchColumn", () => {
112
+ describe("name matching", () => {
113
+ test("exact name match", () => {
114
+ const s = spec({ name: "pl7.app/vdj/sequence" });
115
+ const sel: MultiColumnSelector = { name: [{ type: "exact", value: "pl7.app/vdj/sequence" }] };
116
+ expect(matchColumn(s, sel)).toBe(true);
117
+ });
118
+
119
+ test("exact name mismatch", () => {
120
+ const s = spec({ name: "pl7.app/vdj/sequence" });
121
+ const sel: MultiColumnSelector = { name: [{ type: "exact", value: "pl7.app/vdj/other" }] };
122
+ expect(matchColumn(s, sel)).toBe(false);
123
+ });
124
+
125
+ test("regex name match", () => {
126
+ const s = spec({ name: "pl7.app/vdj/sequence" });
127
+ const sel: MultiColumnSelector = {
128
+ name: [{ type: "regex", value: "pl7\\.app/vdj/.*" as RegExpString }],
129
+ };
130
+ expect(matchColumn(s, sel)).toBe(true);
131
+ });
132
+
133
+ test("regex full match required", () => {
134
+ const s = spec({ name: "pl7.app/vdj/sequence" });
135
+ const sel: MultiColumnSelector = { name: [{ type: "regex", value: "vdj" as RegExpString }] };
136
+ expect(matchColumn(s, sel)).toBe(false);
137
+ });
138
+
139
+ test("OR across name matchers", () => {
140
+ const s = spec({ name: "colB" });
141
+ const sel: MultiColumnSelector = {
142
+ name: [
143
+ { type: "exact", value: "colA" },
144
+ { type: "exact", value: "colB" },
145
+ ],
146
+ };
147
+ expect(matchColumn(s, sel)).toBe(true);
148
+ });
149
+ });
150
+
151
+ describe("type matching", () => {
152
+ test("single type match", () => {
153
+ const s = spec({ name: "c", valueType: "Int" });
154
+ const sel: MultiColumnSelector = { type: ["Int"] };
155
+ expect(matchColumn(s, sel)).toBe(true);
156
+ });
157
+
158
+ test("type mismatch", () => {
159
+ const s = spec({ name: "c", valueType: "String" });
160
+ const sel: MultiColumnSelector = { type: ["Int"] };
161
+ expect(matchColumn(s, sel)).toBe(false);
162
+ });
163
+
164
+ test("OR across types", () => {
165
+ const s = spec({ name: "c", valueType: "Long" });
166
+ const sel: MultiColumnSelector = { type: ["Int", "Long"] };
167
+ expect(matchColumn(s, sel)).toBe(true);
168
+ });
169
+ });
170
+
171
+ describe("domain matching", () => {
172
+ test("matches column domain", () => {
173
+ const s = spec({ name: "c", domain: { chain: "IGHeavy" } });
174
+ const sel: MultiColumnSelector = {
175
+ domain: { chain: [{ type: "exact", value: "IGHeavy" }] },
176
+ };
177
+ expect(matchColumn(s, sel)).toBe(true);
178
+ });
179
+
180
+ test("matches combined domain from axes", () => {
181
+ const s = spec({
182
+ name: "c",
183
+ axesSpec: [
184
+ { name: "axis1", type: "String", domain: { chain: "IGHeavy" }, annotations: {} },
185
+ ] as PColumnSpec["axesSpec"],
186
+ });
187
+ const sel: MultiColumnSelector = {
188
+ domain: { chain: [{ type: "exact", value: "IGHeavy" }] },
189
+ };
190
+ expect(matchColumn(s, sel)).toBe(true);
191
+ });
192
+
193
+ test("domain key missing fails", () => {
194
+ const s = spec({ name: "c", domain: {} });
195
+ const sel: MultiColumnSelector = {
196
+ domain: { chain: [{ type: "exact", value: "IGHeavy" }] },
197
+ };
198
+ expect(matchColumn(s, sel)).toBe(false);
199
+ });
200
+
201
+ test("multiple domain keys AND-ed", () => {
202
+ const s = spec({ name: "c", domain: { chain: "IGHeavy", species: "human" } });
203
+ const sel: MultiColumnSelector = {
204
+ domain: {
205
+ chain: [{ type: "exact", value: "IGHeavy" }],
206
+ species: [{ type: "exact", value: "human" }],
207
+ },
208
+ };
209
+ expect(matchColumn(s, sel)).toBe(true);
210
+ });
211
+
212
+ test("one domain key mismatch fails", () => {
213
+ const s = spec({ name: "c", domain: { chain: "IGHeavy", species: "mouse" } });
214
+ const sel: MultiColumnSelector = {
215
+ domain: {
216
+ chain: [{ type: "exact", value: "IGHeavy" }],
217
+ species: [{ type: "exact", value: "human" }],
218
+ },
219
+ };
220
+ expect(matchColumn(s, sel)).toBe(false);
221
+ });
222
+ });
223
+
224
+ describe("annotations matching", () => {
225
+ test("exact annotation match", () => {
226
+ const s = spec({ name: "c", annotations: { label: "CDR3" } });
227
+ const sel: MultiColumnSelector = {
228
+ annotations: { label: [{ type: "exact", value: "CDR3" }] },
229
+ };
230
+ expect(matchColumn(s, sel)).toBe(true);
231
+ });
232
+
233
+ test("regex annotation match", () => {
234
+ const s = spec({ name: "c", annotations: { label: "CDR3-length" } });
235
+ const sel: MultiColumnSelector = {
236
+ annotations: { label: [{ type: "regex", value: ".*CDR3.*" as RegExpString }] },
237
+ };
238
+ expect(matchColumn(s, sel)).toBe(true);
239
+ });
240
+
241
+ test("missing annotation key fails", () => {
242
+ const s = spec({ name: "c", annotations: {} });
243
+ const sel: MultiColumnSelector = {
244
+ annotations: { label: [{ type: "exact", value: "CDR3" }] },
245
+ };
246
+ expect(matchColumn(s, sel)).toBe(false);
247
+ });
248
+ });
249
+
250
+ describe("axes matching", () => {
251
+ const withAxes = (axes: PColumnSpec["axesSpec"]) => spec({ name: "c", axesSpec: axes });
252
+
253
+ const axis = (name: string, type: string = "String") =>
254
+ ({ name, type, domain: {}, annotations: {} }) as PColumnSpec["axesSpec"][number];
255
+
256
+ test("partial match (default) — selector axis found", () => {
257
+ const s = withAxes([axis("a1"), axis("a2")]);
258
+ const sel: MultiColumnSelector = {
259
+ axes: [{ name: [{ type: "exact", value: "a1" }] }],
260
+ };
261
+ expect(matchColumn(s, sel)).toBe(true);
262
+ });
263
+
264
+ test("partial match — selector axis not found", () => {
265
+ const s = withAxes([axis("a1")]);
266
+ const sel: MultiColumnSelector = {
267
+ axes: [{ name: [{ type: "exact", value: "missing" }] }],
268
+ };
269
+ expect(matchColumn(s, sel)).toBe(false);
270
+ });
271
+
272
+ test("exact match — same count and order", () => {
273
+ const s = withAxes([axis("a1"), axis("a2")]);
274
+ const sel: MultiColumnSelector = {
275
+ axes: [
276
+ { name: [{ type: "exact", value: "a1" }] },
277
+ { name: [{ type: "exact", value: "a2" }] },
278
+ ],
279
+ partialAxesMatch: false,
280
+ };
281
+ expect(matchColumn(s, sel)).toBe(true);
282
+ });
283
+
284
+ test("exact match — wrong order fails", () => {
285
+ const s = withAxes([axis("a1"), axis("a2")]);
286
+ const sel: MultiColumnSelector = {
287
+ axes: [
288
+ { name: [{ type: "exact", value: "a2" }] },
289
+ { name: [{ type: "exact", value: "a1" }] },
290
+ ],
291
+ partialAxesMatch: false,
292
+ };
293
+ expect(matchColumn(s, sel)).toBe(false);
294
+ });
295
+
296
+ test("exact match — count mismatch fails", () => {
297
+ const s = withAxes([axis("a1"), axis("a2")]);
298
+ const sel: MultiColumnSelector = {
299
+ axes: [{ name: [{ type: "exact", value: "a1" }] }],
300
+ partialAxesMatch: false,
301
+ };
302
+ expect(matchColumn(s, sel)).toBe(false);
303
+ });
304
+
305
+ test("axis type matching", () => {
306
+ const s = withAxes([axis("a1", "Int")]);
307
+ const sel: MultiColumnSelector = {
308
+ axes: [{ type: ["Int"] }],
309
+ };
310
+ expect(matchColumn(s, sel)).toBe(true);
311
+ });
312
+
313
+ test("axis domain matching", () => {
314
+ const a = {
315
+ name: "a1",
316
+ type: "String",
317
+ domain: { k: "v" },
318
+ annotations: {},
319
+ } as PColumnSpec["axesSpec"][number];
320
+ const s = withAxes([a]);
321
+ const sel: MultiColumnSelector = {
322
+ axes: [{ domain: { k: [{ type: "exact", value: "v" }] } }],
323
+ };
324
+ expect(matchColumn(s, sel)).toBe(true);
325
+ });
326
+ });
327
+
328
+ describe("AND across fields", () => {
329
+ test("all fields must match", () => {
330
+ const s = spec({
331
+ name: "col1",
332
+ valueType: "Int",
333
+ annotations: { label: "x" },
334
+ });
335
+ const sel: MultiColumnSelector = {
336
+ name: [{ type: "exact", value: "col1" }],
337
+ type: ["Int"],
338
+ annotations: { label: [{ type: "exact", value: "x" }] },
339
+ };
340
+ expect(matchColumn(s, sel)).toBe(true);
341
+ });
342
+
343
+ test("one field mismatch fails entire selector", () => {
344
+ const s = spec({
345
+ name: "col1",
346
+ valueType: "String",
347
+ });
348
+ const sel: MultiColumnSelector = {
349
+ name: [{ type: "exact", value: "col1" }],
350
+ type: ["Int"],
351
+ };
352
+ expect(matchColumn(s, sel)).toBe(false);
353
+ });
354
+ });
355
+
356
+ test("empty selector matches everything", () => {
357
+ const s = spec({ name: "anything", valueType: "Double" });
358
+ expect(matchColumn(s, {})).toBe(true);
359
+ });
360
+ });
361
+
362
+ // --- matchColumnSelectors (OR across array) ---
363
+
364
+ describe("matchColumnSelectors", () => {
365
+ test("matches if any selector matches", () => {
366
+ const s = spec({ name: "col2" });
367
+ const selectors: MultiColumnSelector[] = [
368
+ { name: [{ type: "exact", value: "col1" }] },
369
+ { name: [{ type: "exact", value: "col2" }] },
370
+ ];
371
+ expect(matchColumnSelectors(selectors, s)).toBe(true);
372
+ });
373
+
374
+ test("no match if none match", () => {
375
+ const s = spec({ name: "col3" });
376
+ const selectors: MultiColumnSelector[] = [
377
+ { name: [{ type: "exact", value: "col1" }] },
378
+ { name: [{ type: "exact", value: "col2" }] },
379
+ ];
380
+ expect(matchColumnSelectors(selectors, s)).toBe(false);
381
+ });
382
+
383
+ test("empty array matches nothing", () => {
384
+ const s = spec({ name: "col1" });
385
+ expect(matchColumnSelectors([], s)).toBe(false);
386
+ });
387
+ });
388
+
389
+ // --- columnSelectorsToPredicate ---
390
+
391
+ describe("columnSelectorsToPredicate", () => {
392
+ test("works with relaxed single selector", () => {
393
+ const pred = columnSelectorsToPredicate({ name: "col1" });
394
+ expect(pred(spec({ name: "col1" }))).toBe(true);
395
+ expect(pred(spec({ name: "col2" }))).toBe(false);
396
+ });
397
+
398
+ test("works with relaxed array of selectors", () => {
399
+ const pred = columnSelectorsToPredicate([{ name: "col1" }, { name: "col2" }]);
400
+ expect(pred(spec({ name: "col1" }))).toBe(true);
401
+ expect(pred(spec({ name: "col2" }))).toBe(true);
402
+ expect(pred(spec({ name: "col3" }))).toBe(false);
403
+ });
404
+
405
+ test("works with regex in relaxed form", () => {
406
+ const pred = columnSelectorsToPredicate({
407
+ name: { type: "regex", value: "col[12]" as RegExpString },
408
+ });
409
+ expect(pred(spec({ name: "col1" }))).toBe(true);
410
+ expect(pred(spec({ name: "col2" }))).toBe(true);
411
+ expect(pred(spec({ name: "col3" }))).toBe(false);
412
+ });
413
+
414
+ test("domain filter in relaxed form", () => {
415
+ const pred = columnSelectorsToPredicate({
416
+ domain: { chain: "IGHeavy" },
417
+ });
418
+ expect(pred(spec({ name: "c", domain: { chain: "IGHeavy" } }))).toBe(true);
419
+ expect(pred(spec({ name: "c", domain: { chain: "IGLight" } }))).toBe(false);
420
+ expect(pred(spec({ name: "c" }))).toBe(false);
421
+ });
422
+ });
423
+
424
+ // --- Integration: relaxed selectors end-to-end ---
425
+
426
+ describe("relaxed selectors end-to-end", () => {
427
+ test("design doc example: name + domain relaxed", () => {
428
+ const input: RelaxedColumnSelector = {
429
+ name: "pl7.app/vdj/sequence",
430
+ domain: { "pl7.app/vdj/chain": "IGHeavy" },
431
+ };
432
+ const [sel] = normalizeSelectors(input);
433
+ const s = spec({
434
+ name: "pl7.app/vdj/sequence",
435
+ domain: { "pl7.app/vdj/chain": "IGHeavy" },
436
+ });
437
+ expect(matchColumn(s, sel)).toBe(true);
438
+ });
439
+
440
+ test("design doc example: two selectors OR-ed", () => {
441
+ const input: RelaxedColumnSelector[] = [
442
+ { name: "pl7.app/vdj/sequence" },
443
+ { name: "pl7.app/vdj/geneHit", domain: { "pl7.app/vdj/chain": "IGHeavy" } },
444
+ ];
445
+ const selectors = normalizeSelectors(input);
446
+
447
+ expect(matchColumnSelectors(selectors, spec({ name: "pl7.app/vdj/sequence" }))).toBe(true);
448
+
449
+ expect(
450
+ matchColumnSelectors(
451
+ selectors,
452
+ spec({ name: "pl7.app/vdj/geneHit", domain: { "pl7.app/vdj/chain": "IGHeavy" } }),
453
+ ),
454
+ ).toBe(true);
455
+
456
+ expect(
457
+ matchColumnSelectors(
458
+ selectors,
459
+ spec({ name: "pl7.app/vdj/geneHit", domain: { "pl7.app/vdj/chain": "IGLight" } }),
460
+ ),
461
+ ).toBe(false);
462
+ });
463
+
464
+ test("design doc example: domain key with multiple values", () => {
465
+ const pred = columnSelectorsToPredicate({
466
+ domain: { "pl7.app/vdj/chain": ["IGHeavy", "IGLight"] },
467
+ });
468
+ expect(pred(spec({ name: "c", domain: { "pl7.app/vdj/chain": "IGHeavy" } }))).toBe(true);
469
+ expect(pred(spec({ name: "c", domain: { "pl7.app/vdj/chain": "IGLight" } }))).toBe(true);
470
+ expect(pred(spec({ name: "c", domain: { "pl7.app/vdj/chain": "TRA" } }))).toBe(false);
471
+ });
472
+ });
@@ -0,0 +1,212 @@
1
+ import type {
2
+ MultiAxisSelector,
3
+ MultiColumnSelector,
4
+ PColumnSpec,
5
+ StringMatcher,
6
+ ValueType,
7
+ } from "@milaboratories/pl-model-common";
8
+
9
+ export type { StringMatcher } from "@milaboratories/pl-model-common";
10
+
11
+ // --- Relaxed types ---
12
+
13
+ /** Relaxed string matcher input: plain string, single matcher, or array of mixed. */
14
+ export type RelaxedStringMatchers = string | StringMatcher | (string | StringMatcher)[];
15
+
16
+ /** Relaxed record matcher: values can be plain strings or relaxed matchers. */
17
+ export type RelaxedRecord = Record<string, RelaxedStringMatchers>;
18
+
19
+ /** Relaxed axis selector — accepts plain strings where strict requires StringMatcher[]. */
20
+ export interface RelaxedAxisSelector {
21
+ name?: RelaxedStringMatchers;
22
+ type?: ValueType | ValueType[];
23
+ domain?: RelaxedRecord;
24
+ contextDomain?: RelaxedRecord;
25
+ annotations?: RelaxedRecord;
26
+ }
27
+
28
+ /** Relaxed column selector — convenient hand-written form. */
29
+ export interface RelaxedColumnSelector {
30
+ name?: RelaxedStringMatchers;
31
+ type?: ValueType | ValueType[];
32
+ domain?: RelaxedRecord;
33
+ contextDomain?: RelaxedRecord;
34
+ annotations?: RelaxedRecord;
35
+ axes?: RelaxedAxisSelector[];
36
+ partialAxesMatch?: boolean;
37
+ }
38
+
39
+ /** Input that normalizes to ColumnSelector[]. */
40
+ export type ColumnSelectorInput = RelaxedColumnSelector | RelaxedColumnSelector[];
41
+
42
+ // --- Normalization ---
43
+
44
+ function normalizeStringMatchers(input: RelaxedStringMatchers): StringMatcher[] {
45
+ if (typeof input === "string") return [{ type: "regex", value: input }];
46
+ if (!Array.isArray(input)) return [input];
47
+ return input.map((v) =>
48
+ typeof v === "string" ? ({ type: "regex", value: v } satisfies StringMatcher) : v,
49
+ );
50
+ }
51
+
52
+ function normalizeRecord(input: RelaxedRecord): Record<string, StringMatcher[]> {
53
+ const result: Record<string, StringMatcher[]> = {};
54
+ for (const [key, value] of Object.entries(input)) {
55
+ result[key] = normalizeStringMatchers(value);
56
+ }
57
+ return result;
58
+ }
59
+
60
+ function normalizeTypes(input: ValueType | ValueType[]): ValueType[] {
61
+ return Array.isArray(input) ? input : [input];
62
+ }
63
+
64
+ type Mutable<T> = { -readonly [K in keyof T]: T[K] };
65
+
66
+ function normalizeAxisSelector(input: RelaxedAxisSelector): MultiAxisSelector {
67
+ const result: Mutable<MultiAxisSelector> = {};
68
+ if (input.name !== undefined) result.name = normalizeStringMatchers(input.name);
69
+ if (input.type !== undefined) result.type = normalizeTypes(input.type);
70
+ if (input.domain !== undefined) result.domain = normalizeRecord(input.domain);
71
+ if (input.contextDomain !== undefined)
72
+ result.contextDomain = normalizeRecord(input.contextDomain);
73
+ if (input.annotations !== undefined) result.annotations = normalizeRecord(input.annotations);
74
+ return result;
75
+ }
76
+
77
+ /** Normalize relaxed input to strict ColumnSelector[]. */
78
+ export function normalizeSelectors(input: ColumnSelectorInput): MultiColumnSelector[] {
79
+ const arr = Array.isArray(input) ? input : [input];
80
+ return arr.map(normalizeSingleSelector);
81
+ }
82
+
83
+ function normalizeSingleSelector(input: RelaxedColumnSelector): MultiColumnSelector {
84
+ const result: Mutable<MultiColumnSelector> = {};
85
+ if (input.name !== undefined) result.name = normalizeStringMatchers(input.name);
86
+ if (input.type !== undefined) result.type = normalizeTypes(input.type);
87
+ if (input.domain !== undefined) result.domain = normalizeRecord(input.domain);
88
+ if (input.contextDomain !== undefined)
89
+ result.contextDomain = normalizeRecord(input.contextDomain);
90
+ if (input.annotations !== undefined) result.annotations = normalizeRecord(input.annotations);
91
+ if (input.axes !== undefined) result.axes = input.axes.map(normalizeAxisSelector);
92
+ if (input.partialAxesMatch !== undefined) result.partialAxesMatch = input.partialAxesMatch;
93
+ return result;
94
+ }
95
+
96
+ // --- Matching ---
97
+
98
+ function matchStringValue(value: string, matchers: StringMatcher[]): boolean {
99
+ return matchers.some((m) => {
100
+ if (m.type === "exact") return value === m.value;
101
+ return new RegExp(`^(?:${m.value})$`).test(value);
102
+ });
103
+ }
104
+
105
+ function matchRecordField(
106
+ actual: Record<string, string> | undefined,
107
+ required: Record<string, StringMatcher[]>,
108
+ ): boolean {
109
+ const record = actual ?? {};
110
+ for (const [key, matchers] of Object.entries(required)) {
111
+ const value = record[key];
112
+ if (value === undefined) return false;
113
+ if (!matchStringValue(value, matchers)) return false;
114
+ }
115
+ return true;
116
+ }
117
+
118
+ /** Get combined domain: column's own domain merged with all axis domains. */
119
+ function getCombinedDomain(spec: PColumnSpec): Record<string, string> {
120
+ const result: Record<string, string> = {};
121
+ if (spec.domain) Object.assign(result, spec.domain);
122
+ for (const axis of spec.axesSpec) {
123
+ if (axis.domain) Object.assign(result, axis.domain);
124
+ }
125
+ return result;
126
+ }
127
+
128
+ /** Get combined context domain: column's own contextDomain merged with all axis contextDomains. */
129
+ function getCombinedContextDomain(spec: PColumnSpec): Record<string, string> {
130
+ const result: Record<string, string> = {};
131
+ if (spec.contextDomain) Object.assign(result, spec.contextDomain);
132
+ for (const axis of spec.axesSpec) {
133
+ if ("contextDomain" in axis && axis.contextDomain) Object.assign(result, axis.contextDomain);
134
+ }
135
+ return result;
136
+ }
137
+
138
+ function matchAxisSelector(
139
+ axis: PColumnSpec["axesSpec"][number],
140
+ selector: MultiAxisSelector,
141
+ ): boolean {
142
+ if (selector.name !== undefined && !matchStringValue(axis.name, selector.name)) return false;
143
+ if (selector.type !== undefined && !selector.type.includes(axis.type as ValueType)) return false;
144
+ if (selector.domain !== undefined && !matchRecordField(axis.domain, selector.domain))
145
+ return false;
146
+ if (
147
+ selector.contextDomain !== undefined &&
148
+ !matchRecordField(
149
+ "contextDomain" in axis ? (axis.contextDomain as Record<string, string>) : undefined,
150
+ selector.contextDomain,
151
+ )
152
+ )
153
+ return false;
154
+ if (
155
+ selector.annotations !== undefined &&
156
+ !matchRecordField(axis.annotations, selector.annotations)
157
+ )
158
+ return false;
159
+ return true;
160
+ }
161
+
162
+ /** Check if a PColumnSpec matches a single strict ColumnSelector. */
163
+ export function matchColumn(spec: PColumnSpec, selector: MultiColumnSelector): boolean {
164
+ if (selector.name !== undefined && !matchStringValue(spec.name, selector.name)) return false;
165
+ if (selector.type !== undefined && !selector.type.includes(spec.valueType)) return false;
166
+
167
+ if (selector.domain !== undefined) {
168
+ const combined = getCombinedDomain(spec);
169
+ if (!matchRecordField(combined, selector.domain)) return false;
170
+ }
171
+
172
+ if (selector.contextDomain !== undefined) {
173
+ const combined = getCombinedContextDomain(spec);
174
+ if (!matchRecordField(combined, selector.contextDomain)) return false;
175
+ }
176
+
177
+ if (selector.annotations !== undefined) {
178
+ if (!matchRecordField(spec.annotations, selector.annotations)) return false;
179
+ }
180
+
181
+ if (selector.axes !== undefined) {
182
+ const partialMatch = selector.partialAxesMatch ?? true;
183
+ if (partialMatch) {
184
+ for (const axisSel of selector.axes) {
185
+ if (!spec.axesSpec.some((axis) => matchAxisSelector(axis, axisSel))) return false;
186
+ }
187
+ } else {
188
+ if (spec.axesSpec.length !== selector.axes.length) return false;
189
+ for (let i = 0; i < selector.axes.length; i++) {
190
+ if (!matchAxisSelector(spec.axesSpec[i], selector.axes[i])) return false;
191
+ }
192
+ }
193
+ }
194
+
195
+ return true;
196
+ }
197
+
198
+ /** Check if a PColumnSpec matches any of the selectors (OR across array). */
199
+ export function matchColumnSelectors(selectors: MultiColumnSelector[], spec: PColumnSpec): boolean {
200
+ return selectors.some((sel) => matchColumn(spec, sel));
201
+ }
202
+
203
+ /**
204
+ * Convert selector input to a predicate function.
205
+ * Normalizes relaxed form, then returns a function that OR-matches.
206
+ */
207
+ export function columnSelectorsToPredicate(
208
+ input: ColumnSelectorInput,
209
+ ): (spec: PColumnSpec) => boolean {
210
+ const selectors = normalizeSelectors(input);
211
+ return (spec) => matchColumnSelectors(selectors, spec);
212
+ }