@platforma-sdk/model 1.65.10 → 1.67.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.
Files changed (151) hide show
  1. package/dist/block_model.cjs +8 -11
  2. package/dist/block_model.cjs.map +1 -1
  3. package/dist/block_model.d.ts.map +1 -1
  4. package/dist/block_model.js +8 -10
  5. package/dist/block_model.js.map +1 -1
  6. package/dist/block_state_util.cjs +2 -1
  7. package/dist/block_state_util.cjs.map +1 -1
  8. package/dist/block_state_util.js +2 -1
  9. package/dist/block_state_util.js.map +1 -1
  10. package/dist/columns/column_collection_builder.cjs +62 -75
  11. package/dist/columns/column_collection_builder.cjs.map +1 -1
  12. package/dist/columns/column_collection_builder.d.ts +17 -23
  13. package/dist/columns/column_collection_builder.d.ts.map +1 -1
  14. package/dist/columns/column_collection_builder.js +63 -76
  15. package/dist/columns/column_collection_builder.js.map +1 -1
  16. package/dist/columns/column_selector.cjs.map +1 -1
  17. package/dist/columns/column_selector.d.ts +1 -1
  18. package/dist/columns/column_selector.js.map +1 -1
  19. package/dist/columns/column_snapshot.cjs.map +1 -1
  20. package/dist/columns/column_snapshot.d.ts +4 -4
  21. package/dist/columns/column_snapshot.d.ts.map +1 -1
  22. package/dist/columns/column_snapshot.js.map +1 -1
  23. package/dist/columns/ctx_column_sources.cjs.map +1 -1
  24. package/dist/columns/ctx_column_sources.d.ts +1 -1
  25. package/dist/columns/ctx_column_sources.d.ts.map +1 -1
  26. package/dist/columns/ctx_column_sources.js.map +1 -1
  27. package/dist/columns/index.cjs +2 -2
  28. package/dist/columns/index.d.ts +1 -1
  29. package/dist/columns/index.js +2 -2
  30. package/dist/components/PlDataTable/createPlDataTable/createPTableDefV2.cjs +2 -2
  31. package/dist/components/PlDataTable/createPlDataTable/createPTableDefV2.cjs.map +1 -1
  32. package/dist/components/PlDataTable/createPlDataTable/createPTableDefV2.js +2 -2
  33. package/dist/components/PlDataTable/createPlDataTable/createPTableDefV2.js.map +1 -1
  34. package/dist/components/PlDataTable/createPlDataTable/createPTableDefV3.cjs +17 -18
  35. package/dist/components/PlDataTable/createPlDataTable/createPTableDefV3.cjs.map +1 -1
  36. package/dist/components/PlDataTable/createPlDataTable/createPTableDefV3.js +17 -18
  37. package/dist/components/PlDataTable/createPlDataTable/createPTableDefV3.js.map +1 -1
  38. package/dist/components/PlDataTable/createPlDataTable/createPlDataTableV3.cjs +99 -91
  39. package/dist/components/PlDataTable/createPlDataTable/createPlDataTableV3.cjs.map +1 -1
  40. package/dist/components/PlDataTable/createPlDataTable/createPlDataTableV3.d.ts +17 -17
  41. package/dist/components/PlDataTable/createPlDataTable/createPlDataTableV3.d.ts.map +1 -1
  42. package/dist/components/PlDataTable/createPlDataTable/createPlDataTableV3.js +102 -94
  43. package/dist/components/PlDataTable/createPlDataTable/createPlDataTableV3.js.map +1 -1
  44. package/dist/components/PlDataTable/createPlDataTable/discoverColumns.cjs +32 -23
  45. package/dist/components/PlDataTable/createPlDataTable/discoverColumns.cjs.map +1 -1
  46. package/dist/components/PlDataTable/createPlDataTable/discoverColumns.d.ts +6 -6
  47. package/dist/components/PlDataTable/createPlDataTable/discoverColumns.d.ts.map +1 -1
  48. package/dist/components/PlDataTable/createPlDataTable/discoverColumns.js +33 -24
  49. package/dist/components/PlDataTable/createPlDataTable/discoverColumns.js.map +1 -1
  50. package/dist/components/PlDataTable/createPlDataTable/index.cjs.map +1 -1
  51. package/dist/components/PlDataTable/createPlDataTable/index.d.ts +2 -3
  52. package/dist/components/PlDataTable/createPlDataTable/index.d.ts.map +1 -1
  53. package/dist/components/PlDataTable/createPlDataTable/index.js.map +1 -1
  54. package/dist/components/PlDataTable/createPlDataTable/utils.cjs +133 -16
  55. package/dist/components/PlDataTable/createPlDataTable/utils.cjs.map +1 -1
  56. package/dist/components/PlDataTable/createPlDataTable/utils.d.ts +8 -6
  57. package/dist/components/PlDataTable/createPlDataTable/utils.d.ts.map +1 -1
  58. package/dist/components/PlDataTable/createPlDataTable/utils.js +130 -17
  59. package/dist/components/PlDataTable/createPlDataTable/utils.js.map +1 -1
  60. package/dist/components/PlDataTable/labels.cjs +1 -2
  61. package/dist/components/PlDataTable/labels.cjs.map +1 -1
  62. package/dist/components/PlDataTable/labels.js +1 -2
  63. package/dist/components/PlDataTable/labels.js.map +1 -1
  64. package/dist/components/PlDatasetSelector/build_dataset_options.cjs +41 -0
  65. package/dist/components/PlDatasetSelector/build_dataset_options.cjs.map +1 -0
  66. package/dist/components/PlDatasetSelector/build_dataset_options.d.ts +19 -0
  67. package/dist/components/PlDatasetSelector/build_dataset_options.d.ts.map +1 -0
  68. package/dist/components/PlDatasetSelector/build_dataset_options.js +41 -0
  69. package/dist/components/PlDatasetSelector/build_dataset_options.js.map +1 -0
  70. package/dist/components/PlDatasetSelector/filter_discovery.cjs +60 -0
  71. package/dist/components/PlDatasetSelector/filter_discovery.cjs.map +1 -0
  72. package/dist/components/PlDatasetSelector/filter_discovery.d.ts +31 -0
  73. package/dist/components/PlDatasetSelector/filter_discovery.d.ts.map +1 -0
  74. package/dist/components/PlDatasetSelector/filter_discovery.js +56 -0
  75. package/dist/components/PlDatasetSelector/filter_discovery.js.map +1 -0
  76. package/dist/components/PlDatasetSelector/index.cjs +2 -0
  77. package/dist/components/PlDatasetSelector/index.d.ts +2 -0
  78. package/dist/components/PlDatasetSelector/index.js +2 -0
  79. package/dist/components/index.cjs +3 -0
  80. package/dist/components/index.d.ts +2 -0
  81. package/dist/components/index.js +3 -0
  82. package/dist/filters/distill.cjs +73 -30
  83. package/dist/filters/distill.cjs.map +1 -1
  84. package/dist/filters/distill.d.ts.map +1 -1
  85. package/dist/filters/distill.js +73 -30
  86. package/dist/filters/distill.js.map +1 -1
  87. package/dist/index.cjs +29 -19
  88. package/dist/index.d.ts +10 -6
  89. package/dist/index.js +12 -8
  90. package/dist/labels/derive_distinct_tooltips.cjs +85 -0
  91. package/dist/labels/derive_distinct_tooltips.cjs.map +1 -0
  92. package/dist/labels/derive_distinct_tooltips.d.ts +17 -0
  93. package/dist/labels/derive_distinct_tooltips.d.ts.map +1 -0
  94. package/dist/labels/derive_distinct_tooltips.js +84 -0
  95. package/dist/labels/derive_distinct_tooltips.js.map +1 -0
  96. package/dist/labels/index.cjs +1 -0
  97. package/dist/labels/index.d.ts +2 -1
  98. package/dist/labels/index.js +1 -0
  99. package/dist/package.cjs +1 -1
  100. package/dist/package.js +1 -1
  101. package/dist/render/api.cjs +8 -13
  102. package/dist/render/api.cjs.map +1 -1
  103. package/dist/render/api.d.ts +8 -11
  104. package/dist/render/api.d.ts.map +1 -1
  105. package/dist/render/api.js +8 -13
  106. package/dist/render/api.js.map +1 -1
  107. package/dist/services/get_services.cjs +19 -0
  108. package/dist/services/get_services.cjs.map +1 -0
  109. package/dist/services/get_services.d.ts +7 -0
  110. package/dist/services/get_services.d.ts.map +1 -0
  111. package/dist/services/get_services.js +19 -0
  112. package/dist/services/get_services.js.map +1 -0
  113. package/dist/services/index.cjs +1 -0
  114. package/dist/services/index.d.ts +2 -1
  115. package/dist/services/index.js +1 -0
  116. package/dist/services/service_bridge.cjs +4 -4
  117. package/dist/services/service_bridge.cjs.map +1 -1
  118. package/dist/services/service_bridge.d.ts +4 -4
  119. package/dist/services/service_bridge.d.ts.map +1 -1
  120. package/dist/services/service_bridge.js +4 -4
  121. package/dist/services/service_bridge.js.map +1 -1
  122. package/package.json +6 -6
  123. package/src/block_model.ts +8 -11
  124. package/src/block_state_util.ts +2 -1
  125. package/src/columns/column_collection_builder.test.ts +75 -30
  126. package/src/columns/column_collection_builder.ts +96 -133
  127. package/src/columns/column_selector.ts +1 -1
  128. package/src/columns/column_snapshot.ts +7 -4
  129. package/src/columns/ctx_column_sources.ts +1 -3
  130. package/src/components/PlDataTable/createPlDataTable/createPTableDefV2.ts +2 -2
  131. package/src/components/PlDataTable/createPlDataTable/createPTableDefV3.ts +44 -21
  132. package/src/components/PlDataTable/createPlDataTable/createPlDataTableV3.ts +202 -218
  133. package/src/components/PlDataTable/createPlDataTable/discoverColumns.ts +69 -56
  134. package/src/components/PlDataTable/createPlDataTable/index.ts +6 -7
  135. package/src/components/PlDataTable/createPlDataTable/utils.test.ts +97 -1
  136. package/src/components/PlDataTable/createPlDataTable/utils.ts +190 -35
  137. package/src/components/PlDataTable/labels.ts +3 -7
  138. package/src/components/PlDatasetSelector/build_dataset_options.ts +56 -0
  139. package/src/components/PlDatasetSelector/filter_discovery.test.ts +156 -0
  140. package/src/components/PlDatasetSelector/filter_discovery.ts +77 -0
  141. package/src/components/PlDatasetSelector/index.ts +2 -0
  142. package/src/components/index.ts +1 -0
  143. package/src/filters/distill.test.ts +91 -0
  144. package/src/filters/distill.ts +102 -46
  145. package/src/labels/derive_distinct_tooltips.test.ts +233 -0
  146. package/src/labels/derive_distinct_tooltips.ts +130 -0
  147. package/src/labels/index.ts +1 -0
  148. package/src/render/api.ts +15 -50
  149. package/src/services/get_services.ts +28 -0
  150. package/src/services/index.ts +1 -0
  151. package/src/services/service_bridge.ts +5 -5
