Repository navigation
#40 — feat(domain+adapters): engine pre-computes CTE-body shape facts as POD (Option C) - #52
Conversation
… as POD (Option C) Replaces the renderer's whitespace-tokenizing `is_simple_from_select` and `extract_table_leaf_refs` heuristics with engine-computed POD facts on `CteNode`. The CTE engine already parses each model's compiled SQL into a `sqlparser::ast::Query` to build the edge graph; this PR adds a small AST walk over each CTE body during that same single-parse pass to populate two new domain fields: - `CteNode::is_simple_from_shape: bool` — true when the body is a single `SELECT FROM <Table>` with no joins (the import-CTE shape). - `CteNode::body_leaf_table_refs: Vec<String>` — lowercased leaf table identifiers from any FROM/JOIN in the body. Both fields are `#[serde(default)]` for backward-compat. The renderer reads them via accessors and never holds a parser, never sees an AST, and never re-parses the slice. Domain stays POD-only by construction; the hexagonal layering invariant is strengthened, not violated. ## Empirical AC correction (issue body amended in place) Issue #40 cited `select 'from' as col from x` as a heuristic false positive. Empirical trace (`/tmp/heuristic_check.rs` in the worktree during /shape) showed both heuristic AND AST classify it as simple (correctly — it IS a single-source SELECT). The actual false positive is `select * from x, y` (comma cross-join): the heuristic counts one `from` keyword and says "simple"; AST identifies two source relations. Issue #40 body has been amended to reflect this. ## Net change - 4 files changed, +337 / -451 lines (-114 net) - `src/adapters/render.rs`: massive simplification. Removed `is_simple_from_select`, `extract_table_leaf_refs`, `is_from_or_join_keyword`, `extract_leaf_table_identifier`, `is_identifier_char`, `strip_sql_comments`, and helpers `copy_string_literal` / `skip_line_comment` / `skip_block_comment`. Removed ~30 unit tests on those functions. `classify_node_role` and `find_import_node_id` pass-2 now read POD accessors. - `src/adapters/cte_engine.rs`: added `compute_shape_facts`, `is_body_simple_from_select`, `collect_leaf_table_refs`, `collect_leaves_from_join_chain`, `push_leaf`. 6 new tests including the comma-cross-join false-positive case, JOIN body, schema-qualified refs, and terminal-node shape facts. - `src/domain/cte.rs`: 2 new POD fields, `with_shape_facts` builder, 2 accessors, 1 new test, 2 updated tests. - `ARCHITECTURE.md`: §1 paragraph noting the POD-pattern for AST-derived structural facts. ## Measurements - 293/293 nextest pass + 27 cucumber scenarios pass + 0 clippy warnings + cargo fmt clean + cargo deny ok - crap4rs PASS: worst function 8.0 (was 11.0 pre-PR). Both `src/adapters/render.rs` and `src/adapters/cte_engine.rs` stay comfortably under their strict-15 thresholds. - Snapshot tests unchanged on jaffle-shop fixtures (AST agrees with the heuristic on every CTE in the fixture — no comma cross-joins to expose the heuristic bug). ## Decision-record landing ADR amendment added to `ops/decisions/cute-dbt/adr-mvp-architecture.md` under ADR-1 ("Single-crate hexagonal layout with inward-dependency discipline"). The amendment records the build-phase precedent: "AST-derived structural facts flow through the domain as POD, not through trait, port, or AST reference." Future v0.2 sub-selectors follow the same pattern. ## Out of scope - CR-1 through CR-6 (already shipped in PR 11 + PR 12). - `--config` flag (#24), workflow hardening (#27), branch protection (#16) — PR 14/15/16. Closes #40 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…links) Three follow-up fixes to satisfy the pre-push battery (`clippy --all-targets --locked` and `cargo doc --document-private-items` with `RUSTDOCFLAGS=-D warnings`): - cte_engine.rs:1147 — remove unnecessary trailing comma in JOIN shape-facts test (clippy::unnecessary_trailing_comma). - cte_engine.rs:202 — drop intra-doc link to `NodeRole::Import` in `is_body_simple_from_select` docstring; `NodeRole` lives in the render layer and is not in scope in the engine module. Backticks preserved for code style. - render.rs:600 — switch import-CTE body-match doc reference to the full-path link form `[body_leaf_table_refs](crate::domain::CteNode::body_leaf_table_refs)`. `CteNode` is not imported in render.rs's main module (only in the test module); the full-path form resolves without adding an otherwise-unused import that would trip `unused_imports`. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
Warning Review limit reached
Your plan includes 1 review of capacity. Refill in 19 minutes and 24 seconds. Your organization has run out of usage credits. Purchase more in the billing tab. ⌛ How to resolve this issue?After more review capacity refills, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than trial, open-source, and free plans. In all cases, review capacity refills continuously over time. Please see our FAQ for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (4)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Code Review
This pull request refactors the extraction of structural facts from SQL CTEs, replacing whitespace-based heuristics in the rendering layer with a formal AST walk in the adapter layer. These facts, including whether a CTE is a simple import and its leaf table references, are now stored as POD fields on the CteNode domain object. Review feedback identified a compilation error in the AST traversal logic where a non-existent method was called on an identifier. Additionally, the reviewer provided guidance on maintaining semantic distinctions in the dependency graph when dealing with JOIN chains inside UNION arms.
| fn push_leaf(factor: &TableFactor, refs: &mut Vec<String>) { | ||
| match factor { | ||
| TableFactor::Table { name, .. } => { | ||
| if let Some(ident) = name.0.last().and_then(|p| p.as_ident()) { |
There was a problem hiding this comment.
The sqlparser::ast::ObjectName struct in version 0.62 is a tuple struct ObjectName(pub Vec<Ident>). The Ident struct itself does not have an as_ident() method. If name.0.last() returns Some(&Ident), you can access the value field directly. This call will likely cause a compilation error.
| if let Some(ident) = name.0.last().and_then(|p| p.as_ident()) { | |
| if let Some(ident) = name.0.last() { |
There was a problem hiding this comment.
WON'T FIX — Gemini's claim ("Ident does not have an as_ident() method, this will likely cause a compilation error") misreads the sqlparser 0.62 type structure.
Verified against the registry source (~/.cargo/registry/.../sqlparser-0.62.0/src/ast/mod.rs):
- Line 395:
pub struct ObjectName(pub Vec<ObjectNamePart>);— theVecelement isObjectNamePart, notIdent. - Line 419:
pub enum ObjectNamePart { ... } - Line 428:
impl ObjectNamePart { pub fn as_ident(&self) -> Option<&Ident> { ... } }— the canonical accessor.
So name.0.last() returns Option<&ObjectNamePart>, and .and_then(|p| p.as_ident()) resolves to Option<&Ident> — exactly the pattern used by the existing resolve_factor function in this same file (line 388 pre-PR / line 422 post-PR). Empirical confirmation: PR builds cleanly and all 293 nextest tests + 27 cucumber scenarios pass.
Applying the suggested rewrite if let Some(ident) = name.0.last() { would bind ident: &ObjectNamePart (an enum), then ident.value on the next line would fail to compile because ObjectNamePart has no value field — that lives on the Ident inside the as_ident() Option.
Logged in feedback_bot-review-suggestions-must-be-verified (this is the third PR in a row to validate that memory — character-by-character verification of bot patches is load-bearing, even on HIGH-priority findings).
| fn is_body_simple_from_select(body: &SetExpr) -> bool { | ||
| let SetExpr::Select(select) = body else { | ||
| return false; | ||
| }; | ||
| select.from.len() == 1 | ||
| && select.from[0].joins.is_empty() | ||
| && matches!(select.from[0].relation, TableFactor::Table { .. }) | ||
| } |
There was a problem hiding this comment.
This function correctly identifies the 'import-CTE' shape. While this aligns with the PR's stated goal of Option C (engine-computed facts), ensure that the implementation adheres to the rule that the base relation of a JOIN chain inside a UNION arm is classified as EdgeType::From (the access role) rather than inheriting the union type (which would classify it as Transform/set-operation). This maintains the semantic distinction required for the dependency graph.
References
- In a CTE dependency graph, the base relation of a JOIN chain inside a UNION arm should be classified as EdgeType::From (representing the access role) rather than inheriting the union type (representing the set-operation role). This maintains the semantic distinction between the access pattern and the set-operation role.
There was a problem hiding this comment.
Verified — no change needed. The concern (base relation of a JOIN chain inside a UNION arm must classify as EdgeType::From, not inherit the union type) applies to edge classification, which is unchanged by this PR.
The existing edges_from_join_chain at line 397+ already encodes the rule:
if table.joins.is_empty() {
// Plain FROM reference — use union context if present, else From.
let base_type = union_type.unwrap_or(EdgeType::From);
push_edge(&table.relation, consumer_idx, base_type, index, edges);
} else {
// JOIN chain: base gets From; each joined relation gets its type.
push_edge(&table.relation, consumer_idx, EdgeType::From, index, edges);
...
}The base relation gets From (not the union_type) whenever joins are present — exactly Gemini's rule. The module doc at lines 30–31 documents this: "The base relation of a JOIN chain inside a UNION arm gets From, not the union type — only join-free arm sources get the union type."
PR 12.5's is_body_simple_from_select is a top-level body shape classifier, not an edge classifier. It returns false for any body with joins (one of the three conjuncts is select.from[0].joins.is_empty()), so JOIN-inside-UNION patterns can't be misclassified as "simple-from-shape" regardless of the wrapping. The function and Gemini's concern are about orthogonal classifiers; no change here.
Summary
Replaces the renderer's whitespace-tokenizing
is_simple_from_selectand
extract_table_leaf_refsheuristics with engine-computed PODfacts on
CteNode(Option C from the /shape pass).The CTE engine already parses each model's compiled SQL into a
sqlparser::ast::Queryto build the edge graph; this PR adds a smallAST walk over each CTE body during that same single-parse pass to
populate two new domain fields:
CteNode::is_simple_from_shape: bool— true when the body is asingle
SELECT FROM <Table>with no joins (the import-CTE shape).CteNode::body_leaf_table_refs: Vec<String>— lowercased leaf tableidentifiers from any FROM/JOIN in the body.
Both fields are
#[serde(default)]for backward compatibility. Therenderer reads them via accessors and never holds a parser, never
sees an AST, and never re-parses the slice. Domain stays POD-only by
construction; the hexagonal layering invariant is strengthened, not
violated.
Empirical AC correction (issue body amended in place)
Issue #40 originally cited
select 'from' as col from xas aheuristic false positive. The /shape pass empirically traced this
against the current heuristic (
/tmp/heuristic_check.rsin theworktree, captured in
shape-pr-12.5-engine-api.md) and found thatboth the heuristic and the AST classify it as simple — correctly, it
is a single-source SELECT. The actual false positive is comma
cross-join:
Issue #40's body has been amended (
gh issue edit 40, 2026-05-24) toreflect this. The Gemini-2 review on PR #38 was directionally correct
("use the AST") but cited the wrong demonstration — preserved in
memory
feedback_bot-review-suggestions-must-be-verified.Net change
src/adapters/render.rssrc/adapters/cte_engine.rssrc/domain/cte.rsARCHITECTURE.mdNet code REMOVAL. The renderer loses
is_simple_from_select,extract_table_leaf_refs,is_from_or_join_keyword,extract_leaf_table_identifier,is_identifier_char,strip_sql_comments, and helperscopy_string_literal/skip_line_comment/skip_block_comment. ~30 unit tests on thosefunctions are also removed.
Measurements
cargo nextest run --all-targets --lockedcargo test --test bddcargo clippy --all-targets --locked -- -D warningscargo fmt --all --checkcargo deny checkcargo doc --document-private-items --locked(RUSTDOCFLAGS=-D warnings)crap4rs --coverage lcov.infoBoth
src/adapters/render.rsandsrc/adapters/cte_engine.rsstaycomfortably under their crap4rs strict-15 thresholds.
Decision record
ADR amendment added to
ops/decisions/cute-dbt/adr-mvp-architecture.mdunder ADR-1("Single-crate hexagonal layout with inward-dependency discipline").
The amendment records the build-phase precedent:
ARCHITECTURE.md §1carries the public-repo paragraph linking backto the rule.
Options considered (rejected in /shape)
awareness into the renderer (already on crap4rs strict-15); slice
is
name AS (...)form, requires string surgery beforeParser::parse_queryaccepts it.CteNodeexposes parsed AST. Explicit hex-layeringviolation (domain depends on
sqlparser); breaksSerialize + Deserializederive (Queryis notSerialize).&CteNode. Clean layering butsame double-parse + slice-wrapper surgery as A. Strictly dominated
by Option C.
Six-criterion rating (clean architecture, performant engineering,
idiomatic Rust, product vision, code quality, maintainability) had
Option C at 4.8/5 overall vs A at 2.2, B at 2.3, Hybrid at 3.2. Full
analysis in
shape-pr-12.5-engine-api.md.Test plan
cargo nextest run --all-targets --lockedcargo test --test bddcargo clippy --all-targets --locked -- -D warningscargo fmt --all --checkcargo deny checkRUSTDOCFLAGS="-D warnings" cargo doc --no-deps --document-private-items --lockedcrap4rs --coverage lcov.info(PASS, worst 8.0)Out of scope
--configflag (cli: --config flag + AnalysisConfig (deferred from PR 6) #24), workflow hardening (infra: harden CI workflow — scope GITHUB_TOKEN, pin actions to SHA, disable checkout credential persistence #27), branch protection(chore: branch protection on main — require CI after first green PR cycle #16) — PR 14/15/16.
Closes #40
🤖 Generated with Claude Code