@@ -149,6 +149,15 @@ def _estimate_group_count(df: Any, fields: list[str]) -> int:
149149def _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
0 commit comments