Skip to content

Add typed relation column paths - #70

Merged
alexeichhorn merged 22 commits into
mainfrom
feature/relation-paths
Sep 8, 2026
Merged

alexeichhorn merged 22 commits into
mainfrom
feature/relation-paths

Conversation

@alexeichhorn

@alexeichhorn alexeichhorn commented Sep 7, 2026

Copy link
Copy Markdown
Owner
  • Problem: Table-level column references cannot identify which relation a filter means when several foreign keys point to the same model.
  • Solution: Add typed BelongsTo column paths with automatic left joins. Reuse declared relation joins and eager-loading joins, preserve explicit inner/left semantics, and keep different relation paths distinct through nested queries and decoding.

Filter through a relation and load its model with the same join:

let records = Record::query()
    .filter(Record::owner.enabled.eq(true))
    .with(Record::owner.joined())
    .all(&db)
    .await?;

Reuse an explicit inner join and automatically add the nested organization join:

let records = Record::query()
    .join(Record::owner)
    .filter(Record::owner.organization.label.eq("north"))
    .order_by(dbkit::Order::asc(Record::owner.label))
    .all(&db)
    .await?;

Distinguish two relations to the same model:

let assignments = Assignment::query()
    .filter(Assignment::first.enabled.eq(true))
    .filter(Assignment::second.enabled.eq(false))
    .all(&db)
    .await?;

.with(...) controls loading independently of filtering. Related column expressions preserve outer-join nullability, and paths also work in projections, grouping, and ordering. Custom join_on(...) conditions retain their own joins and table references.


Note

Overview Adds typed BelongsTo column paths, enabling expressions such as Record::owner.organization.label in filters, projections, ordering, grouping, and aggregate predicates.

Join planning Automatically creates and reuses LEFT JOINs for referenced paths while coordinating explicit joins and eager loading. Nested, self-referential, same-target, custom-condition, and correlated subquery joins receive correctly scoped aliases.

Type safety Preserves column value types, operator constraints, and outer-join nullability throughout relation expressions. Related columns remain read-only and cannot be passed to mutation APIs such as .set(...).

Model generation Emits relation-path metadata for derived models, avoids ambiguous inverse #[has_many] inference when multiple foreign keys target the same model, and reserves the __dbkit_ field prefix for generated internals.

Comment thread crates/dbkit-core/src/compile.rs Outdated
Comment thread crates/dbkit-core/src/query.rs
Comment thread crates/dbkit-core/src/path.rs
Comment thread crates/dbkit-core/src/compile.rs Outdated
Comment thread crates/dbkit-core/src/compile.rs Outdated
Comment thread crates/dbkit-core/src/path.rs Outdated
Comment thread crates/dbkit-core/src/compile.rs Outdated
Comment thread crates/dbkit-core/src/path.rs
Comment thread crates/dbkit-core/src/path.rs Outdated
Comment thread crates/dbkit-core/src/compile.rs Outdated
Comment thread crates/dbkit-core/src/path.rs
Comment thread crates/dbkit-derive/src/lib.rs
Comment on lines +413 to +415
pub(crate) fn discover(&mut self, expr: &ExprNode) {
expr.visit_paths(&mut |path| self.require(path, None));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Outer typed paths are replanned inside correlated subqueries

discover unconditionally treats every relation path found in an expression as local to the query being compiled. For example, an outer Assignment::query().where_exists(Record::query().filter(Assignment::first.id.eq(Record::owner_id))) causes the inner Record query to add the Assignment::first join, producing an ON ... = path_assignments.first_id reference even though path_assignments is not in the inner FROM; the outer query also never discovers the path because Exists nodes are skipped. Thus typed outer-path correlations—especially the distinct sibling paths this PR is intended to support—generate invalid SQL instead of resolving against the enclosing path alias.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Not addressing in this PR. The reported unaliased example works in PostgreSQL; the failure was reproduced with an explicitly aliased outer query using a typed relation path. This is a limitation of the new typed-path API, not a demonstrated regression of an existing main-branch API, so we are deferring it.


// Keep mutation APIs accepting ordinary Column values. Deref reuses the column
// methods, while trait implementations preserve paths in functions and operators.
pub struct RelatedColumn<P, T>(Column<P, T>);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Relation columns cannot be used in composite row predicates

RelatedColumn is not accepted by the sealed RowColumns implementations, which only cover tuples of Column. Trait resolution does not dereference tuple elements, so a SELECT filter such as row((Record::owner.id, Record::owner.code)).in_(...) fails to compile even though both ordinary columns and relation paths are supported in filters.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Not addressing in this PR. Relation-column tuples are not yet supported by RowColumns, but ordinary-column tuples still work and their implementations are unchanged from main. This is a new-API support gap rather than an existing API regression, so we are deferring it.

@alexeichhorn
alexeichhorn marked this pull request as ready for review September 8, 2026 13:50

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit ce74378. Configure here.

return qualifier;
}
self.relation_alias(table).unwrap_or(table.qualifier())
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Aliased self-relations misbind base columns

Medium Severity

column_qualifier matches the base table with full Table equality, including alias, then falls back to a unique relation join on the same table. On an aliased self-relation query, unaliased model columns such as Node::id or Node::label compile against the parent join alias instead of the base row. Join ON keys avoid this by matching name and schema only, so filters and projections can silently use the parent row.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit ce74378. Configure here.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Deferring under the agreed regression-only scope. The reported parent-column binding reproduces, but on main an unaliased Node column likewise refers to the unaliased joined Node table rather than an explicitly aliased base table. There is no demonstrated loss of a previously supported base-column binding; explicitly aliased Column values identify the base row.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ce7437880a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +134 to +138
let (_, alias) = self
.relation_aliases
.iter()
.find(|(existing, _)| existing == path)
.expect("relation columns require a SELECT query containing their path");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reject relation paths in mutation predicates before compilation

When a related expression is passed to a mutation predicate, such as Record::update().set(Record::label, "x").filter(Record::owner.enabled.eq(true)).compile() or the equivalent delete, the public API type-checks because mutation filters accept Expr<Option<bool>>, but mutation builders never plan relation aliases. Compilation therefore reaches this expect and panics. Either reject related predicates at the mutation API boundary or compile them without requiring a SELECT join plan.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Deferring under the agreed regression-only scope. Both UPDATE and DELETE compilation panic with the new relation-path predicates, so the unsupported case is real. These typed relation-path expressions did not exist on main, and there is no demonstrated regression of an existing mutation API. Supporting or statically rejecting related mutation predicates is outside this PR’s current scope.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-08T13:59:30.755234Z ce74378 Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@alexeichhorn
alexeichhorn merged commit aeee877 into main Sep 8, 2026
6 checks passed
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.

1 participant