@@ -0,0 +1,56 @@
1
+ import type {
2
+ DatasetOption,
3
+ Option,
4
+ PColumnSelector,
5
+ PObjectSpec,
6
+ } from "@milaboratories/pl-model-common";
7
+ import type { DeriveLabelsOptions } from "../../labels/derive_distinct_labels";
8
+ import type { RenderCtxBase } from "../../render";
9
+ import { ColumnCollectionBuilder } from "../../columns/column_collection_builder";
10
+ import { collectCtxColumnSnapshotProviders } from "../../columns/ctx_column_sources";
11
+ import { buildRefMap, filterMatchesToOptions, findFilterColumns } from "./filter_discovery";
12
+
13
+ export type BuildDatasetOptions = {
14
+ /** Which result pool columns qualify as datasets. Defaults to all. */
15
+ selector?: PColumnSelector | PColumnSelector[] | ((spec: PObjectSpec) => boolean);
16
+ /** Formatting options for filter labels. */
17
+ labelOptions?: DeriveLabelsOptions;
18
+ };
19
+
20
+ /**
21
+ * Usage:
22
+ * ```ts
23
+ * .output("datasetOptions", (ctx) => buildDatasetOptions(ctx))
24
+ * ```
25
+ */
26
+ export function buildDatasetOptions(
27
+ ctx: RenderCtxBase,
28
+ opts?: BuildDatasetOptions,
29
+ ): DatasetOption[] | undefined {
30
+ const predicate = opts?.selector ?? (() => true);
31
+ const options = ctx.resultPool.getOptions(predicate, { refsWithEnrichments: true });
32
+ if (options.length === 0) return [];
33
+
34
+ const columnSources = collectCtxColumnSnapshotProviders(ctx);
35
+ const refMap = buildRefMap(ctx.resultPool.getSpecs().entries);
36
+ const pframeSpec = ctx.getService("pframeSpec");
37
+
38
+ return options.map((o: Option): DatasetOption => {
39
+ const datasetSpec = ctx.resultPool.getPColumnSpecByRef(o.ref);
40
+ if (!datasetSpec) return o;
41
+
42
+ const builder = new ColumnCollectionBuilder(pframeSpec);
43
+ for (const src of columnSources) builder.addSource(src);
44
+ const collection = builder.build({ anchors: { main: datasetSpec } });
45
+ if (!collection) return o;
46
+
47
+ try {
48
+ const matches = findFilterColumns(collection);
49
+ if (matches.length === 0) return o;
50
+ const filters = filterMatchesToOptions(matches, refMap, opts?.labelOptions);
51
+ return { ...o, filters };
52
+ } finally {
53
+ collection.dispose();
54
+ }
55
+ });
56
+ }
@@ -0,0 +1,156 @@
1
+ import { Annotation, createPlRef } from "@milaboratories/pl-model-common";
2
+ import type { AxisSpec, PColumnSpec, PlRef, PObjectId } from "@milaboratories/pl-model-common";
3
+ import { SpecDriver } from "@milaboratories/pf-spec-driver";
4
+ import canonicalize from "canonicalize";
5
+ import { afterEach, describe, expect, test } from "vitest";
6
+ import type { ColumnSnapshot } from "../../columns/column_snapshot";
7
+ import { ColumnCollectionBuilder } from "../../columns/column_collection_builder";
8
+ import { buildRefMap, filterMatchesToOptions, findFilterColumns } from "./filter_discovery";
9
+
10
+ const drivers: SpecDriver[] = [];
11
+
12
+ function createSpecFrameCtx() {
13
+ const driver = new SpecDriver();
14
+ drivers.push(driver);
15
+ return driver;
16
+ }
17
+
18
+ afterEach(async () => {
19
+ for (const driver of drivers) await driver.dispose();
20
+ drivers.length = 0;
21
+ });
22
+
23
+ function axis(name: string): AxisSpec {
24
+ return { name, type: "String" } as AxisSpec;
25
+ }
26
+
27
+ function spec(
28
+ name: string,
29
+ axesSpec: AxisSpec[],
30
+ annotations: Record<string, string> = {},
31
+ ): PColumnSpec {
32
+ return { kind: "PColumn", name, valueType: "Int", axesSpec, annotations } as PColumnSpec;
33
+ }
34
+
35
+ function snap(id: string, s: PColumnSpec): ColumnSnapshot<PObjectId> {
36
+ return { id: id as PObjectId, spec: s, dataStatus: "ready", data: { get: () => ({}) as never } };
37
+ }
38
+
39
+ // anchor defines the key space: [sample, gene]
40
+ const anchorAxes = [axis("sample"), axis("gene")];
41
+ const anchorSpec = spec("anchor", anchorAxes);
42
+ const anchorSnap = snap("anchor-id", anchorSpec);
43
+
44
+ describe("findFilterColumns", () => {
45
+ test("returns columns with pl7.app/isSubset annotation", () => {
46
+ const filter = snap("f1", spec("filter1", [axis("sample")], { [Annotation.IsSubset]: "true" }));
47
+ const regular = snap("r1", spec("regular1", [axis("sample")]));
48
+
49
+ const builder = new ColumnCollectionBuilder(createSpecFrameCtx());
50
+ builder.addSource([filter, regular, anchorSnap]);
51
+ const collection = builder.build({ anchors: { main: anchorSpec } })!;
52
+
53
+ const results = findFilterColumns(collection);
54
+ expect(results.every((m) => m.column.spec.name !== "regular1")).toBe(true);
55
+ expect(results.some((m) => m.column.spec.name === "filter1")).toBe(true);
56
+ });
57
+
58
+ test("axes subset: excludes filter whose axes are not a subset of anchor axes", () => {
59
+ // filter with axis "other" — not a subset of anchor axes [sample, gene]
60
+ const badFilter = snap(
61
+ "f2",
62
+ spec("bad-filter", [axis("other")], { [Annotation.IsSubset]: "true" }),
63
+ );
64
+
65
+ const builder = new ColumnCollectionBuilder(createSpecFrameCtx());
66
+ builder.addSource([badFilter, anchorSnap]);
67
+ const collection = builder.build({ anchors: { main: anchorSpec } })!;
68
+
69
+ const results = findFilterColumns(collection);
70
+ expect(results.every((m) => m.column.spec.name !== "bad-filter")).toBe(true);
71
+ });
72
+
73
+ test("empty result when no filters exist", () => {
74
+ const regular = snap("r1", spec("regular1", [axis("sample")]));
75
+
76
+ const builder = new ColumnCollectionBuilder(createSpecFrameCtx());
77
+ builder.addSource([regular, anchorSnap]);
78
+ const collection = builder.build({ anchors: { main: anchorSpec } })!;
79
+
80
+ expect(findFilterColumns(collection)).toHaveLength(0);
81
+ });
82
+ });
83
+
84
+ describe("buildRefMap", () => {
85
+ test("maps canonicalized PlRef to original ref", () => {
86
+ const ref1 = createPlRef("b1", "out1");
87
+ const ref2 = createPlRef("b2", "out2", true);
88
+ const entries = [{ ref: ref1 }, { ref: ref2 }];
89
+
90
+ const map = buildRefMap(entries);
91
+
92
+ expect(map.get(canonicalize(ref1)! as PObjectId)).toBe(ref1);
93
+ expect(map.get(canonicalize(ref2)! as PObjectId)).toBe(ref2);
94
+ expect(map.size).toBe(2);
95
+ });
96
+
97
+ test("returns empty map for empty entries", () => {
98
+ expect(buildRefMap([]).size).toBe(0);
99
+ });
100
+ });
101
+
102
+ describe("filterMatchesToOptions", () => {
103
+ test("converts filter matches to Option[] with derived labels", () => {
104
+ const filterRef1 = createPlRef("b1", "filter-top1000");
105
+ const filterRef2 = createPlRef("b1", "filter-highconf");
106
+
107
+ // Build ref map from entries (simulating result pool)
108
+ const refMap = buildRefMap([
109
+ { ref: anchorSnap.id as unknown as PlRef }, // anchor — won't be looked up
110
+ { ref: filterRef1 },
111
+ { ref: filterRef2 },
112
+ ]);
113
+
114
+ // Build filter specs with isSubset annotation
115
+ const filterSpec1 = spec("filter1", [axis("sample")], { [Annotation.IsSubset]: "true" });
116
+ const filterSpec2 = spec("filter2", [axis("sample")], { [Annotation.IsSubset]: "true" });
117
+
118
+ // Use the canonical PlRef as the PObjectId (matches how result pool works)
119
+ const f1Snap = snap(canonicalize(filterRef1)! as string, filterSpec1);
120
+ const f2Snap = snap(canonicalize(filterRef2)! as string, filterSpec2);
121
+
122
+ const builder = new ColumnCollectionBuilder(createSpecFrameCtx());
123
+ builder.addSource([f1Snap, f2Snap, anchorSnap]);
124
+ const collection = builder.build({ anchors: { main: anchorSpec } })!;
125
+
126
+ const matches = findFilterColumns(collection);
127
+ expect(matches.length).toBe(2);
128
+
129
+ const options = filterMatchesToOptions(matches, refMap);
130
+ expect(options).toHaveLength(2);
131
+ // Each option has a ref and label
132
+ for (const opt of options) {
133
+ expect(opt.ref).toBeDefined();
134
+ expect(opt.label).toBeDefined();
135
+ expect(typeof opt.label).toBe("string");
136
+ }
137
+ });
138
+
139
+ test("returns empty array for empty matches", () => {
140
+ expect(filterMatchesToOptions([], new Map())).toEqual([]);
141
+ });
142
+
143
+ test("throws when ref not found in map", () => {
144
+ const filterSpec1 = spec("orphan", [axis("sample")], { [Annotation.IsSubset]: "true" });
145
+ const f1Snap = snap("orphan-id", filterSpec1);
146
+
147
+ const builder = new ColumnCollectionBuilder(createSpecFrameCtx());
148
+ builder.addSource([f1Snap, anchorSnap]);
149
+ const collection = builder.build({ anchors: { main: anchorSpec } })!;
150
+
151
+ const matches = findFilterColumns(collection);
152
+ expect(matches.length).toBe(1);
153
+
154
+ expect(() => filterMatchesToOptions(matches, new Map())).toThrow(/no PlRef found/);
155
+ });
156
+ });
@@ -0,0 +1,77 @@
1
+ import { Annotation } from "@milaboratories/pl-model-common";
2
+ import type { Option, PlRef, PObjectId } from "@milaboratories/pl-model-common";
3
+ import canonicalize from "canonicalize";
4
+ import type {
5
+ AnchoredColumnCollection,
6
+ ColumnMatch,
7
+ } from "../../columns/column_collection_builder";
8
+ import {
9
+ deriveDistinctLabels,
10
+ type DeriveLabelsOptions,
11
+ type Entry,
12
+ } from "../../labels/derive_distinct_labels";
13
+
14
+ /**
15
+ * Matches columns annotated `pl7.app/isSubset: "true"` whose axes ⊆ anchor axes.
16
+ *
17
+ * The axes-subset constraint is enforced by `mode: "enrichment"`, which sets
18
+ * `allowFloatingHitAxes: false` — every axis of the matched column must be
19
+ * present in the anchor's axes. See `matchingModeToConstraints()` in
20
+ * `column_collection_builder.ts`.
21
+ */
22
+ export function findFilterColumns(collection: AnchoredColumnCollection): ColumnMatch[] {
23
+ return collection.findColumns({
24
+ mode: "enrichment",
25
+ include: {
26
+ annotations: { [Annotation.IsSubset]: "true" },
27
+ },
28
+ });
29
+ }
30
+
31
+ /**
32
+ * Derive labeled options from filter column matches, for use in DatasetOption.filters.
33
+ *
34
+ * @param matches - from findFilterColumns()
35
+ * @param refsByObjectId - from {@link buildRefMap}
36
+ * @param labelOptions - forwarded to deriveDistinctLabels()
37
+ */
38
+ export function filterMatchesToOptions(
39
+ matches: ColumnMatch[],
40
+ refsByObjectId: ReadonlyMap<PObjectId, PlRef>,
41
+ labelOptions?: DeriveLabelsOptions,
42
+ ): Option[] {
43
+ if (matches.length === 0) return [];
44
+
45
+ // Each ColumnMatch can be reached via multiple variants (different linker
46
+ // paths / qualifications). We emit one Option per variant so the user can
47
+ // pick a specific path — `deriveDistinctLabels` disambiguates labels by
48
+ // path.
49
+ const flattened = matches.flatMap((m) => m.variants.map((v) => ({ match: m, variant: v })));
50
+
51
+ const entries: Entry[] = flattened.map(({ match, variant }) => ({
52
+ spec: match.column.spec,
53
+ linkerPath: variant.path.map((p) => ({ spec: p.linker.spec })),
54
+ }));
55
+
56
+ const labels = deriveDistinctLabels(entries, labelOptions);
57
+
58
+ return flattened.map(({ match }, i) => {
59
+ const ref = refsByObjectId.get(match.column.id);
60
+ if (ref === undefined)
61
+ throw new Error(
62
+ `no PlRef found for filter column ${match.column.spec.name} (id: ${match.column.id})`,
63
+ );
64
+ return { ref, label: labels[i] };
65
+ });
66
+ }
67
+
68
+ /**
69
+ * Usage: `buildRefMap(ctx.resultPool.getSpecs().entries)`
70
+ */
71
+ export function buildRefMap(entries: readonly { readonly ref: PlRef }[]): Map<PObjectId, PlRef> {
72
+ const map = new Map<PObjectId, PlRef>();
73
+ for (const entry of entries) {
74
+ map.set(canonicalize(entry.ref)! as PObjectId, entry.ref);
75
+ }
76
+ return map;
77
+ }
@@ -0,0 +1,2 @@
1
+ export * from "./filter_discovery";
2
+ export * from "./build_dataset_options";
@@ -1,5 +1,6 @@
1
1
  export * from "./PFrameForGraphs";
