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) <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-09-24 23:24:20 -04:00
parent 94af6a5f13
commit 709c9d0395
2 changed files with 28 additions and 33 deletions

View File

@ -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 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. 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 **A blank on the column axis is read back from the column name.** A column
joined with `|`, and a null joins as the empty string, so by the time the name name is its values joined with `|`, each rendered by `t_tscalar::to_string` in
reaches the client the two blanks are the same text. Those cells are refused — `scalar.cpp`, which gives `''` for an empty string and the literal text `"null"`
with a message, and counted once for a drag — rather than widened, since a slice for a null. So `cellFilters()` maps `"null"` to null and `''` to `''`, and both
missing a split_by level means every value of it. Filtering on the column blanks are isolatable on either axis. The one ambiguity left is a dimension
instead (`scope`, which carries `is null`) is the way to reach them. 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 **The pivot's own filter travels alongside, as `scope`.** A slice is
`{col: value}` and can only mean equality, so a view narrowed to `{col: value}` and can only mean equality, so a view narrowed to

View File

@ -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, // 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 // 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. // 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 const viewer = viewerRef.current
if (!viewer) return [] if (!viewer) return []
const cfg = await viewer.save() const cfg = await viewer.save()
@ -382,10 +382,6 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
const c0 = win.start_col ?? 0 const c0 = win.start_col ?? 0
const c1 = win.end_col ?? userKeys.length const c1 = win.end_col ?? userKeys.length
const out = [] 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++) { for (let i = 0; i < rows.length; i++) {
const path = rows[i].__ROW_PATH__ || [] const path = rows[i].__ROW_PATH__ || []
// the grand-total row resolves to no dimension at all, which would mean // 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++) { for (let c = c0; c < c1; c++) {
const key = userKeys[c] const key = userKeys[c]
if (!key) continue if (!key) continue
const { rowFilters, colFilters, colBlank } = cellFilters(cfg, rows[i], key) const { rowFilters, colFilters } = cellFilters(cfg, rows[i], key)
if (colBlank) { blankCols++; continue }
if (groupBy.length && !rowFilters.length) continue if (groupBy.length && !rowFilters.length) continue
const slice = sliceFromFilters([...base, ...rowFilters, ...colFilters], const slice = sliceFromFilters([...base, ...rowFilters, ...colFilters],
(cfg.columns || []).filter(Boolean)) (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 } }) 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 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 // 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. // traversal, and a slow redraw of a highlight is not worth having.
if (!n || n > MAX_RELOCATE_ROWS) { areasRef.current.clear(); paintAreas(); return } 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 live = new Set(slicesRef.current.map(sliceKey))
const next = new Map() const next = new Map()
for (const { slice, area } of found) { for (const { slice, area } of found) {
@ -1101,11 +1091,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
const measures = (config.columns || []).filter(Boolean) const measures = (config.columns || []).filter(Boolean)
let s let s
if (cell) { if (cell) {
const { rowFilters, colFilters, colBlank } = cellFilters(config, detail.row, colKey) const { rowFilters, colFilters } = cellFilters(config, detail.row, colKey)
if (colBlank) {
flash('A blank column-axis value cannot be isolated — filter on it instead', 'error')
return
}
if (!rowFilters.length) return if (!rowFilters.length) return
s = sliceFromFilters([...(config.filter || []), ...rowFilters, ...colFilters], measures) s = sliceFromFilters([...(config.filter || []), ...rowFilters, ...colFilters], measures)
} else { } 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 // 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. // 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 // A blank on the *column* axis arrives as text, and the two blanks arrive as
// values joined with '|', and a null joins as the empty string, so the two are // different text: a column name is its values joined with '|', each rendered by
// the same text by the time it reaches us. Those cells are refused rather than // t_tscalar::to_string, which gives the empty string for '' and the literal
// widened -- `colBlank` says so -- since a slice missing a split_by level means // "null" for a null (scalar.cpp -- an invalid scalar returns {"null"} before the
// every value of it. // 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) { function cellFilters(cfg, row, colKey) {
const groupBy = cfg.group_by || [] const groupBy = cfg.group_by || []
const splitBy = cfg.split_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)) .map((col, ix) => (ix < path.length && path[ix] !== undefined ? [col, '==', path[ix]] : null))
.filter(Boolean) .filter(Boolean)
let colFilters = [] let colFilters = []
let colBlank = false
if (colKey != null) { if (colKey != null) {
// A column name is dimension values joined by | with the measure last, and // 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 // 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 if (v === undefined) return null
// the measure name read positionally as a dimension value // the measure name read positionally as a dimension value
if (META_COL_RE.test(v)) return null if (META_COL_RE.test(v)) return null
if (v === '') { colBlank = true; return null } return [col, '==', v === 'null' ? null : v]
return [col, '==', v]
}) })
.filter(Boolean) .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 // the user keys of a to_json row, in the order the grid lays its columns out