Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>` 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
Expand Down
16 changes: 14 additions & 2 deletions sqlx-mysql/src/connection/executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
52 changes: 51 additions & 1 deletion tests/mysql/describe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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));

Expand All @@ -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::<MySql>().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::<MySql>().await?;
Expand Down
4 changes: 3 additions & 1 deletion tests/mysql/macros.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<OffsetDateTime>,
owner_id: Option<Uuid>,
}

Expand Down
Loading