@@ -389,7 +389,13 @@ def metric_needs_window(m):
389389 pass
390390
391391 # Classify filters for pushdown optimization
392- pushdown_filters , main_query_filters = self ._classify_filters_for_pushdown (filters or [], all_models )
392+ pushdown_filters , main_query_filters , window_dim_filters = self ._classify_filters_for_pushdown (
393+ filters or [], all_models
394+ )
395+ # In the standard (non-preagg) path, window-dim filters belong in the
396+ # outer query WHERE, so merge them into main_query_filters.
397+ for wf_list in window_dim_filters .values ():
398+ main_query_filters .extend (wf_list )
393399
394400 # Determine which models have filters (for join type decision)
395401 models_with_filters = set ()
@@ -657,7 +663,7 @@ def collect_models_from_metric(metric_ref: str):
657663
658664 def _classify_filters_for_pushdown (
659665 self , filters : list [str ], all_models : set [str ]
660- ) -> tuple [dict [str , list [str ]], list [str ]]:
666+ ) -> tuple [dict [str , list [str ]], list [str ], dict [ str , list [ str ]] ]:
661667 """Classify filters into those that can be pushed down vs those that must stay in main query.
662668
663669 Compound AND expressions are split into individual conjuncts first so that
@@ -670,12 +676,17 @@ def _classify_filters_for_pushdown(
670676 all_models: Set of all model names in the query
671677
672678 Returns:
673- Tuple of (pushdown_filters_by_model, main_query_filters)
679+ Tuple of (pushdown_filters_by_model, main_query_filters, window_dim_filters_by_model )
674680 - pushdown_filters_by_model: Dict mapping model name to list of filters for that model
675- - main_query_filters: Filters that reference multiple models (can't push down)
681+ - main_query_filters: Filters that reference multiple models or metrics (can't push down)
682+ - window_dim_filters_by_model: Dict mapping model name to filters on window dimensions.
683+ These can't be pushed into CTE WHERE (window not yet evaluated) but reference a
684+ single model. In the standard path they belong in the outer query; in the preagg
685+ path they are pushed into each model's sub-query (which has its own outer WHERE).
676686 """
677687 pushdown_filters = {model : [] for model in all_models }
678688 main_query_filters = []
689+ window_dim_filters : dict [str , list [str ]] = {model : [] for model in all_models }
679690
680691 # Flatten compound AND expressions into individual conjuncts so each
681692 # part can be classified independently.
@@ -727,9 +738,15 @@ def _classify_filters_for_pushdown(
727738 # because metrics don't exist in CTEs (only _raw columns)
728739 if references_metric :
729740 main_query_filters .append (filter_expr )
730- # Filters on window dimensions must stay in outer query because the
731- # window function hasn't been evaluated yet in the CTE WHERE context
741+ # Single-model window-dim filters: kept separate so callers can
742+ # handle them appropriately. In the standard path they go to the
743+ # outer WHERE; in the preagg path they are pushed into each model's
744+ # sub-query (which has its own outer WHERE after window evaluation).
745+ elif references_window_dim and len (referenced_models ) == 1 :
746+ model_name = list (referenced_models )[0 ]
747+ window_dim_filters [model_name ].append (filter_expr )
732748 elif references_window_dim :
749+ # Window-dim filter spanning multiple models: outer query
733750 main_query_filters .append (filter_expr )
734751 # If filter references exactly one model and no metrics, push it down
735752 elif len (referenced_models ) == 1 :
@@ -739,7 +756,7 @@ def _classify_filters_for_pushdown(
739756 # Filter references multiple models or no models - keep in main query
740757 main_query_filters .append (filter_expr )
741758
742- return pushdown_filters , main_query_filters
759+ return pushdown_filters , main_query_filters , window_dim_filters
743760
744761 def _extract_metric_filter_columns (self , metrics : list [str ]) -> dict [str , set [str ]]:
745762 """Extract columns referenced in metric-level filters and SQL expressions.
@@ -1439,7 +1456,9 @@ def _generate_with_preaggregation(
14391456 # Cross-model filters (referencing models outside the sub-query) would
14401457 # produce invalid SQL referencing CTEs that don't exist.
14411458 all_model_names = set (metrics_by_model .keys ())
1442- pushdown_by_model , shared_filters = self ._classify_filters_for_pushdown (all_filters , all_model_names )
1459+ pushdown_by_model , shared_filters , window_dim_filters = self ._classify_filters_for_pushdown (
1460+ all_filters , all_model_names
1461+ )
14431462
14441463 # Generate a pre-aggregated CTE for each metric model
14451464 preagg_ctes = []
@@ -1449,8 +1468,11 @@ def _generate_with_preaggregation(
14491468 cte_name = f"{ model_name } _preagg"
14501469 cte_names .append (cte_name )
14511470
1452- # Only pass filters relevant to this model's sub-query
1453- model_filters = pushdown_by_model .get (model_name , [])
1471+ # Only pass filters relevant to this model's sub-query.
1472+ # Window-dim filters are included here (not in shared_filters) because
1473+ # preagg CTEs only project query dimensions/metrics, not window dims.
1474+ # The recursive generate() call handles them correctly in its outer WHERE.
1475+ model_filters = pushdown_by_model .get (model_name , []) + window_dim_filters .get (model_name , [])
14541476
14551477 # Generate sub-query for this model's metrics at the dimension grain
14561478 # We call generate() recursively but it won't trigger pre-aggregation
0 commit comments