Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
40fcb6d
Test relation column paths
alexeichhorn Sep 7, 2026
6d84cc8
Implement relation column paths
alexeichhorn Sep 7, 2026
6a39445
Test correlated relation column paths
alexeichhorn Sep 7, 2026
fee754e
Fix correlated relation column scopes
alexeichhorn Sep 7, 2026
7b394bc
Test automatic join lock scoping
alexeichhorn Sep 7, 2026
0d5fa9d
Fix automatic join lock scoping
alexeichhorn Sep 7, 2026
7adda76
Test aliased self-relation joins
alexeichhorn Sep 7, 2026
8a8e851
Fix aliased self-relation joins
alexeichhorn Sep 7, 2026
9813cb2
Test correlations to outer relation joins
alexeichhorn Sep 7, 2026
b6853f5
Fix correlations to outer relation joins
alexeichhorn Sep 7, 2026
b7a0f68
Test custom joins with dynamic relation columns
alexeichhorn Sep 7, 2026
afc63e2
Fix custom joins with dynamic relation columns
alexeichhorn Sep 7, 2026
796dd12
Test nearer relation bindings in nested subqueries
alexeichhorn Sep 7, 2026
2c7d1f7
Fix nearer relation bindings in nested subqueries
alexeichhorn Sep 7, 2026
421eb51
Fix relation join ordering for custom joins
alexeichhorn Sep 7, 2026
e412e07
Test scalar operators with relation columns
alexeichhorn Sep 7, 2026
d54db77
Support scalar operators with relation columns
alexeichhorn Sep 7, 2026
7caf7fc
Test ambiguous relation scope bindings
alexeichhorn Sep 8, 2026
18e88f5
Fix ambiguous relation scope bindings
alexeichhorn Sep 8, 2026
66aa68a
Test correlations in join-condition subqueries
alexeichhorn Sep 8, 2026
301377c
Fix correlations in join-condition subqueries
alexeichhorn Sep 8, 2026
ce74378
Reserve internal model field names
alexeichhorn Sep 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 144 additions & 2 deletions crates/dbkit-core/src/compile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,36 @@ pub struct CompiledSql {
pub binds: Vec<Value>,
}

/// Enclosing table bindings and SQL qualifiers visible to a subquery.
#[derive(Debug, Default, Clone)]
pub(crate) struct QueryScope {
bindings: Vec<(crate::Table, String)>,
pub(crate) qualifiers: Vec<String>,
}

#[derive(Debug, Default)]
pub struct SqlBuilder {
sql: String,
binds: Vec<Value>,
base_table: Option<crate::Table>,
relation_aliases: Vec<(Vec<crate::Relation>, String)>,
declared_tables: Vec<crate::Table>,
outer_scope: QueryScope,
referenced_qualifiers: Vec<String>,
}

impl SqlBuilder {
pub fn new() -> Self {
Self::default()
}

pub(crate) fn for_table(table: crate::Table) -> Self {
Self {
base_table: Some(table),
..Self::default()
}
}

pub fn push_sql(&mut self, fragment: &str) {
self.sql.push_str(fragment);
}
Expand Down Expand Up @@ -55,8 +74,130 @@ impl SqlBuilder {
}
}

pub(crate) fn for_query(
base: crate::Table,
relation_aliases: Vec<(Vec<crate::Relation>, String)>,
declared_tables: Vec<crate::Table>,
outer_scope: QueryScope,
) -> Self {
Self {
base_table: Some(base),
relation_aliases,
declared_tables,
outer_scope,
..Self::default()
}
}

pub fn push_column(&mut self, col: ColumnRef) {
self.sql.push_str(&col.qualified_name());
if let Some(path) = col.path {
self.push_related_column(col, &path.steps());
return;
}
let qualifier = self.column_qualifier(col.table).to_owned();
self.sql.push_str(&qualifier);
self.sql.push('.');
self.sql.push_str(col.name);
self.referenced_qualifiers.push(qualifier);
}

