Skip to content

refactor(core): avoid throwaway ModelPlanNode build for aliased model scans - #2612

Open
AmirF194 wants to merge 2 commits into
Canner:mainfrom
AmirF194:fix/2451-aliased-model-scan-throwaway-plan
Open

refactor(core): avoid throwaway ModelPlanNode build for aliased model scans#2612
AmirF194 wants to merge 2 commits into
Canner:mainfrom
AmirF194:fix/2451-aliased-model-scan-throwaway-plan

Conversation

@AmirF194

@AmirF194 AmirF194 commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Summary

For FROM customer AS e, the bottom-up ModelAnalyzeRule walk visits the bare
TableScan before its SubqueryAlias parent, so it looks up required columns
under the alias, finds none, and builds a full wildcard-expanded ModelPlanNode
that analyze_subquery_alias_model immediately discards and rebuilds correctly
once the alias is known. This skips that first, discarded build for the common
SubqueryAlias -> TableScan shape, via a pre-order rewriter pass that builds the
node once with the alias's real required columns.

No behavior change: same plan, same SQL output, one fewer RLAC/CLAC parse per
aliased model scan. refactor:, not fix:.

What failure does this repair?

N/A, no failure. This is a refactor:: it removes a wasted build, it does not
change the query result. PR #2449 already fixed the correctness bug the
throwaway build used to cause (wildcard-expansion access denial on unreferenced
CLS columns); that fix is untouched by this change, it lives in
ModelSourceNode::new (plan.rs), which this PR does not modify.

How is it tested?

  • New test test_aliased_model_scan_builds_model_plan_node_once
    (core/src/mdl/mod.rs) asserts, via a #[cfg(test)] call counter on
    build_model_plan_node, that an aliased model scan builds it once. Confirmed
    it fails on current main (counts 2) and passes with this change.
  • Full wren-semantic-core suite (148 tests, including sqllogictest's
    model.slt/type.slt/view.slt/tpch.slt) passes unchanged.
  • cargo check --all-targets, cargo clippy --all-targets --all-features -- -D warnings, and cargo fmt --check are all clean.
  • Not checked: performance impact was not benchmarked; the claim is one fewer
    build call, not a measured latency number.

Duplicate check

Searched open PRs (is:pr is:open) for ModelPlanNode, analyze_model, and
this issue number in the body: none touch model_anlayze.rs or reference
#2451. git log -G on the touched functions shows no prior attempt at this
specific change.

Fixes #2451

Summary by CodeRabbit

  • Bug Fixes

    • Improved query analysis for aliased models, including nested subqueries and joins.
    • Correctly resolves required fields for column selections and count(*) queries.
    • Preserved validation errors for unknown tables and prevented redundant processing.
    • Ensured transformed queries produce valid, executable SQL.
    • Improved pruning of unreferenced protected columns across supported query patterns.
  • Tests

    • Added regression coverage for aliased model selections, counts, joins, SQL execution, and transformed query results.

… scans

For a model scanned through a table alias (FROM customer AS e), the
bottom-up ModelAnalyzeRule walk visits the bare TableScan before its
SubqueryAlias parent. Required columns are keyed by the alias, so this
first pass finds none and builds a ModelPlanNode via the wildcard-
expansion path; analyze_subquery_alias_model then discards it and
rebuilds the node correctly once the alias is known. The first build
runs the full RLAC/CLAC parsing for nothing.

PR Canner#2449 already fixed the correctness bug this caused (the throwaway
build was denying access to columns under column-level security instead
of pruning them); the wasted build itself remained.

This adds a pre-order TreeNodeRewriter pass that recognizes
SubqueryAlias -> TableScan for a model directly and builds the
ModelPlanNode once, with the alias's real required columns, skipping
the discarded intermediate build. Everything else keeps using the
original bottom-up analyze_model_internal pass.

Fixes Canner#2451
@github-actions github-actions Bot added rust Pull requests that update rust code core labels Jul 28, 2026
@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: d0e6d5a9-f86f-4f19-894b-2674fc8ad8b7

