Skip to content

Commit 58c5136

Browse files
committed
Close some parity gaps
1 parent d9bed7f commit 58c5136

8 files changed

Lines changed: 428 additions & 35 deletions

File tree

streamlit_pivot/__init__.py

Lines changed: 48 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,15 @@ def _estimate_group_count(df: Any, fields: list[str]) -> int:
149149
def _can_use_threshold_hybrid(config: PivotConfig) -> tuple[bool, str]:
150150
if config.get("synthetic_measures"):
151151
return False, "threshold_hybrid currently skips synthetic measures"
152+
filters = config.get("filters", {})
153+
if filters:
154+
dim_set = set(config.get("rows", []) + config.get("columns", []))
155+
non_dim = [f for f in filters if f not in dim_set]
156+
if non_dim:
157+
return False, (
158+
f"threshold_hybrid requires filters on row/column dimensions only; "
159+
f"filter on {non_dim} is not in the current layout"
160+
)
152161
return True, "config is compatible with threshold_hybrid"
153162

154163

@@ -193,7 +202,9 @@ def _should_use_threshold_hybrid(
193202
return False, "auto-selected client_only because the dataset stays within budget"
194203

195204

196-
def _prepare_threshold_hybrid_frame(df: Any, config: PivotConfig) -> Any:
205+
def _prepare_threshold_hybrid_frame(
206+
df: Any, config: PivotConfig, null_handling: Any = None
207+
) -> Any:
197208
group_fields = [*config.get("rows", []), *config.get("columns", [])]
198209
aggregation = dict(config.get("aggregation", {}))
199210
value_fields = list(config.get("values", []))
@@ -224,11 +235,13 @@ def _prepare_threshold_hybrid_frame(df: Any, config: PivotConfig) -> Any:
224235
else:
225236
named[vf] = pd.NamedAgg(column=vf, aggfunc=agg)
226237

238+
filtered_df = _resolve_and_filter(df, config.get("filters", {}), null_handling)
239+
227240
if not group_fields:
228241
row: dict[str, Any] = {}
229242
for vf in value_fields:
230243
agg = aggregation.get(vf, "sum")
231-
ser = df[vf]
244+
ser = filtered_df[vf]
232245
if agg in _NUMERIC_COERCE_AGGS:
233246
ser = pd.to_numeric(ser, errors="coerce")
234247
if agg == "avg":
@@ -253,7 +266,7 @@ def _prepare_threshold_hybrid_frame(df: Any, config: PivotConfig) -> Any:
253266
row[vf] = ser.agg(agg)
254267
return pd.DataFrame([row])
255268

256-
working = df.copy()
269+
working = filtered_df.copy()
257270
for vf in numeric_coerce_fields:
258271
working[vf] = pd.to_numeric(working[vf], errors="coerce")
259272

@@ -308,29 +321,33 @@ def _normalize_dim_values(df: Any, dims: list[str], null_handling: Any) -> Any:
308321
return df
309322

310323

311-
def _apply_hybrid_filters(
324+
def _resolve_and_filter(
312325
df: Any,
313-
config: PivotConfig,
314-
dims: list[str],
326+
filters: dict[str, dict] | None,
327+
null_handling: Any,
315328
) -> Any:
316-
"""Apply config filters to a DataFrame whose dimension columns have already
317-
been normalized via _normalize_dim_values (values are resolved strings)."""
318-
filters = config.get("filters", {})
329+
"""Apply dimension filters to a raw DataFrame using resolved-value semantics.
330+
331+
Mirrors PivotData._shouldIncludeRow + _resolveDimValue: for every filter
332+
field, resolve null/empty values via per-field _get_null_mode, then compare.
333+
"""
319334
if not filters:
320335
return df
321336
mask = pd.Series(True, index=df.index)
322337
for field, filt in filters.items():
323338
if field not in df.columns:
324339
continue
325-
col_str = (
326-
df[field].astype(str) if field in dims else df[field].fillna("").astype(str)
327-
)
340+
mode = _get_null_mode(field, null_handling)
341+
if mode == "separate":
342+
resolved = df[field].fillna("(null)").replace("", "(null)").astype(str)
343+
else:
344+
resolved = df[field].fillna("").astype(str)
328345
inc = filt.get("include")
329346
exc = filt.get("exclude")
330347
if inc:
331-
mask &= col_str.isin(inc)
348+
mask &= resolved.isin(inc)
332349
elif exc:
333-
mask &= ~col_str.isin(exc)
350+
mask &= ~resolved.isin(exc)
334351
return df[mask]
335352

336353

@@ -475,7 +492,7 @@ def _compute_hybrid_totals(
475492

476493
all_dims = rows + columns
477494
working = _normalize_dim_values(df, all_dims, null_handling)
478-
working = _apply_hybrid_filters(working, config, all_dims)
495+
working = _resolve_and_filter(working, config.get("filters", {}), null_handling)
479496

480497
fingerprint = _build_sidecar_fingerprint(config, null_handling)
481498

@@ -603,35 +620,34 @@ def _compute_hybrid_drilldown(
603620
drilldown_request: dict[str, Any],
604621
null_handling: Any = None,
605622
dims: list[str] | None = None,
623+
config_filters: dict[str, dict] | None = None,
606624
page_size: int = _DRILLDOWN_PAGE_SIZE,
607625
) -> tuple[list[dict[str, Any]], list[str], int, int]:
608626
"""Filter the original DataFrame for a hybrid-mode drill-down request.
609627
610628
Uses resolved-dimension semantics (matching _resolveDimValue on the
611629
frontend) so that filter values like "(null)" align correctly with
612-
null_handling modes.
630+
null_handling modes. Applies config-level dimension filters first
631+
(matching _shouldIncludeRow), then cell-click filters.
613632
614633
Returns (records_list, column_names, total_matching_count, page).
615634
"""
635+
working = _resolve_and_filter(df, config_filters or {}, null_handling)
636+
616637
filters: dict[str, str] = drilldown_request.get("filters", {})
617638
page: int = max(0, int(drilldown_request.get("page", 0)))
618639

619-
dim_set = set(dims) if dims else set()
620-
621-
mask = pd.Series(True, index=df.index)
640+
mask = pd.Series(True, index=working.index)
622641
for col, val in filters.items():
623-
if col not in df.columns:
642+
if col not in working.columns:
624643
continue
625-
if col in dim_set:
626-
mode = _get_null_mode(col, null_handling)
627-
if mode == "separate":
628-
resolved = df[col].fillna("(null)").replace("", "(null)").astype(str)
629-
else:
630-
resolved = df[col].fillna("").astype(str)
631-
mask &= resolved == str(val)
644+
mode = _get_null_mode(col, null_handling)
645+
if mode == "separate":
646+
resolved = working[col].fillna("(null)").replace("", "(null)").astype(str)
632647
else:
633-
mask &= df[col].fillna("").astype(str) == str(val)
634-
filtered = df[mask]
648+
resolved = working[col].fillna("").astype(str)
649+
mask &= resolved == str(val)
650+
filtered = working[mask]
635651
total_count = len(filtered)
636652
offset = page * page_size
637653
page_slice = filtered.iloc[offset : offset + page_size]
@@ -1478,7 +1494,7 @@ def st_pivot_table(
14781494
if drill_note not in threshold_reason:
14791495
threshold_reason = f"{threshold_reason}{drill_note}"
14801496
materialized_data = (
1481-
_prepare_threshold_hybrid_frame(data, config_to_send)
1497+
_prepare_threshold_hybrid_frame(data, config_to_send, null_handling)
14821498
if use_threshold_hybrid
14831499
else data
14841500
)
@@ -1497,6 +1513,7 @@ def st_pivot_table(
14971513
}
14981514

14991515
if use_threshold_hybrid:
1516+
data_payload["source_row_count"] = len(data)
15001517
agg_dict = config_to_send.get("aggregation", {})
15011518
agg_remap = _build_hybrid_agg_remap(agg_dict)
15021519
if agg_remap:
@@ -1555,6 +1572,7 @@ def st_pivot_table(
15551572
drilldown_request,
15561573
null_handling=null_handling,
15571574
dims=all_dims,
1575+
config_filters=config_to_send.get("filters"),
15581576
)
15591577
data_payload["drilldown_records"] = records
15601578
data_payload["drilldown_columns"] = columns
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
/**
2+
* Copyright 2025 Snowflake Inc.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
import { describe, expect, it, vi } from "vitest";
19+
import { render, act } from "@testing-library/react";
20+
import { tableToIPC, tableFromArrays } from "apache-arrow";
21+
import PivotRoot from "./PivotRoot";
22+
import { makeConfig } from "./test-utils";
23+
24+
function makeArrowBytes(data: Record<string, unknown[]>): Uint8Array {
25+
return tableToIPC(tableFromArrays(data));
26+
}
27+
28+
describe("PivotRoot - source_row_count metric", () => {
29+
it("uses source_row_count for sourceRows when provided (hybrid mode)", async () => {
30+
const dataframe = makeArrowBytes({
31+
region: ["US", "EU"],
32+
year: ["2023", "2023"],
33+
revenue: [100, 200],
34+
});
35+
36+
const setStateValue = vi.fn();
37+
const setTriggerValue = vi.fn();
38+
const config = makeConfig();
39+
40+
let container: HTMLElement;
41+
act(() => {
42+
const result = render(
43+
<PivotRoot
44+
config={config}
45+
dataframe={dataframe}
46+
height={null}
47+
max_height={500}
48+
source_row_count={100000}
49+
execution_mode="threshold_hybrid"
50+
server_mode_reason="forced"
51+
setStateValue={setStateValue}
52+
setTriggerValue={setTriggerValue}
53+
/>,
54+
);
55+
container = result.container;
56+
});
57+
58+
await act(async () => {
59+
await new Promise((r) => setTimeout(r, 50));
60+
});
61+
62+
const perfEl = container!.querySelector("[data-perf-metrics]");
63+
expect(perfEl).not.toBeNull();
64+
const metrics = JSON.parse(
65+
perfEl!.getAttribute("data-perf-metrics") ?? "{}",
66+
);
67+
expect(metrics.sourceRows).toBe(100000);
68+
});
69+
70+
it("falls back to pivotData.recordCount when source_row_count is absent", async () => {
71+
const dataframe = makeArrowBytes({
72+
region: ["US", "EU", "JP"],
73+
year: ["2023", "2023", "2024"],
74+
revenue: [100, 200, 300],
75+
});
76+
77+
const setStateValue = vi.fn();
78+
const setTriggerValue = vi.fn();
79+
const config = makeConfig();
80+
81+
let container: HTMLElement;
82+
act(() => {
83+
const result = render(
84+
<PivotRoot
85+
config={config}
86+
dataframe={dataframe}
87+
height={null}
88+
max_height={500}
89+
execution_mode="client_only"
90+
setStateValue={setStateValue}
91+
setTriggerValue={setTriggerValue}
92+
/>,
93+
);
94+
container = result.container;
95+
});
96+
97+
await act(async () => {
98+
await new Promise((r) => setTimeout(r, 50));
99+
});
100+
101+
const perfEl = container!.querySelector("[data-perf-metrics]");
102+
expect(perfEl).not.toBeNull();
103+
const metrics = JSON.parse(
104+
perfEl!.getAttribute("data-perf-metrics") ?? "{}",
105+
);
106+
expect(metrics.sourceRows).toBe(3);
107+
});
108+
});

streamlit_pivot/frontend/src/PivotRoot.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ const PivotRoot: FC<PivotRootProps> = ({
100100
drilldown_page_size,
101101
hybrid_totals,
102102
hybrid_agg_remap,
103+
source_row_count,
103104
setStateValue,
104105
setTriggerValue,
105106
}): ReactElement => {
@@ -291,7 +292,7 @@ const PivotRoot: FC<PivotRootProps> = ({
291292
firstMountMs:
292293
debugMetrics?.firstMountMs ??
293294
Math.round((parseMs + computeMs + renderMs) * 100) / 100,
294-
sourceRows: pivotData.recordCount,
295+
sourceRows: source_row_count ?? pivotData.recordCount,
295296
sourceCols: rawAllColumns.length,
296297
totalRows: pivotData.uniqueRowKeyCount,
297298
totalCols: pivotData.uniqueColKeyCount,
@@ -358,7 +359,7 @@ const PivotRoot: FC<PivotRootProps> = ({
358359
if (execution_mode === "threshold_hybrid") {
359360
const hybridInfo =
360361
server_mode_reason?.trim() ||
361-
"This table uses server pre-aggregated data. Drill-down to raw rows is not available.";
362+
"This table uses server pre-aggregated data for performance.";
362363
if (!w.includes(hybridInfo)) w.push(hybridInfo);
363364
}
364365
return w;

streamlit_pivot/frontend/src/engine/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -884,6 +884,8 @@ export interface PivotTableData {
884884
drilldown_page?: number;
885885
/** Number of rows per page (set by the server). */
886886
drilldown_page_size?: number;
887+
/** Original row count before hybrid pre-aggregation. */
888+
source_row_count?: number;
887889
}
888890

889891
// ---------------------------------------------------------------------------

streamlit_pivot/frontend/src/index.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ const PivotTableRoot: FrontendRenderer<PivotRootState, PivotTableData> = (
7070
drilldown_page_size={data?.drilldown_page_size}
7171
hybrid_totals={data?.hybrid_totals}
7272
hybrid_agg_remap={data?.hybrid_agg_remap}
73+
source_row_count={data?.source_row_count}
7374
setStateValue={setStateValue}
7475
setTriggerValue={setTriggerValue}
7576
/>

streamlit_pivot/frontend/src/test/setup.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,11 @@
1616
*/
1717

1818
import "@testing-library/jest-dom/vitest";
19+
20+
if (typeof globalThis.ResizeObserver === "undefined") {
21+
globalThis.ResizeObserver = class ResizeObserver {
22+
observe() {}
23+
unobserve() {}
24+
disconnect() {}
25+
} as unknown as typeof globalThis.ResizeObserver;
26+
}

tests/test_component_mount.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,3 +297,54 @@ def test_threshold_hybrid_omits_sidecar_for_decomposable(
297297
payload = calls[0]["data"]
298298
assert "hybrid_totals" not in payload
299299
assert "hybrid_agg_remap" not in payload
300+
301+
302+
def test_threshold_hybrid_includes_source_row_count(
303+
sample_df, pivot_module, mount_recorder
304+
):
305+
calls = mount_recorder()
306+
large_df = sample_df.loc[sample_df.index.repeat(20000)].reset_index(drop=True)
307+
308+
pivot_module.st_pivot_table(
309+
large_df,
310+
key="pivot",
311+
rows=["Region"],
312+
columns=["Year"],
313+
values=["Revenue"],
314+
aggregation="sum",
315+
execution_mode="threshold_hybrid",
316+
)
317+
318+
payload = calls[0]["data"]
319+
assert payload["source_row_count"] == len(large_df)
320+
321+
322+
def test_client_only_omits_source_row_count(sample_df, pivot_module, mount_recorder):
323+
calls = mount_recorder()
324+
325+
pivot_module.st_pivot_table(
326+
sample_df,
327+
key="pivot",
328+
rows=["Region"],
329+
columns=["Year"],
330+
values=["Revenue"],
331+
aggregation="sum",
332+
execution_mode="client_only",
333+
)
334+
335+
payload = calls[0]["data"]
336+
assert "source_row_count" not in payload
337+
338+
339+
def test_non_dimension_filter_causes_client_fallback(pivot_module):
340+
"""Programmatic filter on non-dimension field makes hybrid incompatible."""
341+
cfg = {
342+
"rows": ["Region"],
343+
"columns": ["Year"],
344+
"values": ["Revenue"],
345+
"aggregation": {"Revenue": "sum"},
346+
"filters": {"Category": {"include": ["A"]}},
347+
}
348+
ok, msg = pivot_module._can_use_threshold_hybrid(cfg)
349+
assert ok is False
350+
assert "Category" in msg

0 commit comments

Comments
 (0)