fn column_qualifier(&self, table: crate::Table) -> &str {
if Some(table) == self.base_table || self.declared_tables.contains(&table) {
return table.qualifier();
}
// Enclosing bindings precede the legacy shorthand for a local relation
// target. Explicit paths always identify local joins.
if let Some((_, qualifier)) = self.outer_scope.bindings.iter().rev().find(|(bound, _)| *bound == table) {
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.


fn relation_alias(&self, table: crate::Table) -> Option<&str> {
let mut matches = self
.relation_aliases
.iter()
.filter(|(path, _)| path.last().is_some_and(|rel| rel.join_table() == table));
matches.next().filter(|_| matches.next().is_none()).map(|(_, alias)| alias.as_str())
}

pub fn push_related_column(&mut self, col: ColumnRef, path: &[crate::Relation]) {
if path.is_empty() {
let table = self
.base_table
.filter(|base| base.name == col.table.name && base.schema == col.table.schema)
.unwrap_or(col.table);
self.sql.push_str(&ColumnRef { table, ..col }.qualified_name());
self.referenced_qualifiers.push(table.qualifier().to_owned());
return;
}
let (_, alias) = self
.relation_aliases
.iter()
.find(|(existing, _)| existing == path)
.expect("relation columns require a SELECT query containing their path");
Comment on lines +134 to +138

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.

self.sql.push_str(alias);
self.sql.push('.');
self.sql.push_str(col.name);
self.referenced_qualifiers.push(alias.clone());
}

pub(crate) fn compile_expression(&self, expr: &ExprNode) -> Self {
let mut builder = Self {
base_table: self.base_table,
relation_aliases: self.relation_aliases.clone(),
declared_tables: self.declared_tables.clone(),
outer_scope: self.outer_scope.clone(),
..Self::default()
};
expr.to_sql(&mut builder);
builder
}

pub(crate) fn references_qualifier(&self, qualifier: &str) -> bool {
self.referenced_qualifiers.iter().any(|reference| reference == qualifier)
}

pub(crate) fn push_expression(&mut self, mut expression: Self) {
self.referenced_qualifiers.append(&mut expression.referenced_qualifiers);
self.push_compiled_sql(&expression.finish());
}

fn push_subquery(&mut self, subquery: &crate::query::Select<()>) {
let mut scope = self.outer_scope.clone();
let local_start = scope.bindings.len();
for table in self.base_table.iter().chain(&self.declared_tables) {
scope.bindings.push((*table, table.qualifier().to_owned()));
scope.qualifiers.push(table.qualifier().to_owned());
}
for (path, alias) in &self.relation_aliases {
let table = path.last().expect("relation joins have a nonempty path").join_table();
// Only unique relation targets shadow farther bindings in a child scope.
if !scope.bindings[local_start..].iter().any(|(bound, _)| *bound == table) {
if let Some(qualifier) = self.relation_alias(table) {
scope.bindings.push((table, qualifier.to_owned()));
}
}
scope.qualifiers.push(alias.clone());
}
let subquery = subquery.compile_for_exists(scope);
// Only correlations escape a subquery. Its own tables and aliases may
// shadow enclosing names and cannot create dependencies in the parent.
self.referenced_qualifiers.extend(
subquery
.referenced_qualifiers
.iter()
.filter(|qualifier| {
!subquery
.base_table
.iter()
.chain(&subquery.declared_tables)
.any(|table| table.qualifier() == *qualifier)
&& !subquery.relation_aliases.iter().any(|(_, alias)| alias == *qualifier)
})
.cloned(),
);
self.push_compiled_sql(&subquery.finish());
}

pub fn push_compiled_sql(&mut self, compiled: &CompiledSql) {
Expand Down Expand Up @@ -130,6 +271,7 @@ impl ToSql for ExprNode {
fn to_sql(&self, builder: &mut SqlBuilder) {
match self {
ExprNode::Column(col) => builder.push_column(*col),
ExprNode::RelatedColumn { column, path } => builder.push_related_column(*column, path),
ExprNode::Value(value) => builder.push_value(value.clone()),
ExprNode::Row { values } => {
builder.push_sql("(");
Expand Down Expand Up @@ -347,7 +489,7 @@ impl ToSql for ExprNode {
}
ExprNode::Exists { subquery } => {
builder.push_sql("EXISTS (");
builder.push_compiled_sql(subquery);
builder.push_subquery(subquery);
builder.push_sql(")");
}
}
Expand Down
7 changes: 5 additions & 2 deletions crates/dbkit-core/src/expr.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
use std::marker::PhantomData;
use std::ops::{Add, BitAnd, BitOr, BitXor, Div, Mul, Not, Shl, Shr, Sub};

use crate::compile::CompiledSql;
use crate::func::{StringBinaryExpr, StringUnaryExpr};
use crate::schema::{Column, ColumnRef};
use crate::types::{PgInterval, PgVector};
Expand Down Expand Up @@ -284,6 +283,10 @@ pub enum TrimDirection {
#[derive(Debug, Clone)]
pub enum ExprNode {
Column(ColumnRef),
RelatedColumn {
column: ColumnRef,
path: Vec<crate::rel::Relation>,
},
Value(Value),
Row {
values: Vec<ExprNode>,
Expand Down Expand Up @@ -350,7 +353,7 @@ pub enum ExprNode {
case_insensitive: bool,
},
Exists {
subquery: CompiledSql,
subquery: Box<crate::query::Select<()>>,
},
}

Expand Down
10 changes: 4 additions & 6 deletions crates/dbkit-core/src/func.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
use bitflags::bitflags;

use crate::compile::CompiledSql;
use crate::expr::{AggregateExpr, Expr, ExprNode, ExprOperand, IntoExpr, NumericExprType, TrimDirection, Value, VectorBinaryOp};
use crate::query::Select;
use crate::PgVector;
Expand Down Expand Up @@ -987,12 +986,11 @@ pub fn date_trunc<T>(part: impl IntoExpr<String>, value: impl IntoExpr<T>) -> Ex
})
}

fn exists_expr(subquery: CompiledSql) -> Expr<bool> {
Expr::new(ExprNode::Exists { subquery })
}

pub fn exists<Out, Loads, Lock, DistinctState, GroupState>(subquery: Select<Out, Loads, Lock, DistinctState, GroupState>) -> Expr<bool> {
exists_expr(subquery.compile_for_exists())
// Keep the query tree until compilation can see its enclosing SQL scopes.
Expr::new(ExprNode::Exists {
subquery: Box::new(subquery.into_subquery()),
})
}

/// Marker trait for values that can participate in vector distance/similarity expressions.
Expand Down
2 changes: 2 additions & 0 deletions crates/dbkit-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ pub mod func;
pub mod interval;
pub mod load;
pub mod mutation;
#[doc(hidden)]
pub mod path;
pub mod query;
pub mod rel;
pub mod schema;
Expand Down
4 changes: 2 additions & 2 deletions crates/dbkit-core/src/mutation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -394,7 +394,7 @@ impl<Out> Update<Out> {
}

pub fn compile(&self) -> CompiledSql {
let mut builder = SqlBuilder::new();
let mut builder = SqlBuilder::for_table(self.table);
builder.push_sql("UPDATE ");
builder.push_sql(&self.table.qualified_name());
builder.push_sql(" SET ");
Expand Down Expand Up @@ -485,7 +485,7 @@ impl Delete {
}

pub fn compile(&self) -> CompiledSql {
let mut builder = SqlBuilder::new();
let mut builder = SqlBuilder::for_table(self.table);
builder.push_sql("DELETE FROM ");
builder.push_sql(&self.table.qualified_name());
if !self.filters.is_empty() {
Expand Down
Loading
Loading