Skip to content

S2a: query-backed models as a first-class source mode (#58)#67

Merged
ZmeiGorynych merged 8 commits into
mainfrom
egor/S2c
May 3, 2026
Merged

S2a: query-backed models as a first-class source mode (#58)#67
ZmeiGorynych merged 8 commits into
mainfrom
egor/S2c

Conversation

@ZmeiGorynych
Copy link
Copy Markdown
Member

@ZmeiGorynych ZmeiGorynych commented May 2, 2026

Summary

Closes #58. Makes source_queries a first-class source mode on SlayerModel, peer to sql_table and sql, and surfaces query-backed models end-to-end across engine, MCP, REST, and CLI.

  • New SlayerModel fields (query_variables, backing_query_sql), source-mode exclusivity + stage-name validators, and source_queries: List[SlayerQuery] typing with auto-parsing of dicts.
  • engine.execute(str, variables=...) runs a query-backed model by name; engine.save_model(model) is the engine save helper that rejects user-supplied cache fields and refreshes columns + backing_query_sql on every execute (real / dry-run / explain) with write-if-changed.
  • Variable precedence threaded through resolution: runtime kwarg > stage > outer query > model defaults. Save-time validation substitutes literal '0' for unresolved {var} placeholders.
  • MCP inspect_model adds a backing_query section and source_type; create_model accepts variables= and list-of-stages; query tool dispatches to execute(str) for query-backed names; edit_model handles source_queries / query_variables. REST POST /query accepts {"name": "<model>", "variables": {...}}. CLI slayer query <model_name> and --variables KEY=VALUE / --variables-json flags added.

Out of scope per the design pass: dbt-import emission, a standalone slayer models inspect CLI, and CLI YAML model files for slayer query. Variable-handling regime tracked separately in #65.

Design pass and answer-summary posted as a comment on #58.

Test plan

  • poetry run pytest tests/ -m "not integration" — 1102 passed
  • poetry run pytest tests/integration/test_integration.py -m integration — 59 passed (SQLite)
  • poetry run ruff check slayer/ tests/ — clean
  • Manual: slayer models create query-backed.yaml, then slayer query <name> --variables k=v
  • Manual: REST POST /models with source_queries and POST /query with {"name": "..."}
  • Manual: MCP inspect_model shows backing_query section; create_model with query= populates the cache

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Save queries as query-backed models, run saved models by name across engine/CLI/REST/MCP, and execute sync/async with runtime variables, dry-run, and explain options.
  • Documentation

    • Expanded guides for query-backed models, explicit source-mode rules, variable precedence, run-by-name request shapes, CLI/REST/MCP examples, and caching semantics.
  • Bug Fixes

    • Enforced mutually exclusive source modes; reject user-supplied cached fields for query-backed models; clearer run-by-name validation errors.
  • Tests

    • Extensive tests for query-backed models, execution modes, variable precedence, caching, and interface behaviors.

Make ``source_queries`` a peer of ``sql_table`` / ``sql`` on ``SlayerModel``
and surface the query-backed source mode end-to-end across engine, MCP,
REST, and CLI.

Engine + data model
- New SlayerModel fields: ``query_variables`` (defaults for ``{var}``
  placeholders) and ``backing_query_sql`` (engine-managed cached rendered
  SQL). ``source_queries`` is typed ``List[SlayerQuery]`` via a before-
  validator that auto-parses dicts.
- Validators: source-mode exclusivity (exactly one source); empty
  ``source_queries=[]`` rejected; non-final stages must have a ``name``;
  no duplicate stage names.
- ``_query_as_model`` / ``_resolve_model`` / ``_resolve_model_inner``
  thread ``outer_vars``, ``runtime_kwarg``, ``dry_run_placeholders``
  through nested resolution. Variable precedence: ``runtime kwarg >
  stage > outer query > model defaults``. Save-time validation
  substitutes literal ``'0'`` for unresolved placeholders.
- ``engine.execute(str, variables=...)`` runs a query-backed model by
  name. ``engine.save_model(model)`` is the engine-side save helper that
  rejects user-supplied ``columns`` / ``backing_query_sql`` for query-
  backed models, runs save-time validation, and refreshes the cache.
- Cache refreshes on every execute path (real, dry-run, explain) with
  write-if-changed semantics.

Surfaces
- MCP: ``inspect_model`` adds a ``backing_query`` section + ``source_type``
  + (with ``show_sql``) ``backing_query_sql``; ``models_summary`` reports
  ``source_type``; ``create_model`` accepts ``variables=`` and a list-of-
  stages ``query=`` payload; ``query`` tool dispatches to ``execute(str)``
  for query-backed model names with no overrides; ``edit_model`` accepts
  ``source_queries`` / ``query_variables`` and routes through
  ``engine.save_model``.
- REST: ``POST/PUT /models`` route through ``engine.save_model``;
  ``POST /query`` accepts ``{"name": "<model>", "variables": {...}}``
  for run-by-name and ``variables=`` on the normal body shape.
- CLI: ``slayer query`` accepts a model name (heuristic: doesn't start
  with ``{`` / ``[`` / ``@``); new ``--variables KEY=VALUE`` (repeatable)
  and ``--variables-json`` flags; ``slayer models create`` routes
  through ``engine.save_model``.

Tests
- 18 new model-validator tests in ``tests/test_models.py``.
- New ``tests/test_query_backed_models.py`` (20 tests) covering
  ``execute(str)``, variable precedence, cache refresh, save-time
  placeholder fill, ModelExtension regressions over query-backed and
  named-query stages.
- New tests in ``tests/test_mcp_server.py`` and ``tests/test_api_server.py``
  for the new MCP / REST surface.
- ``tests/test_migrations.py`` updated to add ``sql_table`` fixtures and
  switch ``source_queries`` assertions to attribute access (entries are
  now parsed into ``SlayerQuery`` instances).

Docs / skills
- ``CLAUDE.md``, ``docs/concepts/models.md``, ``docs/concepts/queries.md``,
  ``docs/interfaces/{cli,mcp,rest-api}.md``, ``.claude/skills/slayer-{overview,
  models,query}.md`` updated for source modes, ``query_variables``,
  run-by-name, variable precedence, and the cache policy.

Out of scope per the design pass on #58: dbt-import emission, standalone
``slayer models inspect`` CLI, CLI YAML model files for ``slayer query``.
Variable-handling regime tracked separately in #65.

Final state: 1102 unit tests + 59 SQLite integration tests pass; ruff
clean.

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

coderabbitai Bot commented May 2, 2026

Note

Reviews paused

It 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 reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR makes query-backed models a first-class SlayerModel source mode (sql_table | sql | source_queries), adds engine-managed caching of columns/backing_query_sql, introduces runtime variables with defined precedence and run-by-name execution, and wires these behaviors through API/CLI/MCP, validators, engine save/execute flows, and tests.

Changes

Query-backed models & execution

Layer / File(s) Summary
Data Shape / Validation
slayer/core/models.py
SlayerModel.source_queries coerced from dicts (BeforeValidator(_coerce_source_queries)); adds query_variables: Dict[str, Any] = {} and backing_query_sql: Optional[str] = None; validators enforce exactly one source among sql_table/sql/non-empty source_queries, require non-final stages to have a name, and ensure stage-name uniqueness.
Placeholder Extraction
slayer/core/query.py
Adds extract_placeholder_names(query) to extract {var} placeholders from filters; sanitizes CR/LF in a logging call.
Engine: API + Variable Core
slayer/engine/query_engine.py
execute() accepts str (run-by-name), variables, dry_run, explain; adds execute_sync and sync create helper; implements _merge_query_variables (precedence runtime > stage > outer) and _apply_placeholder_fill (fill missing vars with "0" in dry-run placeholder mode); merges runtime vars into query execution; create_model_from_query(..., variables=None) persists model.query_variables and populates engine-managed cache via dry-run rendering.
Engine: Model resolution & Caching
slayer/engine/query_engine.py
Refactors model resolution (_resolve_model_inner / circular-protecting wrapper); _expand_query_backed_model renders virtual model with merged vars, refreshes columns/backing_query_sql using write-if-changed logic, and avoids persisting request-scoped runtime values into cache.
Engine: Join / Enrich Integration
slayer/engine/query_engine.py
Threads outer_vars into enrichment/join resolution, adds task-local recursion guard (ContextVar) and _render_query_backed_join_target() that reuses cached SQL when safe or re-renders with merged variables otherwise.
Server API
slayer/api/server.py
QueryRequest adds optional name and variables, source_model becomes optional; /query branches on name to run-by-name (rejecting disallowed query-defining fields) or to build a SlayerQuery and pass runtime variables to engine.execute; /models create/update use engine.save_model(...) and map ValueError→HTTP 400; SQL included in responses based on a consolidated dry_or_explain.
MCP Server
slayer/mcp/server.py
Adds backing-query introspection builders and markdown renderers, source_type classification, query tool accepts variables and supports run-by-name dispatch, create_model/edit_model accept query/source_queries/query_variables, enforce source-mode exclusivity, strip engine-managed cache fields on query-backed save, and include backing-query info in inspect output (JSON/markdown).
CLI
slayer/cli.py
Adds --variables KEY=VALUE and --variables-json flags and _parse_cli_variables; refactors _run_query to detect inline JSON/@file vs model-name, injects dry_run/explain into JSON inputs when applicable, forwards runtime variables into engine.execute_sync(...), and routes slayer models create via SlayerQueryEngine.save_model.
Docs / Guides
.claude/skills/*, docs/concepts/*, docs/interfaces/*, CLAUDE.md
Adds/expands “Source modes” and “Query-backed models” sections; documents create_model_from_query(query, name, variables=None), variable precedence (runtime > stage > outer), run-by-name execution shapes and REST/CLI/MCP wiring, and that columns/backing_query_sql are engine-managed and rejected on save.
Tests: Unit / Validation
tests/test_models.py, tests/test_migrations.py
Adds tests for source-mode exclusivity, stage naming rules, dict→SlayerQuery coercion, query_variables/backing_query_sql defaults; updates migration/round-trip fixtures to include sql_table and to assert source_queries parse to SlayerQuery instances.
Tests: Engine / Behavior
tests/test_query_backed_models.py
New async hermetic suite covering run-by-name execution, variable precedence, create_model_from_query caching/persistence and placeholder handling, save-model guards (reject user columns/backing_query_sql), cache refresh on execute, backing-query SQL hygiene, inline query-backed source models, join-target expansion, and plan-flag behaviors (dry_run/explain).
Tests: API / MCP / CLI
tests/test_api_server.py, tests/test_mcp_server.py, tests/test_cli.py
Adds REST tests for POST /models (creating query-backed models, rejecting columns), POST /query run-by-name validation; MCP inspect/create/edit tests for backing-query introspection, edit flows, required-variable reporting, and plan-flag behavior; CLI tests for variable parsing.

Sequence Diagram(s)

sequenceDiagram
    actor User
    participant CLI
    participant Engine
    participant Storage
    participant SQL

    rect rgba(100, 149, 237, 0.5)
    note over User,SQL: Save query-backed model (create_model_from_query)
    User->>CLI: slayer models create model.yaml (includes source_queries, variables)
    CLI->>Engine: save_model(SlayerModel{source_queries, query_variables})
    Engine->>Engine: dry-run render with placeholder fill (missing vars -> '0')
    Engine->>SQL: render probe query to derive columns/backing_query_sql
    Engine->>Storage: save(model with backing_query_sql, columns, query_variables)
    Storage-->>CLI: persisted
    end

    rect rgba(144, 238, 144, 0.5)
    note over User,SQL: Execute by name with runtime variables
    User->>CLI: slayer query my_model --variables foo=bar
    CLI->>Engine: execute("my_model", variables={foo: "bar"})
    Engine->>Storage: load("my_model") -> SlayerModel(source_queries, query_variables)
    Engine->>Engine: merge variables (runtime > stage > model.query_variables)
    Engine->>SQL: execute rendered final-stage SQL with merged variables
    SQL-->>Engine: rows
    Engine-->>CLI: results
    end

    rect rgba(255, 192, 203, 0.5)
    note over User,Engine: Inspect model (introspection)
    User->>CLI: slayer models inspect my_model --show-sql
    CLI->>Engine: inspect_model("my_model", show_sql=True)
    Engine->>Storage: load("my_model")
    Engine->>Engine: build backing_query info (variables, required_variables, stages)
    Engine->>SQL: optionally render SQL with placeholder fill for show_sql
    Engine-->>CLI: backing-query report + SQL
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • AivanF
  • whimo

"🐰
I dug a burrow in the code so deep,
Saved queries nest where caching sleeps.
Variables hop from runtime down the tree,
Backing SQL waits, clean and free.
Hooray — another carrot in our spree!"

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 48.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 PR title clearly and specifically describes the main change: introducing query-backed models as a first-class source mode on SlayerModel, with the issue reference (#58).
Linked Issues check ✅ Passed The PR comprehensively addresses all coding requirements from issue #58: source-mode exclusivity validation, persist query-backed model metadata, create_model_from_query with variables, variable precedence and handling, engine.execute(name, variables) support, model resolution expansion, introspection enhancements, REST/CLI/MCP surface changes, and extensive test coverage.
Out of Scope Changes check ✅ Passed All changes remain within scope of issue #58 objectives. Out-of-scope items (dbt-import, standalone CLI inspect, YAML model files for slayer query, variable-handling regime tracking) are explicitly documented as deferred and not included in implementation.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch egor/S2c

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.

Actionable comments posted: 13

Caution

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

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

567-609: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Resolve inline query-backed models before returning them.

The SlayerModel and validated-dict branches return the model as-is, so source_model=<SlayerModel source_queries=[...]> never goes through _query_as_model(). Those inline models still do not have an executable sql/sql_table, so enrichment/SQL generation will fail once they are used directly. Inline query-backed models need the same expansion path as stored ones.

Suggested fix
         elif isinstance(query_model, SlayerModel):
-            return query_model
+            if query_model.source_queries:
+                stages = list(query_model.source_queries)
+                merged_outer = {**query_model.query_variables, **(outer_vars or {})}
+                return await self._query_as_model(
+                    inner_query=stages[-1],
+                    named_queries={q.name: q for q in stages[:-1] if q.name},
+                    override_name=query_model.name,
+                    _resolving=_resolving,
+                    outer_vars=merged_outer,
+                    runtime_kwarg=runtime_kwarg,
+                    dry_run_placeholders=dry_run_placeholders,
+                )
+            return query_model
@@
             else:
                 model = SlayerModel.model_validate(query_model)
-                return model
+                return await self._resolve_query_model(
+                    query_model=model,
+                    named_queries=named_queries,
+                    _resolving=_resolving,
+                    outer_vars=outer_vars,
+                    runtime_kwarg=runtime_kwarg,
+                    dry_run_placeholders=dry_run_placeholders,
+                )
🤖 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 567 - 609, The code returns
inline SlayerModel instances (and dict-validated SlayerModel) directly from
_resolve_model, so models with source_queries never get expanded via
_query_as_model/_resolve_query_model and thus lack sql/sql_table; update the
SlayerModel branch and the dict branch that validates to SlayerModel to detect
query-backed models (e.g., check model.source_name or model.source_queries) and
route them through the same expansion path used for stored models—call
self._query_as_model(...) or await self._resolve_query_model(...) with the same
parameters (named_queries, _resolving, outer_vars, runtime_kwarg,
dry_run_placeholders) instead of returning the raw SlayerModel, ensuring
ModelMeasure/ModelJoin/ModelExtension handling remains unchanged.

815-832: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Resolve join targets through the same query-backed path.

This helper still fetches non-named targets with storage.get_model(), so a saved query-backed join target never gets expanded into a virtual model and falls through with neither sql_table nor sql. The named-query branch also drops runtime_kwarg and dry_run_placeholders, so joined stages can ignore runtime precedence and miss save-time placeholder filling. Route both branches through _resolve_model(...) with the active variable context instead of bypassing it here.

🤖 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 815 - 832, The
_resolve_join_target helper currently bypasses the normal model resolution by
calling storage.get_model for non-named targets and by using _query_as_model for
named queries (losing runtime_kwarg and dry_run_placeholders); change it to
route both named and non-named targets through _resolve_model using the active
variable context (query.variables) and passing through runtime_kwargs and
dry_run_placeholders so saved query-backed models are expanded into virtual
models and runtime precedence / placeholder filling is preserved; update
references inside _resolve_join_target to call _resolve_model(...) instead of
storage.get_model(...) or _query_as_model(...) and ensure it returns the same
tuple shape (sql_table/sql or sql string, model) as before.
🧹 Nitpick comments (1)
tests/test_mcp_server.py (1)

1324-1348: ⚡ Quick win

Make this source-mode switch test deterministic.

This assertion passes on almost any validation failure and never verifies the stored model state, so regressions in the actual switch-to-query-backed flow can slip through unnoticed. Use a non-cyclic backing query here and assert that the saved model has source_queries set and sql_table cleared.

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

In `@tests/test_mcp_server.py` around lines 1324 - 1348, The test is
non-deterministic because it uses a self-referencing (cyclic) source_query and
only checks that some error text appears; instead create a non-cyclic backing
model and assert the persisted model state after the edit: before calling
edit_model save a separate backing model (e.g., name "orders_source") via
storage.save_model(SlayerModel(...)), call _call(... "edit_model" ...) with
source_queries referencing "orders_source", then load the saved model from
storage (use the storage load/get API used elsewhere in tests) and assert that
the model's source_queries is set and that sql_table has been cleared/removed
(e.g., None or empty) to verify the switch-to-query-backed flow; keep references
to test_edit_set_source_queries_makes_model_query_backed, storage.save_model,
_call, SlayerModel, source_queries, and sql_table so you can locate and update
the test.
🤖 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/skills/slayer-models.md:
- Around line 57-74: The docs file mixes v2 source-mode terminology with legacy
v1 examples which will confuse users; update the
`.claude/skills/slayer-models.md` content to be internally consistent by
removing or converting legacy v1 references (dimensions/measures, row-level
measures) to the v2 model vocabulary (SlayerModel source modes like `sql_table`,
`sql`, `source_queries`) and ensure all examples and YAML snippets align with
the new API (e.g., `create_model_from_query`, `model.source_queries`,
`model.query_variables`, `model.columns`, `model.backing_query_sql`, and
`source_model` usage); also add a short migration note explaining that `columns`
and `backing_query_sql` are engine-managed and cannot be supplied when saving
query-backed models and update any ingestion bullets/YAML examples to reflect
query-backed model shapes and variable precedence semantics.

In @.claude/skills/slayer-query.md:
- Around line 71-76: The examples show synchronous calls to the async method
SlayerQueryEngine.execute which can cause coroutine misuse; update the snippets
to use await when calling SlayerQueryEngine.execute (e.g., await
engine.execute(...)) or explicitly show the synchronous alternative
SlayerQueryEngine.execute_sync(...) in sync examples, and adjust surrounding
text to indicate the async/sync variant being demonstrated.

In `@docs/concepts/models.md`:
- Around line 290-339: Update the canonical "Model Fields Reference" section to
include the three new query-backed fields: source_queries (list of SlayerQuery
stages, describe required stage name for multi-stage queries), query_variables
(dict of model default values, explain precedence and that unresolved
placeholders default to '0' at save-time validation), and backing_query_sql
(string, read-only/engine-managed cached rendered SQL); also mark model.columns
as a cached/engine-managed discoverability snapshot, state that user-supplied
columns or backing_query_sql are rejected at save with a clear error, and ensure
descriptions/types and read-only/write behavior match the earlier "Two ways to
use a saved query" / "What gets cached" text.
- Around line 276-287: The example uses a Python constructor (SlayerQuery) which
reduces portability; replace the SlayerQuery(...) argument passed to
engine.create_model_from_query with a plain dict/JSON object using the same keys
(e.g., "source_model", "measures", "dimensions", "time_dimensions") and keep
"variables" as a dict literal; update the call site
(engine.create_model_from_query) to pass that dict for the query parameter and
preserve name/description/variables values.

In `@docs/interfaces/rest-api.md`:
- Around line 48-49: Update the "Run-by-name" sentence to clarify that while
query-defining fields are disallowed, the request body may still include
`variables` plus the optional flags `dry_run` and `explain`; change the phrasing
in docs/interfaces/rest-api.md for the "Run-by-name" entry and make the
analogous edit where the same rule is described in docs/concepts/queries.md so
both say "provide `name` and (optionally) `variables`; `dry_run` and `explain`
are also permitted — only query-defining fields (e.g., the actual query/prompt)
are disallowed."

In `@slayer/api/server.py`:
- Around line 81-105: The run-by-name branch currently ignores the
dry_run/explain flags because it always calls engine.execute(request.name, ...);
change it to respect those flags by computing dry_or_explain from
request.dry_run/request.explain before invoking the engine and then call
engine.execute with those flags (e.g. engine.execute(request.name,
variables=..., dry_run=request.dry_run, explain=request.explain)) or, if your
engine API exposes a separate method for plan-only/explain, call that method
when dry_run or explain is true; update the code around request.name, the
existing dry_or_explain variable, and the engine.execute call so named runs do
not perform a full execution when dry_run or explain are set.

In `@slayer/cli.py`:
- Around line 410-414: The block that parses CLI input via
_json.loads(args.variables_json) can raise a JSON decoding exception and
currently emits a traceback; catch the JSON parsing error
(ValueError/JSONDecodeError) around the call in the has_json branch and instead
raise SystemExit with a clear user-facing message like "--variables-json
contains invalid JSON: <error message>"; keep the existing check that parsed is
a dict and raise SystemExit("--variables-json must decode to a JSON object.") if
not.
- Around line 461-477: When handling args.dry_run or args.explain for a named
model (query_input) you currently synthesize a SlayerQuery wrapper
unconditionally, which incorrectly allows table-backed models; before building
the wrapper (before calling SlayerQuery.model_validate and engine.execute_sync),
resolve the named model and verify it is query-backed (e.g., call whatever
model-resolve/metadata API exists on the engine such as engine.get_model /
engine.resolve_model or an is_query_backed helper) and if the model is not
query-backed raise the same validation/error path as plain named execution; only
synthesize the SlayerQuery and call SlayerQuery.model_validate +
engine.execute_sync when that check passes.

In `@slayer/core/models.py`:
- Around line 223-245: The validator function _coerce_source_queries currently
raises TypeError for non-list inputs and invalid list items; change those raises
to ValueError so Pydantic v2 wraps them into validation errors: replace the
TypeError raised when v is not a list and the TypeError raised for invalid list
elements inside the loop (including the index-based message) with ValueError,
keeping the same messages and behavior; update references in this function only
(function name: _coerce_source_queries, and uses of SlayerQuery.model_validate)
so other validators remain consistent.

In `@slayer/engine/query_engine.py`:
- Around line 674-690: The current flow renders a virtual model using the live
runtime_kwarg and then calls _refresh_cache_after_resolution(model, virtual),
which causes backing_query_sql to be persisted with request-specific variables;
change this so persisted SQL stays canonical: when calling _query_as_model here
(inside the if model.source_queries block) avoid passing caller-specific runtime
variables into the rendering used for cache refresh—either call _query_as_model
twice (one full render for execution with runtime_kwarg, and a second render
with runtime_kwarg=None or dry placeholders only) or pass a flag into
_refresh_cache_after_resolution to skip writing backing_query_sql when
runtime_kwarg is present; update _refresh_cache_after_resolution (or its caller)
so it only persists backing_query_sql derived from model defaults/placeholder
fill and not from request/runtime_kwarg values.

In `@slayer/mcp/server.py`:
- Around line 551-557: The required_variables computation omits stage-scoped
defaults, causing placeholders satisfied by a stage's own variables to be
flagged as required; when iterating model.source_queries (where you call
extract_placeholder_names(q) and append q.model_dump(...)), merge each stage's
q.variables keys into the set of defaulted variables before computing required =
sorted(all_placeholders - set(model.query_variables.keys())), e.g., accumulate
keys from q.variables into the default set (alongside model.query_variables) so
required_variables reflects both global query_variables and per-stage
q.variables.
- Around line 778-785: The shortcut branch using no_overrides + await
storage.get_model(source_model) bypasses the normal SlayerQuery path and calls
engine.execute(source_model, variables=variables) without propagating dry_run or
explain, so plan-only requests may execute the query; modify this branch to
either construct and dispatch a SlayerQuery object (including dry_run and
explain) instead of calling engine.execute with just source_model, or extend the
engine.execute overload to accept and forward dry_run and explain (and
variables) — specifically update the code around no_overrides /
storage.get_model / target.source_queries to call the same code path used for
named queries (create SlayerQuery with source_model, variables, dry_run, explain
and pass it to the engine) so the planning flags are honored and result.sql is
returned without executing the backing query.
- Around line 1615-1621: The code currently wipes validated.columns when
validated.source_queries is true (in the branch using validated.model_copy),
which silently discards client column edits; instead, detect when a query-backed
save is being attempted with columns provided and reject it by raising an
explicit error (e.g., HTTPException/ValueError) referencing the same check
(validated.source_queries) rather than blanking via validated.model_copy, or if
you prefer to allow the request to succeed, strip columns before building the
recorded changes so no "created/updated column ..." entries are appended; update
the branch around validated.source_queries and the validated.model_copy call to
either raise an error when validated.columns is non-empty or remove columns from
the change-tracking payload before persisting.

---

Outside diff comments:
In `@slayer/engine/query_engine.py`:
- Around line 567-609: The code returns inline SlayerModel instances (and
dict-validated SlayerModel) directly from _resolve_model, so models with
source_queries never get expanded via _query_as_model/_resolve_query_model and
thus lack sql/sql_table; update the SlayerModel branch and the dict branch that
validates to SlayerModel to detect query-backed models (e.g., check
model.source_name or model.source_queries) and route them through the same
expansion path used for stored models—call self._query_as_model(...) or await
self._resolve_query_model(...) with the same parameters (named_queries,
_resolving, outer_vars, runtime_kwarg, dry_run_placeholders) instead of
returning the raw SlayerModel, ensuring ModelMeasure/ModelJoin/ModelExtension
handling remains unchanged.
- Around line 815-832: The _resolve_join_target helper currently bypasses the
normal model resolution by calling storage.get_model for non-named targets and
by using _query_as_model for named queries (losing runtime_kwarg and
dry_run_placeholders); change it to route both named and non-named targets
through _resolve_model using the active variable context (query.variables) and
passing through runtime_kwargs and dry_run_placeholders so saved query-backed
models are expanded into virtual models and runtime precedence / placeholder
filling is preserved; update references inside _resolve_join_target to call
_resolve_model(...) instead of storage.get_model(...) or _query_as_model(...)
and ensure it returns the same tuple shape (sql_table/sql or sql string, model)
as before.

---

Nitpick comments:
In `@tests/test_mcp_server.py`:
- Around line 1324-1348: The test is non-deterministic because it uses a
self-referencing (cyclic) source_query and only checks that some error text
appears; instead create a non-cyclic backing model and assert the persisted
model state after the edit: before calling edit_model save a separate backing
model (e.g., name "orders_source") via storage.save_model(SlayerModel(...)),
call _call(... "edit_model" ...) with source_queries referencing
"orders_source", then load the saved model from storage (use the storage
load/get API used elsewhere in tests) and assert that the model's source_queries
is set and that sql_table has been cleared/removed (e.g., None or empty) to
verify the switch-to-query-backed flow; keep references to
test_edit_set_source_queries_makes_model_query_backed, storage.save_model,
_call, SlayerModel, source_queries, and sql_table so you can locate and update
the test.
🪄 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: ad6187fb-91f0-4033-95dc-ba4f113916e9

📥 Commits

Reviewing files that changed from the base of the PR and between dd322e6 and 0b5aa2f.

📒 Files selected for processing (20)
  • .claude/skills/slayer-models.md
  • .claude/skills/slayer-overview.md
  • .claude/skills/slayer-query.md
  • CLAUDE.md
  • docs/concepts/models.md
  • docs/concepts/queries.md
  • docs/interfaces/cli.md
  • docs/interfaces/mcp.md
  • docs/interfaces/rest-api.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_api_server.py
  • tests/test_mcp_server.py
  • tests/test_migrations.py
  • tests/test_models.py
  • tests/test_query_backed_models.py

Comment thread .claude/skills/slayer-models.md
Comment thread .claude/skills/slayer-query.md
Comment thread docs/concepts/models.md
Comment thread docs/concepts/models.md
Comment thread docs/interfaces/rest-api.md Outdated
Comment thread slayer/core/models.py
Comment thread slayer/engine/query_engine.py Outdated
Comment thread slayer/mcp/server.py
Comment thread slayer/mcp/server.py
Comment thread slayer/mcp/server.py
@ZmeiGorynych
Copy link
Copy Markdown
Member Author

Review fix-up summary (responding to review at #67 (review) + SonarCloud quality gate)

13 inline CodeRabbit comments answered on their threads. This top-level reply covers the 2 outside-diff items, the 1 nitpick, and every SonarQube finding.

CodeRabbit — outside-diff

  • slayer/engine/query_engine.py:567-609 (Major)Inline SlayerModel with source_queries not expanded. Fixed (Batch 2). Extracted _expand_query_backed_model(...) and called it from both the SlayerModel and dict-validated branches in _resolve_query_model, plus from _resolve_model_inner. Test: tests/test_query_backed_models.py::TestInlineQueryBackedSourceModel.
  • slayer/engine/query_engine.py:815-832 (Major)Join target bypasses model resolution. Fixed (Batch 2). _resolve_join_target now prefers the target's cached backing_query_sql (kept fresh by the main resolution path), and falls back to a one-shot _query_as_model expansion when the cache is empty. Going back through _resolve_model directly caused enrich-time recursion (orders → join target → enrich orders → join target → …); the cache-first approach gives the same result without the loop. Test: tests/test_query_backed_models.py::TestJoinTargetIsQueryBacked::test_join_target_is_query_backed_model.

CodeRabbit — nitpick

  • tests/test_mcp_server.py:1324-1348self-referencing source_queries; assertion accepted almost any failure. Fixed (Batch 5). Rewrote the test: pre-saves a separate orders_source model so the backing query is non-cyclic, calls edit_model with that as source_model, then asserts the persisted state — success: true, reloaded.source_queries[0].source_model == "orders_source", reloaded.sql_table is cleared.

SonarQube — fixed (issues should auto-close on next scan)

File:Line Rule What changed
slayer/api/server.py:94, 108, 175, 188 S8415 Added responses={400: {"description": ...}} on the /query, POST /models, PUT /models/{name} route decorators (Batch 8). New tests: tests/test_api_server.py::TestOpenAPI400Documentation.
slayer/mcp/server.py:564 (_backing_query_markdown_section) S3776 (59→well below 15) Extracted _render_field_value, _render_stage_field_list, _render_source_model, _render_stage helpers. The remaining function is a 3-line orchestrator (Batch 6).
slayer/core/query.py:437-440 S5145 Sanitize model_name for CR/LF before logging (Batch 7).
slayer/engine/query_engine.py:307-312 S5145 Same sanitization for the cache-refresh warning (Batch 7).

SonarQube — accepted with rationale (intentionally not refactored / framework-mandated)

These I'll mark as Accept / False Positive in SonarCloud and have left a # NOSONAR annotation in the code with the per-site rationale so it survives across re-scans.

File:Line Rule Disposition Why
slayer/core/models.py:447 (_validate_source_query_stages) S3516 (BLOCKER) False positive Pydantic v2 @model_validator(mode="after") is contractually required to return self. The "always returns same value" rule doesn't apply to validator methods.
slayer/mcp/server.py:695-708 (query MCP tool) S107 False positive FastMCP introspects this signature to expose each query option as a typed MCP tool argument. Collapsing into a kwargs dict would degrade the agent-facing schema (no per-arg types, no descriptions).
slayer/cli.py:426 (_run_query) S3776 Accept argparse-driven dispatch; one straight-line function reads better than threaded helpers.
slayer/engine/query_engine.py:201 (execute) S3776 Accept Public dispatch over str / dict / list / SlayerQuery. The shape contract is clearer when all four branches sit in one place.
slayer/engine/query_engine.py:316 (_execute_pipeline) S3776 Accept Linear pipeline (resolve → enrich → generate → execute). Splitting obscures the order of operations.
slayer/engine/query_engine.py:544 (_resolve_query_model) S3776 Accept Type-dispatch on str / SlayerModel / ModelExtension / dict. Flat dispatch is clearer than per-shape helpers.
slayer/engine/query_engine.py:852 (_query_as_model) S3776 Accept Variable-precedence merge + enrich + SQL-gen + virtual-model assembly is a single conceptual unit.

All 1120 unit tests + 121 integration tests (SQLite 59 / Postgres 37 / DuckDB 25) pass. Ruff clean.

Eight logical batches covering all 13 actionable CodeRabbit comments + 2
outside-diff items + 1 nitpick + the SonarQube quality-gate findings.

Correctness:
- Run-by-name plumbing now honors dry_run/explain across REST, MCP, CLI;
  engine.execute(str, ...) and _execute_by_name take the new kwargs and
  the CLI drops its SlayerQuery synthesis trick so the "must be query-
  backed" guard fires uniformly (Batch 1).
- Inline SlayerModel(source_queries=[...]) is now expanded via a new
  _expand_query_backed_model helper from both _resolve_query_model and
  _resolve_model_inner; _resolve_join_target uses the cached
  backing_query_sql when present and falls back to a one-shot expansion
  to avoid enrich-time recursion (Batch 2).
- backing_query_sql cache stays canonical: when runtime variables are
  present, a second placeholder-fill render produces the cache value so
  per-request data never persists. edit_model rejects user-supplied
  columns on query-backed models with an explicit error; required_variables
  now folds per-stage q.variables into the defaulted set (Batch 3).
- _coerce_source_queries raises ValueError instead of TypeError so
  Pydantic v2 wraps it into a ValidationError (Batch 4).
- CLI --variables-json catches JSONDecodeError and exits with a clean
  message (Batch 4).

SonarQube:
- _backing_query_markdown_section refactored into _render_field_value /
  _render_stage_field_list / _render_source_model / _render_stage helpers
  (S3776 cognitive complexity 59 -> well below 15) (Batch 6).
- log statements that include user-supplied model names sanitize CR/LF
  before logging (S5145, Batch 7).
- /query, POST /models, PUT /models/{name} now declare responses={400}
  for OpenAPI completeness (S8415, Batch 8).
- 7 deferred sites annotated with NOSONAR + per-site rationale (Batches
  6, 7); marked accept/false-positive in SonarCloud.

Docs:
- slayer-models / slayer-query skills migrated from v1 dimensions/measures
  to v2 source-mode + columns vocabulary; clarified async execute usage.
- docs/concepts/models.md gains source_queries/query_variables/
  backing_query_sql in the Model Fields Reference; SlayerQuery() example
  switched to the dict form (Batch 5).
- docs/concepts/queries.md and docs/interfaces/rest-api.md clarify that
  run-by-name allows variables/dry_run/explain (Batch 1).

Tests: +14 new tests across the 8 batches. All 1120 unit tests pass;
SQLite (59), Postgres (37), DuckDB (25) integration suites green.

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

♻️ Duplicate comments (1)
slayer/engine/query_engine.py (1)

616-635: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Keep backing_query_sql canonical when outer-query variables shadow model defaults.

This still writes request-specific SQL into the persisted cache when values arrive through outer_vars instead of runtime_kwarg. A query like {"source_model": "saved_qb", "variables": {"tenant": "A"}} will refresh backing_query_sql with tenant A, causing the same inspect/export leak and cache churn as the earlier runtime-only bug. Please trigger the canonical second render whenever the effective outer vars differ from model.query_variables, not only when runtime_kwarg is set.

Suggested fix
-        if refresh_cache and not dry_run_placeholders:
-            # Render a canonical version for the cache when runtime variables
-            # were in play; otherwise the virtual already IS canonical.
-            if runtime_kwarg:
+        if refresh_cache and not dry_run_placeholders:
+            # Any caller-provided outer/runtime vars make `virtual.sql`
+            # request-specific. Persist only the canonical render based on
+            # model defaults + placeholder fill.
+            needs_canonical = bool(runtime_kwarg) or merged_outer != dict(model.query_variables)
+            if needs_canonical:
                 try:
                     canonical = await self._query_as_model(
                         inner_query=stages[-1],
                         named_queries=named_q,
                         override_name=model.name,
                         _resolving=set(),  # fresh — different render
                         outer_vars=dict(model.query_variables),
                         runtime_kwarg=None,
                         dry_run_placeholders=True,
                     )
🤖 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 616 - 635, The cache-refresh path
only triggers a canonical re-render when runtime_kwarg is set, but it must also
run when incoming outer_vars shadow model defaults; update the conditional so
that if refresh_cache and not dry_run_placeholders and (runtime_kwarg is truthy
OR outer_vars is provided and outer_vars != model.query_variables) you perform
the canonical render: call _query_as_model with inner_query=stages[-1],
named_queries=named_q, override_name=model.name, _resolving=set(),
outer_vars=dict(model.query_variables), runtime_kwarg=None,
dry_run_placeholders=True (same as the runtime_kwarg branch), then pass the
resulting canonical to _refresh_cache_after_resolution (otherwise use virtual) —
i.e., detect shadowing via outer_vars != model.query_variables and treat it like
the runtime_kwarg case.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@slayer/engine/query_engine.py`:
- Around line 924-942: The join-target resolution currently always uses
target.backing_query_sql or falls back to _query_as_model with only
target.query_variables, which ignores the enclosing query's variables; change
the logic in the block handling storage-backed targets so that if the enclosing
query has variables (e.g., self.query.variables or the current query's
variables), you prefer a fresh expansion via _query_as_model (even if
backing_query_sql exists) and call _query_as_model with outer_vars equal to the
merged dict of target.query_variables updated by the enclosing query's variables
(caller variables should override target defaults), i.e., pass
outer_vars={**target.query_variables, **enclosing_query_variables} to
_query_as_model; keep using target.model_copy(update={"sql":
target.backing_query_sql}) only when there are no enclosing variables.

In `@tests/test_mcp_server.py`:
- Around line 367-375: The test currently guards the assertion with if
parsed.get("backing_query_sql") which hides regressions; change it to require
the field unconditionally: after calling result = await _call(mcp_server,
name="inspect_model", arguments={... "show_sql": True}), parse JSON into parsed
and assert that "backing_query_sql" is in parsed (i.e.
parsed.get("backing_query_sql") is not None) and that
parsed["backing_query_sql"].lower() contains "amount"; remove the conditional so
the test fails when the warmed cache field is missing.

---

Duplicate comments:
In `@slayer/engine/query_engine.py`:
- Around line 616-635: The cache-refresh path only triggers a canonical
re-render when runtime_kwarg is set, but it must also run when incoming
outer_vars shadow model defaults; update the conditional so that if
refresh_cache and not dry_run_placeholders and (runtime_kwarg is truthy OR
outer_vars is provided and outer_vars != model.query_variables) you perform the
canonical render: call _query_as_model with inner_query=stages[-1],
named_queries=named_q, override_name=model.name, _resolving=set(),
outer_vars=dict(model.query_variables), runtime_kwarg=None,
dry_run_placeholders=True (same as the runtime_kwarg branch), then pass the
resulting canonical to _refresh_cache_after_resolution (otherwise use virtual) —
i.e., detect shadowing via outer_vars != model.query_variables and treat it like
the runtime_kwarg case.
🪄 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: 8bb3dbff-9e5b-4ff8-aa0e-e4f5379b3c14

📥 Commits

Reviewing files that changed from the base of the PR and between 0b5aa2f and ed43392.

📒 Files selected for processing (16)
  • .claude/skills/slayer-models.md
  • .claude/skills/slayer-query.md
  • docs/concepts/models.md
  • docs/concepts/queries.md
  • docs/interfaces/rest-api.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_api_server.py
  • tests/test_cli.py
  • tests/test_mcp_server.py
  • tests/test_models.py
  • tests/test_query_backed_models.py
✅ Files skipped from review due to trivial changes (3)
  • docs/interfaces/rest-api.md
  • docs/concepts/queries.md
  • tests/test_api_server.py
🚧 Files skipped from review as they are similar to previous changes (7)
  • .claude/skills/slayer-query.md
  • slayer/core/query.py
  • slayer/core/models.py
  • slayer/cli.py
  • tests/test_models.py
  • slayer/api/server.py
  • slayer/mcp/server.py

Comment thread slayer/engine/query_engine.py Outdated
Comment thread tests/test_mcp_server.py Outdated
ZmeiGorynych and others added 2 commits May 2, 2026 20:31
Addresses follow-up review on commit ed43392.

CodeRabbit (Major) — slayer/engine/query_engine.py:942: stored query-
backed join targets were resolved either from cached backing_query_sql
or from _query_as_model with outer_vars=target.query_variables, so a
target like {"filters": ["amount > {threshold}"]} was stuck on cached
or default values and ignored the enclosing query's runtime variables.

Fix: extracted _render_query_backed_join_target which merges
target.query_variables with the outer query's variables (outer wins) and
passes them as both outer_vars and runtime_kwarg to _query_as_model. The
cache fast-path is only taken when the enclosing query has no variables.
Added a class-level _join_target_resolving set on the engine to break
loops on cyclic schemas (the call stack crosses _enrich invocations, so
a closure-local set isn't sufficient).

CodeRabbit (Minor) — tests/test_mcp_server.py:375: hardened
test_json_show_sql_includes_backing_query_sql to assert the field is
present + non-empty + contains the column reference. The previous
"if parsed.get(...)" guard let regressions in the cache-refresh path
slip through.

SonarQube — slayer/engine/query_engine.py:901 (S3776 cognitive
complexity 21/15 on _enrich): NOSONAR-annotated with rationale (the
function orchestrates resolve-callback closures + cross-model
post-processing; splitting hides the closure variables threaded through
enrich_query). Marked Accept in SonarCloud.

New regression test:
tests/test_query_backed_models.py::TestJoinTargetIsQueryBacked::test_join_target_with_variables_uses_runtime_value

All 1129 unit tests pass; SQLite + Postgres + DuckDB + Jaffle Shop
integration suites (185 tests) green. Ruff clean.

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

🧹 Nitpick comments (1)
tests/test_query_backed_models.py (1)

309-310: ⚡ Quick win

Move these test imports to module scope.

This new file introduces several inline imports even though the repo standard keeps Python imports at the top of the module. Please hoist DataType, Column, ModelJoin, SlayerSQLClient, and slayer.engine.query_engine into the top-level import block.

As per coding guidelines, "**/*.py: Place imports at the top of files`."

Also applies to: 588-588, 633-633, 693-693, 727-727

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

In `@tests/test_query_backed_models.py` around lines 309 - 310, Several tests in
tests/test_query_backed_models.py perform inline imports (DataType, Column,
ModelJoin, SlayerSQLClient, and slayer.engine.query_engine) inside test
functions; move these imports to the module scope by hoisting the statements
into the file's top-level import block so they are imported once for the module
rather than inside individual tests, ensuring DataType, Column, ModelJoin,
SlayerSQLClient, and slayer.engine.query_engine are imported at the top
consistent with project import guidelines.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@slayer/engine/query_engine.py`:
- Around line 200-204: The instance-scoped set _join_target_resolving must not
be shared across concurrent requests because it causes cross-request re-entry
detection in _resolve_join_target/_enrich; change this to a request-scoped guard
(e.g., a ContextVar or an explicit set passed through the resolution call chain)
so each request has its own in-flight set; update usages of
_join_target_resolving in _resolve_join_target and any callers (including
_enrich) to read/modify the request-local set instead of the engine attribute,
and remove or deprecate the engine-level attribute to avoid accidental shared
access.
- Around line 864-905: The refresh path in _refresh_cache_after_resolution only
compares/updates columns and backing_query_sql, leaving stored_model.data_source
stale; update the early-return condition to also compare
stored_model.data_source with virtual.data_source and include "data_source":
virtual.data_source in the model_copy update so the persisted model's
data_source is overwritten when the resolved virtual model changes; touch the
same comparison/update logic in _refresh_cache_after_resolution (function names:
_refresh_cache_after_resolution, stored_model, virtual) to ensure
get_column_types() opens the correct backend.
- Around line 610-640: The cache-refresh path must treat non-default outer_vars
like runtime_kwarg to avoid request-specific SQL in backing_query_sql; modify
the branch inside the refresh_cache block so the condition currently checking
only runtime_kwarg also triggers when outer_vars is provided (i.e., if
runtime_kwarg or outer_vars), and when true re-render canonical via
_query_as_model with outer_vars=dict(model.query_variables) and
_resolving=set(), then call _refresh_cache_after_resolution(model, canonical) as
before; otherwise (no runtime_kwarg and no outer_vars) keep the existing
behavior of refreshing with virtual.

---

Nitpick comments:
In `@tests/test_query_backed_models.py`:
- Around line 309-310: Several tests in tests/test_query_backed_models.py
perform inline imports (DataType, Column, ModelJoin, SlayerSQLClient, and
slayer.engine.query_engine) inside test functions; move these imports to the
module scope by hoisting the statements into the file's top-level import block
so they are imported once for the module rather than inside individual tests,
ensuring DataType, Column, ModelJoin, SlayerSQLClient, and
slayer.engine.query_engine are imported at the top consistent with project
import guidelines.
🪄 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: 1303a01e-4e33-43bb-8fa5-1b8006c77d0f

📥 Commits

Reviewing files that changed from the base of the PR and between 62fe65b and 2e44024.

📒 Files selected for processing (3)
  • slayer/engine/query_engine.py
  • tests/test_mcp_server.py
  • tests/test_query_backed_models.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • tests/test_mcp_server.py

Comment thread slayer/engine/query_engine.py Outdated
Comment thread slayer/engine/query_engine.py
Comment thread slayer/engine/query_engine.py
…fresh)

Addresses 3 follow-up CodeRabbit findings on commit 2e44024.

CodeRabbit Critical — slayer/engine/query_engine.py:204 + 981-1005:
The recursion guard for query-backed join targets was an instance set
(self._join_target_resolving) shared across concurrent requests through
the same engine. Switched to a contextvars.ContextVar with a small
_get_join_target_resolving() helper that lazy-inits a fresh set on first
access in the current asyncio context — each task now gets its own
in-flight set automatically.

CodeRabbit Major — slayer/engine/query_engine.py:610-640:
The cache-refresh canonical re-render only triggered when runtime_kwarg
was truthy. When an enclosing SlayerQuery carried its own variables
(propagated via outer_vars), they could be persisted into
backing_query_sql, causing cache churn and per-request data leakage on
inspect/export. Condition now triggers whenever merged_outer differs
from model.query_variables OR runtime_kwarg is set.

CodeRabbit Major — slayer/engine/query_engine.py:864-905:
data_source was preserved from the stored model (model.data_source or
virtual.data_source), so editing a query-backed model so its final stage
now resolves through a different upstream datasource left the persisted
data_source stale — get_column_types() then opened the wrong client.
_validate_and_populate_cache now overwrites with virtual.data_source;
_refresh_cache_after_resolution adds data_source to both the change
detection and the model_copy update.

New regression tests:
- TestJoinTargetIsQueryBacked::test_join_target_resolving_set_is_per_context
- TestBackingQuerySQLCacheHygiene::test_outer_query_variables_do_not_leak_into_persisted_sql
- TestBackingQuerySQLCacheHygiene::test_data_source_refreshed_when_backing_query_changes

All 1132 unit tests + 185 integration tests pass. Ruff clean.

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

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@slayer/engine/query_engine.py`:
- Around line 323-332: The current logic only sets updates["dry_run"] and
updates["explain"] when the caller-provided dry_run/explain are truthy, which
prevents clearing persisted True flags; change the block that builds updates so
it unconditionally assigns the caller values (i.e., set updates["dry_run"] =
dry_run and updates["explain"] = explain) instead of guarding on truthiness,
then call main_query.model_copy(update=updates) as before; locate this change
around the variables merged, updates dict and the model_copy call in the
function where main_query is updated.
- Around line 535-554: In get_column_types, when you call self._resolve_model
for query-backed models (the model variable is replaced), recompute the
datasource/client from the resolved model before building the probe query and
choosing the dialect; specifically after the await
self._resolve_model(model_name=model_name) assignment, re-evaluate the
datasource (the variable previously used to call self._dialect_for_type and any
DB client selection) from the resolved model so that _build_type_probe_query,
_enrich, _dialect_for_type and SQLGenerator use the correct datasource for the
expanded virtual model.
🪄 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: 50c58c05-a0c5-424c-90dc-288ce95e552e

📥 Commits

Reviewing files that changed from the base of the PR and between 2e44024 and dcb8960.

📒 Files selected for processing (2)
  • slayer/engine/query_engine.py
  • tests/test_query_backed_models.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • tests/test_query_backed_models.py

Comment thread slayer/engine/query_engine.py
Comment thread slayer/engine/query_engine.py Outdated
…d model

CodeRabbit Major (PR #67 thread on commit dcb8960, line 554): when a
query-backed model has a stale or blank stored data_source,
get_column_types() picked the wrong backend (or returned {}) because
_resolve_datasource was called before _resolve_model expanded the model.

Fix:
- Hoist the source_queries expansion to run before datasource resolution
  and the SQL-client open. The datasource is then derived from the
  freshly-resolved virtual model.
- Move the probeable-columns short-circuit past the expansion too — it
  was bailing out on the stored model's empty columns before the engine
  even got to populate them from the inner query.

Filed #71 to cover the dry_run/explain-on-SlayerQuery design issue
flagged by the same review (deferring; root cause is schema-level).

New regression test:
- TestQueryBackedColumnTypes::test_get_column_types_uses_resolved_datasource
  saves a query-backed model via raw storage.save_model so its
  data_source is blank and columns are empty, then verifies the type
  probe runs against the right backend.

All 1133 unit tests + 185 integration tests pass. Ruff clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ZmeiGorynych and others added 2 commits May 3, 2026 09:47
…der fill)

Codex review of commit 73f69b0 surfaced two PR-scope issues; the other 4
findings are deferred to follow-up issues #71/#73/#74.

Minor — slayer/api/server.py:89: REST run-by-name disallowed-field check
forgot whole_periods_only. A request with `{"name": "qb", "whole_periods_only":
true}` was silently accepted and the flag was ignored. Added
request.whole_periods_only to the disallowed list.

Minor — slayer/engine/query_engine.py:528: get_column_types() expanded
query-backed models without dry_run_placeholders=True. Type probing has
no caller-supplied variables, so a model with a required-but-undefaulted
{var} placeholder failed at SQL-gen and the function returned {} —
inspect_model / type metadata silently disappeared for models that are
otherwise executable with runtime variables. Pass dry_run_placeholders=True
so the canonical placeholder fill (literal '0') applies, matching the
save-time validation path.

Deferred (filed as separate issues; see Codex review comments on PR):
- #71 — move dry_run/explain off SlayerQuery (root cause of the
  asymmetric-override bug; CodeRabbit + Codex both flagged it).
- #73 — resolution context refactor: variable precedence is re-derived
  ad-hoc at cross-model / rerooted / join-target call sites and each gets
  a different subset wrong (Codex findings 1+2+3).
- #74 — lost-update race in _refresh_cache_after_resolution: read-path
  side effect saves entire model copy, racing concurrent edits
  (Codex finding 4).

New regression tests:
- test_post_query_run_by_name_rejects_whole_periods_only
- test_get_column_types_with_required_unbound_variable

All 1135 unit tests + 185 integration tests pass. Ruff clean.

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

sonarqubecloud Bot commented May 3, 2026

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.

S2a: Query-backed models as first-class saved queries

1 participant