2
2
  export * from "./PlAnnotations";
3
+ export * from "./PlDatasetSelector";
3
4
  export * from "./PlDataTable";
4
5
  export * from "./PlMultiSequenceAlignment";
5
6
  export * from "./PlSelectionModel";
@@ -188,4 +188,95 @@ describe("distillFilterSpec", () => {
188
188
  const filter: F = { type: "and", filters: [a, b] };
189
189
  expect(distillFilterSpec(filter)).toBeNull();
190
190
  });
191
+
192
+ // --- per-type required-field checks ---
193
+
194
+ it("keeps patternFuzzyContainSubsequence without optional fields", () => {
195
+ const filter: F = { type: "patternFuzzyContainSubsequence", column: "c1", value: "abc" };
196
+ expect(distillFilterSpec(filter)).toEqual(filter);
197
+ });
198
+
199
+ it("keeps patternFuzzyContainSubsequence when optional fields undefined", () => {
200
+ const filter: F = {
201
+ type: "patternFuzzyContainSubsequence",
202
+ column: "c1",
203
+ value: "abc",
204
+ maxEdits: undefined,
205
+ substitutionsOnly: undefined,
206
+ wildcard: undefined,
207
+ };
208
+ expect(distillFilterSpec(filter)).toEqual(filter);
209
+ });
210
+
211
+ it("preserves optional fields on patternFuzzyContainSubsequence when filled", () => {
212
+ const filter: F = {
213
+ type: "patternFuzzyContainSubsequence",
214
+ column: "c1",
215
+ value: "abc",
216
+ maxEdits: 2,
217
+ substitutionsOnly: true,
218
+ wildcard: "*",
219
+ };
220
+ expect(distillFilterSpec(filter)).toEqual(filter);
221
+ });
222
+
223
+ it("keeps lessThanColumn without optional minDiff", () => {
224
+ const filter: F = { type: "lessThanColumn", column: "c1", rhs: "c2" };
225
+ expect(distillFilterSpec(filter)).toEqual(filter);
226
+ });
227
+
228
+ it("returns null for lessThanColumn with missing required rhs", () => {
229
+ const filter: F = {
230
+ type: "lessThanColumn",
231
+ column: "c1",
232
+ rhs: undefined as unknown as string,
233
+ };
234
+ expect(distillFilterSpec(filter)).toBeNull();
235
+ });
236
+
237
+ it("returns null for ifNa with missing required replacement", () => {
238
+ const filter: F = {
239
+ type: "ifNa",
240
+ column: "c1",
241
+ replacement: undefined as unknown as string,
242
+ };
243
+ expect(distillFilterSpec(filter)).toBeNull();
244
+ });
245
+
246
+ // --- isFilledValue edge cases ---
247
+
248
+ it("returns null for empty-string value after trim", () => {
249
+ const filter: F = { type: "patternEquals", column: "c1", value: " " };
250
+ expect(distillFilterSpec(filter)).toBeNull();
251
+ });
252
+
253
+ it("returns null for inSet with empty array", () => {
254
+ const filter: F = { type: "inSet", column: "c1", value: [] };
255
+ expect(distillFilterSpec(filter)).toBeNull();
256
+ });
257
+
258
+ it("returns null for inSet with array containing empty string", () => {
259
+ const filter: F = { type: "inSet", column: "c1", value: ["a", " "] };
260
+ expect(distillFilterSpec(filter)).toBeNull();
261
+ });
262
+
263
+ it("keeps inSet with non-empty string items", () => {
264
+ const filter: F = { type: "inSet", column: "c1", value: ["a", "b"] };
265
+ expect(distillFilterSpec(filter)).toEqual(filter);
266
+ });
267
+
268
+ it("keeps equal with x=0 (falsy but filled)", () => {
269
+ const filter: F = { type: "equal", column: "c1", x: 0 };
270
+ expect(distillFilterSpec(filter)).toEqual(filter);
271
+ });
272
+
273
+ it("keeps isNA (single required column)", () => {
274
+ const filter: F = { type: "isNA", column: "c1" };
275
+ expect(distillFilterSpec(filter)).toEqual(filter);
276
+ });
277
+
278
+ it("returns null for isNA with missing column", () => {
279
+ const filter: F = { type: "isNA", column: undefined as unknown as string };
280
+ expect(distillFilterSpec(filter)).toBeNull();
281
+ });
191
282
  });
