Skip to content

Commit a396044

Browse files
refactor phase 3a: break the core knot — the import graph is now a DAG (#298)
* test: SQL-snapshot corpus guarding the phase-3 compiler restructure Ten representative query shapes (flat agg, filters+order+limit, derived time dim, join_one, join_many fan-out pre-agg, mean decomposition, calc percent-of-total, three-way join, filtered join, JSON query path) compile to golden SQL files. Any compilation-output drift during the restructure fails loudly; BSL_UPDATE_SNAPSHOTS=1 regenerates after an intentional change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor: ops no longer imports expr, query, or format — the compiler leaves the SCC - The 12 lazy 'from .expr import' sites (fluent wrapper construction on SemanticJoinOp/SemanticIndexOp, _semantic_table) and the build_query site now go through documented call-time module accessors — the same upward-dispatch pattern as .chart(); follow-up moves those methods onto the wrappers entirely - expr.SemanticJoin.group_by/filter construct their wrappers directly instead of ping-ponging through the op layer - _collect_struct (flavor-aware struct collection — nest lowering) moves from expr.py down into ops.py where it's consumed - The SemanticUnnest isinstance check duck-types on SemanticUnnestOp - ops imports fmt from ibis directly (same singledispatch registry the format module registers handlers on) instead of importing format - The SCC scanner now skips TYPE_CHECKING blocks: type-only imports are not runtime edges SQL snapshots byte-identical; SCC: 10 -> 5 modules (expr/query/api + serialization/{__init__,reconstruct}). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor: the import graph is a DAG — SCC allowlist emptied expr's .query()/.compare_periods() and to_tagged() convenience methods now dispatch upward at call time (the .chart() pattern) instead of importing the query and serialization layers. That was the last cycle: Tarjan over the full graph (lazy imports included) finds zero non-trivial SCCs. The ratchet now enforces permanent acyclicity. Full suite green (1703 passed, exit 0); SQL snapshots byte-identical. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 1e2d784 commit a396044

14 files changed

Lines changed: 903 additions & 83 deletions

‎src/boring_semantic_layer/expr.py‎

Lines changed: 25 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,6 @@
1212
from ibis.expr.types.relations import Table as IbisTable
1313
from returns.result import Success, safe
1414

15-
from ._xorq import (
16-
Column as XorqColumn,
17-
)
1815
from ._xorq import (
1916
GroupedTable,
2017
Table,
@@ -35,6 +32,7 @@
3532
SemanticTableOp,
3633
SemanticUnnestOp,
3734
_classify_measure,
35+
_collect_struct,
3836
_exact_filter_fields,
3937
_extract_columns_from_callable,
4038
_extract_join_key_columns,
@@ -46,8 +44,6 @@
4644
_unwrap,
4745
make_bare_ref_lambda,
4846
)
49-
from .query import compare_periods as build_compare_periods
50-
from .query import query as build_query
5147

5248
logger = logging.getLogger(__name__)
5349

@@ -147,10 +143,25 @@ def _flatten_group_keys(keys: tuple) -> tuple:
147143
return tuple(flat)
148144

149145

146+
def _query_module():
147+
"""Call-time accessor for the query layer (which sits above expr).
148+
149+
The .query()/.compare_periods() convenience methods dispatch upward the
150+
same way .chart() does — resolved at call time so the expression layer
151+
holds no import-time dependency on the layers built on top of it.
152+
"""
153+
import importlib
154+
155+
return importlib.import_module("boring_semantic_layer.query")
156+
157+
150158
def to_tagged(expr, aggregate_cache_storage=None):
151-
from .serialization import to_tagged as _to_tagged
159+
# Serialization sits above the expression layer; resolve at call time
160+
# (see _query_module).
161+
import importlib
152162

153-
return _to_tagged(expr, aggregate_cache_storage=aggregate_cache_storage)
163+
ser = importlib.import_module("boring_semantic_layer.serialization")
164+
return ser.to_tagged(expr, aggregate_cache_storage=aggregate_cache_storage)
154165

155166

156167
class SemanticTable(ir.Table):
@@ -355,8 +366,6 @@ def to_untagged(self):
355366
return self.op().to_untagged()
356367

357368
def to_tagged(self, aggregate_cache_storage=None):
358-
from .serialization import to_tagged
359-
360369
return to_tagged(self, aggregate_cache_storage=aggregate_cache_storage)
361370

362371
def execute(self, **kwargs):
@@ -1221,7 +1230,7 @@ def query(
12211230
time_range: dict[str, str] | None = None,
12221231
having: list | None = None,
12231232
):
1224-
return build_query(
1233+
return _query_module().query(
12251234
semantic_table=self,
12261235
dimensions=dimensions,
12271236
measures=measures,
@@ -1247,7 +1256,7 @@ def compare_periods(
12471256
order_by: Sequence[tuple[str, str]] | None = None,
12481257
limit: int | None = None,
12491258
):
1250-
return build_compare_periods(
1259+
return _query_module().compare_periods(
12511260
semantic_table=self,
12521261
dimensions=dimensions,
12531262
measures=measures,
@@ -1412,7 +1421,7 @@ def query(
14121421
time_range: dict[str, str] | None = None,
14131422
having: list | None = None,
14141423
):
1415-
return build_query(
1424+
return _query_module().query(
14161425
semantic_table=self,
14171426
dimensions=dimensions,
14181427
measures=measures,
@@ -1507,10 +1516,10 @@ def join(self, *args, **kwargs):
15071516

15081517
def group_by(self, *keys: str | Deferred):
15091518
normalized = tuple(_normalize_to_name(k) for k in _flatten_group_keys(keys))
1510-
return self.op().group_by(*normalized)
1519+
return SemanticGroupBy(source=self.op(), keys=normalized)
15111520

15121521
def filter(self, predicate: Callable):
1513-
return self.op().filter(predicate)
1522+
return SemanticFilter(source=self.op(), predicate=predicate)
15141523

15151524

15161525
class SemanticFilter(SemanticTable):
@@ -1591,7 +1600,7 @@ def query(
15911600
time_range: dict[str, str] | None = None,
15921601
having: list | None = None,
15931602
):
1594-
return build_query(
1603+
return _query_module().query(
15951604
semantic_table=self,
15961605
dimensions=dimensions,
15971606
measures=measures,
@@ -1617,7 +1626,7 @@ def compare_periods(
16171626
order_by: Sequence[tuple[str, str]] | None = None,
16181627
limit: int | None = None,
16191628
):
1620-
return build_compare_periods(
1629+
return _query_module().compare_periods(
16211630
semantic_table=self,
16221631
dimensions=dimensions,
16231632
measures=measures,
@@ -1714,20 +1723,6 @@ def join(self, *args, **kwargs):
17141723
raise TypeError(_JOIN_REMOVED_MESSAGE)
17151724

17161725

1717-
def _collect_struct(struct_dict: dict[str, Any], **collect_kwargs):
1718-
"""Build ``struct(...).collect()`` from columns of a single ibis flavor.
1719-
1720-
``ibis.struct`` and ``xorq.vendor.ibis.struct`` are not interchangeable:
1721-
each can only infer types from columns of its own module.
1722-
"""
1723-
first_col = next(iter(struct_dict.values()))
1724-
if isinstance(first_col, XorqColumn):
1725-
from ._xorq import ibis as xibis
1726-
1727-
return xibis.struct(struct_dict).collect(**collect_kwargs)
1728-
return ibis.struct(struct_dict).collect(**collect_kwargs)
1729-
1730-
17311726
def _make_row_struct_collector(columns: tuple[str, ...]) -> Callable:
17321727
"""Per-row struct collection for the bare ``group_by`` nest form.
17331728

‎src/boring_semantic_layer/ops.py‎

Lines changed: 58 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@
1919
from returns.result import safe
2020

2121
from . import projection_utils
22+
from ._xorq import (
23+
Column as XorqColumn,
24+
)
2225
from ._xorq import (
2326
FrozenDict,
2427
FrozenOrderedDict,
@@ -455,10 +458,27 @@ def _to_untagged(source: Any) -> ir.Table:
455458
return source.to_untagged() if hasattr(source, "to_untagged") else source.to_expr()
456459

457460

458-
def _semantic_table(*args, **kwargs) -> SemanticTable:
459-
from .expr import SemanticModel
461+
def _expr_module():
462+
"""Call-time accessor for the expression layer (the layer above ops).
463+
464+
Fluent convenience methods on op classes construct user-facing wrappers;
465+
resolving the module at call time keeps ops free of any import-time
466+
dependency on expr. Follow-up: move those methods onto the wrappers.
467+
"""
468+
import importlib
469+
470+
return importlib.import_module("boring_semantic_layer.expr")
471+
472+
473+
def _query_module():
474+
"""Call-time accessor for the query layer (see ``_expr_module``)."""
475+
import importlib
460476

461-
return SemanticModel(*args, **kwargs)
477+
return importlib.import_module("boring_semantic_layer.query")
478+
479+
480+
def _semantic_table(*args, **kwargs) -> SemanticTable:
481+
return _expr_module().SemanticModel(*args, **kwargs)
462482

463483

464484
def _unwrap(wrapped: Any) -> Any:
@@ -576,8 +596,16 @@ def _format_op_summary(op: Relation) -> str:
576596

577597

578598
def _format_root(root_op: SemanticTableOp) -> str:
579-
"""Format a SemanticTableOp root using the fmt registry from format.py."""
580-
from boring_semantic_layer.format import fmt
599+
"""Format a SemanticTableOp root using the ``fmt`` singledispatch registry.
600+
601+
``format.py`` (imported by the package ``__init__``) registers the
602+
semantic-op handlers on ibis's ``fmt``; import it from the same place
603+
so we hit the same registry without depending on the format module.
604+
"""
605+
try:
606+
from ibis.expr.format import fmt
607+
except ImportError:
608+
from ._xorq import fmt
581609

582610
try:
583611
return fmt(root_op)
@@ -1003,6 +1031,20 @@ def _classify_dependencies(
10031031
}
10041032

10051033

1034+
def _collect_struct(struct_dict: dict[str, Any], **collect_kwargs):
1035+
"""Build ``struct(...).collect()`` from columns of a single ibis flavor.
1036+
1037+
``ibis.struct`` and ``xorq.vendor.ibis.struct`` are not interchangeable:
1038+
each can only infer types from columns of its own module.
1039+
"""
1040+
first_col = next(iter(struct_dict.values()))
1041+
if isinstance(first_col, XorqColumn):
1042+
from ._xorq import ibis as xibis
1043+
1044+
return xibis.struct(struct_dict).collect(**collect_kwargs)
1045+
return ibis.struct(struct_dict).collect(**collect_kwargs)
1046+
1047+
10061048
@frozen
10071049
class _CallableWrapper:
10081050
"""Hashable wrapper for Callable and Deferred.
@@ -1092,10 +1134,8 @@ def _infer_unnest(fn: Callable, table: Any) -> tuple[str, ...]:
10921134
to_semantic_table(tbl).unnest("hits").with_measures(...) -> ("hits",)
10931135
unnested.unnest("product").with_measures(...) -> ("product",)
10941136
"""
1095-
from .expr import SemanticUnnest
1096-
1097-
if isinstance(table, SemanticUnnest):
1098-
op = table.op()
1137+
op = table.op() if hasattr(table, "op") else None
1138+
if isinstance(op, SemanticUnnestOp):
10991139
# SemanticUnnestOp always has column attribute
11001140
return (op.column,)
11011141

@@ -4058,7 +4098,6 @@ def _to_untagged_with_nest(self, nest_specs: dict[str, NestAggSpec]):
40584098
before collection; ``order_by``/``limit`` order and truncate each
40594099
group's array.
40604100
"""
4061-
from .expr import _collect_struct
40624101

40634102
plain_aggs = {name: fn for name, fn in self.aggs.items() if name not in nest_specs}
40644103
outer_keys = list(self.keys)
@@ -5791,9 +5830,7 @@ def query(
57915830
time_range: dict[str, str] | None = None,
57925831
having: list | None = None,
57935832
):
5794-
from .query import query as build_query
5795-
5796-
return build_query(
5833+
return _query_module().query(
57975834
semantic_table=self,
57985835
dimensions=dimensions,
57995836
measures=measures,
@@ -5842,14 +5879,10 @@ def with_measures(self, **meas) -> SemanticTable:
58425879
)
58435880

58445881
def group_by(self, *keys: str) -> SemanticGroupBy:
5845-
from .expr import SemanticGroupBy
5846-
5847-
return SemanticGroupBy(source=self, keys=keys)
5882+
return _expr_module().SemanticGroupBy(source=self, keys=keys)
58485883

58495884
def filter(self, predicate: Callable) -> SemanticFilter:
5850-
from .expr import SemanticFilter
5851-
5852-
return SemanticFilter(source=self, predicate=predicate)
5885+
return _expr_module().SemanticFilter(source=self, predicate=predicate)
58535886

58545887
def join_one(
58555888
self,
@@ -5858,9 +5891,7 @@ def join_one(
58585891
how: str = "left",
58595892
):
58605893
"""Join with one-to-one relationship semantics (left outer join)."""
5861-
from .expr import _join_one_with_detected_grain
5862-
5863-
return _join_one_with_detected_grain(self, other, on, how)
5894+
return _expr_module()._join_one_with_detected_grain(self, other, on, how)
58645895

58655896
def join_many(
58665897
self,
@@ -5869,9 +5900,7 @@ def join_many(
58695900
how: str = "left",
58705901
):
58715902
"""Join with one-to-many relationship semantics."""
5872-
from .expr import SemanticJoin
5873-
5874-
return SemanticJoin(
5903+
return _expr_module().SemanticJoin(
58755904
left=self,
58765905
right=other.op(),
58775906
on=on,
@@ -5881,9 +5910,7 @@ def join_many(
58815910

58825911
def join_cross(self, other: SemanticTable):
58835912
"""Cross join (Cartesian product) with another semantic model."""
5884-
from .expr import SemanticJoin
5885-
5886-
return SemanticJoin(
5913+
return _expr_module().SemanticJoin(
58875914
left=self,
58885915
right=other.op(),
58895916
on=None,
@@ -6858,19 +6885,13 @@ def build_fragment(field_name: str) -> Any:
68586885
return reduce(lambda acc, frag: acc.union(frag), fragments[1:], fragments[0])
68596886

68606887
def filter(self, predicate: Callable) -> SemanticFilter:
6861-
from .expr import SemanticFilter
6862-
6863-
return SemanticFilter(source=self, predicate=predicate)
6888+
return _expr_module().SemanticFilter(source=self, predicate=predicate)
68646889

68656890
def order_by(self, *keys: str | ir.Value | Callable) -> SemanticOrderBy:
6866-
from .expr import SemanticOrderBy
6867-
6868-
return SemanticOrderBy(source=self, keys=keys)
6891+
return _expr_module().SemanticOrderBy(source=self, keys=keys)
68696892

68706893
def limit(self, n: int, offset: int = 0) -> SemanticLimit:
6871-
from .expr import SemanticLimit
6872-
6873-
return SemanticLimit(source=self, n=n, offset=offset)
6894+
return _expr_module().SemanticLimit(source=self, n=n, offset=offset)
68746895

68756896
def execute(self):
68766897
return _rebind_to_canonical_backend(self.to_untagged()).execute()
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
SELECT
2+
"t3"."region",
3+
"t3"."revenue",
4+
"t3"."revenue" / "t3"."__bsl_totals__revenue" AS "revenue_share"
5+
FROM (
6+
SELECT
7+
"t2"."region",
8+
SUM("t2"."amount") AS "revenue",
9+
ANY_VALUE("t2"."__bsl_totals__revenue") AS "__bsl_totals__revenue"
10+
FROM (
11+
SELECT
12+
"t1"."order_id",
13+
"t1"."customer_id",
14+
"t1"."region",
15+
"t1"."amount",
16+
"t1"."ts",
17+
SUM("t1"."amount") OVER (ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS "__bsl_totals__revenue"
18+
FROM (
19+
SELECT
20+
*
21+
FROM "memory"."main"."orders" AS "t0"
22+
) AS "t1"
23+
) AS "t2"
24+
GROUP BY
25+
1
26+
) AS "t3"
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
SELECT
2+
*
3+
FROM (
4+
SELECT
5+
"t1"."month",
6+
COUNT(*) AS "order_count"
7+
FROM (
8+
SELECT
9+
"t0"."order_id",
10+
"t0"."customer_id",
11+
"t0"."region",
12+
"t0"."amount",
13+
"t0"."ts",
14+
DATE_TRUNC('MONTH', "t0"."ts") AS "month"
15+
FROM "memory"."main"."orders" AS "t0"
16+
) AS "t1"
17+
GROUP BY
18+
1
19+
) AS "t2"

0 commit comments

Comments
 (0)