📥 Commits

Reviewing files that changed from the base of the PR and between 3b33796 and fd2665d.

📒 Files selected for processing (2)
  • core/wren-core/core/src/logical_plan/analyze/model_anlayze.rs
  • core/wren-core/core/src/mdl/mod.rs

Walkthrough

Model analysis now uses a pre-order tree rewriter to shortcut aliased model scans, resolve required fields through one helper, construct ModelPlanNode once, and recursively analyze subqueries. Tests cover aliased columns, count(*), joins, executable SQL, snapshots, and CLS pruning.

Changes

Model scan rewrite

Layer / File(s) Summary
Pre-order model scan shortcut
core/wren-core/core/src/logical_plan/analyze/model_anlayze.rs
ModelRewriter handles aliased table scans before child analysis, builds scoped ModelPlanNode extensions, skips redundant processing, and recursively analyzes nested subqueries.
Shared required-field resolution
core/wren-core/core/src/logical_plan/analyze/model_anlayze.rs
Table-scan and nested model-alias analysis use shared resolution for recorded fields, visited-only tables, and unknown-table errors.
Regression coverage
core/wren-core/core/src/logical_plan/analyze/model_anlayze.rs, core/wren-core/core/src/mdl/mod.rs
Test instrumentation counts model-node construction. Tests verify aliased column scans, count(*), joins, executable SQL, snapshots, and CLS pruning coverage.

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

Sequence Diagram(s)

sequenceDiagram
  participant LogicalPlan
  participant ModelRewriter
  participant RequiredFieldResolver
  participant ModelPlanNode
  participant RegressionTests
  LogicalPlan->>ModelRewriter: rewrite model plan
  ModelRewriter->>RequiredFieldResolver: resolve aliased required fields
  RequiredFieldResolver-->>ModelRewriter: return fields or empty visited-only set
  ModelRewriter->>ModelPlanNode: build aliased model node
  ModelPlanNode-->>ModelRewriter: return rewritten plan
  RegressionTests->>ModelRewriter: analyze column, count(*), or join query
  ModelRewriter-->>RegressionTests: return executable transformed SQL
Loading

Possibly related PRs

  • Canner/WrenAI#2335: Refactors the same model-analysis and model-plan construction paths.
  • Canner/WrenAI#2449: Covers aliased and count(*) model scans and CLS column pruning.
  • Canner/WrenAI#2619: Modifies the same model-analysis rewrite paths for a different state-management concern.

Suggested reviewers: goldmedal

Poem

A rabbit rewrites the model scan,
With one neat node where two began.
Aliases guide each field,
Count-star paths stay sealed,
Joins pass SQL checks as planned.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes avoiding the unnecessary ModelPlanNode build for aliased model scans.
Description check ✅ Passed The description includes all required sections and explains the refactor, testing, non-goals, and duplicate check.
Linked Issues check ✅ Passed The changes satisfy issue #2451 by removing the throwaway build while preserving behavior, wildcard pruning, and access-control correctness.
Out of Scope Changes check ✅ Passed The code and tests remain within the linked issue scope of optimizing aliased model scan analysis.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

❤️ Share

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
core/wren-core/core/src/logical_plan/analyze/model_anlayze.rs (1)

648-717: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Required-fields resolution logic duplicated a third time.

The try_get_required_columns / try_get_visited_dataset fallback / internal_err! block here (Line 692-709) is now duplicated almost verbatim in analyze_table_scan (Line 735-752) and analyze_subquery_alias_model (Line 794-810). Consider extracting a shared helper (e.g. fn resolve_required_fields(&self, scope_manager, current_scope_id, table_ref) -> Result<Vec<Expr>>) to avoid a third copy of this logic drifting out of sync.

