From 2187f7f00e51fb6e27cfe66309b5e8f1014ff8c5 Mon Sep 17 00:00:00 2001 From: Do Tuan Anh <96874463+DoTuanAnh2k1@users.noreply.github.com> Date: Sat, 1 Aug 2026 12:02:34 +0700 Subject: [PATCH] postgres: infer RETURNING OLD/NEW columns as nullable A `RETURNING` clause can reference the OLD/NEW transition relations (Postgres 18+). Those columns are nullable even when the underlying column is NOT NULL: OLD is null for a row added by INSERT (including `ON CONFLICT DO NOTHING`) and NEW is null for one removed by DELETE. The EXPLAIN-based nullability inference didn't account for this and reported such columns as non-nullable, causing an unexpected-null error at runtime with the query macros. Fixes #4332. --- sqlx-postgres/src/connection/describe.rs | 50 ++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/sqlx-postgres/src/connection/describe.rs b/sqlx-postgres/src/connection/describe.rs index ee9918909d..a971d7d0ad 100644 --- a/sqlx-postgres/src/connection/describe.rs +++ b/sqlx-postgres/src/connection/describe.rs @@ -186,6 +186,19 @@ impl PgConnection { fn visit_plan(plan: &Plan, outputs: &[String], nullables: &mut Vec>) { if let Some(plan_outputs) = &plan.output { + // Columns from the OLD/NEW transition relations of a `RETURNING` clause + // (Postgres 18+) are nullable: OLD is null for a row added by `INSERT` + // (including `ON CONFLICT DO NOTHING`), NEW is null for one removed by `DELETE`. + if plan.node_type.as_deref() == Some("ModifyTable") { + for output in plan_outputs { + if output.starts_with("old.") || output.starts_with("new.") { + if let Some(i) = outputs.iter().position(|o| o == output) { + nullables[i] = Some(true); + } + } + } + } + // 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") @@ -234,6 +247,8 @@ enum Explain { #[derive(serde::Deserialize, Debug)] struct Plan { + #[serde(rename = "Node Type")] + node_type: Option, #[serde(rename = "Join Type")] join_type: Option, #[serde(rename = "Parent Relationship")] @@ -300,3 +315,38 @@ fn explain_parsing() { "unexpected parse from {utility_statement:?}: {utility_statement_parsed:?}" ) } + +// https://github.com/launchbadge/sqlx/issues/4332 +#[test] +fn nullable_from_explain_returning_old() { + // `INSERT ... ON CONFLICT DO NOTHING RETURNING old.hash` on a NOT NULL column: + // `old.hash` is null for the freshly inserted row, so it must be nullable. + let explain = r#"[ + { + "Plan": { + "Node Type": "ModifyTable", + "Operation": "Insert", + "Relation Name": "files", + "Output": ["old.hash"], + "Conflict Resolution": "NOTHING", + "Conflict Arbiter Indexes": ["files_pkey"], + "Plans": [ + { + "Node Type": "Result", + "Parent Relationship": "Outer", + "Output": ["'\\xdeadbeef'::bytea", "NULL::bytea[]"] + } + ] + } + } + ]"#; + + let [Explain::Plan { plan }] = &serde_json::from_str::<[Explain; 1]>(explain).unwrap() else { + panic!("unexpected parse from {explain:?}"); + }; + let outputs = plan.output.clone().unwrap(); + let mut nullables = vec![None; outputs.len()]; + visit_plan(plan, &outputs, &mut nullables); + + assert_eq!(nullables, [Some(true)]); +}