Skip to content

DEV-1710 Stage 6: first/last explicit-time completion - #268

Merged
ZmeiGorynych merged 4 commits into
egor/dev-1703-comprehensive-approach-expressions-crossing-joins-on-thefrom
egor/dev-1710-dev-1703-stage-6-firstlast-explicit-time-completion
Aug 3, 2026
Merged

DEV-1710 Stage 6: first/last explicit-time completion#268
ZmeiGorynych merged 4 commits into
egor/dev-1703-comprehensive-approach-expressions-crossing-joins-on-thefrom
egor/dev-1710-dev-1703-stage-6-firstlast-explicit-time-completion

Conversation

@ZmeiGorynych

@ZmeiGorynych ZmeiGorynych commented Aug 3, 2026

Copy link
Copy Markdown
Member

DEV-1703 Stage 6 (DEV-1710) — closes DEV-1476

Explicit first/last ranking-time args now discover their crossed join through the host ScopeFrame (Law 1), not a bespoke collector — the last first/last-specific legacy join collector is gone.

Changes (slayer/sql/generator.py)

  • _explicit_time_arg_of(key) — one arg-selection contract shared by the raise-gate, the discovery pass, and the render seam, so they can't drift (Codex F1). Fixes a latent gate bug: the old any(...) scan let a scalar-first/col-later shape skip the "requires a ranking time column" raise and then crash building the ROW_NUMBER map.
  • Discovery sub-pass 4 in _resolve_agg_inputs_via_scopescope.resolve(arg) register-only pulls the crossed LEFT JOIN as a Law-1 side effect (bare single/multi-hop, and local-derived args reaching a joined table). Path-bearing ColumnSqlKey (DEV-1526 residual) is skipped.
  • _resolve_explicit_time_col renders through a throwaway host ScopeFrame when a bundle is present (reserved-word quoting for free, DEV-1686); the bundle=None fallback and the DEV-1526 / not-found guards are preserved in order. Safe because time_column is re-parsed downstream, so resolver normalization washes out.
  • Deleted the Phase.AGGREGATE arm of _collect_joined_paths_for_base; it now collects only ROW dimension paths.

Under the agreed Option A: the residual-hop NotImplementedError is kept (narrowed, owned by DEV-1526 / Stage 4), and Stage-5 arg isolation is not pulled forward.

Tests (+23, in tests/test_dev1476_first_last_explicit_time.py)

Written first (TDD), Codex-reviewed against the plan. Shared-helper contract, discovery join-registration (single/multi-hop/derived/composite, cross-model-skip + residual-skip proven with a resolve spy), gate-mutation catch, resolver-path rendering incl. reserved-word quoting + three bundle-set ColumnSqlKey cases, and four end-to-end pins. DEV-1476 fully closes (all four reprose green).

Note — stacked on dev-1708

This branch merges origin/egor/dev-1708 (Stage 4) in, so until dev-1708 lands in dev-1703 the diff below also shows Stage 4's cross-model/isolation CTE renderer. Once dev-1708 merges, the diff collapses to Stage 6 only. Full non-integration suite green on the merged tree: 8219 passed, 49 xfailed, 0 failed (Stage 4 promoted the DEV-1526 source-crossing cluster from 61).

Out of scope (flagged)

A derived time column whose Column.sql references another derived column (nested inlining) — expand_derived_refs_sync doesn't recursively inline bare sibling-derived refs; this fails identically for a plain dimension, so it's a general column-expansion limitation, not a time-arg concern.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Improved cross-model aggregate support across single- and multi-hop relationships.
    • Added more reliable handling for first and last calculations with explicit time fields.
    • Improved support for derived dimensions, filters, routed joins, and nullable shared-grain values.
  • Bug Fixes

    • Prevented incorrect aggregate results when unsupported derived dimensions could cause value duplication.
    • Added dialect-specific null-safe joins for broader database compatibility.
  • Documentation

    • Updated architecture documentation with cross-model rendering behavior and known limitations.

ZmeiGorynych and others added 2 commits August 3, 2026 07:03
…Frame

Explicit first/last ranking-time args now discover their crossed join via
the host ScopeFrame (Law 1) instead of a bespoke collector:

- Extract _explicit_time_arg_of: one arg-selection contract shared by the
  raise-gate, discovery pass, and render seam (Codex F1). Fixes a latent
  gate bug where any()-scan let a scalar-first/col-later shape skip the
  "requires a ranking time column" raise and crash the ROW_NUMBER build.
- _resolve_agg_inputs_via_scope gains sub-pass 4: scope.resolve(arg)
  register-only pulls the crossed LEFT JOIN (bare / multi-hop / local
  derived); a path-bearing ColumnSqlKey (DEV-1526 residual) is skipped.
