Skip to content

Commit 6d92ef7

Browse files
committed
Add report level prefilters
1 parent 58c5136 commit 6d92ef7

5 files changed

Lines changed: 434 additions & 10 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ Returns a `PivotTableResult` dict containing the current `config` state.
111111
| Parameter | Type | Default | Description |
112112
|-----------|------|---------|-------------|
113113
| `null_handling` | `str \| dict[str, str] \| None` | `None` | How to treat null/NaN values. See [Null Handling](#null-handling). |
114+
| `source_filters` | `dict[str, dict[str, list[Any]]] \| None` | `None` | Server-only report-level filters applied before any pivot processing. `include` takes precedence over `exclude`. `None` matches null-like values, `""` matches only literal empty strings, and no type coercion is performed. |
114115
| `hidden_attributes` | `list[str] \| None` | `None` | Column names to hide entirely from the UI. |
115116
| `hidden_from_aggregators` | `list[str] \| None` | `None` | Column names hidden from the values/aggregators dropdown only. |
116117
| `frozen_columns` | `list[str] \| None` | `None` | Column names that cannot be removed from their toolbar zone and cannot be reordered or moved via drag-and-drop. |

streamlit_pivot/__init__.py

Lines changed: 112 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,54 @@ def _estimate_group_count(df: Any, fields: list[str]) -> int:
146146
return min(len(df), int(prod(distinct_counts)))
147147

148148

149+
def _match_source_filter_values(series: Any, values: list[Any]) -> Any:
150+
"""Match raw source-filter values with explicit null handling.
151+
152+
``None`` in the filter list matches null-like pandas values via ``isna()``.
153+
Other values use raw ``isin()`` comparison with no type coercion.
154+
"""
155+
has_null = any(v is None for v in values)
156+
non_null = [v for v in values if v is not None]
157+
mask = pd.Series(False, index=series.index)
158+
if non_null:
159+
mask |= series.isin(non_null)
160+
if has_null:
161+
mask |= series.isna()
162+
return mask
163+
164+
165+
def _apply_source_filters(
166+
df: Any,
167+
source_filters: dict[str, dict[str, list[Any]]] | None,
168+
) -> Any:
169+
"""Apply server-only raw-value filters to the source DataFrame.
170+
171+
Semantics intentionally differ from ``_resolve_and_filter``:
172+
- raw Python values, not resolved frontend keys
173+
- ``None`` matches null-like pandas values
174+
- ``""`` matches only literal empty strings
175+
- no type coercion is performed
176+
- include takes precedence over exclude
177+
"""
178+
if not source_filters:
179+
return df
180+
mask = pd.Series(True, index=df.index)
181+
for field, filt in source_filters.items():
182+
if field not in df.columns:
183+
raise ValueError(
184+
f"source_filters contains column not in DataFrame: {field!r}. "
185+
f"Available columns: {sorted(df.columns.tolist())}"
186+
)
187+
col = df[field]
188+
include = filt.get("include")
189+
exclude = filt.get("exclude")
190+
if include:
191+
mask &= _match_source_filter_values(col, include)
192+
elif exclude:
193+
mask &= ~_match_source_filter_values(col, exclude)
194+
return df[mask]
195+
196+
149197
def _can_use_threshold_hybrid(config: PivotConfig) -> tuple[bool, str]:
150198
if config.get("synthetic_measures"):
151199
return False, "threshold_hybrid currently skips synthetic measures"
@@ -998,6 +1046,8 @@ def st_pivot_table(
9981046
enable_drilldown: bool = True,
9991047
export_filename: str | None = None,
10001048
execution_mode: str = "auto",
1049+
# Report-level filtering
1050+
source_filters: dict[str, dict[str, list[Any]]] | None = None,
10011051
) -> PivotTableResult:
10021052
"""Create a pivot table component.
10031053
@@ -1056,6 +1106,13 @@ def st_pivot_table(
10561106
``st.session_state[key]`` after the callback fires.
10571107
If None, a no-op is supplied at mount to satisfy the CCv2 contract
10581108
(every ``default={}`` key needs a matching ``on_<key>_change``).
1109+
source_filters : dict[str, dict[str, list[Any]]] or None
1110+
Server-only report-level filters applied to the source DataFrame
1111+
before any pivot processing. Unlike interactive ``config.filters``,
1112+
these filters are not sent to the frontend and are not tied to the
1113+
current row/column layout. ``include`` takes precedence over
1114+
``exclude``. ``None`` matches null-like values, while ``""`` matches
1115+
only literal empty strings. No type coercion is performed.
10591116
null_handling : str or dict[str, str] or None
10601117
How to treat null/NaN values. Global mode ("exclude", "zero",
10611118
"separate") or per-field dict mapping column names to modes.
@@ -1261,6 +1318,45 @@ def st_pivot_table(
12611318

12621319
# --- Column list type + membership validation ---
12631320
df_cols = set(data.columns)
1321+
1322+
if source_filters is not None:
1323+
if not isinstance(source_filters, dict):
1324+
raise TypeError(
1325+
f"source_filters must be a dict or None, got {type(source_filters).__name__}"
1326+
)
1327+
if not source_filters:
1328+
source_filters = None
1329+
else:
1330+
for field, filt in source_filters.items():
1331+
if not isinstance(field, str):
1332+
raise TypeError("source_filters keys must be strings")
1333+
if field not in df_cols:
1334+
raise ValueError(
1335+
f"source_filters contains column not in DataFrame: {field!r}. "
1336+
f"Available columns: {sorted(df_cols)}"
1337+
)
1338+
if not isinstance(filt, dict):
1339+
raise TypeError(f"source_filters[{field!r}] must be a dict")
1340+
extra_keys = [k for k in filt if k not in {"include", "exclude"}]
1341+
if extra_keys:
1342+
raise ValueError(
1343+
f"source_filters[{field!r}] contains unsupported keys: {extra_keys}. "
1344+
"Only 'include' and 'exclude' are allowed."
1345+
)
1346+
for op_name in ("include", "exclude"):
1347+
vals = filt.get(op_name)
1348+
if vals is None:
1349+
continue
1350+
if not isinstance(vals, list):
1351+
raise TypeError(
1352+
f"source_filters[{field!r}]['{op_name}'] must be a list"
1353+
)
1354+
for idx, value in enumerate(vals):
1355+
if not pd.api.types.is_scalar(value):
1356+
raise TypeError(
1357+
f"source_filters[{field!r}]['{op_name}'][{idx}] must be a scalar value"
1358+
)
1359+
12641360
for param_name, col_list in [
12651361
("rows", rows),
12661362
("columns", columns),
@@ -1280,18 +1376,22 @@ def st_pivot_table(
12801376
f"Available columns: {sorted(df_cols)}"
12811377
)
12821378

1379+
filtered_data = _apply_source_filters(data, source_filters)
1380+
12831381
# --- Auto-detect dimensions/measures when not specified ---
12841382
resolved_rows = rows
12851383
resolved_columns = columns
12861384
resolved_values = values
12871385

12881386
if resolved_rows is None and resolved_columns is None and resolved_values is None:
1289-
numeric_cols = data.select_dtypes(include="number").columns.tolist()
1290-
categorical_cols = [c for c in data.columns if c not in numeric_cols]
1387+
numeric_cols = filtered_data.select_dtypes(include="number").columns.tolist()
1388+
categorical_cols = [c for c in filtered_data.columns if c not in numeric_cols]
12911389
# Heuristic: numeric columns with few unique values (<=20) likely
12921390
# represent dimensions (e.g. Year) rather than measures.
1293-
likely_measures = [c for c in numeric_cols if data[c].nunique() > 20]
1294-
likely_numeric_dims = [c for c in numeric_cols if data[c].nunique() <= 20]
1391+
likely_measures = [c for c in numeric_cols if filtered_data[c].nunique() > 20]
1392+
likely_numeric_dims = [
1393+
c for c in numeric_cols if filtered_data[c].nunique() <= 20
1394+
]
12951395
# Treat low-cardinality numerics as dimensions alongside categoricals
12961396
all_dims = categorical_cols + likely_numeric_dims
12971397
resolved_rows = all_dims[:1] if all_dims else []
@@ -1484,7 +1584,7 @@ def st_pivot_table(
14841584
# reruns, but let explicit Python config changes take precedence.
14851585
config_to_send = _resolve_config_to_send(st.session_state, key, initial_config)
14861586
use_threshold_hybrid, threshold_reason = _should_use_threshold_hybrid(
1487-
data, config_to_send, execution_mode
1587+
filtered_data, config_to_send, execution_mode
14881588
)
14891589
if use_threshold_hybrid:
14901590
drill_note = (
@@ -1494,9 +1594,9 @@ def st_pivot_table(
14941594
if drill_note not in threshold_reason:
14951595
threshold_reason = f"{threshold_reason}{drill_note}"
14961596
materialized_data = (
1497-
_prepare_threshold_hybrid_frame(data, config_to_send, null_handling)
1597+
_prepare_threshold_hybrid_frame(filtered_data, config_to_send, null_handling)
14981598
if use_threshold_hybrid
1499-
else data
1599+
else filtered_data
15001600
)
15011601
effective_execution_mode = (
15021602
"threshold_hybrid" if use_threshold_hybrid else "client_only"
@@ -1513,12 +1613,14 @@ def st_pivot_table(
15131613
}
15141614

15151615
if use_threshold_hybrid:
1516-
data_payload["source_row_count"] = len(data)
1616+
data_payload["source_row_count"] = len(filtered_data)
15171617
agg_dict = config_to_send.get("aggregation", {})
15181618
agg_remap = _build_hybrid_agg_remap(agg_dict)
15191619
if agg_remap:
15201620
data_payload["hybrid_agg_remap"] = agg_remap
1521-
totals_sidecar = _compute_hybrid_totals(data, config_to_send, null_handling)
1621+
totals_sidecar = _compute_hybrid_totals(
1622+
filtered_data, config_to_send, null_handling
1623+
)
15221624
if totals_sidecar:
15231625
data_payload["hybrid_totals"] = totals_sidecar
15241626

@@ -1568,7 +1670,7 @@ def st_pivot_table(
15681670
config_to_send.get("columns", [])
15691671
)
15701672
records, columns, total, page = _compute_hybrid_drilldown(
1571-
data,
1673+
filtered_data,
15721674
drilldown_request,
15731675
null_handling=null_handling,
15741676
dims=all_dims,

tests/test_api_validation.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,61 @@ def test_invalid_column_alignment_raises(pivot_module, sample_df):
9898
)
9999

100100

101+
def test_invalid_source_filters_type_raises(pivot_module, sample_df):
102+
with pytest.raises(TypeError, match="source_filters must be a dict or None"):
103+
pivot_module.st_pivot_table(
104+
sample_df,
105+
key="pivot",
106+
rows=["Region"],
107+
columns=["Year"],
108+
values=["Revenue"],
109+
source_filters=["not-a-dict"],
110+
)
111+
112+
113+
def test_source_filters_unknown_column_raises(pivot_module, sample_df):
114+
with pytest.raises(
115+
ValueError, match="source_filters contains column not in DataFrame"
116+
):
117+
pivot_module.st_pivot_table(
118+
sample_df,
119+
key="pivot",
120+
rows=["Region"],
121+
columns=["Year"],
122+
values=["Revenue"],
123+
source_filters={"Missing": {"include": ["x"]}},
124+
)
125+
126+
127+
def test_source_filters_non_list_operand_raises(pivot_module, sample_df):
128+
with pytest.raises(
129+
TypeError, match=r"source_filters\['Region'\]\['include'\] must be a list"
130+
):
131+
pivot_module.st_pivot_table(
132+
sample_df,
133+
key="pivot",
134+
rows=["Region"],
135+
columns=["Year"],
136+
values=["Revenue"],
137+
source_filters={"Region": {"include": "East"}},
138+
)
139+
140+
141+
def test_source_filters_non_scalar_value_raises(pivot_module, sample_df):
142+
with pytest.raises(
143+
TypeError,
144+
match=r"source_filters\['Region'\]\['include'\]\[0\] must be a scalar value",
145+
):
146+
pivot_module.st_pivot_table(
147+
sample_df,
148+
key="pivot",
149+
rows=["Region"],
150+
columns=["Year"],
151+
values=["Revenue"],
152+
source_filters={"Region": {"include": [["East"]]}},
153+
)
154+
155+
101156
def test_duplicate_synthetic_measure_ids_raise(pivot_module, sample_df):
102157
with pytest.raises(ValueError, match="duplicate synthetic_measures id"):
103158
pivot_module.st_pivot_table(

0 commit comments

Comments
 (0)