Skip to content

fix(core): emit all relationship JOINs for multi-relationship calculated columns#2450

Merged
goldmedal merged 1 commit into
Canner:mainfrom
fmcmac:fix/multi-rel-calc-col-joins
Jul 9, 2026
Merged

fix(core): emit all relationship JOINs for multi-relationship calculated columns#2450
goldmedal merged 1 commit into
Canner:mainfrom
fmcmac:fix/multi-rel-calc-col-joins

Conversation

@fmcmac

@fmcmac fmcmac commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Summary

A wren-core model with calculated columns spanning two or more distinct relationships fails to plan with No field named __relation__1.<col> — only the first relationship's JOIN is emitted. This surfaces whenever a narrow fact model joins to multiple dimensions and projects an attribute from each (e.g. a DC-inventory fact with market/retailer via a market-retailer relationship and item_num via a product relationship).

Root cause

Two interacting bugs:

  1. ModelPlanNodeBuilder::merge_graph (plan.rs) blindly added every node from each per-calc-col sub-graph into the directed graph without deduping. With calc cols spanning two relationships, the shared base model ended up as multiple disconnected nodes in the merged graph.
  2. RelationChain::with_chain (relation_chain.rs) walked the merged graph as a linear chain via find_edge(start, next), updating start = next each iteration. Combined with the duplicate base-model nodes, the walk hit a duplicate node as next, find_edge returned None, and the join chain was silently truncated after the first relationship.

Fix

  • merge_graph dedupes nodes by Dataset value (Dataset already derives Eq + Hash) and skips parallel edges between the same pair.
  • The fallback add_node for the base model in build() is now conditional on the model not already being present.
  • with_chain tracks prev separately from the original start and prefers find_edge(prev, next), falling back to find_edge(start, next) for star fan-out from the base model. Linear-chain behaviour is preserved (prev == start on the first step); star fan-out (A → B and A → C in one model) now works.

Test

Adds test_multiple_relationship_calculated_columns: a base model with two relationships and three calculated columns (two through one relationship, one through the other), asserting SELECT * transforms successfully and projects all three. Without the fix the test fails with FieldNotFound { __relation__1.item_num }.

The test runs on a dedicated 8 MiB-stack thread — the analyzer's recursion can exceed the 2 MiB default test-thread stack at several chained joins; production tokio worker threads have a larger stack and are unaffected (this is not infinite recursion).

Context

This is one of a small set of patches ZURU has carried on a wren-core fork; we are upstreaming them as we migrate our pin onto the current Canner/WrenAI monorepo. Companion to #2449 (CLS wildcard-prune).

Summary by CodeRabbit

  • Bug Fixes

    • Fixed planning for calculated columns when multiple relationships overlap, preventing missing fields and incorrect query truncation.
    • Improved graph merging so existing model nodes and relationships are reused instead of duplicated, reducing redundant joins.
    • Updated relation-chain handling to support both linear and fan-out relationship traversal.
  • Tests

    • Added regression coverage for models with multiple relationship-based calculated columns.

… cols

A model with calculated columns spanning two or more distinct
relationships failed to plan with "No field named __relation__1.<col>"
— only the first relationship's JOIN was emitted.

Two interacting causes:

1. `ModelPlanNodeBuilder::merge_graph` (plan.rs) blindly added every
   node from each per-calc-col sub-graph into the directed graph without
   deduping. With calc cols spanning two relationships, the base model
   ended up as multiple disconnected nodes in the merged graph.
2. `RelationChain::with_chain` (relation_chain.rs) walked the merged
   graph as a linear chain via `find_edge(start, next)`, updating
   `start = next` each iteration. Combined with the duplicate base-model
   nodes, the walk hit a duplicate as `next`, `find_edge` returned None,
   and the chain was silently truncated after the first relationship.

Fix:
- `merge_graph` dedupes nodes by `Dataset` value (Dataset derives
  Eq + Hash) and skips parallel edges between the same pair.
- The fallback `add_node` for the base model in `build()` is now
  conditional on the model not already being in the graph.
- `with_chain` tracks `prev` separately from the original `start` and
  prefers `find_edge(prev, next)`, falling back to `find_edge(start,
  next)` for star fan-out from the base model. Linear-chain behaviour is
  preserved; star fan-out (A -> B, A -> C in one model) now works.

Adds `test_multiple_relationship_calculated_columns`: a base model with
two relationships and three calc cols (two through one relationship, one
through the other) transforms successfully. Without the fix the test
fails with `FieldNotFound { __relation__1.item_num }`. The test runs on
a dedicated 8 MiB-stack thread — the analyzer's recursion can exceed the
2 MiB default test-thread stack at several chained joins; production
tokio workers have a larger stack and are unaffected.
@github-actions github-actions Bot added rust Pull requests that update rust code core labels Jul 8, 2026
@coderabbitai

coderabbitai Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Modifies model-plan graph construction to reuse existing base dataset nodes and avoid duplicate destination nodes/edges during graph merging. Updates RelationChain traversal to prefer edges from the previously visited node, falling back to the origin node, supporting star fan-out traversal. Adds a regression test covering multiple relationships with calculated columns.

Changes

Multi-relationship calculated column fix