♻️ Sketch of a shared helper
fn resolve_required_fields(
    &self,
    scope_manager: &ScopeManager,
    current_scope_id: ScopeId,
    table_ref: &TableReference,
) -> Result<Vec<Expr>> {
    if let Some(used_columns) =
        scope_manager.try_get_required_columns(current_scope_id, table_ref)
    {
        Ok(used_columns.iter().cloned().collect())
    } else if scope_manager
        .try_get_visited_dataset(current_scope_id, table_ref)
        .is_some()
    {
        Ok(vec![])
    } else {
        internal_err!(
            "Table {} not found in the visited dataset and required columns map",
            table_ref
        )
    }
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@core/wren-core/core/src/logical_plan/analyze/model_anlayze.rs` around lines
648 - 717, Extract the duplicated required-fields resolution from
shortcut_aliased_table_scan, analyze_table_scan, and
analyze_subquery_alias_model into a shared resolve_required_fields helper using
the existing ScopeManager lookups and internal_err! fallback. Replace each
inline block with calls to the helper, passing the appropriate scope, scope ID,
and table reference while preserving the current Vec<Expr> results and error
behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@core/wren-core/core/src/logical_plan/analyze/model_anlayze.rs`:
- Around line 648-717: Extract the duplicated required-fields resolution from
shortcut_aliased_table_scan, analyze_table_scan, and
analyze_subquery_alias_model into a shared resolve_required_fields helper using
the existing ScopeManager lookups and internal_err! fallback. Replace each
inline block with calls to the helper, passing the appropriate scope, scope ID,
and table reference while preserving the current Vec<Expr> results and error
behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 1b1c6b52-1854-4f02-8358-ea07c25ff9d2

📥 Commits

Reviewing files that changed from the base of the PR and between 094d84d and 3b33796.

📒 Files selected for processing (2)
  • core/wren-core/core/src/logical_plan/analyze/model_anlayze.rs
  • core/wren-core/core/src/mdl/mod.rs

@goldmedal goldmedal left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Sound change — verified no behavior change and the build-count win holds. I diffed generated SQL across 19 plan shapes against b590b650 (identical), instrumented build counts (halved for aliased scans, never increased), and ran all CI gates locally including core-py-ci. Comments below are cleanups, none blocking.

Please address in this PR

1. test_clac_unreferenced_column_pruned_not_denied — update the stale comment. It documents the exact mechanism this PR deletes:

"even when the model is referenced with a table alias (which builds a throwaway inner plan with empty required fields, so the model gets wildcard-expanded)"

After this change the aliased-select half no longer wildcard-expands — only the count(*) half does. This is the PR that invalidates the comment, and it's the #2449 guard, so it shouldn't be left describing gone behavior.

2. Add a snapshot assertion to the new test. assert_sql_valid_executable only proves the SQL parses and runs — it would pass on a plan that silently pruned or added a column, which is the actual risk being claimed against. The file uses assert_snapshot! throughout; one here would make the test assert the no-behavior-change claim rather than just the call count.

3. Cover the two branches the new test misses. Both are one-liners with the existing helpers:

  • count(*) on an aliased model — the only path reaching the visited_dataset fallback (field = vec![]), and the only remaining wildcard-expansion path for aliased scans.
  • A join of two aliased models — the only thing exercising shortcut_taken across siblings.

Optional

4. Drop the needless SubqueryAlias rebuild on non-matching paths. The three early returns do Transformed::no(SubqueryAlias::try_new(input, alias)?), so every non-model SubqueryAlias in every plan pays a schema recompute in f_down to hand back an unchanged node. Match on &plan first:

let LogicalPlan::SubqueryAlias(SubqueryAlias { input, alias, .. }) = &plan else {
    return Ok(Transformed::no(plan));
};
let LogicalPlan::TableScan(scan) = input.as_ref() else {
    return Ok(Transformed::no(plan));
};
if !belong_to_mdl(&self.analyzed_wren_mdl.wren_mdl(), scan.table_name.clone(),
                  Arc::clone(&self.session_state)) {
    return Ok(Transformed::no(plan));
}
let Some(model) = self.analyzed_wren_mdl.wren_mdl.get_model(scan.table_name.table()) else {
    return Ok(Transformed::no(plan));
};

original_table_scan is None, so input never needs consuming — this also removes the unreachable!() and the Arc::unwrap_or_clone.

5. Comment the invariant shortcut_taken depends on. It's correct only because Jump from f_down still runs f_up for that node (transform_children rewrites JumpContinue, then transform_parent calls f_up). I confirmed this holds in the pinned DataFusion, but if a bump changed it the failure is silent — a parent's analyze_model_internal gets skipped. One line on the field would make the coupling explicit.

6. Extract the block duplicated from analyze_subquery_alias_model. The required-columns lookup + visited_dataset fallback + build + alias-wrap is copied verbatim, including the internal_err! text. A helper over (model, alias, scope_manager, current_scope_id) would stop the two drifting.

7. Nit. The counter's doc comment explains per-test threads, but the real dependency is #[tokio::test]'s current-thread runtime — under flavor = "multi_thread" the increments land elsewhere and the assert reads 0.

- Update the stale comment on test_clac_unreferenced_column_pruned_not_denied:
  it described the throwaway-plan mechanism this PR deletes.
- Add snapshot assertions (assert_snapshot!) to the new test so it asserts the
  no-behavior-change claim, not just the build-call count.
- Cover the two branches the new test missed: count(*) on an aliased model
  (the visited_dataset fallback) and a join of two aliased models (shortcut_taken
  across siblings).
- Drop the needless SubqueryAlias rebuild on non-matching paths in
  shortcut_aliased_table_scan; match on &plan first and return the original
  Transformed::no(plan) on every early exit.
- Comment the Jump/f_up invariant shortcut_taken depends on.
- Extract the required-columns/visited_dataset-fallback lookup duplicated
  across shortcut_aliased_table_scan, analyze_table_scan and
  analyze_subquery_alias_model into resolve_required_fields.
- Fix the counter's doc comment: the real dependency is #[tokio::test]'s
  current-thread runtime, not "one thread per test".

Addresses goldmedal's review on PR Canner#2612.
@AmirF194

AmirF194 commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review, especially catching the stale comment and the missing branches. Pushed fd2665d2 addressing all three required items plus the optional cleanups.

1. Stale comment. Updated test_clac_unreferenced_column_pruned_not_denied in core/wren-core/core/src/mdl/mod.rs to describe pruning across scan shapes generically instead of naming the throwaway-plan mechanism this PR removes.

2. Snapshot assertion. Added assert_snapshot!(actual, ...) to the existing aliased-scan case in test_aliased_model_scan_builds_model_plan_node_once (core/wren-core/core/src/mdl/mod.rs), so the test now asserts the generated SQL shape, not just the build-call count.

3. Missing branches. Added the two cases to the same test: count(*) on an aliased model (asserts one build via the visited_dataset fallback) and a join of two aliased models (asserts two builds, exercising shortcut_taken across siblings). Both carry assert_sql_valid_executable and assert_snapshot!.

4. SubqueryAlias rebuild. Applied your replacement in shortcut_aliased_table_scan (model_anlayze.rs): matches on &plan first, returns Transformed::no(plan) on every early exit, and the unreachable!() / Arc::unwrap_or_clone are gone. Since original_table_scan stays None here, input is never consumed, so I clone just alias before building the field lookup rather than re-taking ownership of plan.

5. shortcut_taken invariant. Added a comment on the field in ModelRewriter naming the Jump-still-runs-f_up dependency.

6. Extracted the duplicated block. Pulled the required-columns/visited_dataset-fallback lookup into resolve_required_fields(scope_manager, current_scope_id, table_ref), used by shortcut_aliased_table_scan, analyze_table_scan, and analyze_subquery_alias_model.

7. Counter doc comment. Fixed to name #[tokio::test]'s current-thread runtime as the real reason, not "one thread per test".

Re-verified after all changes: cargo test --lib --tests --bins in the pinned rust:1-bookworm container (148/148 passed, same count as before since the new assertions live inside the existing test function), cargo clippy --all-targets --all-features -- -D warnings clean, cargo fmt --check clean.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

core rust Pull requests that update rust code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

refactor(core): avoid throwaway ModelPlanNode build for aliased model scans

2 participants