Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions .changeset/pivot-null-bucket-4056.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
---
"@object-ui/core": patch
"@object-ui/plugin-dashboard": patch
"@object-ui/plugin-report": patch
---

Pivot buckets encode an empty dimension value as JSON `null`, so it no longer collides with a row whose value is literally the placeholder character

objectstack#5473 / objectstack#5665 replaced the pivot's delimiter-joined ids
with `JSON.stringify`, because every delimiter that had been tried — an empty
string, a plain space, a control character — assumed the data would not contain
it, and each assumption failed on ordinary data. This closes the last place the
same assumption survived: the ids were JSON, but the VALUES fed into them were
spelled `String(row[d] ?? '∅')`, so an absent dimension value became the
ordinary string `"∅"` and shared a bucket with a row whose value literally is
that character (U+2205). One bucket, later row overwriting the earlier one — the
cell showed a different row's measure, the overwritten row was unreachable, and
drill-through followed the same wrong index into the wrong records, all without
an error. The trigger requires that character to appear as a dimension value, so
this is the assumption being removed rather than a defect users hit today.

An empty value now encodes as JSON `null`, which `JSON.stringify` renders as a
bare `null` that no string can spell. The normalization lives in
`@object-ui/core` as `pivotDimensionValue` (absent ⇒ `null`, everything else ⇒
its string form) rather than at each call site, because a placeholder spelled by
a caller is a placeholder that can collide again — which is exactly how this one
survived the previous fix. `pivotBucketId` accepts `Array<string | null>`
accordingly; that is a widening, so existing callers passing `string[]` are
unaffected.

Both renderers' bucket keys move together, which the fix requires: a bucket id
and the subtotal map keyed by it are built from the same expression, so changing
one alone would split the headers while the subtotal map still merged, landing
every column subtotal under the wrong header. In `plugin-dashboard`'s
`DatasetWidget` that is the row bucket id, the column bucket id, the cell key,
and both the `rowTotalById` and `colTotalById` lookups; in `plugin-report`'s
`DatasetReportRenderer` the single `bucketId` helper already feeds all five.

The dashboard's column bucket id also stops being a bare string and becomes a
one-element tuple through the same shared encoder. It was the one id in the
family still built by hand, on the reasoning that a single value needs no
boundary — true of the boundary, false of everything else the encoder does, and
it is why the across axis kept carrying this collision after the row ids were
fixed.

No display change: these placeholders only ever entered ids, never labels. An
unset dimension still renders through `formatDimensionValue` exactly as before,
and data containing neither an absent value nor that character buckets
identically — the ids are opaque lookup keys, never parsed back into a value,
never shown, never persisted.
74 changes: 74 additions & 0 deletions packages/core/src/utils/__tests__/dataset-pivot.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect } from 'vitest';
import { pivotBucketId, pivotCellKey, pivotDimensionValue } from '../dataset-pivot.js';

/**
* The encoders' whole job is that DIFFERENT dimension-value tuples never spell
* the SAME id. Every historical defect in this family was one pair of tuples
* that did (objectstack#5473 a space join, objectstack#5665 an empty join,
* objectui#4056 a null placeholder), so the tests are written as pairs that
* used to collide — not as literal id spellings, which is what let the old
* encodings read as correct.
*/
describe('pivotDimensionValue', () => {
it('maps an absent value to null and everything else to its string form', () => {
expect(pivotDimensionValue(null)).toBeNull();
expect(pivotDimensionValue(undefined)).toBeNull();
expect(pivotDimensionValue('North')).toBe('North');
expect(pivotDimensionValue(0)).toBe('0');
expect(pivotDimensionValue(false)).toBe('false');
// The empty string is a VALUE a dimension can hold, not an absent value —
// `?? ` never coerced it and neither does this.
expect(pivotDimensionValue('')).toBe('');
});

it('does not map any string to null — including the placeholder it replaced', () => {
// objectui#4056: the old spelling was `String(v ?? '∅')`, which mapped an
// absent value ONTO a string, so this character had two meanings.
expect(pivotDimensionValue('∅')).toBe('∅');
expect(pivotDimensionValue('null')).toBe('null');
});
});

