diff --git a/sqlx-postgres/src/connection/describe.rs b/sqlx-postgres/src/connection/describe.rs index ee9918909d..2bbe9d76bb 100644 --- a/sqlx-postgres/src/connection/describe.rs +++ b/sqlx-postgres/src/connection/describe.rs @@ -133,7 +133,7 @@ impl PgConnection { /// Infer nullability for columns of this statement using EXPLAIN VERBOSE. /// - /// This currently only marks columns that are on the inner half of an outer join + /// This currently only marks columns that an outer join can set to `NULL` /// and returns `None` for all others. async fn nullables_from_explain( &mut self, @@ -177,20 +177,28 @@ impl PgConnection { }) = explains.first() { nullables.resize(outputs.len(), None); - visit_plan(plan, outputs, &mut nullables); + visit_plan(plan, outputs, &mut nullables, false); } Ok(nullables) } } -fn visit_plan(plan: &Plan, outputs: &[String], nullables: &mut Vec>) { - if let Some(plan_outputs) = &plan.output { - // all outputs of a Full Join must be marked nullable - // otherwise, all outputs of the inner half of an outer join must be marked nullable - if plan.join_type.as_deref() == Some("Full") - || plan.parent_relation.as_deref() == Some("Inner") - { +/// Mark every output of this plan that an outer join can set to `NULL`. +/// +/// `null_extended` is true when this plan is the null-extended input of an outer join above it. +/// `visit_plan` visits every child, because a join can sit below any node, such as `Limit`. +fn visit_plan( + plan: &Plan, + outputs: &[String], + nullables: &mut Vec>, + null_extended: bool, +) { + // all outputs of a Full Join must be marked nullable + let null_extended = null_extended || plan.join_type.as_deref() == Some("Full"); + + if null_extended { + if let Some(plan_outputs) = &plan.output { for output in plan_outputs { if let Some(i) = outputs.iter().position(|o| o == output) { // N.B. this may produce false positives but those don't cause runtime errors @@ -201,10 +209,17 @@ fn visit_plan(plan: &Plan, outputs: &[String], nullables: &mut Vec> } if let Some(plans) = &plan.plans { - if let Some("Left") | Some("Right") = plan.join_type.as_deref() { - for plan in plans { - visit_plan(plan, outputs, nullables); - } + for child in plans { + let child_null_extended = match plan.join_type.as_deref() { + // PostgreSQL defines `JOIN_RIGHT` as the mirror of `JOIN_LEFT`, so the + // null-extended input is the Inner child of a Left join and the Outer + // child of a Right join. See . + Some("Left") => child.parent_relation.as_deref() == Some("Inner"), + Some("Right") => child.parent_relation.as_deref() == Some("Outer"), + _ => false, + }; + + visit_plan(child, outputs, nullables, child_null_extended); } } } diff --git a/tests/postgres/postgres.rs b/tests/postgres/postgres.rs index 126771565a..1d03e383e1 100644 --- a/tests/postgres/postgres.rs +++ b/tests/postgres/postgres.rs @@ -1032,6 +1032,68 @@ from (values (null)) vals(val) assert_eq!(describe.nullable(0), Some(true)); assert_eq!(describe.nullable(1), Some(true)); + // a left join the planner commutes into a `Join Type: Right` node + // language=PostgreSQL + let describe = conn + .describe( + "select tweet.text, tweet_reply.text + from tweet + left join tweet_reply on tweet_reply.tweet_id = tweet.id" + .into_sql_str(), + ) + .await?; + + // tweet.text is on the preserved half, so it must stay NOT NULL + assert_eq!(describe.nullable(0), Some(false)); + assert_eq!(describe.nullable(1), Some(true)); + + // two chained left joins, which nest two `Right` nodes + // language=PostgreSQL + let describe = conn + .describe( + "select tweet.text, reply1.text, reply2.text + from tweet + left join tweet_reply reply1 on reply1.tweet_id = tweet.id + left join tweet_reply reply2 on reply2.tweet_id = tweet.id" + .into_sql_str(), + ) + .await?; + + assert_eq!(describe.nullable(0), Some(false)); + assert_eq!(describe.nullable(1), Some(true)); + assert_eq!(describe.nullable(2), Some(true)); + + // a join below a node that is not a join + // language=PostgreSQL + let describe = conn + .describe( + "select tweet.text, tweet_reply.text + from tweet + left join tweet_reply on tweet_reply.tweet_id = tweet.id + limit 5" + .into_sql_str(), + ) + .await?; + + assert_eq!(describe.nullable(0), Some(false)); + assert_eq!(describe.nullable(1), Some(true)); + + // the same query with `order by`. The planner gives it the opposite join type from the + // `limit` case, so the two cases together cover a `Left` node and a `Right` node. + // language=PostgreSQL + let describe = conn + .describe( + "select tweet.text, tweet_reply.text + from tweet + left join tweet_reply on tweet_reply.tweet_id = tweet.id + order by tweet.id" + .into_sql_str(), + ) + .await?; + + assert_eq!(describe.nullable(0), Some(false)); + assert_eq!(describe.nullable(1), Some(true)); + Ok(()) }