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
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

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,
// 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