From 709c9d0395f8a6e0fdc77963c2eda061626c69ec Mon Sep 17 00:00:00 2001 From: Paul Trowbridge Date: Thu, 24 Sep 2026 23:24:20 -0400 Subject: [PATCH] Read a null on the column axis as null, not as the text "null" The engine renders a null split_by value as the literal "null" in the column name (t_tscalar::to_string on an invalid scalar) and an empty string as ''. cellFilters() passed "null" through as a value, so a slice over a blank segment_new became segment_new = 'null', matched no row, and the adjustment wrote nothing. '' was refused outright, on the mistaken belief that the two blanks joined to the same text. Both are now mapped back: "null" to null, '' to ''. Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 16 ++++++++------ ui/src/views/Forecast.jsx | 45 ++++++++++++++++----------------------- 2 files changed, 28 insertions(+), 33 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 26d0c6f..c2cf090 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -697,12 +697,16 @@ The panel prints `(null)` and `(blank)` rather than nothing, and `—` only when the column is genuinely absent from the slice: a slice isolated to the blank node and one the column plays no part in are opposite statements and looked identical. -**A blank on the column axis cannot be isolated.** A column name is its values -joined with `|`, and a null joins as the empty string, so by the time the name -reaches the client the two blanks are the same text. Those cells are refused — -with a message, and counted once for a drag — rather than widened, since a slice -missing a split_by level means every value of it. Filtering on the column -instead (`scope`, which carries `is null`) is the way to reach them. +**A blank on the column axis is read back from the column name.** A column +name is its values joined with `|`, each rendered by `t_tscalar::to_string` in +`scalar.cpp`, which gives `''` for an empty string and the literal text `"null"` +for a null. So `cellFilters()` maps `"null"` to null and `''` to `''`, and both +blanks are isolatable on either axis. The one ambiguity left is a dimension +whose value is really the text `null`; none exists in this data. + +This used to be believed impossible — the claim was that a null joined as `''` +too — so `''` cells were refused, and `"null"` was taken at its word: the slice +became `segment_new = 'null'`, matched no row, and the adjustment reported zero. **The pivot's own filter travels alongside, as `scope`.** A slice is `{col: value}` and can only mean equality, so a view narrowed to diff --git a/ui/src/views/Forecast.jsx b/ui/src/views/Forecast.jsx index e3f6ab9..fd9ff0c 100644 --- a/ui/src/views/Forecast.jsx +++ b/ui/src/views/Forecast.jsx @@ -369,7 +369,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio // attaches to a click. Row dimensions come from the view's __ROW_PATH__ (raw values, // so dates stay epoch millis rather than whatever the grid formatted them as), and // column dimensions from the split_by segments of the column name. - async function slicesFromRegion(win, { quiet = false } = {}) { + async function slicesFromRegion(win) { const viewer = viewerRef.current if (!viewer) return [] const cfg = await viewer.save() @@ -382,10 +382,6 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio const c0 = win.start_col ?? 0 const c1 = win.end_col ?? userKeys.length const out = [] - // cells whose column-axis value is blank, which cannot be isolated (see - // cellFilters) -- counted so the drag can say why it skipped them once, - // rather than per cell - let blankCols = 0 for (let i = 0; i < rows.length; i++) { const path = rows[i].__ROW_PATH__ || [] // the grand-total row resolves to no dimension at all, which would mean @@ -394,8 +390,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio for (let c = c0; c < c1; c++) { const key = userKeys[c] if (!key) continue - const { rowFilters, colFilters, colBlank } = cellFilters(cfg, rows[i], key) - if (colBlank) { blankCols++; continue } + const { rowFilters, colFilters } = cellFilters(cfg, rows[i], key) if (groupBy.length && !rowFilters.length) continue const slice = sliceFromFilters([...base, ...rowFilters, ...colFilters], (cfg.columns || []).filter(Boolean)) @@ -404,11 +399,6 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio out.push({ slice, area: { x0: c, x1: c, y0: y, y1: y } }) } } - // Quiet for a relocate, which sweeps the whole grid rather than a region the - // user dragged: the blanks it passes over are not cells anyone asked for. - if (blankCols && !quiet) { - flash(`${blankCols} cell${blankCols > 1 ? 's' : ''} skipped: a blank column-axis value cannot be isolated`, 'error') - } return out } @@ -462,7 +452,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio // any ordinary depth it is a handful. Fully expanded it is the whole // traversal, and a slow redraw of a highlight is not worth having. if (!n || n > MAX_RELOCATE_ROWS) { areasRef.current.clear(); paintAreas(); return } - const found = await slicesFromRegion({ start_row: 0, end_row: n }, { quiet: true }) + const found = await slicesFromRegion({ start_row: 0, end_row: n }) const live = new Set(slicesRef.current.map(sliceKey)) const next = new Map() for (const { slice, area } of found) { @@ -1101,11 +1091,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio const measures = (config.columns || []).filter(Boolean) let s if (cell) { - const { rowFilters, colFilters, colBlank } = cellFilters(config, detail.row, colKey) - if (colBlank) { - flash('A blank column-axis value cannot be isolated — filter on it instead', 'error') - return - } + const { rowFilters, colFilters } = cellFilters(config, detail.row, colKey) if (!rowFilters.length) return s = sliceFromFilters([...(config.filter || []), ...rowFilters, ...colFilters], measures) } else { @@ -2590,11 +2576,18 @@ function sliceFromFilters(filters, measures = []) { // a dimension whose value is 0 goes the same way. The row path is the raw truth // and carries the blank, so the slice is built from that instead. // -// A blank on the *column* axis cannot be recovered at all: a column name is its -// values joined with '|', and a null joins as the empty string, so the two are -// the same text by the time it reaches us. Those cells are refused rather than -// widened -- `colBlank` says so -- since a slice missing a split_by level means -// every value of it. +// A blank on the *column* axis arrives as text, and the two blanks arrive as +// different text: a column name is its values joined with '|', each rendered by +// t_tscalar::to_string, which gives the empty string for '' and the literal +// "null" for a null (scalar.cpp -- an invalid scalar returns {"null"} before the +// type switch). So "null" is read back as null and '' as ''. Both used to be +// mishandled: '' was refused as unrecoverable, on the belief that a null joined +// as '' too, and "null" was taken at its word -- a slice of segment_new = +// 'null', which matches no row, so the adjustment wrote nothing. +// +// The one thing this cannot tell apart is a null from a dimension value that is +// really the four-letter text 'null'. None exists in this data; one would be +// read as null. function cellFilters(cfg, row, colKey) { const groupBy = cfg.group_by || [] const splitBy = cfg.split_by || [] @@ -2603,7 +2596,6 @@ function cellFilters(cfg, row, colKey) { .map((col, ix) => (ix < path.length && path[ix] !== undefined ? [col, '==', path[ix]] : null)) .filter(Boolean) let colFilters = [] - let colBlank = false if (colKey != null) { // A column name is dimension values joined by | with the measure last, and // only as many values as the axis currently shows -- collapse the column @@ -2621,12 +2613,11 @@ function cellFilters(cfg, row, colKey) { if (v === undefined) return null // the measure name read positionally as a dimension value if (META_COL_RE.test(v)) return null - if (v === '') { colBlank = true; return null } - return [col, '==', v] + return [col, '==', v === 'null' ? null : v] }) .filter(Boolean) } - return { rowFilters, colFilters, colBlank } + return { rowFilters, colFilters } } // the user keys of a to_json row, in the order the grid lays its columns out