- _resolve_explicit_time_col renders through a throwaway host ScopeFrame
  when a bundle is present (reserved-word quoting for free); the bundle=None
  fallback and the DEV-1526 / not-found guards are preserved in order.
- Delete the Phase.AGGREGATE arm of _collect_joined_paths_for_base.

Closes DEV-1476 (all four reprose green). Residual-hop guard kept
(DEV-1526 / Stage 4) per Option A. +23 tests; full non-integration suite
green (8156 passed, 61 xfailed unchanged).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rame)

Integrates DEV-1708 Stage 4 into the Stage 6 branch. Only conflict was the
DECISIONS.md append (kept both entries, Stage 4 then Stage 6); generator.py
auto-merged cleanly.

Composition verified: Stage 6's cross-model first/last tests run through
Stage 4's rewritten _render_cross_model_cte and pass; Stage 6's DEV-1526
time-arg residual guard is orthogonal to Stage 4's DEV-1526 source-crossing
closure and still holds. Full non-integration suite green: 8219 passed,
49 xfailed (Stage 4 promoted the DEV-1526 source cluster from 61).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@linear

linear Bot commented Aug 3, 2026

Copy link
Copy Markdown

DEV-1476

DEV-1710

DEV-1703

@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 26 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 6372270a-ce96-4ed6-88da-076901bf6b31

📥 Commits

Reviewing files that changed from the base of the PR and between e09620c and b422b23.

📒 Files selected for processing (1)
  • slayer/sql/generator.py
📝 Walkthrough

Walkthrough

The SQL generator now resolves cross-model CTE expressions through scoped join registration, allocates aliases across the full render, materializes joined first/last values, and emits dialect-specific null-safe grain predicates. Shared-grain planning rejects unsupported plain derived dimensions.

Changes

Cross-model SQL rendering

