From 35db48a2b5ff9f97fc631b0ae8a2ba16dc1eec54 Mon Sep 17 00:00:00 2001 From: Dmitry Bolotin Date: Tue, 16 Jun 2026 18:34:03 +0200 Subject: [PATCH] feat(pframes): add keysOnly column option to tableBuilder addColumn/addColumns accept { keysOnly: true }: the column participates in the join (its key space is unioned into the output rows and any new axes are surfaced) but its value column is not written to the exported file. Implemented by skipping the value selector in buildExportSelectors; the flag is serialized into the build-table template input only when true, so existing (non-keys-only) usage produces identical resources and dedup is preserved. --- .changeset/tablebuilder-keys-only.md | 9 ++ .../src/pframes/build-table.tpl.tengo | 6 +- .../src/pframes/export-util.lib.tengo | 6 + .../src/pframes/table-builder.lib.tengo | 26 +++- .../src/pframes/table-builder.test.ts | 144 ++++++++++++++++++ 5 files changed, 187 insertions(+), 4 deletions(-) create mode 100644 .changeset/tablebuilder-keys-only.md diff --git a/.changeset/tablebuilder-keys-only.md b/.changeset/tablebuilder-keys-only.md new file mode 100644 index 0000000000..a04f1b5fce --- /dev/null +++ b/.changeset/tablebuilder-keys-only.md @@ -0,0 +1,9 @@ +--- +"@platforma-sdk/workflow-tengo": minor +--- + +tableBuilder: add `keysOnly` option to `addColumn`/`addColumns`. A keys-only +column participates in the join (its key space is unioned into the output rows +and any new axes are surfaced) but its value column is not written to the +exported file. Useful for expanding the table's key space from a column whose +values are not needed in the output. diff --git a/sdk/workflow-tengo/src/pframes/build-table.tpl.tengo b/sdk/workflow-tengo/src/pframes/build-table.tpl.tengo index 295a2b9e98..8565302c26 100644 --- a/sdk/workflow-tengo/src/pframes/build-table.tpl.tengo +++ b/sdk/workflow-tengo/src/pframes/build-table.tpl.tengo @@ -81,6 +81,7 @@ self.body(func(inputs) { enrichments := [] for idx := 0; idx < columnCount; idx++ { c := inputs.columns[string(idx)] + keysOnly := c.keysOnly == true if !is_undefined(c.multiResult) { for sub in c.multiResult { enrichments = append(enrichments, { @@ -89,6 +90,7 @@ self.body(func(inputs) { header: c.header, headerPrefix: c.headerPrefix, headerSuffix: c.headerSuffix, + keysOnly: keysOnly, pFrameKey: "col" + string(nextKey), mode: c.mode, path: [] @@ -115,6 +117,7 @@ self.body(func(inputs) { header: c.header, headerPrefix: c.headerPrefix, headerSuffix: c.headerSuffix, + keysOnly: keysOnly, pFrameKey: "col" + string(nextKey), mode: c.mode, path: path, @@ -246,7 +249,8 @@ self.body(func(inputs) { spec: spec, header: e.header, headerPrefix: e.headerPrefix, - headerSuffix: e.headerSuffix + headerSuffix: e.headerSuffix, + keysOnly: e.keysOnly }) } diff --git a/sdk/workflow-tengo/src/pframes/export-util.lib.tengo b/sdk/workflow-tengo/src/pframes/export-util.lib.tengo index 71dc530430..13065e44ee 100644 --- a/sdk/workflow-tengo/src/pframes/export-util.lib.tengo +++ b/sdk/workflow-tengo/src/pframes/export-util.lib.tengo @@ -97,6 +97,12 @@ buildExportSelectors := func(rawColumns, opts) { columnSelectors = append(columnSelectors, pt.axis(ua.spec).alias(ua.label)) } for column in rawColumns { + // keys-only columns contribute their axes (collected above) to the + // output key space but emit no value column — skip the value selector + // and its label (no header required for them). + if column.keysOnly { + continue + } label := columnLabel(column) registerLabel(label, column.spec.name) columnSelectors = append(columnSelectors, pt.col(column.frameKey).alias(label)) diff --git a/sdk/workflow-tengo/src/pframes/table-builder.lib.tengo b/sdk/workflow-tengo/src/pframes/table-builder.lib.tengo index 612ba2e457..e02ea5c179 100644 --- a/sdk/workflow-tengo/src/pframes/table-builder.lib.tengo +++ b/sdk/workflow-tengo/src/pframes/table-builder.lib.tengo @@ -192,13 +192,21 @@ tableBuilder := func(format, ...ctxArg) { * * @param query: ColumnQuerySpec (anchored axes reference primary names) | * PColumnResult | PlRef | EnrichmentRef | ResolvedEnrichmentRef - * @param opts: (optional) { header?: string } — column header in exported file + * @param opts: (optional) { header?: string, keysOnly?: bool } + * header — column header in exported file. + * keysOnly — when true, the column participates in the join + * (its key space is unioned into the output rows + * and any new axes are surfaced), but its value + * column is NOT written to the file. `header` is + * ignored for keys-only columns. * @return self (for chaining) */ addColumn: func(query, ...opts) { header := undefined + keysOnly := false if len(opts) > 0 && ll.isMap(opts[0]) { header = opts[0].header + keysOnly = opts[0].keysOnly == true } if isUnresolved(query) || isEnrichmentRef(query) { _hasUnresolved = true @@ -206,6 +214,7 @@ tableBuilder := func(format, ...ctxArg) { _columns = append(_columns, { query: query, header: header, + keysOnly: keysOnly, mode: "single" }) return self @@ -216,15 +225,21 @@ tableBuilder := func(format, ...ctxArg) { * of pre-resolved columns. Empty match set is allowed (produces no enrichments). * * @param query: ColumnQuerySpec | (PColumnResult | EnrichmentRef | ResolvedEnrichmentRef)[] | PlRef - * @param opts: (optional) { headerPrefix?: string, headerSuffix?: string } + * @param opts: (optional) { headerPrefix?: string, headerSuffix?: string, keysOnly?: bool } + * keysOnly — when true, every matched column participates in + * the join (key space unioned, new axes surfaced) but no + * value columns are written. headerPrefix/headerSuffix are + * ignored for keys-only columns. * @return self (for chaining) */ addColumns: func(query, ...opts) { headerPrefix := undefined headerSuffix := undefined + keysOnly := false if len(opts) > 0 && ll.isMap(opts[0]) { headerPrefix = opts[0].headerPrefix headerSuffix = opts[0].headerSuffix + keysOnly = opts[0].keysOnly == true } // Per-element entries. Labeled enrichments override the @@ -254,6 +269,7 @@ tableBuilder := func(format, ...ctxArg) { header: entryHeader, headerPrefix: entryPrefix, headerSuffix: entrySuffix, + keysOnly: keysOnly, mode: "multi" }) } @@ -267,6 +283,7 @@ tableBuilder := func(format, ...ctxArg) { query: query, headerPrefix: headerPrefix, headerSuffix: headerSuffix, + keysOnly: keysOnly, mode: "multi" }) return self @@ -469,7 +486,8 @@ tableBuilder := func(format, ...ctxArg) { mode: c.mode, header: c.header, headerPrefix: c.headerPrefix, - headerSuffix: c.headerSuffix + headerSuffix: c.headerSuffix, + keysOnly: c.keysOnly } if isEnrichmentRef(c.query) { resolvedHit := resolveColumn(c.query.hit) @@ -560,6 +578,8 @@ tableBuilder := func(format, ...ctxArg) { if !is_undefined(c.header) { entry.header = c.header } if !is_undefined(c.headerPrefix) { entry.headerPrefix = c.headerPrefix } if !is_undefined(c.headerSuffix) { entry.headerSuffix = c.headerSuffix } + // Emit only when set — the template defaults a missing flag to false. + if c.keysOnly { entry.keysOnly = true } columnsMap[string(idx)] = entry } diff --git a/tests/workflow-tengo/src/pframes/table-builder.test.ts b/tests/workflow-tengo/src/pframes/table-builder.test.ts index 9fdcee8936..d256673f8b 100644 --- a/tests/workflow-tengo/src/pframes/table-builder.test.ts +++ b/tests/workflow-tengo/src/pframes/table-builder.test.ts @@ -408,3 +408,147 @@ tplTest.concurrent( expect(lines.length).toBe(4); // header + 3 data rows }, ); + +// keys-only column: an enrichment whose key space is unioned into the output +// (its keys appear as rows) but whose value column is NOT written to the file. +// `extra` carries id rows A, C, D — primary carries A, B, C. The full-join +// surfaces D (a key only the keys-only column knows about) while its value +// stays out of the table. +const extraCsv = dedent` + id,extra + A,111 + C,333 + D,444 +`; +const extraSpec = specWithLong("extra", "pl7.app/extra", "Extra"); + +tplTest.concurrent( + "tableBuilder: keysOnly column expands key space without emitting a value column", + async ({ helper, expect, driverKit }) => { + const content = await runEphemeralBuilder(helper, driverKit, { + format: "tsv", + imports: { + main: { csv: primaryCsv, spec: primarySpec }, + extra: { csv: extraCsv, spec: extraSpec }, + }, + primaries: [{ name: "main", sourceImport: "main" }], + columns: [{ mode: "single", sourceImport: "extra", opts: { keysOnly: true } }], + }); + const lines = content.trim().split("\n"); + + // Header has ONLY the primary's axis + value column — the keys-only + // column's value ("Extra") is absent. This is the core assertion. + expect(lines[0]).toBe("ID\tScore"); + + // Union of keys {A,B,C} ∪ {A,C,D} = {A,B,C,D}. D comes solely from the + // keys-only column, proving its key space was unioned into the output. + // (Assert on the key column rather than full rows: D's Score cell is + // empty, and content.trim() would strip that trailing tab.) + const dataLines = lines.slice(1); + const ids = dataLines.map((l) => l.split("\t")[0]).sort(); + expect(ids).toEqual(["A", "B", "C", "D"]); + + // D carries no Score value (empty cell) — it contributed only its key. + const dRow = dataLines.find((l) => l.split("\t")[0] === "D")!; + expect(dRow.split("\t")[1] ?? "").toBe(""); + // Existing keys keep their primary value. + expect(dataLines.find((l) => l.split("\t")[0] === "A")).toBe("A\t10"); + }, +); + +tplTest.concurrent( + "tableBuilder: same column without keysOnly emits its value column (contrast)", + async ({ helper, expect, driverKit }) => { + const content = await runEphemeralBuilder(helper, driverKit, { + format: "tsv", + imports: { + main: { csv: primaryCsv, spec: primarySpec }, + extra: { csv: extraCsv, spec: extraSpec }, + }, + primaries: [{ name: "main", sourceImport: "main" }], + columns: [{ mode: "single", sourceImport: "extra", opts: { header: "Extra" } }], + }); + const lines = content.trim().split("\n"); + + // Without keysOnly the value column IS written, proving the flag is what + // suppresses it (not some unrelated join behaviour). + expect(lines[0]).toBe("ID\tScore\tExtra"); + expect(lines.slice(1).sort()).toEqual(["A\t10\t111", "B\t20\t", "C\t30\t333", "D\t\t444"]); + }, +); + +// keys-only column that introduces a NEW axis. The primary is keyed by `id` +// only; the keys-only `byRegion` column is keyed by (id, region). The full-join +// surfaces `region` as a new output column (its keys expand the rows) while the +// column's own value is NOT written. This guards the "new axes are surfaced" +// half of the feature — distinct from the shared-axis case above, this test +// fails if the axis-collection loop skipped keys-only columns. +const byRegionCsv = dedent` + id,region,rv + A,north,1 + A,south,2 + B,north,3 +`; +const byRegionSpec = { + axes: [ + { + column: "id", + spec: { + name: "pl7.app/id", + type: "String", + annotations: { [Annotation.Label]: "ID" } satisfies Annotation, + }, + }, + { + column: "region", + spec: { + name: "pl7.app/region", + type: "String", + annotations: { [Annotation.Label]: "Region" } satisfies Annotation, + }, + }, + ], + columns: [ + { + column: "rv", + id: "rv", + spec: { + valueType: "Long", + name: "pl7.app/region-value", + annotations: { [Annotation.Label]: "RegionValue" } satisfies Annotation, + }, + }, + ], + storageFormat: "Json", + partitionKeyLength: 0, +}; + +tplTest.concurrent( + "tableBuilder: keysOnly column surfaces a new axis without emitting its value", + async ({ helper, expect, driverKit }) => { + const content = await runEphemeralBuilder(helper, driverKit, { + format: "tsv", + imports: { + main: { csv: primaryCsv, spec: primarySpec }, + byRegion: { csv: byRegionCsv, spec: byRegionSpec }, + }, + primaries: [{ name: "main", sourceImport: "main" }], + columns: [{ mode: "single", sourceImport: "byRegion", opts: { keysOnly: true } }], + }); + const lines = content.trim().split("\n"); + + // New axis "Region" is surfaced (between the primary axis and value), but + // the keys-only column's own value ("RegionValue") is NOT in the header. + expect(lines[0]).toBe("ID\tRegion\tScore"); + + // Full-join over the (id, region) key space: A expands to north+south + // (Score broadcast = 10), B to north (20), C has no region row so its + // Region cell is empty (Score 30). Every row keeps its primary Score. + expect(lines.slice(1).sort()).toEqual([ + "A\tnorth\t10", + "A\tsouth\t10", + "B\tnorth\t20", + "C\t\t30", + ]); + }, +);