Layer / File(s) Summary
Graph node/edge deduplication in model plan builder
core/wren-core/core/src/logical_plan/analyze/plan.rs
ModelPlanNodeBuilder::build reuses an existing base dataset node instead of always adding a new one; merge_graph builds a destination-node lookup and node map to reuse nodes and skips adding edges that already exist between the same source/target pair.
Relation chain traversal for star fan-out support
core/wren-core/core/src/logical_plan/analyze/relation_chain.rs
RelationChain::with_chain tracks a separate prev node, prefers an edge from prev to next, falls back to an edge from the original start node, and advances prev instead of start.
Regression test for multiple relationships with calculated columns
core/wren-core/core/src/mdl/mod.rs
Adds test_multiple_relationship_calculated_columns (run on a dedicated thread with an enlarged stack) and test_multiple_relationship_calculated_columns_inner, which builds a manifest with two relationships and three calculated columns and asserts the transformed SQL includes all expected projections.

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

Suggested reviewers: goldmedal

Poem

A rabbit hopped through graphs so tangled,
Where nodes once doubled, edges wrangled 🕸️
Now prev remembers where we've been,
Star and chain both link again,
One test to prove it, clean and true —
Hop hop hooray, the bug's undone! 🐇✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main fix: emitting all relationship JOINs for multi-relationship calculated columns.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 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/mdl/mod.rs (1)

2760-2775: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Substring assertions are largely redundant with the model/table name and don't add much verification beyond the .expect().

"market_retailer" (the relationship target's model/table name, which appears in the generated SQL regardless of whether the calculated columns are correctly projected) already contains both substrings "market" and "retailer", so those two assertions can pass even if the market/retailer calculated columns were dropped from the outer projection. Similarly, item_num is also a physical column name on the product model and will appear in inner subqueries independent of whether it correctly bubbles up through the calculated-column chain. The real regression protection here comes entirely from result.expect(...) not erroring; these three assert! calls add little additional signal.

Consider asserting on the qualified/aliased output form (e.g. "dc_inventory".market, or the final outer projection list) or switching to assert_snapshot! like the rest of this test module, so a future regression that drops a column from the final projection — while market_retailer/product still appear internally — would actually be caught.

Example tightened assertions
-        assert!(
-            transformed.contains("market"),
-            "transformed SQL missing 'market': {transformed}"
-        );
-        assert!(
-            transformed.contains("retailer"),
-            "transformed SQL missing 'retailer': {transformed}"
-        );
-        assert!(
-            transformed.contains("item_num"),
-            "transformed SQL missing 'item_num': {transformed}"
-        );
+        // Assert the outer projection (SELECT list before the first FROM)
+        // actually contains the calculated columns, not just that the
+        // related table/model names appear somewhere in the query.
+        let outer_select = transformed.split(" FROM ").next().unwrap_or_default();
+        for col in ["market", "retailer", "item_num"] {
+            assert!(
+                outer_select.contains(col),
+                "outer projection missing '{col}': {transformed}"
+            );
+        }
🤖 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/mdl/mod.rs` around lines 2760 - 2775, The current
assertions in the multi-relationship calc cols test are too weak because they
match substrings that also appear in model/table names or inner subqueries.
Update the test around the transformed SQL produced from the
`result.expect(...)` path to assert on the final outer projection form instead
of raw substrings, such as checking qualified/aliased calculated columns or
using a snapshot like the rest of `core/src/mdl/mod.rs`. This will ensure the
test fails if `market`, `retailer`, or `item_num` are dropped from the final
SELECT while still appearing elsewhere in the generated SQL.
🤖 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/mdl/mod.rs`:
- Around line 2760-2775: The current assertions in the multi-relationship calc
cols test are too weak because they match substrings that also appear in
model/table names or inner subqueries. Update the test around the transformed
SQL produced from the `result.expect(...)` path to assert on the final outer
projection form instead of raw substrings, such as checking qualified/aliased
calculated columns or using a snapshot like the rest of `core/src/mdl/mod.rs`.
This will ensure the test fails if `market`, `retailer`, or `item_num` are
dropped from the final SELECT while still appearing elsewhere in the generated
SQL.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: d3ee46a2-94ee-4fdb-9474-30d30a726ccf

📥 Commits

Reviewing files that changed from the base of the PR and between 7db2651 and 5b6231b.

📒 Files selected for processing (3)
  • core/wren-core/core/src/logical_plan/analyze/plan.rs
  • core/wren-core/core/src/logical_plan/analyze/relation_chain.rs
  • core/wren-core/core/src/mdl/mod.rs

// Production tokio worker threads have a larger stack and are
// unaffected; this is not infinite recursion.
std::thread::Builder::new()
.stack_size(8 * 1024 * 1024)

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.

Minor / non-blocking: consider following the repo test convention here instead of the bespoke thread.

Every other test in mdl/mod.rs is a plain #[tokio::test], and the suite is already run under RUST_MIN_STACK=8388608 (see wren-core/CLAUDE.md and the CI test step). So the hand-rolled std::thread::Builder::stack_size(8 MiB) + current-thread runtime shouldn't be necessary — a plain #[tokio::test] should get the enlarged stack from the env var like the rest of the tests, and drops ~20 lines of boilerplate.

Could you switch test_multiple_relationship_calculated_columns to a plain #[tokio::test] (inlining _inner) to stay consistent? If it turns out the enlarged stack genuinely isn't picked up in that context, keeping the explicit thread is fine — just leave the justification comment as-is.

@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.

Thanks @fmcmac, look great 👍. Because I'm planning to work on the crate publishing work, I'll merge this PR first. I filed the follow-up issue #2435 for the refactoring.
Nice work.

@goldmedal goldmedal merged commit c4de8ac into Canner:main Jul 9, 2026
17 checks passed
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.

2 participants