@@ -1,4 +1,4 @@
1
- import { DistributiveKeys, UnionToTuples } from "@milaboratories/helpers";
1
+ import { DistributiveKeys, isNil, UnionToTuples } from "@milaboratories/helpers";
2
2
  import {
3
3
  RootFilterSpec,
4
4
  type FilterSpec,
@@ -8,51 +8,6 @@ import { traverseFilterSpec } from "./traverse";
8
8
  import { InferFilterSpecLeaf } from "@milaboratories/pl-model-common";
9
9
  import { isEmpty } from "es-toolkit/compat";
10
10
 
11
- /** All possible field names that can appear in any FilterSpecLeaf variant. */
12
- type FilterSpecLeafKey = DistributiveKeys<FilterSpecLeaf<string>>;
13
-
14
- /** Compile-time check: every key in the tuple is a valid leaf key (via satisfies). */
15
- const KNOWN_LEAF_KEYS_TUPLE: UnionToTuples<FilterSpecLeafKey> = [
16
- "n",
17
- "x",
18
- "rhs",
19
- "type",
20
- "value",
21
- "column",
22
- "minDiff",
23
- "maxEdits",
24
- "wildcard",
25
- "replacement",
26
- "substitutionsOnly",
27
- ];
28
- const KNOWN_LEAF_KEYS: Set<FilterSpecLeafKey> = new Set(KNOWN_LEAF_KEYS_TUPLE);
29
-
30
- /** Returns true if the leaf is filled — type is defined and no required fields are undefined. */
31
- function isFilledLeaf<T>(node: FilterSpecLeaf<T>): boolean {
32
- if (node.type == null) return false;
33
- return !Object.values(node).some((value) => {
34
- switch (typeof value) {
35
- case "number":
36
- case "boolean":
37
- return false;
38
- case "string":
39
- return value.trim() === "";
40
- default: // undefined, null, empty objects/arrays
41
- return isEmpty(value);
42
- }
43
- });
44
- }
45
-
46
- function distillLeaf<T>(node: FilterSpecLeaf<T>): FilterSpecLeaf<T> {
47
- const result: Record<string, unknown> = {};
48
- for (const [key, value] of Object.entries(node)) {
49
- if (KNOWN_LEAF_KEYS.has(key as FilterSpecLeafKey)) {
50
- result[key] = value;
51
- }
52
- }
53
- return result as FilterSpecLeaf<T>;
54
- }
55
-
56
11
  /**
57
12
  * Strips non-FilterSpec metadata (whitelist approach) and removes
58
13
  * unfilled leaves (type is undefined or any required field is undefined).
@@ -80,3 +35,104 @@ export function distillFilterSpec<
80
35
  not: (result) => (result === null ? null : ({ type: "not", filter: result } as R)),
81
36
  });
82
37
  }
38
+
39
+ function distillLeaf<T>(node: FilterSpecLeaf<T>): FilterSpecLeaf<T> {
40
+ const result: Record<string, unknown> = {};
41
+ for (const [key, value] of Object.entries(node)) {
42
+ if (KNOWN_LEAF_KEYS.has(key as FilterSpecLeafKey)) {
43
+ result[key] = value;
44
+ }
45
+ }
46
+ return result as FilterSpecLeaf<T>;
47
+ }
48
+
49
+ /** Returns true if the leaf is filled — type is defined and every required field per-type is filled. */
50
+ function isFilledLeaf<T>(node: FilterSpecLeaf<T>): boolean {
51
+ if (isNil(node.type)) return false;
52
+ const required = REQUIRED_KEYS_BY_TYPE[node.type];
53
+ const record = node as Record<string, unknown>;
54
+ return required.every((key) => isFilledValue(record[key]));
55
+ }
56
+
57
+ /**
58
+ * Returns true if the value is considered "filled":
59
+ * - primitives (number, boolean): always true
60
+ * - string: non-empty after trim
61
+ * - array: non-empty AND every item is filled
62
+ * - plain object: non-empty AND every field value is filled
63
+ * - null/undefined: false
64
+ */
65
+ function isFilledValue(value: unknown): boolean {
66
+ if (isNil(value)) return false;
67
+ switch (typeof value) {
68
+ case "number":
69
+ case "boolean":
70
+ return true;
71
+ case "string":
72
+ return value.trim() !== "";
73
+ default:
74
+ if (isEmpty(value)) return false;
75
+ if (Array.isArray(value)) return value.every(isFilledValue);
76
+ return Object.values(value as Record<string, unknown>).every(isFilledValue);
77
+ }
78
+ }
79
+
80
+ /** All possible field names that can appear in any FilterSpecLeaf variant. */
81
+ type FilterSpecLeafKey = DistributiveKeys<FilterSpecLeaf<string>>;
82
+
83
+ /** Leaf type discriminators (excludes the placeholder `undefined` variant). */
84
+ type FilterSpecLeafType = Exclude<FilterSpecLeaf<unknown>, { type: undefined }>["type"];
85
+
86
+ type LeafOfType<T extends FilterSpecLeafType> = Extract<FilterSpecLeaf<unknown>, { type: T }>;
87
+
88
+ type RequiredKeys<O> = { [K in keyof O]-?: {} extends Pick<O, K> ? never : K }[keyof O];
89
+
90
+ /** Required field keys of a given leaf variant (excluding the `type` discriminator). */
91
+ type RequiredLeafKeys<T extends FilterSpecLeafType> = Exclude<RequiredKeys<LeafOfType<T>>, "type">;
92
+
93
+ /** Exact per-type shape — adding a key not required by that variant becomes a type error. */
94
+ type RequiredKeysByType = { readonly [T in FilterSpecLeafType]: readonly RequiredLeafKeys<T>[] };
95
+
96
+ /** Compile-time check: every key in the tuple is a valid leaf key (via satisfies). */
97
+ const KNOWN_LEAF_KEYS_TUPLE: UnionToTuples<FilterSpecLeafKey> = [
98
+ "n",
99
+ "x",
100
+ "rhs",
101
+ "type",
102
+ "value",
103
+ "column",
104
+ "minDiff",
105
+ "maxEdits",
106
+ "wildcard",
107
+ "replacement",
108
+ "substitutionsOnly",
109
+ ];
110
+ const KNOWN_LEAF_KEYS: Set<FilterSpecLeafKey> = new Set(KNOWN_LEAF_KEYS_TUPLE);
111
+
112
+ /** Required fields per leaf type. Optional fields (e.g. minDiff, maxEdits) excluded. */
113
+ const REQUIRED_KEYS_BY_TYPE = {
114
+ isNA: ["column"],
115
+ isNotNA: ["column"],
116
+ ifNa: ["column", "replacement"],
117
+ patternEquals: ["column", "value"],
118
+ patternNotEquals: ["column", "value"],
119
+ patternContainSubsequence: ["column", "value"],
120
+ patternNotContainSubsequence: ["column", "value"],
121
+ patternMatchesRegularExpression: ["column", "value"],
122
+ patternFuzzyContainSubsequence: ["column", "value"],
123
+ inSet: ["column", "value"],
124
+ notInSet: ["column", "value"],
125
+ topN: ["column", "n"],
126
+ bottomN: ["column", "n"],
127
+ equal: ["column", "x"],
128
+ notEqual: ["column", "x"],
129
+ lessThan: ["column", "x"],
130
+ greaterThan: ["column", "x"],
131
+ lessThanOrEqual: ["column", "x"],
132
+ greaterThanOrEqual: ["column", "x"],
133
+ equalToColumn: ["column", "rhs"],
134
+ lessThanColumn: ["column", "rhs"],
135
+ greaterThanColumn: ["column", "rhs"],
136
+ lessThanColumnOrEqual: ["column", "rhs"],
137
+ greaterThanColumnOrEqual: ["column", "rhs"],
138
+ } as const satisfies RequiredKeysByType;