Skip to content

Commit cc815c1

Browse files
committed
readyset-sql-passes: Fix schema resolution in JOIN ON conditions
When an unqualified table reference appears in a JOIN ON condition, it should resolve to the schema of the table being joined rather than defaulting to the first matching table in the search path. Previously, with a query like: SELECT d.id FROM s2.t1 d LEFT JOIN s2.t2 ON d.id = t2.d_id And a search path of [s1, s2], the unqualified "t2" in the ON clause would incorrectly resolve to s1.t2 (first in search path) instead of s2.t2 (the table actually being joined). This patch tracks schemas from explicitly-qualified tables in the JOIN context and checks this context before falling back to search path resolution. This ensures unqualified table references in JOIN ON clauses correctly bind to the tables being joined, matching SQL semantics. AI-Use: level:3 Release-Note-Core: Fixed incorrect schema resolution for unqualified tables in JOIN ON clauses when multiple schemas contain tables with the same name. Change-Id: I180c27b9f3eec6096ce4ea14c959ab51d334bf0a Reviewed-on: https://gerrit.readyset.name/c/readyset/+/11239 Tested-by: Buildkite CI Reviewed-by: Vassili Zarouba <vassili@readyset.io>
1 parent 2979527 commit cc815c1

File tree

1 file changed

+82
-1
lines changed

1 file changed

+82
-1
lines changed

readyset-sql-passes/src/resolve_schemas.rs

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ use readyset_sql::ast::{
1010
CreateTableStatement, JoinRightSide, Relation, SelectStatement, SqlIdentifier, SqlType,
1111
TableExpr, TableExprInner,
1212
};
13-
use std::collections::HashSet;
13+
use std::collections::{HashMap, HashSet};
1414
use std::iter;
1515

1616
pub trait ResolveSchemasContext {
@@ -70,6 +70,16 @@ struct ResolveSchemaVisitor<R: ResolveSchemasContext> {
7070
/// Each element of this `Vec` is a level of subquery nesting, which can be `pop()`ed after
7171
/// walking through a query.
7272
alias_stack: Vec<HashSet<SqlIdentifier>>,
73+
74+
/// Stack of schemas from tables in the current JOIN context.
75+
///
76+
/// When resolving tables in JOIN ON conditions, we should prefer tables from the schemas
77+
/// of the tables being joined over the general search path. This avoids incorrectly resolving
78+
/// to tables with the same name in earlier schemas in the search path.
79+
///
80+
/// Each element maps a table name to its schema, tracking tables visible in the current
81+
/// JOIN ON clause context.
82+
join_context_schemas: Vec<HashMap<SqlIdentifier, SqlIdentifier>>,
7383
}
7484

7585
impl<R> ResolveSchemaVisitor<R>
@@ -82,7 +92,47 @@ where
8292
.any(|frame| frame.contains(&table.name))
8393
}
8494

95+
/// Collect table names and their schemas from a TableExpr if it's a direct table reference
96+
/// with an explicit schema. This is used to track JOIN context for proper resolution.
97+
fn collect_table_schemas_from_expr(
98+
&self,
99+
table_expr: &TableExpr,
100+
schemas_map: &mut HashMap<SqlIdentifier, SqlIdentifier>,
101+
) {
102+
if let TableExprInner::Table(relation) = &table_expr.inner
103+
&& let Some(ref schema) = relation.schema
104+
{
105+
schemas_map.insert(relation.name.clone(), schema.clone());
106+
}
107+
}
108+
85109
fn resolve_schema(&mut self, table: &mut Relation) -> Result<(), ReadySetError> {
110+
// First, check if this table appears in the current JOIN context with an explicit schema.
111+
// This ensures that unqualified table references in JOIN ON conditions resolve to the
112+
// tables being joined, rather than to tables with the same name in earlier search path
113+
// schemas.
114+
for context_map in self.join_context_schemas.iter().rev() {
115+
if let Some(schema) = context_map.get(&table.name) {
116+
// Verify the table exists in this schema
117+
match self.context.can_query_table(schema, &table.name) {
118+
Some(CanQuery::Yes) => {
119+
table.schema = Some(schema.clone());
120+
return Ok(());
121+
}
122+
Some(CanQuery::No) => {
123+
return Err(ReadySetError::TableNotReplicated {
124+
name: table.name.clone().into(),
125+
schema: Some(schema.into()),
126+
});
127+
}
128+
None => {
129+
// Fall through to search path if table doesn't exist in this schema
130+
}
131+
}
132+
}
133+
}
134+
135+
// Fall back to search path resolution
86136
for schema in self.context.search_path() {
87137
match self.context.can_query_table(schema, &table.name) {
88138
Some(CanQuery::Yes) => {
@@ -196,19 +246,40 @@ impl<'ast, R: ResolveSchemasContext> VisitorMut<'ast> for ResolveSchemaVisitor<R
196246
// this visitation.
197247
let mut from_aliases: HashSet<SqlIdentifier> = HashSet::new();
198248

249+
// Track schemas from explicitly-qualified tables to help resolve unqualified table
250+
// references in JOIN ON conditions
251+
let mut join_context_schemas = HashMap::new();
252+
253+
// Collect schemas from initial FROM tables
254+
for table_expr in &select_statement.tables {
255+
self.collect_table_schemas_from_expr(table_expr, &mut join_context_schemas);
256+
}
257+
199258
from_aliases = self
200259
.visit_from_items_with_from_aliases(select_statement.tables.iter_mut(), from_aliases)?;
201260

202261
for join in &mut select_statement.join {
262+
// Collect schemas from the table(s) being joined
263+
for table in join.right.table_exprs() {
264+
self.collect_table_schemas_from_expr(table, &mut join_context_schemas);
265+
}
266+
203267
from_aliases = self.visit_from_items_with_from_aliases(
204268
match &mut join.right {
205269
JoinRightSide::Table(table) => Either::Left(iter::once(table)),
206270
JoinRightSide::Tables(tables) => Either::Right(tables.iter_mut()),
207271
},
208272
from_aliases,
209273
)?;
274+
275+
// Push the accumulated join context before visiting the ON constraint
210276
self.alias_stack.push(from_aliases);
277+
self.join_context_schemas.push(join_context_schemas.clone());
278+
211279
self.visit_join_constraint(&mut join.constraint)?;
280+
281+
// Pop the join context after visiting the constraint
282+
self.join_context_schemas.pop();
212283
from_aliases = self
213284
.alias_stack
214285
.pop()
@@ -316,6 +387,7 @@ impl ResolveSchemas for SelectStatement {
316387
ResolveSchemaVisitor {
317388
context,
318389
alias_stack: Default::default(),
390+
join_context_schemas: Default::default(),
319391
}
320392
.visit_select_statement(self)?;
321393
Ok(self)
@@ -330,6 +402,7 @@ impl ResolveSchemas for CreateTableStatement {
330402
ResolveSchemaVisitor {
331403
context,
332404
alias_stack: Default::default(),
405+
join_context_schemas: Default::default(),
333406
}
334407
.visit_create_table_statement(self)?;
335408

@@ -703,4 +776,12 @@ mod tests {
703776
WHERE bar.id = t1.id AND t1.id = foo.id",
704777
);
705778
}
779+
780+
#[test]
781+
fn resolves_unqualified_table_in_join_on_to_joined_table_schema() {
782+
select_rewrites_to(
783+
"select d.id from s2.t1 d left join s2.t2 ON d.id = t2.d_id",
784+
"select d.id from s2.t1 d left join s2.t2 ON d.id = s2.t2.d_id",
785+
);
786+
}
706787
}

0 commit comments

Comments
 (0)