describe('pivotBucketId', () => {
it('keeps an absent value apart from every string spelling of it (objectui#4056)', () => {
expect(pivotBucketId([null])).not.toBe(pivotBucketId(['∅']));
expect(pivotBucketId([null])).not.toBe(pivotBucketId(['null']));
expect(pivotBucketId([null])).not.toBe(pivotBucketId(['']));
// …and the pair as the callers actually build it, raw value in.
expect(pivotBucketId([pivotDimensionValue(null)])).not.toBe(
pivotBucketId([pivotDimensionValue('∅')]),
);
});

it('keeps tuples apart that concatenate to the same string', () => {
expect(pivotBucketId(['x', 'yz'])).not.toBe(pivotBucketId(['xy', 'z']));
expect(pivotBucketId(['New', 'York Q1'])).not.toBe(pivotBucketId(['New York', 'Q1']));
});

it('distinguishes an absent value by POSITION, not just by presence', () => {
expect(pivotBucketId([null, 'a'])).not.toBe(pivotBucketId(['a', null]));
expect(pivotBucketId([null, 'a'])).not.toBe(pivotBucketId([null, null, 'a']));
});

it('is deterministic, and unchanged for all-string tuples', () => {
expect(pivotBucketId(['North', 'Q1'])).toBe(pivotBucketId(['North', 'Q1']));
// Regrouping stability: data with no absent values encodes exactly as it did
// before objectui#4056, so existing buckets do not re-key.
expect(pivotBucketId(['North', 'Q1'])).toBe(JSON.stringify(['North', 'Q1']));
});
});

