Skip to content

Commit c74f1a2

Browse files
committed
Fix: unblock synthetic measures in server-side mode and temporal/header gaps
1 parent dd224b4 commit c74f1a2

6 files changed

Lines changed: 211 additions & 34 deletions

File tree

e2e_playwright/pivot_table_interactions_test.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2027,7 +2027,9 @@ def _select_builder_option(
20272027
panel.get_by_test_id(test_id).click()
20282028
option = page.get_by_test_id(f"{test_id}-{value}")
20292029
expect(option).to_be_visible(timeout=5000)
2030-
option.click()
2030+
option.evaluate(
2031+
"el => { el.scrollIntoView({ block: 'center', inline: 'nearest' }); el.click(); }"
2032+
)
20312033

20322034

20332035
def test_formula_measures_render_in_table(page_at_app: Page):

streamlit_pivot/__init__.py

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -300,8 +300,6 @@ def _apply_source_filters(
300300

301301

302302
def _can_use_threshold_hybrid(config: PivotConfig) -> tuple[bool, str]:
303-
if config.get("synthetic_measures"):
304-
return False, "threshold_hybrid currently skips synthetic measures"
305303
filters = config.get("filters", {})
306304
if filters:
307305
dim_set = set(config.get("rows", []) + config.get("columns", []))
@@ -366,7 +364,13 @@ def _prepare_threshold_hybrid_frame(
366364
date_grains = config.get("date_grains", {})
367365
auto_date_hierarchy = config.get("auto_date_hierarchy", True)
368366
aggregation = dict(config.get("aggregation", {}))
369-
value_fields = list(config.get("values", []))
367+
synth_sources = _get_synthetic_source_fields(config.get("synthetic_measures"))
368+
df_cols = set(df.columns)
369+
value_fields = [
370+
f
371+
for f in dict.fromkeys(list(config.get("values", [])) + synth_sources)
372+
if f in df_cols
373+
]
370374

371375
if not value_fields:
372376
if not group_fields:
@@ -1033,19 +1037,25 @@ def _compute_hybrid_totals(
10331037
aggregation = config.get("aggregation", {})
10341038
rows = config.get("rows", [])
10351039
columns = config.get("columns", [])
1036-
values = config.get("values", [])
1040+
synth_sources = _get_synthetic_source_fields(config.get("synthetic_measures"))
1041+
df_cols = set(df.columns)
1042+
all_value_fields = [
1043+
f
1044+
for f in dict.fromkeys(list(config.get("values", [])) + synth_sources)
1045+
if f in df_cols
1046+
]
10371047
show_subtotals = config.get("show_subtotals", False)
10381048
date_grains = config.get("date_grains", {})
10391049
auto_date_hierarchy = config.get("auto_date_hierarchy", True)
10401050

10411051
sidecar_fields = {
10421052
vf: agg
1043-
for vf in values
1053+
for vf in all_value_fields
10441054
if (agg := aggregation.get(vf, "sum")) in _SIDECAR_REQUIRED_AGGS
10451055
}
10461056
remap_only_fields = {
10471057
vf: agg
1048-
for vf in values
1058+
for vf in all_value_fields
10491059
if (agg := aggregation.get(vf, "sum")) in ("count", "count_distinct")
10501060
and vf not in sidecar_fields
10511061
}

streamlit_pivot/frontend/src/engine/PivotData.test.ts

Lines changed: 31 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2547,34 +2547,30 @@ describe("PivotData - formula engine in hybrid mode", () => {
25472547
expect(pd.getAggregator(["EU"], ["2024"], "margin").value()).toBe(200);
25482548
});
25492549

2550-
it("formula row totals work alongside hybrid pre-computed totals", () => {
2550+
it("formula row totals use hybrid pre-computed source values when available", () => {
25512551
const cfg = hybridFormulaConfig({ aggregation: "median" });
25522552
const totals = makeHybridTotals(cfg, { revenue: 187.5, cost: 70 }, [
25532553
{ key: ["US"], values: { revenue: 150, cost: 60 } },
25542554
{ key: ["EU"], values: { revenue: 225, cost: 80 } },
25552555
]);
25562556
const pd = new PivotData(HYBRID_DATA, cfg, { hybridTotals: totals });
2557-
// Source field row totals use hybrid pre-computed values
25582557
expect(pd.getRowTotal(["US"], "revenue").value()).toBe(150);
2559-
// Formula evaluates from client-side median aggregators:
2560-
// US rows: revenue [100, 200] → median 150, cost [40, 80] → median 60
2558+
// Formula uses hybrid-precomputed row values: revenue=150, cost=60
25612559
const margin = pd.getRowTotal(["US"], "margin").value();
25622560
expect(margin).toBe(90); // 150 - 60
25632561
});
25642562

2565-
it("formula grand total evaluates from client aggregators even with hybrid totals", () => {
2563+
it("formula grand total uses hybrid pre-computed source values when available", () => {
25662564
const cfg = hybridFormulaConfig({ aggregation: "median" });
25672565
const totals = makeHybridTotals(cfg, { revenue: 999, cost: 888 });
25682566
const pd = new PivotData(HYBRID_DATA, cfg, { hybridTotals: totals });
2569-
// Hybrid grand total for regular field uses pre-computed
25702567
expect(pd.getGrandTotal("revenue").value()).toBe(999);
2571-
// Formula grand total uses client-side median aggregators:
2572-
// revenue [100,200,150,300] → median 175, cost [40,80,60,100] → median 70
2568+
// Formula uses hybrid-precomputed grand values: revenue=999, cost=888
25732569
const margin = pd.getGrandTotal("margin").value();
2574-
expect(margin).toBe(105); // 175 - 70
2570+
expect(margin).toBe(111); // 999 - 888
25752571
});
25762572

2577-
it("formula col totals evaluate from client aggregators even with hybrid totals", () => {
2573+
it("formula col totals use hybrid pre-computed source values when available", () => {
25782574
const cfg = hybridFormulaConfig({ aggregation: "median" });
25792575
const totals = makeHybridTotals(
25802576
cfg,
@@ -2586,10 +2582,8 @@ describe("PivotData - formula engine in hybrid mode", () => {
25862582
],
25872583
);
25882584
const pd = new PivotData(HYBRID_DATA, cfg, { hybridTotals: totals });
2589-
// Hybrid col total for regular field uses pre-computed
25902585
expect(pd.getColTotal(["2023"], "revenue").value()).toBe(125);
2591-
// Formula col total uses client-side median aggregators:
2592-
// 2023 col: revenue [100, 150] → median 125, cost [40, 60] → median 50
2586+
// Formula uses hybrid-precomputed col values: revenue=125, cost=50
25932587
const margin = pd.getColTotal(["2023"], "margin").value();
25942588
expect(margin).toBe(75); // 125 - 50
25952589
});
@@ -2615,6 +2609,30 @@ describe("PivotData - formula engine in hybrid mode", () => {
26152609
expect(pd.getAggregator(["US"], ["2023"], "safe_ratio").value()).toBe(2.5);
26162610
});
26172611

2612+
it("formula prefers hybrid values over client-side re-aggregated values", () => {
2613+
const cfg = hybridFormulaConfig({ aggregation: "median" });
2614+
// Provide hybrid grand values that differ from what client-side re-aggregation would produce
2615+
const totals = makeHybridTotals(
2616+
cfg,
2617+
{ revenue: 500, cost: 200 },
2618+
[
2619+
{ key: ["US"], values: { revenue: 300, cost: 100 } },
2620+
{ key: ["EU"], values: { revenue: 400, cost: 150 } },
2621+
],
2622+
[
2623+
{ key: ["2023"], values: { revenue: 350, cost: 120 } },
2624+
{ key: ["2024"], values: { revenue: 450, cost: 180 } },
2625+
],
2626+
);
2627+
const pd = new PivotData(HYBRID_DATA, cfg, { hybridTotals: totals });
2628+
// Grand total formula should use hybrid values: 500 - 200 = 300
2629+
expect(pd.getGrandTotal("margin").value()).toBe(300);
2630+
// Row total formula should use hybrid values: 300 - 100 = 200
2631+
expect(pd.getRowTotal(["US"], "margin").value()).toBe(200);
2632+
// Col total formula should use hybrid values: 350 - 120 = 230
2633+
expect(pd.getColTotal(["2023"], "margin").value()).toBe(230);
2634+
});
2635+
26182636
it("formula source fields not in values are aggregated correctly in hybrid mode", () => {
26192637
const cfg = hybridFormulaConfig({
26202638
values: ["revenue"],

streamlit_pivot/frontend/src/engine/PivotData.ts

Lines changed: 48 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1210,7 +1210,10 @@ export class PivotData {
12101210
const value = this._evaluateSynthetic(
12111211
synthetic,
12121212
(sf) => this._rowTotalSumAggs.get(prefix + sf)?.value() ?? null,
1213-
(sf) => this._rowTotalAggs.get(prefix + sf)?.value() ?? null,
1213+
(sf) =>
1214+
this._hybridRowTotals?.get(prefix + sf) ??
1215+
this._rowTotalAggs.get(prefix + sf)?.value() ??
1216+
null,
12141217
);
12151218
return this._fixedAggregator(value);
12161219
}
@@ -1233,7 +1236,10 @@ export class PivotData {
12331236
const value = this._evaluateSynthetic(
12341237
synthetic,
12351238
(sf) => this._colTotalSumAggs.get(prefix + sf)?.value() ?? null,
1236-
(sf) => this._colTotalAggs.get(prefix + sf)?.value() ?? null,
1239+
(sf) =>
1240+
this._hybridColTotals?.get(prefix + sf) ??
1241+
this._colTotalAggs.get(prefix + sf)?.value() ??
1242+
null,
12371243
);
12381244
return this._fixedAggregator(value);
12391245
}
@@ -1255,7 +1261,10 @@ export class PivotData {
12551261
const value = this._evaluateSynthetic(
12561262
synthetic,
12571263
(sf) => this._grandTotalSumAggs.get(sf)?.value() ?? null,
1258-
(sf) => this._grandTotalAggs.get(sf)?.value() ?? null,
1264+
(sf) =>
1265+
this._hybridGrand?.get(sf) ??
1266+
this._grandTotalAggs.get(sf)?.value() ??
1267+
null,
12591268
);
12601269
return this._fixedAggregator(value);
12611270
}
@@ -1366,7 +1375,10 @@ export class PivotData {
13661375
const value = this._evaluateSynthetic(
13671376
synthetic,
13681377
(sf) => this._subtotalSumAggs?.get(prefix + sf)?.value() ?? null,
1369-
(sf) => this._subtotalAggs?.get(prefix + sf)?.value() ?? null,
1378+
(sf) =>
1379+
this._hybridSubtotals?.get(prefix + sf) ??
1380+
this._subtotalAggs?.get(prefix + sf)?.value() ??
1381+
null,
13701382
);
13711383
return this._fixedAggregator(value);
13721384
}
@@ -1681,7 +1693,10 @@ export class PivotData {
16811693
const value = this._evaluateSynthetic(
16821694
synthetic,
16831695
(sf) => this._colSubtotalSumAggs?.get(prefix + sf)?.value() ?? null,
1684-
(sf) => this._colSubtotalAggs?.get(prefix + sf)?.value() ?? null,
1696+
(sf) =>
1697+
this._hybridColPrefix?.get(prefix + sf) ??
1698+
this._colSubtotalAggs?.get(prefix + sf)?.value() ??
1699+
null,
16851700
);
16861701
return this._fixedAggregator(value);
16871702
}
@@ -1709,7 +1724,10 @@ export class PivotData {
17091724
const value = this._evaluateSynthetic(
17101725
synthetic,
17111726
(sf) => this._colSubtotalSumAggs?.get(prefix + sf)?.value() ?? null,
1712-
(sf) => this._colSubtotalAggs?.get(prefix + sf)?.value() ?? null,
1727+
(sf) =>
1728+
this._hybridColPrefixGrand?.get(`${colPrefixStr}\x01${sf}`) ??
1729+
this._colSubtotalAggs?.get(prefix + sf)?.value() ??
1730+
null,
17131731
);
17141732
return this._fixedAggregator(value);
17151733
}
@@ -1741,7 +1759,10 @@ export class PivotData {
17411759
const value = this._evaluateSynthetic(
17421760
synthetic,
17431761
(sf) => this._crossSubtotalSumAggs?.get(prefix + sf)?.value() ?? null,
1744-
(sf) => this._crossSubtotalAggs?.get(prefix + sf)?.value() ?? null,
1762+
(sf) =>
1763+
this._hybridCrossSubtotals?.get(prefix + sf) ??
1764+
this._crossSubtotalAggs?.get(prefix + sf)?.value() ??
1765+
null,
17451766
);
17461767
return this._fixedAggregator(value);
17471768
}
@@ -1869,7 +1890,10 @@ export class PivotData {
18691890
const value = this._evaluateSynthetic(
18701891
synthetic,
18711892
(sf) => aggs.get(prefix + sf)?.value() ?? null,
1872-
(sf) => aggs.get(prefix + sf)?.value() ?? null,
1893+
(sf) =>
1894+
this._hybridTemporalParent?.get(prefix + sf) ??
1895+
aggs.get(prefix + sf)?.value() ??
1896+
null,
18731897
);
18741898
return this._fixedAggregator(value);
18751899
}
@@ -1899,7 +1923,12 @@ export class PivotData {
18991923
const value = this._evaluateSynthetic(
19001924
synthetic,
19011925
(sf) => aggs.get(prefix + sf)?.value() ?? null,
1902-
(sf) => aggs.get(prefix + sf)?.value() ?? null,
1926+
(sf) =>
1927+
this._hybridTemporalParentGrand?.get(
1928+
`${modifiedColKeyStr}\x01${sf}`,
1929+
) ??
1930+
aggs.get(prefix + sf)?.value() ??
1931+
null,
19031932
);
19041933
return this._fixedAggregator(value);
19051934
}
@@ -2067,7 +2096,10 @@ export class PivotData {
20672096
const value = this._evaluateSynthetic(
20682097
synthetic,
20692098
(sf) => aggs.get(prefix + sf)?.value() ?? null,
2070-
(sf) => aggs.get(prefix + sf)?.value() ?? null,
2099+
(sf) =>
2100+
this._hybridTemporalRowParent?.get(prefix + sf) ??
2101+
aggs.get(prefix + sf)?.value() ??
2102+
null,
20712103
);
20722104
return this._fixedAggregator(value);
20732105
}
@@ -2096,7 +2128,12 @@ export class PivotData {
20962128
const value = this._evaluateSynthetic(
20972129
synthetic,
20982130
(sf) => aggs.get(prefix + sf)?.value() ?? null,
2099-
(sf) => aggs.get(prefix + sf)?.value() ?? null,
2131+
(sf) =>
2132+
this._hybridTemporalRowParentGrand?.get(
2133+
`${modifiedRowKeyStr}\x01${sf}`,
2134+
) ??
2135+
aggs.get(prefix + sf)?.value() ??
2136+
null,
21002137
);
21012138
return this._fixedAggregator(value);
21022139
}

tests/test_component_mount.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -273,10 +273,10 @@ def test_threshold_hybrid_preaggregates_compatible_large_configs(
273273
assert "Drill-down" in payload["server_mode_reason"]
274274

275275

276-
def test_threshold_hybrid_falls_back_for_incompatible_configs(
276+
def test_threshold_hybrid_works_with_synthetic_measures(
277277
sample_df, pivot_module, mount_recorder
278278
):
279-
"""Synthetic measures still force fallback to client_only."""
279+
"""Synthetic measures are now compatible with threshold_hybrid."""
280280
calls = mount_recorder()
281281
large_df = sample_df.loc[sample_df.index.repeat(20000)].reset_index(drop=True)
282282

@@ -300,7 +300,7 @@ def test_threshold_hybrid_falls_back_for_incompatible_configs(
300300
)
301301

302302
payload = calls[0]["data"]
303-
assert payload["execution_mode"] == "client_only"
303+
assert payload["execution_mode"] == "threshold_hybrid"
304304

305305

306306
def test_threshold_hybrid_median_no_longer_falls_back(

0 commit comments

Comments
 (0)