From 9285edf3da5ac435e3ea4bb8c878591aa28555b8 Mon Sep 17 00:00:00 2001 From: Hussain Sultan Date: Fri, 8 May 2026 07:27:33 -0400 Subject: [PATCH] fix: preserve preagg + canonical backend through with_measures on joins SemanticJoin.with_measures()/with_dimensions() wraps the joined table in a new SemanticTableOp with name=None. _to_untagged_with_preagg partitioned aggregates via _partition_agg_specs_by_source(plan.agg_specs, all_roots) where all_roots = [wrapper_op]; since the wrapper has no name, prefixed aggregates collapsed into the unprefixed bucket and bypassed per-grain pre-aggregation, fanning out the coarser table's measures. Partition by _find_all_root_models(join_op) instead, recovering the original per-table roots from the SemanticJoinOp. For non-wrapper paths this is equivalent to all_roots; only the wrapper case changes behavior. Also: to_untagged() now applies _rebind_to_canonical_backend so callers of to_untagged()/to_tagged() get expressions that .execute() directly. SemanticTable.execute()/.compile()/.sql() already did this rebind; the public conversion functions did not, surfacing "Multiple backends found" errors on join + preagg lowering. to_tagged() benefits transitively. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/boring_semantic_layer/expr.py | 6 ++++-- src/boring_semantic_layer/ops.py | 8 +++++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/boring_semantic_layer/expr.py b/src/boring_semantic_layer/expr.py index db75766..d11fd2c 100644 --- a/src/boring_semantic_layer/expr.py +++ b/src/boring_semantic_layer/expr.py @@ -109,12 +109,14 @@ def to_untagged(expr): + from .ops import _rebind_to_canonical_backend + if isinstance(expr, SemanticTable): - return expr.op().to_untagged() + return _rebind_to_canonical_backend(expr.op().to_untagged()) result = safe(lambda: expr.to_untagged())() if isinstance(result, Success): - return result.unwrap() + return _rebind_to_canonical_backend(result.unwrap()) raise TypeError(f"Cannot convert {type(expr)} to Ibis expression") diff --git a/src/boring_semantic_layer/ops.py b/src/boring_semantic_layer/ops.py index 038bd0c..c163639 100644 --- a/src/boring_semantic_layer/ops.py +++ b/src/boring_semantic_layer/ops.py @@ -2510,7 +2510,13 @@ def _to_untagged_with_preagg( ) # --- 3. Partition agg_specs by source table --- - partitioned = _partition_agg_specs_by_source(dict(plan.agg_specs), all_roots) + # Partition by the join's per-table roots, not ``all_roots``. When a + # wrapper SemanticTableOp from ``SemanticJoin.with_measures()`` / + # ``with_dimensions()`` is in the tree, ``all_roots`` collapses to a + # single name=None root, so prefixed aggregates would all fall into the + # unprefixed bucket and bypass per-grain pre-aggregation. + partition_roots = _find_all_root_models(join_op) + partitioned = _partition_agg_specs_by_source(dict(plan.agg_specs), partition_roots) # --- 4. Pre-aggregate each source table on its raw table --- _preagg_results: list = []