Type a slice's values before filtering the ledger with them

A slice carries every value as a string -- built from filters the grid
reports, and shaped to survive JSON on the way to the API. Perspective
matches on type, and a string '2027' against an integer column is not a
filter that matches nothing, it is a filter that is dropped.

So the pivot's own season filter never reached the ledger: with the grid
scoped to sseas_e = 2027 the ledger totalled 956,485.13 against a cell
reading 921,225.71, the difference being eleven rows of a baseline segment
whose shipments fall in the next season. Only dates were being coerced, and
only because someone had hit this before with them.

Values are now typed against the loaded table's schema rather than against
col_meta's role, which is the thing that actually decides the match. The
server side was already right -- Postgres casts the literal -- and returns
921,225.71 for the same slice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-09-18 08:33:34 -04:00
parent f94aa4ec99
commit c6ef350283

View File

@ -419,22 +419,31 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
const dateNames = new Set(colMetaRef.current.filter(c => c.role === 'date').map(c => c.cname)) const dateNames = new Set(colMetaRef.current.filter(c => c.role === 'date').map(c => c.cname))
const ITER_ORDER = ['baseline', 'scale', 'recode', 'clone'] const ITER_ORDER = ['baseline', 'scale', 'recode', 'clone']
// A slice carries every value as a string -- it is built from filters the
// grid reports and from a payload that has to survive JSON. Perspective
// matches on type, so a string '2027' against an integer column does not
// filter to nothing, it is dropped: the ledger then totalled rows the pivot
// was hiding, which is how a season filter on sseas_e went unnoticed while
// the numbers disagreed by exactly the out-of-season rows.
const schema = await tableRef.current.schema()
const typed = (col, val) => {
switch (schema[col]) {
case 'integer': case 'float': return Number(val)
case 'boolean': return val === true || val === 'true'
case 'date': case 'datetime': return Number(val)
default: return String(val)
}
}
async function totalsFor(sliceObj) { async function totalsFor(sliceObj) {
const filters = [ // pf_segment and pf_bucket are computed server-side but are ordinary
// pf_segment and pf_bucket are computed server-side but are ordinary // columns in the loaded table, so here they filter directly. They have to
// string columns in the loaded table, so here they filter directly. They // be applied, or the ledger totals a wider selection than the operation
// have to be applied, or the ledger totals a wider selection than the // will write.
// operation will write. const filters = Object.entries(sliceObj)
...Object.entries(sliceObj) .filter(([col]) => COMPUTED_SLICE_COLS.has(col) || dimNames.has(col) || dateNames.has(col)
.filter(([col]) => COMPUTED_SLICE_COLS.has(col)) || schema[col] !== undefined)
.map(([col, val]) => [col, '==', String(val)]), .map(([col, val]) => [col, '==', typed(col, val)])
...Object.entries(sliceObj)
.filter(([col]) => dimNames.has(col))
.map(([col, val]) => [col, '==', val]),
...Object.entries(sliceObj)
.filter(([col]) => dateNames.has(col))
.map(([col, val]) => [col, '==', Number(val)]),
]
const view = await tableRef.current.view({ filter: filters }) const view = await tableRef.current.view({ filter: filters })
const rows = await view.to_json() const rows = await view.to_json()
await view.delete() await view.delete()