From 90f2d6d6dc166b16e77de9e0bb4d3c37e70d3c02 Mon Sep 17 00:00:00 2001 From: Armaan Sandhu Date: Wed, 3 Jun 2026 12:50:48 +0530 Subject: [PATCH] fix(mysql): infer temporal NOT NULL columns as nullable for zero dates A MySQL zero date (0000-00-00 00:00:00) is decoded as NULL/None, so a NOT NULL DATE/DATETIME/TIMESTAMP column can still yield None at runtime. runtime is_null semantics, fixing UnexpectedNullError at runtime. Fixes #4283 --- CHANGELOG.md | 14 ++++++++ sqlx-mysql/src/connection/executor.rs | 16 +++++++-- tests/mysql/describe.rs | 52 ++++++++++++++++++++++++++- tests/mysql/macros.rs | 4 ++- 4 files changed, 82 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 237c1d6a35..445bac35f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Fixed + +* [[#4283]]: MySQL: infer `DATE`/`DATETIME`/`TIMESTAMP` columns as nullable in `query!`/`query_as!` + * SQLx decodes a MySQL "zero date" (e.g. `0000-00-00 00:00:00`) as `NULL`/`None` (see `MySqlValueRef::is_null`), + so a `NOT NULL` temporal column can still yield `None` at runtime. The macros previously inferred these columns + as non-nullable, causing an `UnexpectedNullError` at runtime when a zero date was returned. + * **Behavior change:** `DATE`/`DATETIME`/`TIMESTAMP` columns now produce `Option` in `query!`/`query_as!`. + Use the `!` column override (e.g. ``SELECT created_at AS "created_at!"``) to force the non-nullable type when you + know the column cannot contain a zero date. `TIME` and non-temporal columns are unaffected. + +[#4283]: https://github.com/launchbadge/sqlx/issues/4283 + ## 0.9.0 - 2026-05-06 ### Important Announcements diff --git a/sqlx-mysql/src/connection/executor.rs b/sqlx-mysql/src/connection/executor.rs index ee59d03d0a..38d0836f47 100644 --- a/sqlx-mysql/src/connection/executor.rs +++ b/sqlx-mysql/src/connection/executor.rs @@ -375,8 +375,20 @@ impl<'c> Executor<'c> for &'c mut MySqlConnection { let nullable = columns .iter() .map(|col| { - col.flags - .map(|flags| !flags.contains(crate::protocol::text::ColumnFlags::NOT_NULL)) + use crate::protocol::text::{ColumnFlags, ColumnType}; + + col.flags.map(|flags| { + // A `NOT NULL` temporal column can still hold a "zero date" + // (e.g. `0000-00-00 00:00:00`), which SQLx decodes as `NULL`/`None` + // (see `MySqlValueRef::is_null`). Such a value would fail to decode + // into a non-`Option` field, so we treat these columns as nullable + // for compile-time inference regardless of the `NOT_NULL` flag. + !flags.contains(ColumnFlags::NOT_NULL) + || matches!( + col.type_info.r#type, + ColumnType::Date | ColumnType::Datetime | ColumnType::Timestamp + ) + }) }) .collect(); diff --git a/tests/mysql/describe.rs b/tests/mysql/describe.rs index d50c86a93a..98162c75c0 100644 --- a/tests/mysql/describe.rs +++ b/tests/mysql/describe.rs @@ -14,7 +14,10 @@ async fn it_describes_simple() -> anyhow::Result<()> { assert_eq!(d.columns()[3].name(), "owner_id"); assert_eq!(d.nullable(0), Some(false)); - assert_eq!(d.nullable(1), Some(false)); + // `created_at` is `TIMESTAMP NOT NULL`, but a "zero date" (`0000-00-00 00:00:00`) + // is decoded as `NULL`/`None` (see `MySqlValueRef::is_null`), so SQLx reports + // temporal columns as nullable regardless of the `NOT_NULL` flag. + assert_eq!(d.nullable(1), Some(true)); assert_eq!(d.nullable(2), Some(false)); assert_eq!(d.nullable(3), Some(true)); @@ -26,6 +29,53 @@ async fn it_describes_simple() -> anyhow::Result<()> { Ok(()) } +// `DATE`/`DATETIME`/`TIMESTAMP` columns can yield a "zero date" which SQLx decodes +// as `NULL`/`None`, so they must be inferred as nullable even when declared `NOT NULL` +// (https://github.com/launchbadge/sqlx/issues/4283). `TIME` has no zero value that maps +// to NULL, so it (and non-temporal types) must keep the `NOT NULL` inference. +#[sqlx_macros::test] +async fn it_describes_temporal_not_null_as_nullable() -> anyhow::Result<()> { + let mut conn = new::().await?; + + conn.execute( + r#" +CREATE TEMPORARY TABLE temporal_not_null ( + d DATE NOT NULL, + dt DATETIME NOT NULL, + ts TIMESTAMP NOT NULL, + t TIME NOT NULL, + n INTEGER NOT NULL, + nullable INTEGER +); + "#, + ) + .await?; + + let d = conn + .describe("SELECT * FROM temporal_not_null".into_sql_str()) + .await?; + + // zero-date-bearing temporal types: nullable despite `NOT NULL` + assert_eq!(d.column(0).name(), "d"); + assert_eq!(d.nullable(0), Some(true)); + assert_eq!(d.column(1).name(), "dt"); + assert_eq!(d.nullable(1), Some(true)); + assert_eq!(d.column(2).name(), "ts"); + assert_eq!(d.nullable(2), Some(true)); + + // `TIME` and non-temporal `NOT NULL` columns stay non-nullable + assert_eq!(d.column(3).name(), "t"); + assert_eq!(d.nullable(3), Some(false)); + assert_eq!(d.column(4).name(), "n"); + assert_eq!(d.nullable(4), Some(false)); + + // an actually-nullable column is still nullable + assert_eq!(d.column(5).name(), "nullable"); + assert_eq!(d.nullable(5), Some(true)); + + Ok(()) +} + #[sqlx_macros::test] async fn test_boolean() -> anyhow::Result<()> { let mut conn = new::().await?; diff --git a/tests/mysql/macros.rs b/tests/mysql/macros.rs index 8186e344c4..fda6ccac4e 100644 --- a/tests/mysql/macros.rs +++ b/tests/mysql/macros.rs @@ -564,7 +564,9 @@ async fn test_uuid_is_compatible_mariadb() -> anyhow::Result<()> { struct Tweet { id: Uuid, text: String, - created_at: OffsetDateTime, + // `TIMESTAMP NOT NULL` is inferred as nullable because a "zero date" decodes + // as `None` (see https://github.com/launchbadge/sqlx/issues/4283). + created_at: Option, owner_id: Option, }