Layer / File(s) Summary
Dialect and shared-grain contracts
slayer/sql/dialects/*, slayer/engine/cross_model_planner.py, slayer/sql/generator.py, tests/test_dev1708_stage4_cte_scope.py
Added dialect-specific null-safe equality. Shared-grain planning now rejects unsupported plain derived dimensions. Tests cover nullable grain joins and deterministic aliases.
Scoped host and CTE rendering
slayer/sql/generator.py, docs/architecture/cross-model-aggregates.md, DECISIONS.md, tests/test_dev1708_stage4_cte_scope.py, tests/test_sql_generator.py
CTE rendering registers joins from nested expressions, filters, kwargs, and derived paths before building SQL. Former strict expected failures now run as regular tests.
Explicit first/last time handling
slayer/sql/generator.py, tests/test_dev1476_first_last_explicit_time.py, tests/test_dev1708_stage4_cte_scope.py
First/last aggregates use unified explicit-time selection and scoped resolution. Joined source values are materialized under generated aliases and reused in HAVING clauses.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant PlannedQuery
  participant SQLGenerator
  participant ScopeFrame
  participant CrossModelCTE
  participant SqlDialect
  PlannedQuery->>SQLGenerator: generate_from_planned
  SQLGenerator->>ScopeFrame: resolve expressions and register joins
  ScopeFrame->>CrossModelCTE: provide scoped paths and aliases
  CrossModelCTE->>SqlDialect: build null-safe grain predicates
  CrossModelCTE-->>SQLGenerator: rendered CTE SQL
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 53.60% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely identifies the completion of first/last explicit-time handling, which is the primary change in the pull request.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch egor/dev-1710-dev-1703-stage-6-firstlast-explicit-time-completion

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (6)
tests/test_dev1708_stage4_cte_scope.py (2)

777-782: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Use tmp_path so the fixture cleans up its temporary directory.

tempfile.mkdtemp() creates a directory that is never removed. The fixture runs once per test that requests it, so each run leaves a SQLite file and a YAML store behind. The pytest tmp_path fixture removes the directory automatically.

♻️ Proposed change
 `@pytest.fixture`
-async def sqlite_engine() -> AsyncIterator[SlayerQueryEngine]:
-    d = tempfile.mkdtemp()
-    db_path = os.path.join(d, "t.db")
+async def sqlite_engine(tmp_path) -> AsyncIterator[SlayerQueryEngine]:
+    d = str(tmp_path)
+    db_path = os.path.join(d, "t.db")
🤖 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_dev1708_stage4_cte_scope.py` around lines 777 - 782, Update the
async fixture sqlite_engine to accept pytest’s tmp_path fixture and use it as
the temporary base directory instead of creating a directory with
tempfile.mkdtemp(). Build the database and YAMLStorage paths from tmp_path so
pytest automatically cleans up all generated files.

418-418: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Split the composite assertion.

SonarCloud flags this line. Two separate assertions report which condition failed.

✏️ Proposed fix
-        assert "ORDER BY" in inner and "regions.opened_at" in inner, inner
+        assert "ORDER BY" in inner, inner
+        assert "regions.opened_at" in inner, inner
🤖 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_dev1708_stage4_cte_scope.py` at line 418, Split the composite
assertion in the relevant test into two independent assertions: one verifying
"ORDER BY" is present in inner and another verifying "regions.opened_at" is
present, preserving the existing failure context where appropriate.

Source: Linters/SAST tools

slayer/sql/generator.py (3)

4494-4498: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Replace the single-iteration loop with a direct first-element lookup.

The for loop returns on its first iteration, so it never iterates. The intent — "the first positional arg, if it is a column ref" — is clearer as an index lookup. This helper is the shared contract for three call sites, so its selection rule should read unambiguously.

♻️ Proposed simplification
         if key.agg not in ("first", "last"):
             return None
-        for a in key.args:
-            return a if isinstance(a, (ColumnKey, ColumnSqlKey)) else None
-        return None
+        if not key.args:
+            return None
+        first = key.args[0]
+        return first if isinstance(first, (ColumnKey, ColumnSqlKey)) else None
🤖 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/sql/generator.py` around lines 4494 - 4498, In the helper containing
the key.agg check, replace the single-iteration loop over key.args with a direct
lookup of the first positional argument, returning it only when it is a
ColumnKey or ColumnSqlKey and otherwise returning None. Preserve the existing
"first"/"last" aggregation guard and empty-arguments behavior.

6492-6508: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Share the unsupported-derived-grain message with the planner.

slayer/engine/cross_model_planner.py raises NotImplementedError with the same multi-line text for the same condition. Two copies of one user-facing message drift apart on the next edit. Extract the message (or a small raise_derived_shared_grain_unsupported(column_name) helper) into one module and call it from both sites.

Note also that the planner check runs first in the engine path, so this generator branch is reachable only from direct _render_cross_model_cte calls. Keep it as the defence-in-depth guard, but say so in the comment.

🤖 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/sql/generator.py` around lines 6492 - 6508, Centralize the unsupported
plain-derived shared-grain error message used by the generator branch and the
planner’s corresponding NotImplementedError, preferably via a small shared
helper such as raise_derived_shared_grain_unsupported(column_name), and call it
from both sites. Preserve the existing message and condition, keep the generator
check as a defense-in-depth guard for direct _render_cross_model_cte calls, and
state that purpose in its comment.

5899-5912: 🚀 Performance & Scalability | 🔵 Trivial

Consider the join-plan cost of the expanded null-safe predicate.

On T-SQL, Oracle, and Redshift the join-back becomes a = b OR (a IS NULL AND b IS NULL). That is not an equijoin, so those planners can fall back to a nested-loop or hash-with-residual plan on large grain sets. The native forms (IS NOT DISTINCT FROM, <=>, IS) usually keep the equijoin. Track query latency on the expanded-form dialects, and consider a COALESCE(col, <sentinel>) grain key if a regression appears.

🤖 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/sql/generator.py` around lines 5899 - 5912, Review the join-back
generated by _null_safe_join_pair_sql for T-SQL, Oracle, and Redshift, and
measure query latency and execution plans on large grain sets where it expands
to OR-based NULL-safe equality. If a regression is observed, replace that
dialect-specific form with a planner-friendly COALESCE-based grain key using a
type-safe sentinel, while preserving NULL-equals-NULL semantics and leaving
native IS NOT DISTINCT FROM, <=>, and IS forms unchanged.
tests/test_dev1476_first_last_explicit_time.py (1)

1073-1082: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Hoist the bundle construction out of the pytest.raises block.

SonarCloud flags both blocks: _u_bundle(...) is evaluated inside the with body, so a failure there would also satisfy the assertion. Build the bundle first, then call the method under test.

💚 Proposed fix (same shape for both tests)
         orders = _u_orders()
+        bundle = _u_bundle(orders)
         key = AggregateKey(
             source=ColumnKey(leaf="amount"), agg="last",
             args=(ColumnSqlKey(path=(), model="orders", column_name="not_a_real_col"),),
         )
         with pytest.raises(ValueError, match="Derived time column 'not_a_real_col'"):
             self._gen()._resolve_explicit_time_col(
                 key=key, source_model=orders, source_relation="orders",
-                bundle=_u_bundle(orders),
+                bundle=bundle,
             )

Also applies to: 1088-1097

🤖 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_dev1476_first_last_explicit_time.py` around lines 1073 - 1082,
Construct the `_u_bundle(orders)` result before entering each `pytest.raises`
block in the affected tests. Then pass the prebuilt bundle to
`_resolve_explicit_time_col`, ensuring only that method’s ValueError can satisfy
the assertion while preserving the existing test setup and expectations.

Source: Linters/SAST tools

🤖 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/sql/generator.py`:
- Around line 6608-6613: Update the positional-argument registration near
local_agg_key.args to resolve only the argument selected by
_explicit_time_arg_of, matching _resolve_first_last_time_arg, and preserve the
host pass’s behavior of skipping a residual ColumnSqlKey. Do not resolve every
ColumnKey/ColumnSqlKey argument, so the ranked CTE registers only joins required
by the rendered ORDER BY.
- Around line 6912-6946: Update _register_routed_filter_joins to traverse filter
dependencies through walk_value_keys, covering BetweenKey, ScalarCallKey, and
TransformKey alongside existing key types. Preserve the current AggregateKey
rerooting, scope resolution, and column_filter join-path handling while reusing
the shared traversal instead of maintaining incomplete local recursion.

---

Nitpick comments:
In `@slayer/sql/generator.py`:
- Around line 4494-4498: In the helper containing the key.agg check, replace the
single-iteration loop over key.args with a direct lookup of the first positional
argument, returning it only when it is a ColumnKey or ColumnSqlKey and otherwise
returning None. Preserve the existing "first"/"last" aggregation guard and
empty-arguments behavior.
- Around line 6492-6508: Centralize the unsupported plain-derived shared-grain
error message used by the generator branch and the planner’s corresponding
NotImplementedError, preferably via a small shared helper such as
raise_derived_shared_grain_unsupported(column_name), and call it from both
sites. Preserve the existing message and condition, keep the generator check as
a defense-in-depth guard for direct _render_cross_model_cte calls, and state
that purpose in its comment.
- Around line 5899-5912: Review the join-back generated by
_null_safe_join_pair_sql for T-SQL, Oracle, and Redshift, and measure query
latency and execution plans on large grain sets where it expands to OR-based
NULL-safe equality. If a regression is observed, replace that dialect-specific
form with a planner-friendly COALESCE-based grain key using a type-safe
sentinel, while preserving NULL-equals-NULL semantics and leaving native IS NOT
DISTINCT FROM, <=>, and IS forms unchanged.

In `@tests/test_dev1476_first_last_explicit_time.py`:
- Around line 1073-1082: Construct the `_u_bundle(orders)` result before
entering each `pytest.raises` block in the affected tests. Then pass the
prebuilt bundle to `_resolve_explicit_time_col`, ensuring only that method’s
ValueError can satisfy the assertion while preserving the existing test setup
and expectations.

In `@tests/test_dev1708_stage4_cte_scope.py`:
- Around line 777-782: Update the async fixture sqlite_engine to accept pytest’s
tmp_path fixture and use it as the temporary base directory instead of creating
a directory with tempfile.mkdtemp(). Build the database and YAMLStorage paths
from tmp_path so pytest automatically cleans up all generated files.
- Line 418: Split the composite assertion in the relevant test into two
independent assertions: one verifying "ORDER BY" is present in inner and another
verifying "regions.opened_at" is present, preserving the existing failure
context where appropriate.
🪄 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: 3761d652-8d73-4ed2-bdfe-46edb9252351

📥 Commits

Reviewing files that changed from the base of the PR and between 0a14862 and 96c8a32.

📒 Files selected for processing (12)
  • DECISIONS.md
  • docs/architecture/cross-model-aggregates.md
  • slayer/engine/cross_model_planner.py
  • slayer/sql/dialects/_tier2.py
  • slayer/sql/dialects/base.py
  • slayer/sql/dialects/sqlite.py
  • slayer/sql/dialects/tsql.py
  • slayer/sql/generator.py
  • tests/test_carrier_scope_matrix.py
  • tests/test_dev1476_first_last_explicit_time.py
  • tests/test_dev1708_stage4_cte_scope.py
  • tests/test_sql_generator.py
💤 Files with no reviewable changes (1)
  • tests/test_carrier_scope_matrix.py

Comment thread slayer/sql/generator.py
Comment thread slayer/sql/generator.py
Two new exception tests called self._gen() inside the pytest.raises block, so
Sonar flagged two potentially-throwing invocations. Hoist gen + bundle out so
the only call that can raise inside the block is the one under test — matches
the existing pattern in test_reroot_aggregate_key.py.
…ng-joins-on-the' of https://github.com/MotleyAI/slayer into egor/dev-1710-dev-1703-stage-6-firstlast-explicit-time-completion
@sonarqubecloud

sonarqubecloud Bot commented Aug 3, 2026

Copy link
Copy Markdown

@ZmeiGorynych
ZmeiGorynych merged commit 31f9aae into egor/dev-1703-comprehensive-approach-expressions-crossing-joins-on-the Aug 3, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant