From 3bf88b0f07928d45f866f32744ae846a885cbb18 Mon Sep 17 00:00:00 2001 From: barry3406 Date: Thu, 9 Apr 2026 12:34:51 -0700 Subject: [PATCH] fix(sqlite): preserve NOT NULL from schema over explain analysis When a query uses ORDER BY, SQLite routes data through an ephemeral sorter table. The explain-based nullability analysis can lose NOT NULL constraints through this indirection, and sqlite3_column_table_name() returns NULL for ephemeral columns so the schema lookup also fails. Previously, the explain result took priority: exp_nullable.or(col_nullable). This meant a false "nullable" from explain would override the schema. Now, if the schema definitively says NOT NULL (col_nullable = Some(false)), that takes priority regardless of what explain inferred. The schema is the source of truth for column constraints. Fixes #4147 --- sqlx-sqlite/src/connection/describe.rs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/sqlx-sqlite/src/connection/describe.rs b/sqlx-sqlite/src/connection/describe.rs index 400c671d96..8325742899 100644 --- a/sqlx-sqlite/src/connection/describe.rs +++ b/sqlx-sqlite/src/connection/describe.rs @@ -82,7 +82,23 @@ pub(crate) fn describe( let col_nullable = stmt.handle.column_nullable(col)?; let exp_nullable = fallback_nullable.get(col).copied().and_then(identity); - nullable.push(exp_nullable.or(col_nullable)); + // If the column has a known schema origin that says NOT NULL, + // trust that over the explain analysis which may lose NOT NULL + // constraints through ephemeral tables / sorters (e.g. ORDER BY). + // See: https://github.com/launchbadge/sqlx/issues/4147 + let result_nullable = match (col_nullable, exp_nullable) { + // Schema says NOT NULL — trust it regardless of explain result + (Some(false), _) => Some(false), + // Schema doesn't know (e.g. expression column), use explain + (None, exp) => exp, + // Both agree or only schema has info + (col, None) => col, + // Schema says nullable, explain says not — be conservative, say nullable + (Some(true), Some(false)) => Some(true), + // Both say nullable + (Some(true), Some(true)) => Some(true), + }; + nullable.push(result_nullable); columns.push(SqliteColumn { name: name.into(),