Skip to content

Move dry_run/explain off SlayerQuery (v3 schema, closes #71)#77

Merged
ZmeiGorynych merged 8 commits into
mainfrom
egor/dry-run-separate
May 3, 2026
Merged

Move dry_run/explain off SlayerQuery (v3 schema, closes #71)#77
ZmeiGorynych merged 8 commits into
mainfrom
egor/dry-run-separate

Conversation

@ZmeiGorynych
Copy link
Copy Markdown
Member

@ZmeiGorynych ZmeiGorynych commented May 3, 2026

Summary

Closes #71. dry_run and explain were execution-mode flags persisted into SlayerQuery bodies, where they survived round-trips and broke query-backed model execution (every load short-circuited to dry_run if the saved stage had it). They are now engine.execute() kwargs only.

  • Schema bump: SlayerQuery and SlayerModel both go to v3. slayer/storage/v3_migration.py registers _query_v2_to_v3 (drops dry_run/explain with one logger.warning + one DeprecationWarning per migrated query, identifying it by name/source_model) and _model_v2_to_v3 (walks source_queries entries through the SlayerQuery chain so nested stale flags get stripped).
  • extra=\"forbid\" on SlayerQuery v3 so typos raise.
  • Surface routing (REST QueryRequest, MCP query() tool, CLI flags, Python client methods): wire formats unchanged. Internal routing changes only — they pop the flags before constructing the SlayerQuery and thread them through engine kwargs.
  • Engine: execute()/execute_sync() gain dry_run/explain kwargs (apply to all input shapes including list — the kwarg targets the single resulting SQL statement). Also adds a str input shorthand (engine.execute(\"model_name\")) for the regression test in Move dry_run/explain off SlayerQuery — they're execution flags, not query shape #71's acceptance criteria.

Breaking changes

  • Direct Python construction SlayerQuery(unknown_field=...) now raises ValidationError (extra=forbid). The deprecated dry_run/explain themselves are intercepted by the migration and emit a DeprecationWarning rather than raising — soft landing for callers porting away from v2.
  • On first load after upgrade, every persisted query-backed model emits one logger.warning per nested SlayerQuery whose v2 YAML carried dry_run/explain. Re-saving normalizes the on-disk YAML.

Test plan

  • poetry run pytest -m \"not integration\" — 1063 passed (12 new).
  • poetry run pytest tests/integration/test_integration.py tests/integration/test_integration_duckdb.py -m integration — 84 passed.
  • Postgres integration (sandbox blocks pg spawn locally — runs in CI).
  • poetry run ruff check slayer/ tests/ — clean.

New tests in tests/test_migrations.py:

  • Migration unit tests: drop dry_run, drop explain, drop both, no-op when neither present, identifier uses name when set.
  • extra=\"forbid\" regression: typo field raises; direct SlayerQuery(dry_run=True) warns rather than raises.
  • Engine kwargs across input shapes: str, SlayerQuery, dict, list — each returns SlayerResponse with empty data + SQL.
  • End-to-end stale-YAML regression: hand-written v2 YAML with dry_run: true nested inside source_queries is migrated on load, the warning identifies the inner query by name, and engine.execute(\"name\") actually executes (not short-circuits).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Documentation

    • Schema bumped to version 3; docs describe v2→v3 migration, load-time warnings, strict validation, and that cache refresh happens only on save while execution never writes storage.
  • Refactor

    • Execution flags (dry_run, explain) are runtime-only and consistently forwarded by APIs, CLI, and client helpers; unknown query fields are rejected.
  • Tests

    • Added/updated migration and dry-run tests covering warnings, field stripping, validation, and execution behavior.

These were execution-mode flags persisted into query bodies, where
they survived round-trips and broke query-backed model execution
(every load short-circuited to dry_run if the saved stage had it).
They are now engine.execute() kwargs only.

Schema: SlayerQuery and SlayerModel both bump to v3.
- v3_migration.py registers _query_v2_to_v3 (drops dry_run/explain
  with one logger.warning + DeprecationWarning per migrated query,
  identified by name/source_model) and _model_v2_to_v3 (walks
  source_queries entries through the SlayerQuery migration chain so
  nested stale flags get stripped too).
- SlayerQuery v3 sets extra="forbid" so typos raise.

Surface routing: REST QueryRequest, MCP query() tool, CLI flags, and
Python client method signatures all unchanged. Internal routing
changes only — they pop the flags before constructing the SlayerQuery
and pass them as engine kwargs.

Engine: execute()/execute_sync() gain dry_run/explain kwargs and a
str input shorthand (engine.execute("model_name")).

Tests: 12 new tests in tests/test_migrations.py — migration cases,
extra=forbid behaviour, engine kwargs across all input shapes, and
end-to-end stale v2 YAML regression with dry_run nested in
source_queries.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 3, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 7177f46b-01dc-40cb-ae0d-44841f949eaa

📥 Commits

Reviewing files that changed from the base of the PR and between 05e0ca8 and 5bcde53.

📒 Files selected for processing (2)
  • CLAUDE.md
  • tests/integration/test_ingestion_jaffle_shop.py
✅ Files skipped from review due to trivial changes (1)
  • tests/integration/test_ingestion_jaffle_shop.py

📝 Walkthrough

Walkthrough

This PR removes persistent dry_run/explain from SlayerQuery, bumps SlayerQuery and SlayerModel schema versions to 3, adds v2→v3 migrations that strip those flags (with warnings), extends engine APIs to accept dry_run/explain for all input shapes, and updates API/CLI/MCP/client docs and tests accordingly.

Changes

Schema Versioning & Execution Flag Migration

Layer / File(s) Summary
Data Shape & Schema
slayer/core/query.py, slayer/core/models.py
Bumped SlayerQuery.version and SlayerModel.version defaults from 23; removed SlayerQuery fields dry_run/explain; added model_config = ConfigDict(extra="forbid").
Storage Migrations
slayer/storage/v3_migration.py, slayer/storage/migrations.py
Added v2→v3 migrations: _query_v2_to_v3 pops legacy dry_run/explain, logs logger.warning and emits DeprecationWarning; _model_v2_to_v3 migrates source_queries. CURRENT_VERSIONS updated to 3 and v3 migration module imported.
Engine Core
slayer/engine/query_engine.py
execute()/execute_sync() signatures extended with *, dry_run: bool = False, explain: bool = False (and accept str run-by-name); _execute_pipeline now uses these kwargs (not model fields) to short-circuit dry-run, run EXPLAIN, or execute.
Entry Point Wiring
slayer/api/server.py, slayer/cli.py, slayer/mcp/server.py, slayer/client/slayer_client.py
API and MCP no longer inject dry_run/explain into SlayerQuery payloads; they derive/forward them as engine kwargs. CLI and Python client accept/forward dry_run/explain (or include them in HTTP body when contacting server). sql/explain helpers call via new kwargs.
Documentation & Tests
CLAUDE.md, docs/concepts/models.md, tests/*
Docs updated to declare current schema 3, document v2→v3 migration behavior and extra="forbid". Tests: migration tests for v2→v3 and warnings, regressions for extra="forbid", engine dry-run coverage for various input shapes, many tests switched to engine.execute(..., dry_run=True) to avoid DB side-effects, and an integration test ensuring on-disk v2 YAML with nested source_queries[].dry_run migrates and executes.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant API_Server as API/CLI/MCP
    participant Engine as SlayerQueryEngine
    participant Storage

    Client->>API_Server: POST /query (payload + dry_run/explain)
    API_Server->>Engine: engine.execute(query=..., dry_run=bool, explain=bool)
    Engine->>Storage: if name -> load model YAML (migrate v2→v3 at load)
    Storage-->>Engine: migrated model/query (no dry_run/explain)
    Engine->>Engine: build SQL / EXPLAIN / short-circuit on dry_run
    Engine-->>API_Server: SlayerResponse (sql, data, explain)
    API_Server-->>Client: HTTP response with SQL / rows / explain
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related issues

  • #58 — Overlaps on run-by-name handling and source-query traversal; this PR advances schema and run-by-name behavior relevant to that issue.

Possibly related PRs

  • PR #67 — Directly related: earlier run-by-name/dry_run handling this change completes and formalizes.
  • PR #10 — Related: touches the same query/engine surface and schema evolution concerns.
  • PR #9 — Related: modifies core schema models and engine/query handling overlapping these changes.

Suggested reviewers

  • AivanF

Poem

🐰 I nudged flags from fields to args today,

Dry runs now hop in at call-time play.
Warnings whispered when old bits fall away,
Migrations tidy nests — no stale flags stay. 🥕

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 71.21% 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 accurately summarizes the main change: moving dry_run/explain off SlayerQuery and bumping to v3 schema, matching the PR's primary objective.
Linked Issues check ✅ Passed All major objectives from issue #71 are implemented: dry_run/explain removed from SlayerQuery, v3 schema bump, storage migration with warnings, extra='forbid' added, engine.execute() kwargs work for all input shapes, surface routing remains compatible, and tests cover migrations and regressions.
Out of Scope Changes check ✅ Passed All changes are directly related to the stated objective of removing dry_run/explain from SlayerQuery and updating the schema. Changes to test files, documentation, migrations, client routing, and engine signatures are all necessary to implement this feature without out-of-scope modifications.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch egor/dry-run-separate

Review rate limit: 0/5 reviews remaining, refill in 54 minutes and 1 second.

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

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
docs/concepts/models.md (1)

264-273: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Switch this example to dict syntax.

This page is language-agnostic docs, so the SlayerQuery(...) constructor should be shown as a plain query dict instead.

As per coding guidelines, use JSON/dict syntax for all query objects in docs and examples — not Python class constructors.

Proposed replacement
 engine.create_model_from_query(
-    query=SlayerQuery(
-        source_model="orders",
-        time_dimensions=[...],
-        measures=["*:count", "amount:sum"],
-    ),
+    query={
+        "source_model": "orders",
+        "time_dimensions": [...],
+        "measures": ["*:count", "amount:sum"],
+    },
     name="monthly_summary",
 )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/concepts/models.md` around lines 264 - 273, Replace the Python-specific
SlayerQuery(...) constructor usage in the example passed to
engine.create_model_from_query with a plain JSON/dict-style query object (i.e.,
use a dict for the query argument instead of the SlayerQuery class) so the docs
remain language-agnostic; update the call to
engine.create_model_from_query(query=..., name="monthly_summary") where the
query previously referenced SlayerQuery to instead use a dict/JSON structure
with the same keys (source_model, time_dimensions, measures) and values.
slayer/engine/query_engine.py (1)

174-177: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Guard the new list input against empties.

engine.execute([]) currently raises IndexError at queries[-1]. Since list input is now a supported public shape, this should fail fast with a clearer ValueError instead of crashing.

🔧 Proposed fix
         if isinstance(query, list):
+            if not query:
+                raise ValueError("query list must not be empty")
             queries = [SlayerQuery.model_validate(q) if isinstance(q, dict) else q for q in query]
             query = queries[-1]
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@slayer/engine/query_engine.py` around lines 174 - 177, When handling list
inputs in query_engine.execute (the branch that checks isinstance(query, list)),
guard against an empty list before accessing queries[-1]; if the incoming list
is empty raise a clear ValueError (e.g. "empty query list not allowed") instead
of letting an IndexError occur. Keep the existing behavior of validating dict
entries with SlayerQuery.model_validate and assigning query = queries[-1] for
non-empty lists, and ensure named_queries is still initialized afterward.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@docs/concepts/models.md`:
- Around line 264-273: Replace the Python-specific SlayerQuery(...) constructor
usage in the example passed to engine.create_model_from_query with a plain
JSON/dict-style query object (i.e., use a dict for the query argument instead of
the SlayerQuery class) so the docs remain language-agnostic; update the call to
engine.create_model_from_query(query=..., name="monthly_summary") where the
query previously referenced SlayerQuery to instead use a dict/JSON structure
with the same keys (source_model, time_dimensions, measures) and values.

In `@slayer/engine/query_engine.py`:
- Around line 174-177: When handling list inputs in query_engine.execute (the
branch that checks isinstance(query, list)), guard against an empty list before
accessing queries[-1]; if the incoming list is empty raise a clear ValueError
(e.g. "empty query list not allowed") instead of letting an IndexError occur.
Keep the existing behavior of validating dict entries with
SlayerQuery.model_validate and assigning query = queries[-1] for non-empty
lists, and ensure named_queries is still initialized afterward.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 77bbc92a-146a-48f7-9e1a-3a006205db1c

📥 Commits

Reviewing files that changed from the base of the PR and between cb64905 and f536d73.

📒 Files selected for processing (12)
  • CLAUDE.md
  • docs/concepts/models.md
  • slayer/api/server.py
  • slayer/cli.py
  • slayer/client/slayer_client.py
  • slayer/core/models.py
  • slayer/core/query.py
  • slayer/engine/query_engine.py
  • slayer/mcp/server.py
  • slayer/storage/migrations.py
  • slayer/storage/v3_migration.py
  • tests/test_migrations.py

# Conflicts:
#	CLAUDE.md
#	slayer/api/server.py
#	slayer/cli.py
#	slayer/engine/query_engine.py
#	slayer/mcp/server.py
#	tests/test_migrations.py
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
slayer/engine/query_engine.py (1)

245-248: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Reject empty query lists before indexing the final stage.

execute() now accepts list input, but queries[-1] will still raise an internal IndexError for []. Please fail fast with a clear ValueError so callers get a deterministic validation error instead of a traceback.

🔧 Suggested fix
         if isinstance(query, list):
+            if not query:
+                raise ValueError("query list must not be empty")
             queries = [SlayerQuery.model_validate(q) if isinstance(q, dict) else q for q in query]
             query = queries[-1]
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@slayer/engine/query_engine.py` around lines 245 - 248, The code accepts list
input but immediately uses queries[-1], which crashes on an empty list; update
the execute() handling where it builds queries (using SlayerQuery.model_validate
and variables query/queries/named_queries) to first check if the incoming list
is empty and raise a clear ValueError (e.g. "query list must not be empty")
before creating 'queries' or indexing queries[-1], ensuring callers receive a
deterministic validation error instead of an IndexError.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@slayer/engine/query_engine.py`:
- Around line 245-248: The code accepts list input but immediately uses
queries[-1], which crashes on an empty list; update the execute() handling where
it builds queries (using SlayerQuery.model_validate and variables
query/queries/named_queries) to first check if the incoming list is empty and
raise a clear ValueError (e.g. "query list must not be empty") before creating
'queries' or indexing queries[-1], ensuring callers receive a deterministic
validation error instead of an IndexError.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 2d30778b-96a8-4f0a-8bb2-0d2220d67f6a

📥 Commits

Reviewing files that changed from the base of the PR and between f536d73 and ab46840.

📒 Files selected for processing (10)
  • CLAUDE.md
  • docs/concepts/models.md
  • slayer/api/server.py
  • slayer/cli.py
  • slayer/core/models.py
  • slayer/core/query.py
  • slayer/engine/query_engine.py
  • slayer/mcp/server.py
  • tests/test_migrations.py
  • tests/test_query_backed_models.py
✅ Files skipped from review due to trivial changes (2)
  • CLAUDE.md
  • docs/concepts/models.md
🚧 Files skipped from review as they are similar to previous changes (3)
  • slayer/core/models.py
  • slayer/mcp/server.py
  • slayer/cli.py

ZmeiGorynych and others added 5 commits May 3, 2026 12:01
- Lines 699/768 (python:S2068): test datasource never connects (dry_run)
- Line 786 (python:S7493): hermetic test I/O matches existing pattern

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Use mig.CURRENT_VERSIONS["SlayerModel"] instead of the hard-coded 2;
v3 schema bump on main means re-saved YAML stamps version=3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sonar's Python suppression syntax requires alphanumeric-only rule keys
inside the parens — the leading 'python:' caused S7632 to flag all
three of yesterday's NOSONAR comments as malformed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reuse the existing _build_engine_with_orders helper from the dry_run
kwarg tests instead of re-creating the postgres datasource and orders
model inline. Removes ~14 duplicated lines flagged by SonarCloud's
new_duplicated_lines_density gate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
# Conflicts:
#	tests/test_query_backed_models.py
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
tests/integration/test_ingestion_jaffle_shop.py (1)

204-204: 💤 Low value

Move the migration import to module scope.

This is a Python file, and the repo guideline is to keep imports at the top of the file. The test doesn't gain anything from a function-local import here.

As per coding guidelines, "Place imports at the top of files."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/integration/test_ingestion_jaffle_shop.py` at line 204, Move the local
import "from slayer.storage import migrations as mig" out of the test body and
place it at module scope (top of the file) so imports follow the project's
guidelines; locate the test that currently does the function-local import and
replace it with a top-level import for the symbol "mig" to avoid function-scoped
imports and improve readability.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@CLAUDE.md`:
- Line 55: The doc line incorrectly states the cache refresh happens on every
engine.execute; update the wording to say cache refresh and persistence occur
only on save-time paths (engine.save_model and its helper
_validate_and_populate_cache) and not during engine.execute (real, dry-run,
explain); keep the note that engine.save_model rejects user-supplied
columns/backing_query_sql for query-backed models and that the cache is
persisted only when values have changed, and mention SlayerModel.query_variables
and engine.create_model_from_query as relevant symbols.

---

Nitpick comments:
In `@tests/integration/test_ingestion_jaffle_shop.py`:
- Line 204: Move the local import "from slayer.storage import migrations as mig"
out of the test body and place it at module scope (top of the file) so imports
follow the project's guidelines; locate the test that currently does the
function-local import and replace it with a top-level import for the symbol
"mig" to avoid function-scoped imports and improve readability.
🪄 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: f4387f60-c8ef-4006-9562-4b611d098234

📥 Commits

Reviewing files that changed from the base of the PR and between ab46840 and 05e0ca8.

📒 Files selected for processing (6)
  • CLAUDE.md
  • docs/concepts/models.md
  • slayer/engine/query_engine.py
  • tests/integration/test_ingestion_jaffle_shop.py
  • tests/test_migrations.py
  • tests/test_query_backed_models.py
🚧 Files skipped from review as they are similar to previous changes (3)
  • docs/concepts/models.md
  • tests/test_query_backed_models.py
  • tests/test_migrations.py

Comment thread CLAUDE.md Outdated
- CLAUDE.md: narrow the cache-refresh wording — engine.execute never
  writes to storage after #74 (only save paths refresh the cache).
- tests/integration/test_ingestion_jaffle_shop.py: hoist the
  function-local migrations import to module scope per CLAUDE.md
  imports-at-the-top rule.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@sonarqubecloud
Copy link
Copy Markdown

sonarqubecloud Bot commented May 3, 2026

@ZmeiGorynych ZmeiGorynych merged commit 62bdc1d into main May 3, 2026
4 checks passed
ZmeiGorynych added a commit that referenced this pull request May 3, 2026
- `examples/embedded/verify.py:84-86`: guard `regions_model` before
  dereferencing `.sql_table`. Mirrors the existing `orders_model` check
  pattern; turns a potential `AttributeError` into a recorded failed
  check. (CodeRabbit r3178151...)
- `examples/embedded/verify.py`: hoist `AVG_QTY_KEY = "orders.avg_qty"`
  and `CUMULATIVE_KEY = "orders.cumulative"` (3 uses each, the two
  remaining S1192 hits). (Sonar AZ3t50XxIhX985J8J4BV +
  AZ3t50XxIhX985J8J4BU; CR nitpick pullrequestreview-4216330491.)
  The other 4 column keys CR mentioned earlier are below Sonar's
  threshold (2 uses each) and stay inline.
- `pyproject.toml`: 0.3.2 → 0.4.0 to flag the BC-breaking v3 schema
  cut (PR #77 dropped `dry_run`/`explain` from `SlayerQuery` and added
  `extra="forbid"`). `slayer/__init__.py:__version__` is still
  `"0.1.0rc2"` and predates this PR — flagged but left alone.

Verification: ruff clean; embedded `verify.py` is 42/42 green
(one new check from the regions guard).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.

Move dry_run/explain off SlayerQuery — they're execution flags, not query shape

1 participant