describe('pivotCellKey', () => {
it('keeps cell keys apart whose row/column ids meet at a different point', () => {
expect(pivotCellKey('a', 'bc')).not.toBe(pivotCellKey('ab', 'c'));
});

it('keys a null bucket and a placeholder bucket to different cells', () => {
const col = pivotBucketId(['Q1']);
expect(pivotCellKey(pivotBucketId([null]), col)).not.toBe(
pivotCellKey(pivotBucketId(['∅']), col),
);
});
});
38 changes: 31 additions & 7 deletions packages/core/src/utils/dataset-pivot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,20 +38,44 @@
*
* Pure (no React / i18n), like its `dataset-format` neighbour.
*
* Known residual, tracked separately in objectstack#5666: callers encode a
* null/undefined dimension value as a placeholder string, so it still collides
* with a value that literally equals that placeholder. That is a property of
* the placeholder, not of the encoding below.
* The empty-value encoding closed the last instance of the same assumption
* (objectui#4056): callers used to spell a null/undefined dimension value as
* the STRING `'∅'` before encoding it, so a row whose value literally IS that
* character shared a bucket with a row whose value is absent — the placeholder
* reintroduced, one level up, exactly the "no value contains this character"
* assumption the encoder below had just removed. An empty value is now JSON
* `null`, which `JSON.stringify` renders as a bare `null` no string can spell,
* and `pivotDimensionValue` owns that normalization so no caller has to hold a
* placeholder literal of its own.
*/

/**
* Encode a pivot BUCKET id from its dimension values.
* Normalize ONE raw dimension value for `pivotBucketId`: an absent value (null
* or undefined) becomes JSON `null`, everything else its string form.
*
* This lives here rather than at each call site because a placeholder spelled
* by the caller is a placeholder that can collide: `String(v ?? '∅')` put an
* ordinary string into the tuple, so `null` and the character `'∅'` encoded
* identically (objectui#4056). `null` is not a string, so nothing a dimension
* can hold encodes to it — the same reason the tuple is JSON rather than a
* delimiter join.
*/
export const pivotDimensionValue = (value: unknown): string | null =>
value == null ? null : String(value);

/**
* Encode a pivot BUCKET id from its dimension values, each already normalized
* by `pivotDimensionValue` (empty ⇒ `null`).
*
* Axis-neutral on purpose: a DOWN bucket and an ACROSS bucket are the same kind
* of thing (a dimension-value tuple), and a cross-tab with multiple across
* dimensions collides on that axis just as readily as on the down axis.
* dimensions collides on that axis just as readily as on the down axis. That
* holds for a SINGLE-value across bucket too — a bare value is a one-element
* tuple, and spelling it as the raw string instead is what left the dashboard's
* column ids on a second, colliding encoding after the row ids were fixed.
*/
export const pivotBucketId = (dimensionValues: string[]): string => JSON.stringify(dimensionValues);
export const pivotBucketId = (dimensionValues: Array<string | null>): string =>
JSON.stringify(dimensionValues);

/**
* Encode the cell key for a (down bucket, across bucket) pair — the key of the
Expand Down
45 changes: 27 additions & 18 deletions packages/plugin-dashboard/src/DatasetWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,12 @@ import {
// The pivot key encoders now live in `@object-ui/core` so this widget and the
// report renderer's cross-tab share ONE implementation — each having written
// its own is why the same collision had to be fixed twice (objectstack#5473,
// objectstack#5665). Aliased to the local name: `pivotRowId` reads right here
// (this widget only ever encodes DOWN buckets — its across axis is a single
// dimension), while the shared helper is axis-neutral because the report's
// cross-tab keys multi-dimension ACROSS buckets with it too.
pivotBucketId as pivotRowId,
// objectstack#5665). Imported under the shared name because BOTH of this
// widget's axes now use it: the across axis used to spell its single-value id
// as a bare string, which was a second encoding of the same kind of id and
// carried the placeholder collision on its own (objectui#4056).
pivotBucketId,
pivotDimensionValue,
pivotCellKey,
compareToTrendLabelKey,
type CompareToConfig,
Expand Down Expand Up @@ -81,12 +82,18 @@ export const buildDrillFilter = buildDatasetDrillFilter;
* live in `@object-ui/core` (`pivotBucketId` / `pivotCellKey`) so this widget
* and the report renderer's cross-tab key their buckets identically. See that
* module for why both are `JSON.stringify` rather than a delimiter character,
* and for the null-placeholder residual tracked in objectstack#5666.
* and why an empty value encodes as JSON `null` rather than a placeholder
* string (objectui#4056).
*
* Every consumer of a row id — the cell index below AND the row-total lookup in
* the cross-tab renderer — must build its key with these; a second, hand-rolled
* encoding of the same id is what made the old bug invisible.
* Every consumer of a bucket id — the cell index below, the row-total lookup
* AND the column-total lookup in the cross-tab renderer — must build its key
* with these, over values normalized by `pivotDimensionValue`; a second,
* hand-rolled encoding of the same id is what made the old bug invisible.
*
* `pivotRowId` is the historical name of the axis-neutral encoder, kept as an
* alias so this package's published surface does not change.
*/
const pivotRowId = pivotBucketId;
export { pivotRowId, pivotCellKey };

/**
Expand All @@ -113,13 +120,15 @@ export function buildPivot(
const cellIndex = new Map<string, number>();
rows.forEach((row, index) => {
// Both ids are opaque lookup keys, never displayed — the visible text comes
// from `labels`/`label` via formatDimensionValue. The column id stays the
// bare value because a single value needs no boundary; only the row id joins
// several values, and pivotRowId encodes that join unambiguously. (It used to
// join them with a control character no dimension value was ASSUMED to carry;
// pivotRowId needs no such assumption.)
const rid = pivotRowId(rowDims.map((d) => String(row[d] ?? '∅')));
const cid = String(row[colDim] ?? '∅');
// from `labels`/`label` via formatDimensionValue. BOTH go through the shared
// encoder, over values normalized by `pivotDimensionValue`. The column id
// used to be the bare value on the reasoning that a single value needs no
// boundary; that is true of the boundary and false of everything else the
// encoder does, and it left the across axis on its own encoding — which then
// carried the null-placeholder collision independently (objectui#4056). A
// one-element tuple costs nothing and keeps one encoding for one kind of id.
const rid = pivotBucketId(rowDims.map((d) => pivotDimensionValue(row[d])));
const cid = pivotBucketId([pivotDimensionValue(row[colDim])]);
if (!rowSeen.has(rid)) { rowSeen.add(rid); rowHeaders.push({ id: rid, labels: rowDims.map((d) => formatDimensionValue(row[d])) }); }
if (!colSeen.has(cid)) { colSeen.add(cid); colHeaders.push({ id: cid, label: formatDimensionValue(row[colDim]) }); }
cellIndex.set(pivotCellKey(rid, cid), index);
Expand Down Expand Up @@ -943,9 +952,9 @@ export function DatasetWidget({ widget, dataSource }: { widget: any; dataSource:
const findTotals = (dims: string[]) =>
state.totals?.find((t) => Array.isArray(t.dimensions) && t.dimensions.join(',') === dims.join(','))?.rows;
const rowTotalById = new Map<string, Row>();
for (const r of findTotals(rowDims) ?? []) rowTotalById.set(pivotRowId(rowDims.map((d) => String(r[d] ?? '∅'))), r);
for (const r of findTotals(rowDims) ?? []) rowTotalById.set(pivotBucketId(rowDims.map((d) => pivotDimensionValue(r[d]))), r);
const colTotalById = new Map<string, Row>();
for (const r of findTotals([colDim]) ?? []) colTotalById.set(String(r[colDim] ?? '∅'), r);
for (const r of findTotals([colDim]) ?? []) colTotalById.set(pivotBucketId([pivotDimensionValue(r[colDim])]), r);
const grandTotal = findTotals([])?.[0];
const showTotalCol = rowTotalById.size > 0;
const showTotalRow = colTotalById.size > 0;
Expand Down
Loading
Loading