DEV-1450 principled redesign of syntax (WIP)#139
Conversation
Add `slayer/core/keys.py` with the typed-key family that the new resolution pipeline uses for structural identity (P2): - `ColumnKey(path, leaf)` — row-level base column ref. Local and cross-model share this shape; only `path` (empty vs non-empty) distinguishes (P3). - `ColumnSqlKey(model, column_name)` — derived column ref. - `StarKey` — sentinel source for `*:count`. - `SqlExprKey(canonical_sql)` — identity for Mode-A fragments (used as `AggregateKey.column_filter_key`). - `AggregateKey(source, agg, args, kwargs, column_filter_key)` — unified local + cross-model aggregate identity. Kwargs sorted by validator; scalars normalised; `column_filter_key` distinguishes same-column-different-filter aggregates. - `TransformKey(op, input, args, kwargs, partition_keys, time_key)` — window / temporal operator over a value. `partition_keys` is a frozenset (order-independent). - `ArithmeticKey(op, operands)` — operand order matters. - `ScalarCallKey(name, args)` — closed-allowlist scalar call (C12). - `Phase` IntEnum (ROW=0, AGGREGATE=1, POST=2) — `.phase` property on every key; ArithmeticKey/ScalarCallKey take max over operands. - `SCALAR_FUNCTIONS` frozenset — the closed allowlist. - `normalize_scalar(value)` — bool/None passthrough; int→Decimal; float→Decimal(str(...)) to avoid binary imprecision; Decimal/str passthrough; anything else raises TypeError. All keys are frozen Pydantic models; hashable; usable as dict keys for slot interning. 60 tests cover identity, hashing, kwarg canonicalisation, phase composition, and the C12 allowlist contents. Dormant: no engine code routes through these yet (stages 7a/7b wire them up). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add the typed scope kinds (P5) and the resolved-source bundle (P11): - `slayer/core/scope.py` — `StageColumn` (typed projection element, P6), `StageSchema` (flat-namespace projection of one stage that downstream stages bind against), `ModelScope` (join-graph-bearing scope used during binding). - `slayer/engine/source_bundle.py` — `ResolvedSourceBundle`: eagerly resolved query inputs (source_model + referenced_models + inline_extensions + named_queries + query_variables + datasource_hint). Built once by the orchestrator; the binder reads purely (P11 — no ContextVar machinery). I2 (anchor-optional, extension injection): `ModelScope.source_model` and `ResolvedSourceBundle.source_model` are `Optional[SlayerModel]` from day one. DEV-1450's binder asserts `source_model is not None` at use sites so behavior is unchanged. The type-level optionality is the extension point for a future anchor-less mode (where the global join graph is auto-discovered from `model.column` refs). 25 tests cover: hidden vs visible slots, flat namespace contract (__-bearing names are flat in StageSchema), lookup helpers, construction with/without source_model. Dormant: no engine code routes through these yet. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Move the consolidated DEV-1369 join walker out of `SlayerQueryEngine` into `slayer/engine/path_resolution.py` so the new binder modules can import it directly without dragging the engine in. - New `walk_join_chain(*, source_model, hop_names, resolve_model, named_queries, strict_missing_join)` — free function that takes the engine's `_resolve_model` as a callback. - `NoJoinError` sentinel moves with it (the lenient-missing-join signal used by dimension-resolution callers). - `SlayerQueryEngine._walk_join_chain` becomes a thin shim that passes `self._resolve_model` to the new function. All existing call sites keep their signature. - `_NoJoinError` is re-imported into the engine module under the same name so internal usages don't change. 13 tests pin the contract: cycle detection, strict vs lenient missing-join, multi-hop returns (terminal_model, first_join), prefer_data_source threading, named_queries default. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lift agg-name collection and parameter resolution out of enrichment.py and generator.py into `slayer/engine/agg_registry.py` so the new binder modules don't have to reach into those tangles. Helpers are pure: given a model + a resolve_join_target callback, they produce structured results without touching storage or spawning side maps. - `collect_reachable_agg_names(source_model, resolve_join_target, named_queries)` — BFS the join graph for custom aggregation names (lifted from `enrichment.py:_collect_reachable_agg_names`). Cycle-safe via visited set. - `is_known_aggregation_name(name, custom_names)` — built-in or in the custom set. - `resolve_aggregation(name, available_aggs)` — find the `Aggregation` definition for a name, returning None when only a built-in default should apply. - `required_params_for(agg_name)` — required built-in params (e.g. `weighted_avg` requires `weight`). - `merge_agg_params(agg_def, query_kwargs)` — combine defaults with query-time overrides. 25 tests cover the BFS, custom-name lookup, builtin-override resolution, and param merging. Dormant: existing enrichment / generator code still inlines its own logic; stages 7a/7b switch over. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add the typed error / warning classes for the new pipeline with a
stable str() format so snapshot tests can pin the contract:
<ErrorName>: <one-line summary>
at <location>
scope: <short scope summary>
suggestion: <did-you-mean>
Errors (subclass `SlayerError`):
- `UnknownReferenceError(name, scope_kind, scope_summary, suggestion)`
- `AmbiguousReferenceError(name, candidates)` — sorts candidates
- `IllegalScopeReferenceError(name, scope_kind, reason)` — covers
C8 (`__` in ModelScope) and DEV-1449 (dotted refs against
StageSchema)
- `IllegalWindowInFilterError(filter_expr, source, suggestion)` —
raw `OVER(...)` in DSL filter OR filter naming windowed
`Column.sql` (predicate promotion was removed in DEV-1336)
- `AggregationNotAllowedError(column, agg, reason)` — type-bucket
/ PK / allowed_aggregations violations
- `UnknownFunctionError(name, location, suggestion)` — Mode-B call
outside SCALAR_FUNCTIONS / transforms / aggregations (C12)
- `MeasureRecursionLimitError(chain, limit)` /
`MeasureCycleError(chain)` — named-measure expansion guards
- `DuplicateMeasureNameError(name, occurrences)` /
`MeasureNameCollidesWithColumnError(name, model)` /
`CanonicalAliasShadowsColumnError(formula, canonical, model)` —
DEV-1443 alias-collision validations
- `UnreachableFilterDroppedWarning(filter_text, reason)` — warning,
emitted by the cross-model planner when it drops an unreachable
host filter from a sub-query CTE
Slack normalization payload + carrier (`slayer/core/warnings.py`):
- `NormalizationWarning` — Pydantic payload with `rule_id`,
`original`, `normalized`, `location`, `rule_doc_url`.
- `SlayerNormalizationWarning` — UserWarning carrier so callers
can route via `warnings.catch_warnings()` AND via the structured
`SlayerResponse.warnings` list (stage 6 wiring).
36 tests snapshot str(error) for every class and verify the
warning carrier roundtrips through `warnings.warn(...)`.
Dormant: no code raises these yet.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ED_MEASURE) Add `slayer/engine/normalization.py` and wire it into the engine. The layer rewrites tolerant-but-unambiguous agent input to canonical form before the typed pipeline sees it (P0), and emits structured `NormalizationWarning` payloads alongside the existing in-tree UserWarnings (which still fire from `_rewrite_funcstyle_aggregations` until stage 7b removes them). Active rules in stage 6: - `FUNC_STYLE_AGG` (Mode B): `sum(revenue)` → `revenue:sum`; `count(*)` → `*:count`; `percentile(amount, p=0.5)` → `amount:percentile(p=0.5)`. Applied to `ModelMeasure.formula`, `SlayerQuery.measures[].formula`, and `SlayerQuery.filters` entries. Custom model-level aggregation names are recognised in `normalize_model` via the model's `aggregations` list. - `MISPLACED_MEASURE` (query shape): bare entries in `SlayerQuery.measures` that resolve as a column on the model (and not as a `ModelMeasure` name) move to `SlayerQuery.dimensions`. Mirrors the existing `_auto_move_fields_to_dimensions` heuristic but emits the structured warning. - `DOT_PATH_IN_SQL` (Mode A): wired but stubbed — returns input unchanged. Full sqlglot-AST, scope-aware rewrite lands in a follow-up. The slot is wired so downstream activation needs no plumbing changes. Engine wiring (`slayer/engine/query_engine.py`): - `SlayerResponse.warnings: List[NormalizationWarning]` additive field (default empty). - `_execute_pipeline` runs `normalize_query` immediately after the source model is resolved (so MISPLACED_MEASURE has model context); rewrites flow into the existing `_auto_move_fields_to_dimensions` + enrichment, which see already-canonical input and silently no-op. - All three `SlayerResponse(...)` construction sites (dry_run, explain, normal execute) propagate `warnings=slack_warnings`. - `save_model` runs `normalize_model` before persistence so stored formulas land in canonical form. 17 tests cover FUNC_STYLE_AGG rewrites + warning emission, MISPLACED_MEASURE detection + move, custom-agg recognition, canonical-input no-op behavior, engine wiring through `engine.execute(..., dry_run=True)` and `engine.save_model(...)`. Total: 3056 unit tests passing, no regressions. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…threading
Codex review of stages 1-6 surfaced two important bugs. Fix both
plus pin the contract with new tests.
1. **bool vs Decimal interning collision in typed keys** (stage 1).
Python collapses ``True == 1 == Decimal('1')`` and the same for
``False`` / ``0`` at both equality and hashing. So
``AggregateKey(args=(True,))`` previously interned with
``AggregateKey(args=(Decimal('1'),))`` — silently sharing a slot
for two semantically different aggregations
(``percentile(p=True)`` vs ``percentile(p=1)``).
Add `_typed_leaf` / `_typed_args` / `_typed_kwargs` helpers in
`slayer/core/keys.py` that wrap scalar leaves as
``(type_tag, value)`` at hash/eq time without changing the
stored representation. Override `__hash__` and `__eq__` on
`AggregateKey`, `TransformKey`, and `ScalarCallKey` to use them.
`ColumnKey` / `ColumnSqlKey` / `StarKey` / `SqlExprKey` are
unaffected (they don't take scalar args).
4 new tests in `tests/test_keys.py::TestBoolDecimalDisambiguation`
pin the contract across all three key classes.
2. **custom_agg_names not threaded to normalize_query** (stage 6).
The engine called `normalize_query(query, model=model)` without
custom aggregation names, so a slack `custom_sum(revenue)` in a
query measure or filter was left to the legacy
`_rewrite_funcstyle_aggregations` rewrite (which still
functions correctly) but didn't surface a structured warning in
`SlayerResponse.warnings`.
Collect custom names from `model.aggregations` in
`_execute_pipeline` and pass them through. Joined-model custom
aggs are still out of scope (need the full reachable-agg-names
walk) — that arrives with stage 7a's binder; the legacy rewrite
continues to catch them until then.
1 new test in `tests/test_slack_normalization.py` verifies the
structured warning surfaces for a model-defined custom aggregation.
Codex finding 3 (FUNC_STYLE_AGG on Mode-A filters) is a false
positive: `SlayerQuery.filters` is Mode B per CLAUDE.md / DEV-1378.
Mode-A filters live on `Column.filter` and `SlayerModel.filters`,
which the normalization layer does not touch.
Codex finding 4 (backslash-escaped quote handling in literal-span
detection) is a minor edge case for SQL dialects that allow ``\\'``
inside literals; noted as a follow-up.
Net: 3061 unit tests passing (was 3056), no regressions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add `slayer/engine/planned.py` with the typed shapes consumed by the SQL generator after stage 7b cutover. Dormant — no engine code routes through them yet. - `ValueSlot(id, key, declared_name, public_name, public_aliases, hidden, phase, label, type, expression)` — one materialised slot. `key` is structural identity; rendering metadata lives here. P4 / C13 multi-alias supported. Hidden invariant enforced by a model validator: hidden slots must not carry public_name / public_aliases. - `JoinRequirement(source_model, target_model, join_pairs, join_type)` — one hop in a cross-model chain. `join_type` defaults to LEFT to match `ModelJoin`. Non-empty + well-formed `join_pairs` validated. - `CrossModelAggregatePlan(aggregate_slot_id, target_model, datasource, join_chain, join_back_pairs, cte_stage_schema, shared_grain_slots, applied_filter_ids, dropped_filter_warnings, hidden, public_alias)` — plan for one cross-model aggregate slot (P3). Strategy-agnostic: the I1 CrossModelPlanner Protocol (stage 7a.2) populates this struct; the struct doesn't lock in isolated-CTE. - `TransformLayer(op, slot_ids)` — one transform layer; generator picks the render strategy per op (window function vs self-join CTE). - `FilterPhase(id, phase, text, expression)` — bound filter routed by phase (P8). `expression` carries the renderable payload so the generator never re-parses text. - `OrderEntry(slot_id, direction)` — ORDER BY entry; strict lowercase `asc`/`desc` since OrderEntry is planner-produced. - `BoundExpr(value_key, sql_text)` — minimum scaffold for the bound-expression payload that stage 7a.5's binder will fill in. Carried by ValueSlot.expression and FilterPhase.expression. - `PlannedQuery(source_relation, join_plan, row_slots, aggregate_slots, cross_model_aggregate_plans, combined_expression_slots, transform_layers, filters_by_phase, projection, order, limit, offset, stage_schema)` — the fully typed plan that the SQL generator consumes. Codex review (round 1) on the new module surfaced three important items: FilterPhase lacked a bound-expression payload (fixed via BoundExpr scaffold), JoinRequirement was missing join_type (fixed), ValueSlot hidden invariant was not enforced (fixed via model validator). All addressed. 32 unit tests pin the contract, including the hidden invariant, join_type defaults, OrderEntry case strictness, and BoundExpr propagation through slots. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the no-op stub of `_apply_dot_path_in_sql` with an AST-based, scope-aware Mode-A rewrite over `Column.sql`, `Column.filter`, and `SlayerModel.filters`. Root-scope `<join>.<intermediate>.<leaf>` refs collapse to `<join>__<intermediate>.<leaf>`; first-segment shadowing by CTE name, AS alias, Subquery/CTE source, or FROM-table schema/catalog qualifier emits an ambiguity warning without rewriting. Refs inside subqueries / CTE bodies / set-op branches are left alone via lexical ancestor walking. Multi-statement slack input is a no-op. Wired through `normalize_model`. The legacy regex `slayer.core.models._fix_multidot_sql` still runs at construction time and pre-empts most real-world inputs; Stage 7b deletes the regex and hands full ownership to this rule. 32 new tests in `tests/test_dot_path_in_sql.py`. Full unit suite green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… (I1) Adds slayer/engine/cross_model_planner.py with: - CrossModelPlanner Protocol (substitutable strategy). - IsolatedCteCrossModelPlanner — default impl encoding the inherited_filter_policy decision table. - HostFilterRouting input + classify_host_filter() helper. - FilterRoute enum: DROP_HOST_LOCAL / PROPAGATE_WHERE / PROPAGATE_HAVING / DROP_UNREACHABLE / STAY_AT_HOST_POST. Extends CrossModelAggregatePlan in planned.py with route-explicit fields the SQL generator (7b) needs without re-classifying: - where_filter_ids / having_filter_ids / target_model_filters. Multi-hop semantics: join_back_pairs and CTE schema reference the FIRST hop's target grain (e.g. customers.id for orders→customers→regions), while the aggregate output column's type is sourced from the terminal model. ColumnSqlKey routing uses host_model_name to distinguish host-local derived columns (DROP_HOST_LOCAL), derived columns on the target path (PROPAGATE_WHERE), and derived columns on other branches (DROP_UNREACHABLE). 32 new tests covering Protocol substitution, single + multi-hop join chains, every row of the decision table, ColumnSqlKey routing, edge cases (unknown slot id, empty refs). Full unit suite green. Dormant — no engine wiring. Stage 7a.6's ProjectionPlanner is the first consumer. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds slayer/engine/syntax.py with parse_expr() — pure syntax for the Mode-B DSL (no scope resolution, no named-measure expansion, no slack rewriting; those are upstream). ParsedExpr family: Ref / DottedRef / StarSource / Literal / AggCall / TransformCall / ScalarCall / Arith / UnaryOp / Cmp / BoolOp. All frozen Pydantic models with value-based equality. Pipeline: colon preprocess (skipping string-literal spans) → Python ast.parse → AST walk to typed nodes. Closed allowlist for scalar functions (SCALAR_FUNCTIONS in keys.py); transforms from ALL_TRANSFORMS; aggregations via placeholder lookup. AST-level dunder rejection across Name / Attribute / keyword.arg positions — robust to string literals. Rejections (raises): - Unknown function call → UnknownFunctionError. - Raw OVER(...) → IllegalWindowInFilterError. - `__` in user identifier → ValueError. - Chained comparisons (`1 < x < 10`) → ValueError. - Function-style aggregations (`sum(revenue)`) → UnknownFunctionError (slack normalization owns the rewrite; if it reaches the parser, that's a bypass). - Scalar-function kwargs (`lower(name=...)`) → ValueError. 62 tests in tests/test_syntax.py. Full unit suite green: 3219 passed. Dormant — no engine wiring. Stage 7a.5 binder is the first consumer. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds slayer/sql/sql_expr.py with: - parse_sql_expr(text, *, dialect=None) → SqlExprKey - canonicalize_sql(text, *, dialect=None) → str - has_window_function(text) → bool (re-export) - assert_no_window_in_filter(text, *, source) → None / raises parse_sql_expr wraps as `SELECT (<text>) AS _` before sqlglot parsing, then strips the wrapper paren. Mirrors the generator's predicate-parse trick — necessary because sqlglot's SQLite/MySQL parser otherwise falls back to a Command node for top-level `replace(...)` and other keyword conflicts. Dialect-specific rewrites preserved through the canonical key: - SQLite json_extract function form (vs `->` operator) - log10 / log2 preservation for dialects with native single-arg aliases (allowlist duplicated from slayer/sql/generator.py — comments in both call out the sync point; consolidation deferred). 23 tests in tests/test_sql_expr.py. Full unit suite green. Dormant — no engine wiring. Stage 7a.5 binder is the first consumer (AggregateKey.column_filter_key). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds slayer/engine/binding.py with: - bind_expr(parsed, *, scope, bundle) → BoundExpr - bind_filter(parsed, *, scope, bundle) → BoundFilter - walk_value_keys(key) — traverse helper for referenced-key collection Two scope kinds (P5): - ModelScope: dotted refs walk the join graph via bundle. C14 self- prefix stripped before walk. `__` rejected unless it exact-matches a column on the model. - StageSchema: flat namespace; dotted refs raise IllegalScopeReferenceError. FilterBinder layers phase classification (Phase.ROW / AGGREGATE / POST = max of referenced slot phases) plus IllegalWindowInFilterError when a filter references a ColumnSqlKey whose Column.sql contains a window function (DEV-1369: no auto-promotion). keys.py: add LiteralKey to ValueKey union for arithmetic over scalars (`amount + 1`); add `path: Tuple[str, ...]` to ColumnSqlKey so joined derived columns carry the same path-bearing identity ColumnKey has. Defence-in-depth: _bind_scalar re-checks SCALAR_FUNCTIONS in case the parser was bypassed via direct ParsedExpr construction. 31 tests in tests/test_binding.py. Full unit suite green: 3273 passed. Dormant — no engine wiring. Stage 7a.6 planner is the first consumer. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds slayer/engine/planning.py with three composable concerns: 1. ValueRegistry — interns ValueKeys by structural identity. Two structurally-equal keys share one ValueSlot (P2). Same key declared with multiple `name`s accumulates multiple public_aliases on one slot (P4 / C13). Alias-collision validations preserved from DEV-1443: DuplicateMeasureNameError, MeasureNameCollidesWithColumnError, CanonicalAliasShadowsColumnError. 2. desugar_change / desugar_change_pct — lower sugar transforms to `x - time_shift(x)` / `(x - time_shift(x)) / time_shift(x)`. The inner aggregate keeps the same ValueKey instance across both operands so the registry interns it once (DEV-1446). `partition_by` from the sugar form threads through to the underlying time_shift (C6) — the binder lifts it onto TransformKey.partition_keys, the lowerer passes it through. 3. ProjectionPlanner — allocates slots for declared measures + creates hidden slots for refs that appear ONLY in order/filter. Slot dependency selection (`_iter_slot_deps`) only materialises ColumnKey, ColumnSqlKey, AggregateKey, TransformKey — composite nodes (ArithmeticKey, ScalarCallKey) and literals stay inlined. binding.py: _bind_transform now lifts the `partition_by` kwarg onto TransformKey.partition_keys; non-column partition_by values are rejected with a clear error. 26 tests in tests/test_value_registry.py / test_transform_lowerer.py / test_projection_planner.py. Full unit suite green: 3299 passed. Dormant — no engine wiring. Stage 7a.7 stage_planner is the first consumer. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds slayer/engine/stage_planner.py with:
- plan_query(*, query, bundle, scope=None, ...) → PlannedQuery
- Parses + binds measures / dimensions / filters / order against
the supplied scope (ModelScope by default, StageSchema for
downstream stages).
- Runs ProjectionPlanner + assembles a PlannedQuery with stage_schema.
- plan_stages(*, queries, bundle, ...) → List[PlannedQuery]
- Topologically sorts via Kahn's algorithm (rejects duplicate stage
names and cycles).
- Per-stage StageSchema becomes the binding scope for downstream
stages (DEV-1449: dotted refs in downstream stages raise
IllegalScopeReferenceError).
- User-supplied `name` on a measure becomes the StageSchema column
alias (DEV-1448). Multi-alias declarations (same key, two names)
emit one StageSchema column per alias.
ValueRegistry: exempts self-named dimensions (ColumnKey(leaf=X)
declared as X) from MeasureNameCollidesWithColumnError — the
declaration is the column itself, not a rename.
10 tests in tests/test_stage_planner.py covering:
- single-stage smoke (measure + dimension + filter),
- DEV-1448 (named measure → schema column alias),
- DEV-1449 (downstream flat refs; dotted refs rejected),
- topo sort (duplicate name and cycle rejection),
- multi-alias StageSchema columns.
Full unit suite green: 3309 passed.
Dormant — no engine wiring. cross_model_planner parameter is built
but not yet invoked from PlannedQuery construction; Stage 7b wires
the full cross-model-aggregate plan emission. Same for BoundExpr
payload propagation onto ValueSlot / FilterPhase.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Move {var} placeholder substitution out of the legacy enrichment path
(slayer/engine/enrichment.py:1162) into a small, pipeline-friendly
slayer/engine/variables.py module. Dormant in this commit — wired in by
stage 7b.15 (engine cutover).
Public surface:
- merge_query_variables(*, runtime, stage, outer, model_defaults):
4-layer merge replacing the legacy 3-layer _merge_query_variables.
Precedence runtime > stage > outer > model_defaults; None / empty
layers act as identities; inputs unmutated.
- apply_variables_to_query(*, query, variables=None, dry_run_placeholders=False):
returns a fresh SlayerQuery copy with {var} substituted in `filters`.
Always copies (no shared empty-list aliasing). variables=None is
normalized to empty dict. dry_run_placeholders=True fills missing
valid placeholders with "0" but does NOT mask invalid names.
Scope deliberately matches legacy: only SlayerQuery.filters is
substituted. Formula text, Column.sql, Column.filter, and
SlayerModel.filters are not variable-substituted today and this module
preserves that contract.
39 new tests in tests/test_variables_planner.py cover precedence,
escape (`{{` / `}}`), invalid names, unmatched braces, dry-run +
invalid-name interaction, idempotence, copy semantics, and re-export
identity. Codex review of tests caught HIGH#1 (copy vs identity) and
HIGH#2 (explicit idempotence test); both folded in. Codex review of
impl caught two LOW findings (empty-list aliasing and Optional
variables); both folded in.
Full unit suite green (3348 passed, 2 skipped); ruff clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New slayer/engine/measure_expansion.py: AST-to-AST rewrite of a
ParsedExpr tree. Every Ref(name=X) whose X resolves to a ModelMeasure
(on the model or in extra_measures) is replaced with the recursively-
expanded ParsedExpr of that measure's formula.
Per Codex F4 fold-in on the DEV-1450 plan: expansion lives PRE-BIND, not
planner-layer. The binder (slayer/engine/binding.py:341-354) currently
raises UnknownReferenceError for bare measure names; this module runs
ahead of the binder and rewrites those refs into binder-resolvable AST
shapes.
Public API:
expand_model_measures(
*,
expr: ParsedExpr,
model: SlayerModel,
extra_measures: Sequence[ModelMeasure] = (),
depth_limit: Optional[int] = None,
) -> ParsedExpr
Eligible positions: root; Arith / UnaryOp / Cmp / BoolOp operands;
ScalarCall.args; TransformCall.input / args / kwarg values.
Not eligible: DottedRef (cross-model paths), AggCall in any position
(source / args / kwargs are column-level by contract), function-name
slots on TransformCall.op / ScalarCall.name, Literal, StarSource,
SlayerQuery.order entries.
Depth limit: SLAYER_MEASURE_EXPANSION_DEPTH env var (default 32);
explicit kwarg wins; <1 raises ValueError. Exceeded chain raises
MeasureRecursionLimitError. Per-chain cycle detection raises
MeasureCycleError with the full traversal chain.
Per-call memoization of parsed measure formulas (Codex impl review
MEDIUM#1). _PARSED_EXPR_TYPES tuple derived from get_args(ParsedExpr) so
future syntax additions are auto-walked (Codex LOW#5).
41 new tests in tests/test_model_measure_expansion.py cover the
eligibility matrix, recursion / cycle semantics, depth-limit boundaries
(default 32 succeeds at 32, fails at 33), env-var override + explicit
override precedence, extra_measures shadowing model measures, purity
under re-expansion, and pre-bind "does not validate columns" contract.
Full unit suite green (3389 passed, 2 skipped); ruff clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add slayer/core/keys.py::TimeTruncKey — row-phase ValueKey identifying a time-truncated column by (column: ColumnKey, granularity: str). The underlying column is recoverable via key.column so date-range filters (stage 7b.3c) can bind against the raw column independently of the truncation. Identity is structural: same (column, granularity) interns to one slot; different granularities on the same column are distinct slots. The ValueRegistry can keep month / day / raw uses of the same column as separate materialised values without special-casing — Codex's recommendation (Option A) over a granularity-on-slot or TransformKey(op='date_trunc') encoding. Granularity stored as the str value of a TimeGranularity StrEnum so the key stays pure data without an enum import at the keys layer. TimeTruncKey added to the ValueKey union so binders and planners that dispatch on isinstance(key, ValueKey) see it. Dormant — no engine wiring yet. Stage 7b.3b adds bind_time_dimension + planner integration; 7b.3c adds main-TD resolution and date_range -> filter conversion. 12 new tests in tests/test_time_trunc_key.py cover construction, identity (same / different grain / different column / cross-model path), phase=ROW, frozen-immutability, and ValueKey union membership. Full unit suite green (3401 passed, 2 skipped); ruff clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires SlayerQuery.time_dimensions through to TimeTruncKey slots in the
new pipeline.
New: bind_time_dimension(td, scope, bundle) in slayer/engine/binding.py.
Resolves the underlying column via the existing identifier / dotted-ref
binders, validates it's a base ColumnKey with a temporal (DATE/TIMESTAMP)
type, and returns a BoundExpr whose value_key is
TimeTruncKey(column, granularity). Derived Column.sql temporal columns
(which would resolve to ColumnSqlKey) raise NotImplementedError with a
clear message — TimeTruncKey.column is typed as ColumnKey and widening
it is deferred to a follow-up. StageSchema scopes raise
IllegalScopeReferenceError (downstream stages see the upstream's
already-truncated column as a flat name).
Planner: TimeTruncKey added to _SLOTTABLE_KIND, _iter_slot_deps, and
_canonical_name. The TimeTruncKey itself is the materialized slot
(generator emits DATE_TRUNC at SELECT time); the inner ColumnKey is NOT
yielded as a separate dep — adding a TD must not auto-add the raw
column as a separate output (matches legacy). Canonical name drops the
granularity suffix to match legacy alias contract
(EnrichedTimeDimension.alias = f"{model}.{td.dimension.full_name}").
ValueRegistry: extended the self-named-dimension exemption to cover a
local TimeTruncKey over a same-named column, so declaring a TD on
created_at doesn't trip MeasureNameCollidesWithColumnError. Joined TDs
flatten through __ paths and so don't collide with host source columns.
stage_planner.plan_query: _declared_measures_from_query now iterates
query.time_dimensions between dimensions and measures (legacy
user_projection order: dims → time dims → measures). Each TD becomes a
DeclaredMeasure with flat declared_name / public_name; label propagates.
Codex review of tests caught 4 findings: added joined-TD label
propagation, multi-hop joined TD coverage (orders → customers →
regions), tightened stage schema membership assertions to exact-match
shape, and explicit derived-Column.sql rejection coverage. Codex review
of impl returned 1 MEDIUM (cross_model_planner TimeTruncKey
classification, deferred to 7b.5 when cross-model TD routing surfaces)
and 2 LOW findings deferred (data_type_bucket reuse would introduce an
import cycle through schema_drift → ingestion → query_engine;
NotImplementedError is acceptable for a documented temporary limit).
26 new tests in tests/test_time_dimensions_planner.py.
3427 unit tests passing, 0 regressions, ruff clean. Integration tests
not run locally.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two pieces wired into stage_planner.plan_query:
1. ``_build_date_range_filter(td, scope, bundle)`` converts a
TimeDimension's ``date_range=[start, end]`` into a row-phase
BoundFilter whose predicate is ``ArithmeticKey("and", (>=, <=))``
over the BARE underlying ColumnKey. Bound literals normalize via
``normalize_scalar`` (strings pass through). The filter binds to
the raw column, not the TimeTruncKey, so generator slice 7b.11 can
apply it on the outer projection while the shifted self-join CTE
reads unfiltered raw data (legacy edge-period semantics for
time_shift / change / change_pct).
FilterPhase.expression is populated with PlannedBoundExpr for
auto-generated filters (date_range has no user text). User-filter
expression population stays deferred to stage 7b.6
(BoundExpr type unification).
Malformed date_range entries (``[]``, ``[single]``) silently no-op,
matching legacy ``slayer/sql/generator.py:2517``.
2. ``_resolve_main_time_dimension(query, model)`` resolves the active
TD for transform / windowing semantics. 0 TDs → None; 1 TD →
that TD (ignores main_time_dimension, matches legacy); 2+ TDs with
main_time_dimension → match by full_name, fall back to leaf,
ambiguous leaf raises AmbiguousReferenceError, unknown raises
UnknownReferenceError. 2+ TDs with model.default_time_dimension →
leaf match restricted to host-local TDs (legacy
``_resolve_time_alias`` returns f"{model}.{default}" so the joined
case is never selected through that path). Neither → None.
Codex review of impl caught two MEDIUM findings folded in here:
ambiguous-leaf matches now raise AmbiguousReferenceError listing both
candidates rather than returning by query order; default_time_dimension
matching restricts to host-local TDs to preserve legacy alias
semantics.
snap_to_whole_periods ownership stays with SlayerQuery's existing
method called pre-normalization in query_engine._execute_pipeline:595;
the planner consumes already-snapped queries and never re-snaps.
25 new tests in tests/test_time_dimensions_filters.py covering:
helper resolution (0/1/multi TDs, full-name vs leaf precedence,
ambiguity, default-host-vs-joined, unknown default no-op);
date_range filter shape (AND-of-comparisons, row-phase, expression
populated, binds raw ColumnKey not TimeTruncKey, hidden raw-column
slot materialized, joined TD path-bearing); malformed date_range
emits no filter; multiple TDs each emit their own filter; user
filters + date_range coexist with date_range last.
3452 unit tests passing, 0 regressions, ruff clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three changes:
1. Per-op binder validation in slayer/engine/binding.py:_bind_transform.
New _TRANSFORM_KWARG_RULES whitelist per op (partition_by is
special-cased and accepted everywhere it makes sense). ntile
requires positive integer n (rejects bool, float-with-fractional,
negative, zero); time_shift requires periods; lag/lead default
periods=normalize_scalar(1) when missing. Transforms take exactly
one positional argument (the value); anything else raises forcing
the kwarg form (e.g. ``lag(value, periods=2)`` instead of
``lag(value, 2)``). New _fold_to_scalar helper folds Literal and
UnaryOp(-, Literal) into normalised scalars; non-scalar shapes raise
(TransformKey.kwargs only accepts Scalar, not arbitrary ValueKey).
2. _iter_slot_deps in slayer/engine/planning.py now walks
TransformKey.partition_keys and TransformKey.time_key so partition
columns and time-key columns surface as slot deps. The
ProjectionPlanner now walks measure deps too and interns them as
hidden slots, materialising inner aggregates / partition columns /
time-key columns for the generator to consume.
3. New lower_sugar_transforms(value_key) -> value_key recursive walker
in planning.py. Replaces TransformKey(op="change"|"change_pct") with
the desugared arithmetic form, preserving inner aggregate identity
(DEV-1446). Wired into stage_planner._declared_measures_from_query.
desugar_change / desugar_change_pct now set
kwargs=(("periods", normalize_scalar(-1)),) on the produced
time_shift TransformKey, matching the new typed invariant that
time_shift requires explicit periods.
4. New _emit_transform_layers in stage_planner — one TransformLayer
per TransformKey slot in topological dependency order (Kahn's
algorithm). Nested transforms like ``cumsum(change(amount:sum))``
emit the inner time_shift layer before the outer cumsum layer; the
generator slices can render windows / self-joins in the right order
without re-walking. Per-slot transform metadata (partition_keys /
time_key / args / kwargs) lives on the slot's TransformKey so
TransformLayer stays minimal.
Codex review caught: positional-args ambiguity (now rejected), missing
periods in desugar output (now -1 explicitly), and op-grouping
collapsing nested same-op layers (now per-slot topological).
Deferred: partition_by=[list, of, cols] parser syntax (Mode-B parser
extension scope); consecutive_periods.period value validation (defer
until generator slice 7b.11 consumes it).
29 new tests in tests/test_transforms_planner.py covering per-op
validation (ntile.n positive int + bool reject + integer-only;
time_shift periods required; lag default; unknown kwargs on rank-family
+ consecutive_periods; positional-arg rejection); transform aux slot
materialisation (single + multi partition keys, time_key
ColumnKey/TimeTruncKey); transform_layers population (per-slot, topo
order, no op-grouping); change → time_shift lowering identity
preservation (DEV-1446 nested-transform dedup).
3481 unit tests passing, 0 regressions, ruff clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires the dormant IsolatedCteCrossModelPlanner into plan_query. 1. New filter_referenced_slot_ids(bound_filter, registry) -> set[SlotId] in slayer/engine/planning.py. Walks _iter_slot_deps and looks each slottable key up in the registry. Codex HIGH #3/#4 fold-in: does not mutate BoundFilter.referenced_keys (those are pre-interning ValueKeys); does walk composite predicates so "rev >= 100 AND customers.revenue:sum < 500" resolves both leaves. Silently skips literals and other non-slottable refs. 2. stage_planner.plan_query now, after filters_by_phase is built, constructs HostFilterRouting records (with referenced_slot_ids sorted for deterministic plan snapshots — Codex LOW #3 fold-in) and iterates aggregate slots. For every AggregateKey slot whose source.path is non-empty, invokes cross_model_planner.plan() with the host_slots + host_filter_routings + slot.public_name + slot.hidden, and appends the resulting CrossModelAggregatePlan to PlannedQuery.cross_model_aggregate_plans. 3. cross_model_planner._aggregate_alias now uses slayer.core.refs.canonical_agg_name so parameterised aggregates (revenue:percentile(p=0.5) vs p=0.95) get distinct CTE column aliases (Codex HIGH #1 fold-in). Test exercises this with two percentile aggregates. Deferred to follow-ups: - Cross-model aggregates with column-valued kwargs (weighted_avg / corr crossing a join) — the host-local column ref can't be evaluated inside the customers CTE. Known limitation; documented for a follow-up issue. (Codex HIGH #2 in the impl review.) - FilterPhase.text / HostFilterRouting.text population for user filters — depends on the BoundExpr unification in stage 7b.6. 12 new tests in tests/test_cross_model_planner_wiring.py covering filter_referenced_slot_ids (simple column, composite predicate, no composite-only nodes in result, unknown slot silently skipped); plan_query wiring (local agg → no plan; cross-model agg → emits plan with target_model/datasource; aggregate_slot_id matches the slot; two distinct aggregates emit separate plans; parameterised aggs get distinct CTE aliases; local filter → DROP_HOST_LOCAL → no propagation; target model filters propagate); classify_host_filter exercise. 3493 unit tests passing, 0 regressions, ruff clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
slayer.engine.planned.BoundExpr was a separate Pydantic class with an optional sql_text cache; slayer.engine.binding.BoundExpr was the binder's typed output. Two classes meant ValueSlot.expression and FilterPhase.expression couldn't carry the binder's payload without type widening or a side adapter (Codex HIGH F2 in the earlier round). Unification: 1. slayer.engine.planned now re-exports slayer.engine.binding.BoundExpr as ``BoundExpr``. Single canonical class going forward. 2. The render-artifact ``sql_text`` field is dropped. Generator slices render from the typed value_key against the slot registry, not a cached string. The branch-new tests/test_planned.py:127 ``test_with_expression_payload`` is updated to assert against the value_key shape instead of sql_text. 3. ValueRegistry.intern now accepts an optional ``expression`` argument; ValueSlot.expression is populated for every materialised slot (public AND hidden) — auto-defaulted to BoundExpr(value_key=key) when no explicit binder output is passed. 4. stage_planner.plan_query now populates FilterPhase.expression for EVERY filter (user-supplied AND auto-generated date_range), so the SQL generator can render filters without re-parsing or consulting a side map. The previous auto_filter_ids tracking is removed (always-populate is simpler and matches the unification goal). 7 new tests in tests/test_boundexpr_unification.py covering: the type re-export identity; ValueSlot.expression populated for measures, dimensions, AND hidden filter-dep slots; FilterPhase.expression populated for user filters AND aggregate-phase (HAVING) filters; the expression's value_key matches the slot's / filter's key identity. 3500 unit tests passing, 0 regressions, ruff clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdds a typed resolution pipeline: typed keys/phases, Mode‑B parser and Mode‑A SQL canonicalizer, measure expansion, binder, projection/planning (ValueRegistry, ProjectionPlanner), cross‑model planning, source‑bundle builder, normalization rules, engine wiring, response metadata, sync derived‑column expander, structured errors/warnings, docs, and extensive tests. ChangesDEV-1450 Typed Pipeline and Planning
Sequence Diagram(s)sequenceDiagram
rect rgba(102, 153, 255, 0.5)
participant Client
participant Engine
end
rect rgba(153, 204, 255, 0.5)
participant Storage
participant SourceBundle
end
rect rgba(204, 255, 204, 0.5)
participant Parser
participant Binder
participant Planner
end
rect rgba(255, 204, 153, 0.5)
participant SQLGen
participant DB
end
Client->>Engine: execute(query)
Engine->>Storage: build_resolved_source_bundle
Storage-->>Engine: models
Engine->>Parser: normalize + parse
Parser-->>Engine: ParsedExpr
Engine->>Binder: bind_expr/bind_filter/bind_time_dimension
Binder-->>Engine: BoundExpr/BoundFilter
Engine->>Planner: plan_query/plan_stages
Planner-->>Engine: PlannedQuery
Engine->>SQLGen: generate_from_planned
SQLGen->>DB: run SQL
DB-->>Engine: rows
Engine-->>Client: SlayerResponse + warnings
Estimated code review effort🎯 5 (Critical) | ⏱️ ~120 minutes Possibly related PRs
Suggested reviewers
Poem
✨ Finishing Touches🧪 Generate unit tests (beta)
|
There was a problem hiding this comment.
Actionable comments posted: 6
🧹 Nitpick comments (8)
tests/test_time_trunc_key.py (1)
109-112: ⚡ Quick winSpecify the exact exception type for frozen instance mutation.
The test uses
pytest.raises(Exception)which is too broad. Specify the exact exception type that's raised when attempting to mutate a frozen Pydantic model or dataclass field (typicallyValidationErrorfor Pydantic v2 orFrozenInstanceErrorfor frozen dataclasses).♻️ Proposed fix to specify exception type
+from pydantic import ValidationError + class TestTimeTruncKeyImmutability: def test_is_frozen(self) -> None: k = TimeTruncKey(column=ColumnKey(leaf="ordered_at"), granularity="month") - with pytest.raises(Exception): + with pytest.raises(ValidationError): k.granularity = "day" # type: ignore[misc]Note: Use
ValidationErrorifTimeTruncKeyis a Pydantic model, ordataclasses.FrozenInstanceErrorif it's a frozen dataclass.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/test_time_trunc_key.py` around lines 109 - 112, The test test_is_frozen should assert the specific exception type instead of pytest.raises(Exception): replace the broad Exception with the concrete exception thrown when mutating a frozen TimeTruncKey (use pydantic.errors.ValidationError / pydantic.ValidationError if TimeTruncKey is a Pydantic model, or dataclasses.FrozenInstanceError if it is a frozen dataclass), update the test import(s) accordingly, and keep the mutating line k.granularity = "day" and references to TimeTruncKey and ColumnKey unchanged.slayer/engine/path_resolution.py (1)
75-75: ⚡ Quick winPrefer more specific type annotation.
The
nqvariable is assigned a dict value, sonq: dict[str, Any]would be clearer thannq: Any.♻️ Suggested improvement
- nq: Any = named_queries if named_queries is not None else {} + nq: dict[str, Any] = named_queries if named_queries is not None else {}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@slayer/engine/path_resolution.py` at line 75, The local variable `nq` is annotated too broadly as `Any`; change its annotation to a more specific mapping type (e.g., `dict[str, Any]`) so callers and linters know it holds a dict produced from `named_queries`. Update the assignment `nq: Any = named_queries if named_queries is not None else {}` to use `nq: dict[str, Any] = ...` (or use `cast(dict[str, Any], named_queries)` if necessary) and ensure `typing` imports include `Any`/`dict` typing forms compatible with the project's Python version.tests/test_path_resolution.py (1)
27-34: 💤 Low valueConsider using keyword arguments consistently in test calls.
The
_make_modelhelper has 2 parameters. Per coding guidelines, functions with more than 1 parameter should be called using keyword arguments. Several calls use positional arguments (e.g., lines 160, 175, 191).Example:
# Current return _make_model(model_name) # Preferred per guideline return _make_model(name=model_name)As per coding guidelines: "Use keyword arguments for functions with more than 1 parameter"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/test_path_resolution.py` around lines 27 - 34, The test helper _make_model accepts parameters (name, joins=None) but several test calls use positional args; update all usages to use keyword arguments per guideline: call _make_model(name=..., joins=...) instead of positional calls (e.g., replace _make_model(model_name) with _make_model(name=model_name) and any two-argument positional calls with explicit joins=...), locating usages by the function name _make_model in the tests and editing each call.tests/test_source_bundle.py (1)
20-29: 💤 Low valueConsider using keyword arguments consistently in test calls.
The
_modelhelper has 2 parameters. Per coding guidelines, functions with more than 1 parameter should be called using keyword arguments for clarity. Multiple calls throughout this file use positional arguments (e.g., lines 39, 49, 50, 56, 64, 74, 83, etc.).Example:
# Current m = _model("orders") m = _model("orders", ds="warehouse") # Preferred per guideline m = _model(name="orders") m = _model(name="orders", ds="warehouse")As per coding guidelines: "Use keyword arguments for functions with more than 1 parameter"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/test_source_bundle.py` around lines 20 - 29, The test helper _model(name: str, ds: str = "prod") is being invoked with positional arguments in multiple places; update all calls to use keyword arguments (e.g., _model(name="orders") or _model(name="orders", ds="warehouse")) so they follow the guideline for functions with more than one parameter; search for usages of _model in tests/test_source_bundle.py and replace positional invocations with keyword form, leaving the SlayerModel construction (and related Column/DataType references) unchanged.tests/test_boundexpr_unification.py (1)
161-161: ⚡ Quick winMove function-local imports to module scope.
Line 161 and Line 177 import inside test methods; this violates the repo rule to keep imports at the top of the file.
Suggested diff
from slayer.core.enums import DataType -from slayer.core.keys import AggregateKey, ColumnKey +from slayer.core.keys import AggregateKey, ArithmeticKey, ColumnKey from slayer.core.models import Column, SlayerModel from slayer.core.query import SlayerQuery from slayer.engine.binding import BoundExpr as BinderBoundExpr +from slayer.engine.binding import walk_value_keys from slayer.engine.planned import BoundExpr as PlannedBoundExpr from slayer.engine.source_bundle import ResolvedSourceBundle from slayer.engine.stage_planner import plan_query @@ - from slayer.core.keys import ArithmeticKey assert isinstance(fp.expression.value_key, ArithmeticKey) @@ - from slayer.engine.binding import walk_value_keys keys = list(walk_value_keys(fp.expression.value_key))As per coding guidelines: "
**/*.py: Place imports at the top of files".Also applies to: 177-177
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/test_boundexpr_unification.py` at line 161, Move the function-local imports to module scope: remove the in-test import of ArithmeticKey (from slayer.core.keys) and any other imports currently performed inside test functions and place them at the top of tests/test_boundexpr_unification.py; update the module-level import section to include "from slayer.core.keys import ArithmeticKey" (and any other symbols currently imported inside tests) so tests reference those symbols without local imports.tests/test_slack_normalization.py (2)
249-257: ⚡ Quick winAvoid
run_until_completein pytest async suites.The fixture manually drives the loop; this is fragile when a loop is already managed by pytest-asyncio. Prefer an async fixture and
awaitthe setup calls directly.As per coding guidelines "
**/tests/**/*.py: Tests must usepytest-asynciowithasyncio_mode = \"auto\"so test functions can beasync defandawaitdirectly."🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/test_slack_normalization.py` around lines 249 - 257, The test uses asyncio.get_event_loop().run_until_complete to call storage.save_datasource and storage.save_model, which conflicts with pytest-asyncio-managed loops; change the fixture to be async (make the fixture function async def) and replace those run_until_complete calls with direct awaits: await storage.save_datasource(DatasourceConfig(...)) and await storage.save_model(_orders()), ensuring the fixture yields/returns as before so pytest-asyncio (asyncio_mode="auto") manages the event loop correctly.
225-229: ⚡ Quick winMove test imports to module scope.
These in-function imports make the test module inconsistent with repo style and harder to scan. Please hoist them to the top-level import block.
As per coding guidelines "
**/*.py: Place imports at the top of files."Also applies to: 249-257, 263-271, 287-295, 314-318, 343-351
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/test_slack_normalization.py` around lines 225 - 229, Several tests in test_slack_normalization.py contain in-function imports (e.g., DatasourceConfig, SlayerQueryEngine, YAMLStorage, sqlite3) — hoist these imports to the module-level import block at the top of the file, remove the now-redundant local imports inside the test functions, and consolidate/organize them with the existing top imports; repeat this change for the other in-function import sites flagged (around the commented ranges) to comply with the project import style and keep test-scoped symbols available to all functions.tests/test_dot_path_in_sql.py (1)
474-474: ⚡ Quick winHoist local imports to the module import section.
These test-local imports should be moved to top-level to match project conventions.
As per coding guidelines "
**/*.py: Place imports at the top of files."Also applies to: 489-490
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/test_dot_path_in_sql.py` at line 474, The test contains local imports (e.g., "from slayer.core.models import ModelMeasure" and the other imports around the later test block) that should be hoisted to the module-level import section; move these local imports to the top of the file with the other imports, remove the in-function/local import statements, and run the tests to ensure no circular import issues—if a circular dependency appears, refactor the import to a lazy import helper or import only the needed attribute at top-level.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@slayer/engine/binding.py`:
- Around line 155-156: The call to _bind uses a positional first argument;
change it to use keyword arguments for all parameters (e.g.,
_bind(parsed=parsed, scope=scope, bundle=bundle, in_filter=False)) and update
every other _bind invocation in this module (including the ones around the other
mentioned call sites) to the same keyword style so it conforms to the repo rule;
keep the surrounding return (BoundExpr(value_key=...)) unchanged.
In `@slayer/engine/syntax.py`:
- Around line 180-188: The current check uses _OVER_RE.search(text) and flags
any "OVER(" even when inside string literals (e.g. "status == 'OVER('"); change
the check to ignore matches inside quotes by scanning text
character-by-character tracking single-quote, double-quote, and escape state and
only applying _OVER_RE when not inside a quoted string, then raise
IllegalWindowInFilterError(filter_expr=text, source=..., suggestion=...) as
before; update the code path that currently calls _OVER_RE.search(text) (the
block raising IllegalWindowInFilterError) so it uses the new quote-aware
detection routine on the variable text.
- Around line 409-412: The code silently ignores keyword unpacking (**kwargs)
because the comprehension in _convert_call filters out ast.keyword entries with
arg is None; update _convert_call to detect any kw in node.keywords with kw.arg
is None and raise a clear error (e.g., ValueError or a parsing-specific
exception) rejecting **kwargs in Mode-B call parsing instead of dropping them,
then proceed to build kwargs by converting remaining keywords via _convert;
reference node.keywords, _convert_call, and _convert when implementing the check
and error raise.
In `@tests/test_agg_registry.py`:
- Around line 62-64: Update the test calls in tests/test_agg_registry.py to use
keyword arguments instead of positional arguments for helper functions with more
than one parameter: change calls to collect_reachable_agg_names(...),
resolve_aggregation(...), merge_agg_params(...), and any other multi-parameter
helper invocations (including the occurrences at lines noted in the comment) so
that each argument is passed as param_name=value (e.g., m=...,
resolve_join_target=..., named_queries=...) rather than by position; ensure you
update all instances mentioned (around the ranges 62-64, 79-81, 99-101, 117,
177, 183, 230, 238, 247, 257, 264) and run tests to confirm no signature
mismatches.
In `@tests/test_binding.py`:
- Around line 145-147: Update all calls to bind_expr and bind_filter (and
similar multi-parameter helpers) to pass the parsed expression/result as a
keyword instead of a positional first argument; e.g., replace
bind_expr(parse_expr("amount"), scope=_scope(), bundle=_bundle()) with a keyword
form like bind_expr(parsed=parse_expr("amount"), scope=_scope(),
bundle=_bundle()) (and do the same for bind_filter), applying this change at
every location listed in the comment so all multi-parameter calls use keyword
arguments for clarity and to follow the repository standard.
In `@tests/test_stage_planner.py`:
- Around line 203-220: The test test_stages_in_dependency_order currently
constructs stage1 and stage2 but passes them to plan_stages in dependency order,
so it doesn't verify reordering; update the test to pass stages in reverse order
(pass stage2 then stage1) to force the planner to reorder, and add an assertion
that the planned result is topologically correct (e.g., check planned[0].name ==
"stage1" and planned[1].name == "stage2" or equivalent checks on the planned
stages returned by plan_stages) so the test fails if plan_stages does not
reorder dependencies; references: test_stages_in_dependency_order, plan_stages,
SlayerQuery, planned.
---
Nitpick comments:
In `@slayer/engine/path_resolution.py`:
- Line 75: The local variable `nq` is annotated too broadly as `Any`; change its
annotation to a more specific mapping type (e.g., `dict[str, Any]`) so callers
and linters know it holds a dict produced from `named_queries`. Update the
assignment `nq: Any = named_queries if named_queries is not None else {}` to use
`nq: dict[str, Any] = ...` (or use `cast(dict[str, Any], named_queries)` if
necessary) and ensure `typing` imports include `Any`/`dict` typing forms
compatible with the project's Python version.
In `@tests/test_boundexpr_unification.py`:
- Line 161: Move the function-local imports to module scope: remove the in-test
import of ArithmeticKey (from slayer.core.keys) and any other imports currently
performed inside test functions and place them at the top of
tests/test_boundexpr_unification.py; update the module-level import section to
include "from slayer.core.keys import ArithmeticKey" (and any other symbols
currently imported inside tests) so tests reference those symbols without local
imports.
In `@tests/test_dot_path_in_sql.py`:
- Line 474: The test contains local imports (e.g., "from slayer.core.models
import ModelMeasure" and the other imports around the later test block) that
should be hoisted to the module-level import section; move these local imports
to the top of the file with the other imports, remove the in-function/local
import statements, and run the tests to ensure no circular import issues—if a
circular dependency appears, refactor the import to a lazy import helper or
import only the needed attribute at top-level.
In `@tests/test_path_resolution.py`:
- Around line 27-34: The test helper _make_model accepts parameters (name,
joins=None) but several test calls use positional args; update all usages to use
keyword arguments per guideline: call _make_model(name=..., joins=...) instead
of positional calls (e.g., replace _make_model(model_name) with
_make_model(name=model_name) and any two-argument positional calls with explicit
joins=...), locating usages by the function name _make_model in the tests and
editing each call.
In `@tests/test_slack_normalization.py`:
- Around line 249-257: The test uses asyncio.get_event_loop().run_until_complete
to call storage.save_datasource and storage.save_model, which conflicts with
pytest-asyncio-managed loops; change the fixture to be async (make the fixture
function async def) and replace those run_until_complete calls with direct
awaits: await storage.save_datasource(DatasourceConfig(...)) and await
storage.save_model(_orders()), ensuring the fixture yields/returns as before so
pytest-asyncio (asyncio_mode="auto") manages the event loop correctly.
- Around line 225-229: Several tests in test_slack_normalization.py contain
in-function imports (e.g., DatasourceConfig, SlayerQueryEngine, YAMLStorage,
sqlite3) — hoist these imports to the module-level import block at the top of
the file, remove the now-redundant local imports inside the test functions, and
consolidate/organize them with the existing top imports; repeat this change for
the other in-function import sites flagged (around the commented ranges) to
comply with the project import style and keep test-scoped symbols available to
all functions.
In `@tests/test_source_bundle.py`:
- Around line 20-29: The test helper _model(name: str, ds: str = "prod") is
being invoked with positional arguments in multiple places; update all calls to
use keyword arguments (e.g., _model(name="orders") or _model(name="orders",
ds="warehouse")) so they follow the guideline for functions with more than one
parameter; search for usages of _model in tests/test_source_bundle.py and
replace positional invocations with keyword form, leaving the SlayerModel
construction (and related Column/DataType references) unchanged.
In `@tests/test_time_trunc_key.py`:
- Around line 109-112: The test test_is_frozen should assert the specific
exception type instead of pytest.raises(Exception): replace the broad Exception
with the concrete exception thrown when mutating a frozen TimeTruncKey (use
pydantic.errors.ValidationError / pydantic.ValidationError if TimeTruncKey is a
Pydantic model, or dataclasses.FrozenInstanceError if it is a frozen dataclass),
update the test import(s) accordingly, and keep the mutating line k.granularity
= "day" and references to TimeTruncKey and ColumnKey unchanged.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 2ce23cea-7e91-49fa-a808-715b179c12a0
📒 Files selected for processing (43)
slayer/core/errors.pyslayer/core/keys.pyslayer/core/scope.pyslayer/core/warnings.pyslayer/engine/agg_registry.pyslayer/engine/binding.pyslayer/engine/cross_model_planner.pyslayer/engine/measure_expansion.pyslayer/engine/normalization.pyslayer/engine/path_resolution.pyslayer/engine/planned.pyslayer/engine/planning.pyslayer/engine/query_engine.pyslayer/engine/source_bundle.pyslayer/engine/stage_planner.pyslayer/engine/syntax.pyslayer/engine/variables.pyslayer/sql/sql_expr.pytests/test_agg_registry.pytests/test_binding.pytests/test_boundexpr_unification.pytests/test_cross_model_planner.pytests/test_cross_model_planner_wiring.pytests/test_dot_path_in_sql.pytests/test_error_messages.pytests/test_keys.pytests/test_model_measure_expansion.pytests/test_path_resolution.pytests/test_planned.pytests/test_projection_planner.pytests/test_scope_schema.pytests/test_slack_normalization.pytests/test_source_bundle.pytests/test_sql_expr.pytests/test_stage_planner.pytests/test_syntax.pytests/test_time_dimensions_filters.pytests/test_time_dimensions_planner.pytests/test_time_trunc_key.pytests/test_transform_lowerer.pytests/test_transforms_planner.pytests/test_value_registry.pytests/test_variables_planner.py
Original scope was a slayer/sql/parity_adapter.py mapping PlannedQuery
back to EnrichedQuery so the legacy SQLGenerator could render it as the
oracle for upcoming generator slices (7b.8-7b.13). A closer read of
slayer/engine/enrichment.py (2300 lines of resolution logic --
derived-column expansion, alias provenance, filter classification,
cross-model rerooting) made it clear that a faithful adapter would
duplicate the bulk of that file in throwaway test-only code, on top of
code already destined for deletion at end of 7b.15.
Pivot: drop the adapter entirely. Each upcoming slice writes parity
tests of the shape
legacy = await legacy_sql_for(engine, model, q)
new = generate_from_planned(plan_query(q, bundle), dialect=...)
assert_sql_equivalent(legacy, new)
This stage lands the shared helpers each slice imports:
* tests/parity_oracle.py
- legacy_sql_for(engine, model, query, named_queries=, dialect=)
routes through engine._enrich + SQLGenerator.generate, the
production legacy code path.
- assert_sql_equivalent(legacy, new) does whitespace-canonical
comparison with a token-level unified-diff on mismatch.
- norm_sql(sql) collapses runs of whitespace.
- build_storage_with_models(tmp_path, *models) seeds a YAMLStorage.
* tests/test_parity_oracle.py
- 9 smoke tests covering norm_sql, assert_sql_equivalent
(pass-on-whitespace-diff, raise-on-real-diff, pass-on-identity),
legacy_sql_for non-empty output, deterministic re-runs, and
joined-dim rendering through the helper.
Codex impl-review fold-ins:
* HIGH: legacy_sql_for now accepts named_queries= so multi-stage and
cross-model parity (where production passes a name -> SlayerQuery
map) works through the helper.
* MEDIUM: legacy_sql_for accepts an optional dialect= and threads it
through to both _enrich and SQLGenerator(dialect=...) so non-postgres
parity is on the table for later slices.
* MEDIUM: build_storage_with_models docstring rewritten to reflect
that save-time join-target validation is permissive, so order is a
convention not a requirement.
Plan amendments (in /home/james/.claude/plans/):
* read-the-linear-issue-mutable-crane.md - 7b.7 redefined as "parity
oracle test helpers"; 7b.8-7b.13 slice descriptions updated to
reference legacy_sql_for directly; 7b.15 safe-deletes list swaps
slayer/sql/parity_adapter.py for tests/parity_oracle.py +
tests/test_parity_oracle.py.
3509 unit tests passing, 0 regressions across the suite, ruff clean.
The legacy /enriched code path stays untouched; this commit only adds
test-side helpers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First SQL-generator slice on the typed pipeline. Adds `SQLGenerator.generate_from_planned(planned_query, *, bundle)` plus module-level `generate_from_planned(planned_query, *, bundle, dialect)` shim alongside the legacy `generate()` path. Mirrors the local-only branch of `_generate_base` but reads typed `PlannedQuery` fields (`row_slots` / `aggregate_slots` / `filters_by_phase` / `order`) instead of `EnrichedQuery`, and reuses legacy dialect helpers (`_resolve_sql` / `_build_agg` / `_wrap_cast_for_type` / `_parse_predicate` / `_apply_order_limit`) via a synthetic- `EnrichedMeasure` adapter so dialect parity holds with one emission codebase. Scope: single-model queries with row-phase dims, local aggregates, Mode-B row filters (`status == 'paid'`), ORDER BY a declared measure/dim alias, LIMIT/OFFSET, and dim-only deduplication. Cross-model, time dimensions, transforms, HAVING-phase filters, `column_filter_key`, hidden ORDER BY targets, and `*:<non-count>` all raise `NotImplementedError` with an explicit `DEV-1450 stage 7b.9+`/`7b.10+`/`7b.12` marker so silent SQL parity drift is impossible. Two planner-side gap fixes flagged in the 7b.7 checkpoint: * ORDER BY resolution against declared-measure aliases — new `(public_name, declared_name, canonical_alias) -> bound` map built from declared measures; order pass checks it before falling back to `bind_expr` (aggregate canonical aliases like `amount_sum` aren't columns, so the binder would have raised). * Pre-bind `ModelMeasure` expansion wired into `_declared_measures_from_query` via `expand_model_measures` (gated on `ModelScope` with a non-None `source_model` — downstream StageSchema stages don't expose saved measures). Tests (branch-new `tests/test_generator2_local.py`): * 13 parametrised parity fixtures + 5-dialect smoke (postgres, sqlite, duckdb, mysql, clickhouse) — each asserts `assert_sql_equivalent(legacy_sql_for(...), generate_from_planned(...))` via the 7b.7 parity oracle. * Regression tests for the four 7b.7-checkpoint planner gaps: ORDER BY canonical alias, `ModelMeasure` expansion, `column_filter_key` rejection (hand-built + xfail for the real planner-path gap), and multi-alias same-key (no-parity, direct shape assertion). * DEV-1443 collision invariants preserved. Codex review fold-ins (two rounds): * test review: ORDER BY without LIMIT case; `count_distinct` + GROUP BY case; column_filter_key xfail covering the real planner gap rather than only the hand-built guard. * impl review round 1: model.filters defer guard (HIGH); `cross_model_aggregate_plans` upfront guard (MEDIUM); unary minus handling in `_build_arithmetic_for_filter` (MEDIUM); custom/parameterised aggregations defer through new `_BUILTIN_BAREARG_AGGS_LOCAL_SLICE` constant (LOW). * impl review round 2: `*:<non-count>` rejection in synthetic measure adapter (MEDIUM); hidden ORDER BY slot defer (MEDIUM). 3533 unit tests pass (24 new + 1 xfailed for the deferred Column.filter planner gap), 0 regressions. Ruff clean. Integration tests not run locally. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extends generate_from_planned to render TimeTruncKey row-phase slots via _build_date_trunc (Postgres/DuckDB/MySQL/ClickHouse DATE_TRUNC, SQLite STRFTIME), and folds SlayerModel.filters (Mode-A SQL always-applied WHERE) into the same entry point with legacy WHERE ordering (date_range -> model.filters -> query.filters). Introduces BetweenKey, a typed value-key for col BETWEEN low AND high. The planner's _build_date_range_filter switches from ArithmeticKey(and, [GE, LE]) to BetweenKey, closing the syntactic parity gap with legacy generator.py:2533 (which emits BETWEEN). User- written `col >= a and col <= b` stays as ArithmeticKey -- the DSL parser never produces BetweenKey, so user-filter parity is preserved. stage_planner._validate_model_filter validates each entry via parse_sql_predicate (rejects DSL constructs, raw OVER), rejects refs to measures (enrichment.py:1147 parity), rejects refs to windowed columns (enrichment.py:1205 parity), and rejects refs to non-trivial derived Column.sql columns (deferred to follow-up; trivial-base columns per _is_trivial_base pass through). Filter is emitted as a text-only FilterPhase with text_columns extracted from ParsedFilter; the new _qualify_mode_a_sql_filter in the generator regex-prepends <source_relation>. to each bare-identifier ref, bit-identical to legacy _build_where_and_having:2566-2580. Test count: 3582 (+49 over 7b.8). 48 tests in tests/test_generator2_time_dims.py cover the granularity sweep (PG + SQLite, 8 each), TD + measures, date_range, multi-TD disambiguation, ORDER-BY-on-TD, model.filters with all rejection variants, whole_periods_only pre-snap integration, dialect cycle smoke, and round-2/round-3 codex regression cases. 1 trivial-base regression test pins _is_trivial_base parity. 7b.3c filter shape tests in tests/test_time_dimensions_filters.py updated to the new BetweenKey shape and user-filter-ordering invariant. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Renders cumsum / lag / lead / rank / percent_rank / dense_rank / ntile / first / last through generate_from_planned via a Kahn-batched step CTE chain wrapped in an outer SELECT (legacy _generate_with_computed shape). Auto-partition by query dimensions only (not TDs); rank-family defaults to no PARTITION BY. POST-phase filters route through a _filtered wrapper between the chain and pagination. Planner side now attaches the active TD as TransformKey.time_key for every time-needing transform (cumsum / lag / lead / first / last / time_shift / consecutive_periods / change / change_pct), closing the 7b.4 carry-over gap. Lowering of change / change_pct runs after the patch so the desugared time_shift inherits the resolved time_key. Validation mirrors legacy enrichment.py:564 -- any unresolved time-needing transform raises "requires an unambiguous time dimension". PlannedQuery gains active_time_dimension_slot_id so the generator can look up the TD slot's alias without re-walking the model graph. time_shift / consecutive_periods / change(lowered) raise NotImplementedError with "7b.11" markers; composite transform inputs (e.g., cumsum(amount:sum / qty:sum)) raise with a follow-up marker. Codex impl-review folded in: list-per-slot alias tracking so duplicate public aliases (DEV-1450 C13) survive the CTE chain; strict lag / lead periods validation rejecting bool / non-integral; POST filter operator coverage (= / <> / unary - / n-ary and-or / typed scalar literals); order-only hidden refs materialised in the base CTE; BoundFilter.referenced_keys recomputed after time-key patching. 40 new tests in tests/test_generator2_window.py (38 pass + 1 typed-only skip for a pre-existing 7b.8 ModelMeasure.type gap + 1 covering nested cases). Full unit suite: 3621 passed, 3 skipped, 1 xfailed (+39 / +1 / 0 vs baseline); ruff clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 1
♻️ Duplicate comments (2)
slayer/engine/syntax.py (1)
189-191:⚠️ Potential issue | 🟠 Major | ⚡ Quick winThe
OVER(guard still misclassifies some valid Python strings.
_STRING_LITERAL_REunderstands doubled-quote SQL literals, not Python backslash escapes, so inputs likestatus == "x\" OVER("still leaveOVER(visible and raiseIllegalWindowInFilterErroreven though it is inside the string literal. This needs a quote-aware scanner/tokenizer that follows Python string rules instead of_STRING_LITERAL_RE.sub("", text).#!/bin/bash set -euo pipefail python - <<'PY' import re OVER_RE = re.compile(r"\bOVER\s*\(", re.IGNORECASE) STRING_LITERAL_RE = re.compile(r"'(?:[^']|'')*'|\"(?:[^\"]|\"\")*\"") cases = [ r'status == "OVER("', r'status == "x\" OVER("', ] for text in cases: masked = STRING_LITERAL_RE.sub("", text) print(f"text : {text}") print(f"masked : {masked}") print(f"match? : {bool(OVER_RE.search(masked))}") print("-" * 40) PY🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@slayer/engine/syntax.py` around lines 189 - 191, The current guard uses _STRING_LITERAL_RE to mask literals but fails for Python-style escapes; replace that masking with a Python-aware tokenizer: use the tokenize module (tokenize.generate_tokens / tokenize.tokenize) on the input text to produce a new string where tokens of type STRING are replaced with equal-length whitespace (so positions remain consistent), then run _OVER_RE.search on that tokenized/masked string instead of _STRING_LITERAL_RE.sub("", text); update the conditional using the new helper (e.g., _mask_python_strings(text) and call it where _STRING_LITERAL_RE.sub(...) is used) and ensure imports for tokenize and io are added and IllegalWindowInFilterError behavior remains unchanged.slayer/engine/stage_planner.py (1)
368-374:⚠️ Potential issue | 🟠 Major | ⚡ Quick winFlatten dotted ORDER refs before rebinding in stage scope.
This fallback still parses
customers.regionverbatim whenscopeis aStageSchema, but downstream stage columns are emitted ascustomers__region. Structured ORDER refs withoutraw_formulawill still fail or rebind to the wrong slot in multi-stage queries.Suggested fix
else: bo = bind_expr( - parse_expr(full_name, allow_dunder=flat_scope), + parse_expr( + _flatten_dotted(full_name) if flat_scope else full_name, + allow_dunder=flat_scope, + ), scope=scope, bundle=bundle, )🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@slayer/engine/stage_planner.py` around lines 368 - 374, The bind is parsing the dotted ORDER ref verbatim (parse_expr(full_name,...)) so when scope is a StageSchema downstream columns are emitted flattened (customers__region) and the current code rebinds incorrectly; fix by detecting StageSchema (or stage scope) and transform dotted full_name into the stage-flattened form before calling parse_expr/bind_expr (e.g. replace dots with the stage separator used by the StageSchema) so bind_expr receives the flattened identifier (use the same flattening logic/utility the StageSchema uses) while preserving allow_dunder=flat_scope and passing scope and bundle to bind_expr.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@slayer/engine/schema_drift.py`:
- Around line 866-870: The comprehension that builds custom_agg_names scans all
models via graph.models_by_name but should only consider models reachable from
the stage source; change the comprehension to iterate over graph.reachable and
then look up models in graph.models_by_name (e.g. for name in graph.reachable
for m in (graph.models_by_name.get(name),) if present) so only reachable models'
aggregations (m.aggregations) contribute to custom_agg_names; update the
expression that defines custom_agg_names accordingly (refer to the existing
variable name custom_agg_names and usages in schema_drift.py).
---
Duplicate comments:
In `@slayer/engine/stage_planner.py`:
- Around line 368-374: The bind is parsing the dotted ORDER ref verbatim
(parse_expr(full_name,...)) so when scope is a StageSchema downstream columns
are emitted flattened (customers__region) and the current code rebinds
incorrectly; fix by detecting StageSchema (or stage scope) and transform dotted
full_name into the stage-flattened form before calling parse_expr/bind_expr
(e.g. replace dots with the stage separator used by the StageSchema) so
bind_expr receives the flattened identifier (use the same flattening
logic/utility the StageSchema uses) while preserving allow_dunder=flat_scope and
passing scope and bundle to bind_expr.
In `@slayer/engine/syntax.py`:
- Around line 189-191: The current guard uses _STRING_LITERAL_RE to mask
literals but fails for Python-style escapes; replace that masking with a
Python-aware tokenizer: use the tokenize module (tokenize.generate_tokens /
tokenize.tokenize) on the input text to produce a new string where tokens of
type STRING are replaced with equal-length whitespace (so positions remain
consistent), then run _OVER_RE.search on that tokenized/masked string instead of
_STRING_LITERAL_RE.sub("", text); update the conditional using the new helper
(e.g., _mask_python_strings(text) and call it where _STRING_LITERAL_RE.sub(...)
is used) and ensure imports for tokenize and io are added and
IllegalWindowInFilterError behavior remains unchanged.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 3ff7eb9f-2ec5-4535-92cf-1e1db5ba5448
📒 Files selected for processing (22)
docs/architecture/errors-and-warnings.mdslayer/core/keys.pyslayer/core/refs.pyslayer/engine/binding.pyslayer/engine/cross_model_planner.pyslayer/engine/normalization.pyslayer/engine/planning.pyslayer/engine/query_engine.pyslayer/engine/response_meta.pyslayer/engine/schema_drift.pyslayer/engine/stage_planner.pyslayer/engine/syntax.pyslayer/memories/resolver.pyslayer/sql/generator.pytests/test_agg_registry.pytests/test_dev1445_cross_model_alias_filter.pytests/test_dev1450fix_cross_model_derived.pytests/test_dev1450fix_derived_time_dimension.pytests/test_dev1450fix_group2_correctness.pytests/test_generator2_multistage.pytests/test_path_resolution.pytests/test_stage_planner.py
💤 Files with no reviewable changes (1)
- slayer/engine/planning.py
…ER edge Group A — a DERIVED column (ColumnSqlKey) used as a CROSS-MODEL aggregate source (e.g. customers.rev_x2:sum where rev_x2 = revenue*2) now re-roots and expands correctly (Codex #1/#2; finishes CodeRabbit cross_model_planner:674): - cross_model_planner._aggregate_alias / _make_cte_schema resolve the source name/type via leaf-or-column_name, so a derived source aliases as `rev_x2_sum` (not the star `_sum`) and carries the right type. - generator cross-model CTE synth re-roots a ColumnSqlKey source to path=() and re-roots ColumnSqlKey kwargs symmetrically with ColumnKey. Group B — schema_drift._measure_refs_on_base scopes custom-agg discovery to graph.reachable models (not the whole registry), so an unrelated custom-agg name can't normalize a coincidental call and cause a false cascade (CodeRabbit schema_drift:870). Codex #3 — the raw-OVER( pre-scan blanks Python string literals with an escape-aware matcher (_PY_STRING_LITERAL_RE), so a value like `status == "x \" OVER("` isn't mistaken for a window clause. New tests in test_dev1450fix_cross_model_derived.py (derived agg source) and test_dev1450fix_group2_correctness.py (escaped-literal OVER). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@slayer/engine/syntax.py`:
- Around line 143-147: Replace the non-escape-aware _STRING_LITERAL_RE with the
new escape-aware _PY_STRING_LITERAL_RE in both _normalize_sql_filter_operators
and _preprocess_colons so rewrites (colon preprocessing and SQL operator
normalization in functions _preprocess_colons and
_normalize_sql_filter_operators) skip over Python-style string literals that
contain backslash-escaped quotes; update any local variable or parameter
references in those functions to use _PY_STRING_LITERAL_RE and run existing
literal-masking logic unchanged so behavior remains identical except now
honoring escaped quotes.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 3c882c1d-4806-4406-9510-0f60db0e0ca6
📒 Files selected for processing (6)
slayer/engine/cross_model_planner.pyslayer/engine/schema_drift.pyslayer/engine/syntax.pyslayer/sql/generator.pytests/test_dev1450fix_cross_model_derived.pytests/test_dev1450fix_group2_correctness.py
…ract Resolved conflicts: - CLAUDE.md: unioned typed parametric keys note (HEAD) with cross-model rename paragraph (main) plus the shared workaround / companion-tickets trailing clause. - slayer/engine/query_engine.py: unioned imports (SourceModelOrigin from HEAD's typed pipeline + NormalizationWarning). Test reconciliation (tests/test_nested_dag_cross_stage_refs.py and tests/test_cross_model_rename_dev1448.py): Dropped 9 tests that assert legacy mechanisms the typed pipeline deliberately replaces (breadcrumb-based dotted-form preservation, intercept Candidate A/B precedence, intercept canonical-collision guards, lenient fall-through path). Adapted 18 tests to the typed-pipeline contract: downstream stages see a flat schema, so dotted refs (`customers.regions.name`) become flat (`customers__regions__name`); cross-model measure re-aggregation becomes `flat_alias:sum` against the inner stage's projected column; result-key assertions updated to match the flat surface. Skipped 3 tests pending follow-up issues: - DEV-1471 (cross-stage time_dim re-binding): two tests skipped (TestCrossStageTimeDimension::test_multi_hop_dotted_time_dim_cross_stage, TestCrossStageTimeShift::test_time_shift_over_multi_hop_dotted_time_dim). - DEV-1472 (hidden-slot ORDER BY): one test skipped (TestCrossModelInterceptDuplicateQfieldGuard::test_intercepted_cmm_order_only_no_qfield_registers_alias). Full unit suite: 3871 passed, 7 skipped. Ruff clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…hedMeasure DEV-1452 prerequisite #3 — first of four staged commits. Net-neutral refactor of slayer/sql/generator.py's aggregation-emission codebase so the legacy `EnrichedMeasure` type can be deleted (Stage D) without forking dialect SQL emission. Existing test_sql_generator.py + unit suite (3688 passed, 0 regressions) act as the byte-identical SQL parity gate. What changed - New frozen Pydantic `AggRenderSpec` in slayer/sql/generator.py with exactly 11 fields (locked decision #4 in the approved plan): {sql, name, model_name, aggregation, aggregation_def, agg_kwargs, alias, filter_sql, time_column, type, column_type}. Drops the ticket's illustrative `agg_args` / `source_measure_name` / `distinct` — the dialect helpers don't read any of those; count_distinct dispatches by agg name, and first/last's positional time arg is pre-resolved into `time_column` at spec-build time. - 7 dialect helpers refactored to consume AggRenderSpec: `_build_agg`, `_build_formula_agg`, `_build_percentile`, `_build_stat_agg`, `_build_ranked_subquery_from_planned`, `_resolve_value_sql`, `_resolve_agg_param`. `_build_last_ranked_from` is deliberately NOT refactored — it's the legacy ranked builder that dies wholesale in Stage D. - `_synthesize_enriched_measure_from_planned` (8 call sites in generator.py) renamed to `_build_agg_render_spec_from_planned` and now returns `AggRenderSpec` directly — no more synthetic-EnrichedMeasure adapter on the typed-pipeline path. - Transitional shim `_agg_render_spec_from_enriched(em)` — a pure field-mapping function the legacy `SQLGenerator.generate(enriched=…)` path uses to feed the refactored helpers. Five legacy call sites in generator.py (cross-model, ranked-from, having-rewrite, base-select, formula-agg-wrapper) and twelve direct-call sites in tests/test_sql_generator.py wrap via this shim. Deleted in Stage D along with the rest of the legacy stack. - `AggregateKey.args` schema loosened from `Tuple[Scalar, ...]` to `Tuple[_AggregateArgValue, ...]` where `_AggregateArgValue = Union[ColumnKey, ColumnSqlKey, Decimal, str, bool, None]`. This was authorized by the user during Stage A after a bug was uncovered: `_bind_agg_arg` (binding.py:818-819) returns `ColumnKey` for identifier positional args, but the old strict-scalar schema rejected them — so `amount:last(created_at)` failed at AggregateKey validation. End-to-end repro on a fresh fixture confirmed the breakage. The legacy `_synthesize_enriched_measure_from_planned`'s `for a in key.args: if isinstance(a, ColumnKey)` branch was actually correct and forward-looking but had been unreachable. The schema fix aligns AggregateKey with the binder's actual output and matches the existing `_AggregateKwargValue` union shape used for kwargs. Tests - tests/test_agg_render_spec.py (new, 666 lines, 48 tests) — the AggRenderSpec field surface + frozen contract + every _build_agg_render_spec_from_planned call-site shape: StarKey count and rejection branches, ColumnKey bare, ColumnKey with column_filter_key, ColumnSqlKey derived, first/last with explicit time arg, custom aggregation_def threading, percentile parametric, stat agg with other=, unknown aggregation, kwarg ColumnKey path mismatch, column-not-found, slot=None HAVING branch, slot.type vs column_type independence. - tests/test_agg_render_spec_shim.py (new, 252 lines, 33 tests) — per-field shim mapping + 9 parametrized SQL byte-identical parity tests (postgres dialect) against legacy outputs captured pre-refactor (basic sum, count*, filtered sum, filtered count*, percentile, stat corr with other=, non-aggregation, sql=None bare-column sum, median). Verification - `poetry run pytest -m "not integration" -q` — 3688 passed, 2 skipped, 251 deselected. No regressions. - `poetry run ruff check slayer/ tests/` — clean. Codex pre-impl review (Stage A test plan): 6 findings, all folded in. Codex post-impl review (Stage A diff): 3 findings. - LOW (test_sql_generator.py:2235 direct _build_percentile calls): fixed in this commit (14 sites wrapped via the shim). - 2 MEDIUM findings on cross-model reroot path (cross_model_planner.py _local_agg_formula, generator.py _maybe_reroot_cross_model_plan) — tracked as follow-up alongside a third related gap (_build_first_last_base_select doesn't honor spec.time_column from args). All three are end-to-end gaps for `first/last(time_col)` that the legacy _query_as_model path currently masks; must be addressed in Stage B (the query-backed expansion migration) before Stage D deletes legacy. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Group A — Codex typed-pipeline correctness regressions (3 × major): * slayer/engine/binding.py: enforce per-column aggregation eligibility gates at bind time, mirroring legacy ``enrichment.py:401-417`` — primary-key columns restricted to count/count_distinct, explicit ``allowed_aggregations`` whitelists honored, type-default whitelist applied for built-ins (custom model aggregations exempt). The typed pipeline previously accepted ``id:sum`` and ``status:avg`` silently. * slayer/engine/syntax.py + slayer/sql/generator.py: ``IS`` / ``IS NOT`` filters work end-to-end. ``_CMP_OP_MAP`` previously rewrote ``IS NULL`` to lowercase ``is None`` but the AST converter rejected ``ast.Is`` / ``ast.IsNot``. Added the ops; both SQL render paths emit ``IS NULL`` / ``IS NOT NULL`` via ``exp.Is`` / ``exp.Not(exp.Is)``. * slayer/engine/binding.py: multi-column ``partition_by=[col1, col2]`` binds correctly. The parser converts a list/tuple kwarg into a Python tuple, but the binder only handled a single column ref; now binds each element and extends ``partition_keys``. Group B — CodeRabbit correctness (2 × major): * slayer/engine/cross_model_planner.py: ``_local_agg_formula`` preserves path info for column-valued POSITIONAL aggregate args during cross-model rerooting (was only handled for kwargs). Falls back to ``_reroot_col_kwarg`` instead of ``_scalar_formula_literal`` for ColumnKey / ColumnSqlKey positional args, so the nested sub-query binds correctly. * slayer/engine/syntax.py: switch ``_normalize_sql_filter_operators`` and ``_preprocess_colons`` from ``_STRING_LITERAL_RE`` (SQL '' / "" doubling) to the escape-aware ``_PY_STRING_LITERAL_RE``, so a backslash-escaped quote doesn't leak ``IS`` / ``IN`` / ``AND`` / ``:sum`` rewrites into the string body. ``_STRING_LITERAL_RE`` was removed (no remaining users). Group C — CodeRabbit hygiene quick-wins: * tests/test_generator2_multistage.py: make ``_run_sqlite`` keyword-only. * slayer/engine/stage_planner.py: pass first arg by keyword in every ``bind_filter`` / ``bind_expr`` / ``bind_time_dimension`` call. * tests/test_dot_path_in_sql.py: hoist function-local imports. * tests/test_time_trunc_key.py: tighten ``pytest.raises(Exception)`` to ``pytest.raises(ValidationError)`` for the frozen-Pydantic mutation. Group D — failed CI deferred via xfail (1): * tests/integration/test_notebooks.py: ``docs/examples/04_time/time_nb.ipynb`` xfailed with DEV-1474 reason. The QoQ-by-store cell triggers the typed pipeline's documented stage 7b.12 gap (cross-model partition in time_shift CTEs). xfail(strict=False) so the test auto-promotes to PASSED when DEV-1474 lands. Follow-up Linear issues filed: * DEV-1474 — cross-model partition in time_shift CTEs (HIGH). * DEV-1475 — Mode-B filters: SQL-style IN / NOT IN with tuple RHS (deferred from this PR; needs a new ParsedExpr node). CodeRabbit thread on ``tests/test_generator2_local.py`` line 279 replied to as outdated (the cited code has moved since the thread was opened). Regression tests added to tests/test_dev1450fix_group2_correctness.py: * test_in_keyword_inside_escaped_string_literal_not_rewritten * test_colon_inside_escaped_string_literal_not_preprocessed * test_is_null_filter_parses_and_renders * test_partition_by_multi_column_parses_and_binds * test_aggregation_eligibility_primary_key_rejected * test_aggregation_eligibility_type_default_rejected * test_aggregation_eligibility_allowed_aggregations_whitelist Full unit suite: 3878 passed, 7 skipped. Ruff clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…n-of-syntax' into egor/dev-1452-delete-legacy-enrichment-pipeline-migrate-pre-existing-tests
Two findings collapse to one method (slayer/sql/generator.py:7143 _build_agg_render_spec_from_planned): - Codex MAJOR on PR #144: the explicit-time-arg loop only matched ColumnKey, silently dropping ColumnSqlKey args. With the Stage A schema loosen, the binder returns ColumnSqlKey for derived columns; amount:last(derived_time) would fall back to the query's default ranking column instead of ordering by the explicit derived expression — incorrect first/last results with no error. - Sonar python:S3776 on the same method: cognitive complexity 43 > 15. Extracts a new _resolve_explicit_time_col helper that handles both ColumnKey and ColumnSqlKey time args. Bare-identifier derived columns expand to their underlying SQL qualified under source_relation; non-trivial derived expressions are emitted as-is and their inner bare refs resolve against the ranked-subquery's FROM clause. Cross- model paths on ColumnSqlKey args raise NotImplementedError rather than silently emitting against the wrong relation alias — that case is tracked alongside the Stage B cross-model reroot bug (DEV-1452 latent bug (c) in the Linear comment). The extraction drops the caller's complexity below 15 as a side effect. End-to-end caveat: first/last with an explicit time arg renders correctly only when a time dimension is also present in the query (default_time_col_sql comes from _resolve_ranking_time_column_from_planned). With no TD, deferred bug (b) — _build_first_last_base_select raises before any spec is rendered — still bites. Stage B fixes that boundary as planned. Tests in tests/test_agg_render_spec.py: - test_last_with_derived_bare_time_column_local (bare-identifier derived column expands to underlying SQL) - test_first_with_derived_expression_time_column_local (non-trivial derived expression round-trips through sqlglot, qualified inside the ranked subquery's FROM) - test_cross_model_derived_time_column_raises (NotImplementedError) - test_unknown_derived_time_column_raises (ValueError mirroring the source-column lookup-miss path) Unit suite: 3758 passed, 7 skipped, 0 failed (flight tests deselected — pyarrow not installed in this env). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…anned Sonar python:S3776 on slayer/sql/generator.py:3822 — cognitive complexity 33 > 15 on _build_ranked_subquery_from_planned. Extracts the two sub-passes into named helpers, leaving the outer method as setup → unfiltered pass → filtered pass → assembly: - _build_unfiltered_rn_columns: one ROW_NUMBER projection per distinct effective time column for unfiltered first/last specs, with stable per-time-column suffixes (first sorted gets "", then "_2", ...). - _build_filtered_rn_columns: one dedicated (ROW_NUMBER + match-flag) pair per distinct (filter, time, agg) triple for filtered first/last, cached so specs sharing a triple reuse the same pair. Pure structural refactor — no behaviour change. The dedup semantics, stable suffix assignment, ORDER BY direction, and CASE WHEN match-flag emission are byte-identical to the pre-split implementation. Unit suite: 3758 passed, 7 skipped, 0 failed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sonar python:S8396 on slayer/sql/generator.py:60 — `sql: Optional[str]` without a default flags as a Pydantic anti-pattern. The intent here is "required field that can hold None" (`*:count` needs to pass `sql=None` explicitly). Per the rule's documented exception, `Type | None` is treated as explicit nullability and does not trigger the warning. Switch `sql: Optional[str]` to `sql: str | None` — preserves the required-but-may-be-None semantics, dodges the false-positive, and adds a docstring note pinning the intent so future readers don't "fix" it back to `Optional[str]`. Also wires the new DEV-1476 ticket reference into the group 1 helper docstring + error message + test comment (DEV-1476 is the consolidated 4-bug Stage B package opened today for the explicit-time-arg gaps on first/last; the cross-model derived-time NotImplementedError in `_resolve_explicit_time_col` is bug (c) of that package). Unit suite: 3758 passed, 7 skipped, 0 failed. Ruff: clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two outside-diff CodeRabbit findings on the spec-build adapter, both verified against current code and fixed minimally. Finding A — slayer/sql/generator.py:~7350 (agg_def lookup for built-ins) The lookup for source_model.aggregations was gated on 'key.agg not in _BUILTIN_BAREARG_AGGS_LOCAL_SLICE'. That set includes weighted_avg / percentile / corr / etc., so a user model that overrode the default params of a built-in (e.g. supplied a default weight=quantity for weighted_avg) had the override silently dropped on the planned path — _resolve_agg_param then raised 'Aggregation \'weighted_avg\' requires parameter \'weight\''. Real regression vs legacy; the pre-existing comment above the set even admits this as a deferred follow-up. Fix: always look up the model-level Aggregation; only raise when the lookup misses AND key.agg is not a built-in. Built-ins keep their default renderer when no override exists. Updated the comment above _BUILTIN_BAREARG_AGGS_LOCAL_SLICE to retire the obsolete TODO. Finding B — slayer/sql/generator.py:~7375 (kwarg path validation gap) The for-kname-kval-in-kwargs loop only matched ColumnKey, so a ColumnSqlKey kwarg with a non-empty path slipped past the host-vs-target-path check and would have rendered against the wrong relation. Bug surfaced by the Stage A AggregateKey.args / kwarg-value schema loosen (which now permits ColumnSqlKey as a kwarg value). Fix: extend the isinstance check to (ColumnKey, ColumnSqlKey); use type(kval).__name__ in the error reason so the message reflects the actual rejected kind. Both Key types carry .path, so the equality check works unchanged. CodeRabbit's third sub-finding on the same comment (derived time arg for first/last silently dropping ColumnSqlKey) was already addressed in group 1 via _resolve_explicit_time_col + tests; verified no residual gap. Tests added in tests/test_agg_render_spec.py: - test_builtin_aggregation_model_override_threaded: model-level weighted_avg override surfaces in spec.aggregation_def with default weight=quantity. - test_builtin_aggregation_no_override_leaves_def_none: a vanilla built-in (sum) with no override keeps aggregation_def=None so the built-in renderer dispatches. - test_kwarg_columnsqlkey_path_mismatch_raises: a ColumnSqlKey kwarg with a non-empty path against a local source raises the same AggregationNotAllowedError as the ColumnKey case. - _orders_model() fixture grew a weighted_avg model-level override so the built-in-override tests don't need a parallel fixture. Unit suite: 3933 passed, 7 skipped, 0 failed. Ruff: clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…an fold Codex round 2 caught two remaining correctness gaps: * slayer/sql/generator.py: ``_build_arithmetic_for_filter`` (the third filter-render site) was missing ``is`` / ``is not`` branches. Commit b7ba22b patched ``_build_arith_or_cmp_ast`` and ``_compose_arithmetic_op`` but missed this one — local-stage filters like ``deleted_at IS NULL`` parsed and bound but raised ``NotImplementedError`` at SQL generation. Added the branches. * slayer/sql/generator.py: ``_build_arith_or_cmp_ast`` (cross-model filter rendering) silently dropped n-ary boolean operands past index 1. The binder produces n-ary ``ArithmeticKey`` for ``a AND b AND c``; the prior implementation took only ``operands[0]`` / ``[1]`` and emitted a single binary node, broadening cross-model HAVING/WHERE results. Fold over every operand the same way ``_compose_arithmetic_op`` and ``_build_arithmetic_for_filter`` already do. Regression tests added to tests/test_dev1450fix_group2_correctness.py: * test_is_null_filter_renders_end_to_end — exercises the full pipeline for ``IS NOT NULL`` against a TEXT joined column. * test_nary_boolean_cross_model_filter_retains_all_operands — direct unit test of ``_build_arith_or_cmp_ast`` for ``a AND b AND c``. Full unit suite: 3880 passed, 7 skipped. Ruff clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sonar python:S3776 re-flagged both functions after groups 1-4 splits:
- _build_ranked_subquery_from_planned cognitive complexity 21 > 15
(was 33 pre-Group-2 split)
- _build_agg_render_spec_from_planned cognitive complexity 26 > 15
(was 43 pre-Group-1 extract)
Hybrid approach (option c on the plan):
1) Extract two more cohesive helpers from _build_agg_render_spec_from_planned:
- _resolve_aggregation_def: model-level Aggregation lookup + raise on
unknown non-built-in. Encapsulates the lookup-runs-for-built-ins-too
semantics (CodeRabbit fold-in on PR #144 group 4) so a future reader
can see the contract in one place.
- _validate_aggregate_kwarg_paths: cross-model kwarg path validation
for both ColumnKey and ColumnSqlKey. Tightens the dispatch in the
main method down to a single line per concern.
2) NOSONAR(S3776) both functions with documented rationale:
- The spec-build adapter is fundamentally a sequential isinstance
dispatch over StarKey / ColumnKey / ColumnSqlKey; each branch has
its own contract. Further splitting would scatter the per-source-
kind contract across helpers and obscure flow.
- The ranked subquery builder, after Group 2 factored
_build_unfiltered_rn_columns / _build_filtered_rn_columns, is left
with exp.Select / from / joins / where assembly that has to live in
one place.
Pure refactor — no behaviour change. Existing tests pin the integrated
behaviour through the public method.
Unit suite: 3933 passed, 7 skipped, 0 failed.
Ruff: clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three legacy-stack call sites (_expand_query_backed_model, _validate_and_populate_cache, get_column_types) now route through build_resolved_source_bundle → plan_stages → generate_planned_stages instead of the legacy _query_as_model / _enrich / SQLGenerator.generate chain. Folds in DEV-1476 bugs (b), (c), (d-cross) so query-backed models using first / last with an explicit positional time arg keep working end-to-end after Stage D deletes the legacy fallback. Helpers extracted (decisions B + F): * slayer/sql/stage_wrapper.py::build_flat_rename_wrapper — shared with generate_planned_stages' CTE chaining + the virtual-model wrap. * slayer/engine/stage_ordering.py::topologically_order_stages — Kahn topo-sort, called by both the runtime-list execute path and the stored source_queries save path. _extract_sibling_refs now recursively walks inline SlayerModel.source_queries (decision E). * slayer/engine/source_bundle.py::expand_query_backed_models_in_bundle — source + referenced + stage_source expansion + inline_extensions re-apply in one place; recursion guard via _resolving handles a query-backed join target that references its parent. Typed primitives extended (decisions #2, #8): * StageColumn.format retyped Optional[NumberFormat]. * ValueSlot.format / .description added. * DeclaredMeasure.type / .format / .description carry through ProjectionPlanner intern → ValueSlot → StageColumn → virtual Column. * Slot-type inference: *:count → INT, count/avg/median → built-ins, sum/min/max inherit source column type. Replaces legacy blanket DOUBLE for virtual-model columns (decision #2). DEV-1476 fixes (B.6): * (b) _build_first_last_base_select now only raises when an explicit time arg is also missing from every first/last spec. * (c) _render_cross_model_cte reroots positional ColumnKey / ColumnSqlKey args symmetric to kwargs, and wraps the FROM in a ROW_NUMBER-ranked subquery for first/last aggregates. * (d-cross) _build_agg_render_spec_from_planned threads ``bundle`` so derived ColumnSqlKey aggregate sources get expand_derived_refs_ sync (their inner bare refs qualify under source_relation). * tests/test_agg_render_spec.py::TestBuilderFirstLast flipped from _raises to _resolves_after_reroot. Migrated-path acceptance (decision D): * SourceModelOrigin / agg_column_names dropped from the virtual model. Outer dotted-ref re-aggregation against canonical aliases uses the flat ``__``-joined name (P4 closure); a dotted ref that matched the legacy lineage walk now surfaces a clear binder error. * The two ContextVars (_join_target_resolving_var, _forbidden_sibling_refs_var) are NOT touched by the migrated paths. They remain alive for _query_as_model until Stage D. Test additions / migrations: * tests/test_query_backed_typed_expansion.py — 32 new tests covering topo-sort acceptance / cycle / root-as-sink / self-ref / duplicate / inline-nested decision E, virtual-model column types / format / description / default_time_dimension / public-only slots, P4 closure pin, ContextVar non-touch, save path with placeholder fill + data_source refresh + save=False, nested query-backed expansion (qb-A joins qb-B / overlay-over-qb-base / runtime stage source), cross-DS rejection, wrapper alias shapes (local agg / cross-model agg / cross-model parametric agg / joined dim). * tests/test_dev1476_first_last_explicit_time.py — 6 tests (3 helper-level _local_agg_formula args reroot + 3 end-to-end against seeded SQLite for bugs b / c / d-cross). * tests/test_topologically_order_stages.py — 12 tests pinning the extracted helper + decision E recursion (typed / dict / nested ModelExtension / inline-nested cycle). * tests/test_stage_wrapper_helper.py — 4 tests pinning the extracted build_flat_rename_wrapper. * tests/test_query_backed_models.py — loosened the inner-stage-rename regex to accept the quoted alias form (DEV-1452 Stage B emits ``AS "rev"``; pre-Stage-B legacy emitted ``AS rev``). * tests/test_projection_trim.py — migrated test_get_column_types_uses_outer_render_mode → _does_not_call_ legacy_generate (the typed pipeline has no render_mode contract). Verification: * poetry run pytest -m "not integration" -q — 3985 passed, 0 failed. * poetry run ruff check slayer/ tests/ — clean. * SQLite + DuckDB integration smoke — 131 passed. * Query-backed model tests — 46 passed. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…adata Two MAJOR Codex findings around typed-metadata propagation through the planner. Both surface as wrong virtual-column ``type`` on query-backed models after the Stage B migration. * _declared_measures_from_query (stage_planner.py:~1005) ignored a user-supplied ``ModelMeasure.type`` on a query measure — only ``_type_for_measure_formula`` ran. Now ``m.type or _type_for_ measure_formula(...)``: explicit override wins, inference is the fallback. Mirrors legacy ``EnrichedMeasure.type``'s "honor explicit type before inference" contract. * ValueRegistry._merge_into_existing (planning.py:~221) dropped ``type`` / ``format`` / ``label`` / ``description`` on hidden→public promotion. Repro: declare ``rank(*:count)`` first (hoists ``*:count`` hidden) then declare ``*:count`` public — the promoted slot kept ``type=None`` and the migrated virtual column regressed to DOUBLE fallback. Now the merge fills any None-on-existing metadata field from the public re-intern; first-intern wins for already-set fields. Tests: * test_query_measure_type_override_wins_over_inference — explicit ``type=DOUBLE`` on ``*:count`` surfaces as DOUBLE despite the INT inference default. * test_promoted_hidden_slot_keeps_metadata — ``rank(*:count)`` + ``*:count`` ordering yields INT on the public ``_count`` column. Full non-integration suite green (3987 passed). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* S1172 stage_planner.py:840 — drop the unused ``formula`` param from ``_format_description_for_measure_formula``. The function takes ``scope`` + ``bound`` already; the formula string was a leftover from an earlier draft. * S100 test_query_backed_typed_expansion.py:698 — rename ``test_qb_A_joins_qb_B_save_path`` → ``test_qb_a_joins_qb_b_save_path`` to match ``^[a-z_][a-z0-9_]*$``. Full non-integration suite green (3987 passed). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Six CRITICAL S3776 cognitive-complexity hits on Stage B surface plus one pre-existing Stage A leftover. Each NOSONAR'd with a documented rationale on the def line. * generator.py:3832 _resolve_explicit_time_col (complexity 21, pre-existing from Stage A b67eecb) — sequential isinstance dispatch over ColumnKey + ColumnSqlKey; splitting fragments the per-shape contract. * generator.py:4068 _build_first_last_base_select (complexity 42) — dim/td/derived-dim classification pass + agg-spec synth + ranked-subquery wrap + outer SELECT/GROUP BY assembly; shared mutable state (partition_exprs / extra_projections / outer_ref_ by_sid / synth_by_sid) keeps it as one conceptual unit. * generator.py:5113 _render_cross_model_cte (complexity 56) — shared-grain projection + agg reroot (source / args / kwargs) + first/last ranked-subquery wrap + target-model-filter qualify + WHERE/HAVING routing; each block is interdependent CTE state. * query_engine.py:788 get_column_types (complexity 22) — linear probe pipeline: query-backed prelude → bundle → expand-nested → plan → render → execute → result-key map-back. * source_bundle.py:411 expand_query_backed_models_in_bundle (complexity 25) — three sequential expansion blocks (source + referenced + stage_source) mutating bundle in series; recursion guard is the only shared state. * stage_ordering.py:47 _walk_spec (complexity 49) — recursive walker over five ``source_model`` shapes plus nested ``source_queries`` recursion. The recursion + isinstance dispatch IS the function. Full non-integration suite green (3987 passed); ruff clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
… cross-model first/last fallback
Two MAJOR Codex findings on the round-1 fixes:
* query_engine.py:_expand_query_backed_model — passed
``outer_vars=final_stage.variables`` to
``expand_query_backed_models_in_bundle``, dropping the outer model's
``query_variables`` layer for nested expansions / sibling stages.
Substitution then diverged between execute (pre-merged path) and
save-time dry-run (placeholder ``0``). Now passes the bundle's
merged ``bundle.query_variables`` (which already includes
``{model.query_variables, outer_vars, stage, runtime}`` per the
layered precedence) so nested layers see the full variable context.
* generator.py:_render_cross_model_cte — cross-model ``first`` /
``last`` without an explicit positional time arg emitted the
``MAX(CASE WHEN _last_rn = 1 ...)`` expression but only built the
ROW_NUMBER-ranked subquery when ``synth.time_column`` was truthy.
Result: agg_expr referenced ``_first_rn`` / ``_last_rn`` against a
bare ``FROM target`` that didn't supply it. Now falls back to
``target_model.default_time_dimension`` (qualified under the
target relation); raises a clear ``first/last requires a ranking
time column`` error when even that is unset (mirrors the local
first/last path's behaviour).
Tests:
* test_cross_model_first_last_uses_target_default_time_dimension —
``customers.amount:last`` (no explicit time arg) against a
customers model with ``default_time_dimension="signup_at"``
executes end-to-end against seeded SQLite.
* test_cross_model_first_last_with_no_time_at_all_raises — same
query against a customers model WITHOUT default_time_dimension
raises the typed error.
Full non-integration suite green (3989 passed).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…act metadata-fill helper The round-1 metadata-carry fix added 4 parallel if-blocks to ``ValueRegistry._merge_into_existing``, bumping cognitive complexity to 18 (over the 15 cap). Extract a module-level ``_fill_missing_metadata`` that walks the four fields in a single loop and mutates the ``updates`` dict in place. The ``slot.field is None and new_value is not None`` rule is identical across the four fields, so the extraction simplifies rather than obscures. Full non-integration suite green (3989 passed); ruff clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
… call sites Codex MAJOR finding (generator.py:4229) flagged the local first/last ranked-subquery call site for skipping ``_expand_derived_column_sql`` on derived ``ColumnSqlKey`` aggregate sources because ``bundle`` was not passed to ``_build_agg_render_spec_from_planned``. Audit revealed the same gap at 4 sibling call sites: * generator.py:4229 _build_first_last_base_select (ranked-subquery path; Codex flagged). * generator.py:4340 _render_aggregate_composite_expr (single-agg- inside-arithmetic). * generator.py:5579 _render_filter_value_key_in_target_scope HAVING agg. * generator.py:6743 transform input agg. * generator.py:7851 _render_value_key_for_filter (filter ref). Two enclosing functions did not have ``bundle`` in scope and were extended to accept it: * _render_aggregate_composite_expr (4306) — gained ``bundle=None`` kwarg; its 5 recursive / external callers (3691, 4287, 4352, 4365, 8166) now thread ``bundle`` through. * _apply_order_limit_from_planned (8135) — gained ``bundle=None``; the one caller at 2901 threads it through. Test: * test_local_first_last_over_derived_column_expands_inner_refs — ``net_amount:last(created_at)`` where ``net_amount.sql="amount * 0.9"`` executes end-to-end against seeded SQLite; without the expansion the bare ``amount`` inside ``MAX(CASE WHEN _last_rn=1 ...)`` would render unqualified and SQLite would error. Full non-integration suite green (3990 passed); ruff clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Codex MAJOR (round 4) — sibling-stage variable substitution in the
migrated ``_expand_query_backed_model`` used ``root_vars =
final_stage.variables`` as a base layer, but
``apply_variables_to_query`` only substitutes into filters and never
updates ``.variables`` — so ``root_vars`` was just the stage-level
dict, dropping ``model.query_variables`` and the outer-query layer.
Repro: a query-backed model with ``query_variables={"threshold":
"100"}`` and a NAMED non-final sibling whose filter references
``{threshold}``. Pre-fix, save-time dry-run silently substituted
the placeholder ``"0"``. Post-fix the sibling sees the outer
default and substitutes ``100``.
Fix: swap ``root_vars`` for ``bundle.query_variables`` as the
base layer. ``bundle.query_variables`` is the merged
``{runtime > final_stage.variables > model.query_variables >
outer_vars > source_model_defaults}`` set so sibling substitution
sees the full outer context.
Test:
* test_sibling_stage_inherits_outer_model_query_variables — pins
the rendered SQL contains ``> 100`` (the outer model default),
not ``> 0`` (the placeholder).
Also dismissed Codex round-4 finding "_get_column_types rejects
__ columns" as a false positive — direct repro returns the
flattened ``customers__region`` key correctly; the binder's
literal-column carve-out for ``Column.name`` containing ``__``
works on the migrated path.
Full non-integration suite green (3991 passed); ruff clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…re.type Two Codex findings (1 MAJOR + 1 MINOR): * MAJOR query_engine.py:_execute_by_name — built ``named_queries`` from persisted ``source_queries`` order before ``_execute_pipeline``, but ``plan_stages._topo_sort`` only handles ``source_model`` deps. A model that saved successfully with out-of-order ``joins[].target_ model`` sibling stages (the save path goes through ``_expand_query_ backed_model`` which DOES topo-sort) would fail when run directly by name. Fix: call ``topologically_order_stages`` at the top of ``_execute_by_name`` so run-by-name matches save-time semantics. * MINOR stage_planner.py:_declared_measures_from_query — a query formula that is a bare reference to a saved ``ModelMeasure(formula =..., type=DataType.X)`` on the source model lost the explicit ``type=X``. ``expand_model_measures`` rewrites the AST but drops the measure's type metadata; the planner then fell back to inference, which only happened to match the explicit type by coincidence on the pre-existing test. Fix: extend the type- priority chain to ``m.type or _saved_model_measure_type(...) or _type_for_measure_formula(...)``. New helper ``_saved_model_measure_type`` re-looks-up the saved measure when the query formula is a bare identifier matching a ``ModelMeasure.name`` on the source model. Tests: * test_run_by_name_accepts_nonordered_join_target_stages — saves a model with out-of-order ``joins.target_model`` sibling, then runs it by name; pre-fix this would fail at ``plan_stages``. * test_saved_model_measure_type_wins_over_inference — ``ModelMeasure(formula="amount:sum", type=DataType.INT)`` is referenced from a query; pre-fix the resulting column had ``type=DOUBLE`` (inference), now ``type=INT`` (explicit). Full non-integration suite green (3993 passed); ruff clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The DEV-1450 typed-pipeline redesign deferred SQL-style ``col in (a, b)`` filters; the legacy enrichment path accepted them via sqlglot parsing. On PR #139 the 02_sql_vs_dsl notebook's ``stores.name in ('Brooklyn', 'Philadelphia')`` cell broke with ``ValueError: unsupported comparison operator In.``. The companion 04_time notebook was xfailed in b7ba22b under DEV-1474; the 02 one was not. Implementation: * slayer/engine/syntax.py: add ``TupleLit`` ParsedExpr node; extend ``_CMP_OP_MAP`` with ``ast.In: "in"`` and ``ast.NotIn: "not in"``; in ``_convert``'s ``ast.Compare`` branch validate the RHS as a non-empty tuple/list of literals and emit a ``Cmp(op, left, TupleLit(...))``. ``walk_parsed_refs`` learns the new node (literals → no refs). * slayer/core/keys.py: add ``InKey(_FrozenKey)`` modelled on ``BetweenKey`` — ``column: ValueKey``, ``values: Tuple[LiteralKey, ...]``, ``negated: bool``. A ``field_validator`` rejects empty ``values`` so direct construction can't reach SQL with ``col IN ()`` (defense in depth — the parser also rejects). Extend the ``ValueKey`` union and the rebuild list. * slayer/engine/binding.py: ``_bind``'s ``Cmp`` branch routes ``"in"``/``"not in"`` to a new ``_bind_in`` helper that binds the LHS through the normal column path and folds each ``TupleLit`` element to a ``LiteralKey``. Extend ``_VALUE_KEY_TYPES`` and ``walk_value_keys`` so cross-model routing / windowed-column rejection see through ``InKey``. * slayer/engine/planning.py: ``lower_sugar_transforms`` / ``_iter_slot_deps`` / ``_canonical_name`` handle ``InKey`` parallel to ``BetweenKey``. Only the LHS column is recursed; RHS values are literals with no slot identity. * slayer/engine/stage_planner.py: ``_attach_time_keys`` / ``_find_unresolved_time_needing_op`` handle ``InKey`` the same way. * slayer/sql/generator.py: three filter-render sites learn ``InKey``: ``_render_value_key_for_filter`` (WHERE/HAVING), ``_render_value_key_against_aliases`` (POST-phase against the ``_filtered`` wrapper), and ``_render_filter_value_key_in_target_scope`` (cross-model CTE). Each emits ``exp.In(this=col, expressions=[lits])`` wrapped in ``exp.Not(...)`` when ``negated``. Walkers in ``_collect_filter_join_paths``, ``_direct_local_column_keys``, the three internal closures (transform / slot dep / readiness), and the ScalarCallKey arg-descent tuples all gain an ``InKey`` branch so scalar-wrapped IN predicates recurse correctly (Codex round 1 finding) and a joined-column IN filter pulls the join into FROM. Codex round 1 findings folded into this commit: * slayer/engine/syntax.py:480: ``amount in (-1, -2)`` was rejected because the AST emits ``UnaryOp(USub, Constant)`` not ``Literal``. Added ``_convert_in_rhs_element`` to collapse ``USub``/``UAdd`` over a numeric constant into a signed ``Literal`` before the literal-only check. Booleans and strings are unaffected. * slayer/core/keys.py: added the ``values`` field_validator so a direct ``InKey(values=())`` raises at construction (parser already rejects this shape — belt and suspenders for programmatic callers). * slayer/sql/generator.py: extended four ``ScalarCallKey`` arg-descent tuples to include ``InKey`` alongside ``BetweenKey`` so a scalar call wrapping an IN predicate recurses through the InKey rather than rendering it as a string literal. Docs: * docs/concepts/queries.md: added the ``not in`` row to the comparison-operators table and a one-line note that the RHS must be literals only (no references or expressions). * docs/examples/02_sql_vs_dsl/sql_vs_dsl_nb.ipynb: re-executed in place (no source change). The previously-failing cell now emits ``stores.name IN ('Brooklyn', 'Philadelphia')`` against the joined stores CTE. Tests: * tests/test_dev1475_in_tuple_filters.py: 16 unit tests covering parser shape (string / numeric / signed / dotted / list syntax), parser guards (empty / scalar / non-literal RHS, direct InKey empty guard), binder shape (InKey + LiteralKey values + negated), and SQL emission (IN, NOT IN, n-ary AND fold from 7a1110f territory, cross-model joined column). * tests/integration/test_integration.py: ``test_filter_in_tuple`` / ``test_filter_not_in_tuple`` SQLite smoke tests asserting the expected row count after filtering. Full unit suite: 3896 passed, 8 skipped (+16 from the 3880 baseline). Ruff clean. 02_sql_vs_dsl notebook test now passes WITHOUT xfail; 04 remains xfailed under DEV-1474 as before. The CI failures on PR #139 from 2026-05-26T12:18Z were infrastructure issues (codeload.github.com 404 on ``actions/setup-java@v4`` archive + ``account suspended`` 403 on integration-examples checkout). Both cleared on later runs of the same SHA on main; the next CI run on this push should exercise the full green path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…/github.com/MotleyAI/slayer into egor/dev-1452-delete-legacy-enrichment-pipeline-migrate-pre-existing-tests
- Codex major (generator.py): cross-model first/last computed ROW_NUMBER over the full target table, then applied target_model_filters + routed WHERE filters on the outer CTE. A filtered-out row could still win _last_rn = 1, NULL-ing out the MAX(CASE WHEN _last_rn = 1 ...) aggregate for that group. Push WHERE into the ranked subquery via the helper's existing where_clause param so RN is computed over filtered rows. Regression test pins last(amount) = 100.0 when the newer row is soft-deleted (instead of NULL). - CodeRabbit thread (test_dev1476): strengthen the default_time_dimension fallback test from "data exists" to an exact expected-value pin (NA last = 50.0 by signup_at). - CodeRabbit nitpick (test_dev1476): drop function-local import os/sqlite3/tempfile in two test bodies; they duplicate the module-top imports and break the imports-at-top rule. - CodeRabbit thread (stage_ordering.py): docstring claimed forward references raise ValueError, but the topo-sort reorders them — that is the entire fault-tolerance contract. Rewrite to list the actual raise cases (cycles, self-refs, dupes, root referenced by other) and call out forward-reference reordering explicitly. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…s-model first/last _render_cross_model_cte built the first/last aggregate via _build_agg BEFORE constructing the ROW_NUMBER-ranked subquery, passing a bare rn_suffix_map and discarding the subquery's filtered_rn_map / filtered_match_map. For a cross-model first/last over a target column carrying a Column.filter, the ranked subquery emits _last_rn_f0 / _match_f0 (skipping the bare _last_rn for filtered specs), so the aggregate referenced a _last_rn column the subquery never projects — "no such column: _last_rn". Reorder to build the ranked subquery first (mirroring the local first/last path), then thread its rn_suffix_map + filtered_rn_map + filtered_match_map into _build_agg. Also drops the previously-wrong tuple-keyed rn_suffix_map in favor of the subquery's correctly time-col-keyed map. Regression test: cross-model customers.active_amount:last where active_amount has filter="status = 'active'" returns the newest ACTIVE row's value (100.0), skipping newer inactive rows. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…qualify; dedup dev1476 tests Three review fixes from the process-reviews triage on PR #144: - stage_planner._type_for_dimension now walks the join chain to the terminal column's type for dotted (joined) dimensions instead of returning None. A None was coerced to DataType.DOUBLE in _query_as_model, mistyping joined string/temporal dims on the persisted query-backed virtual model (Codex #1). - generator._resolve_explicit_time_col routes a derived (ColumnSqlKey) first/last time arg through _expand_derived_column_sql so inner bare refs qualify to the source relation. An unqualified ref in a complex expression (e.g. date(created_at)) could bind to the wrong table when the ranked subquery includes a join with a same-named column (Codex #2). - Finish the in-progress dedup of test_dev1476_first_last_explicit_time: define the _engine_from_sql / _orders_model helpers the file already called, route the fixture through them, and drop the per-test SQLite boilerplate. Clears the SonarCloud new-code duplication gate and the CodeRabbit function-local-import nitpick. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ichment-pipeline-migrate-pre-existing-tests DEV-1452: Delete legacy enrichment pipeline + migrate pre-existing tests
|



Summary
Work-in-progress on DEV-1450 — replacing today's tangled multi-path resolution with a typed pipeline of composable stages:
raw → slack normalize → parse_expr → bind_expr → ProjectionPlanner → plan_query → PlannedQuery.main, broken into ~25 numbered stages.See the DEV-1450 progress checkpoints for stage-by-stage detail.
Test plan
orders.revenue_sum,orders._count,orders.customers.regions.name, renamedorders.<user_name>).🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Bug Fixes
Documentation
Tests