From 18ddc544b5ab803f9c79582299ed55d4d527812b Mon Sep 17 00:00:00 2001 From: Jacob Quinn Date: Thu, 29 Oct 2026 20:05:11 -0600 Subject: [PATCH] Bind DateTime parameters as MYSQL_TYPE_DATETIME MariaDB stores NULL for a MYSQL_TYPE_TIMESTAMP parameter outside the TIMESTAMP range (before 1970 or after 2106), even in a DATETIME column, without a warning and in strict mode. DateTime parameters (and so MySQL.load of DateTime columns) silently lost every such value on MariaDB 11.4 and 11.8. Timestamp parameters already used MYSQL_TYPE_DATETIME; MySQL treats both types the same. Co-Authored-By: Claude Opus 5.5 --- docs/src/migration.md | 1 + src/binary.jl | 4 +++- test/protocol/binary_tests.jl | 3 ++- test/user_workflow.jl | 10 ++++++++++ 4 files changed, 16 insertions(+), 2 deletions(-) diff --git a/docs/src/migration.md b/docs/src/migration.md index 5d63bbc..927bc19 100644 --- a/docs/src/migration.md +++ b/docs/src/migration.md @@ -129,6 +129,7 @@ Deliberate, documented changes relative to Connector/C 1.6.0: | BIT decoding | text: first byte only; binary: little-endian | big-endian value of all bytes (≤ 8) in both protocols | | BIT parameters | little-endian `bitvalue` encoding | big-endian binary string (matches the decode) | | `Bool` parameters | fell through to the `MYSQL_TYPE_STRING` fallback (untested latent bug) | bound as `MYSQL_TYPE_TINY` | +| `DateTime` parameters | bound as `MYSQL_TYPE_TIMESTAMP`: MariaDB stored NULL for values before 1970 or after 2106 | bound as `MYSQL_TYPE_DATETIME`, like `Timestamp` | | TIME decoding | text parse errored on negative/≥24 h; binary ignored sign and days | `Dates.Time` for `0 ≤ t < 24h`, `ConversionError` otherwise; `time_type=Dates.Microsecond` opt-in is lossless and signed | | Zero dates | text special-cased only zero DATETIME; text zero DATE failed; binary mapped zero components to 1970 | unified `zero_dates` policy: `:sentinel` (default, `Date(0)` / `Timestamp{P}(0, 1, 1)`), `:missing` (widens column types to `Union{Missing, T}`), `:error`; partial zero dates (`2024-00-05`) are `ConversionError` unless `:missing` | | `Base.isopen` | `mysql_ping` round trip | local check only; use `MySQL.ping(conn)` for a round trip | diff --git a/src/binary.jl b/src/binary.jl index b2cbae5..b2dfcd7 100644 --- a/src/binary.jl +++ b/src/binary.jl @@ -180,7 +180,9 @@ param_type(::Bit) = return (P.MYSQL_TYPE_BLOB, false) param_type(::Vector{UInt8}) = return (P.MYSQL_TYPE_BLOB, false) param_type(::DataBytes) = return (P.MYSQL_TYPE_BLOB, false) param_type(::Timestamp) = return (P.MYSQL_TYPE_DATETIME, false) -param_type(::DateTime) = return (P.MYSQL_TYPE_TIMESTAMP, false) +# DATETIME, not TIMESTAMP: MariaDB turns a TIMESTAMP-typed parameter outside the TIMESTAMP +# range (before 1970, after 2106) into NULL, even when it is stored in a DATETIME column. +param_type(::DateTime) = return (P.MYSQL_TYPE_DATETIME, false) param_type(::Date) = return (P.MYSQL_TYPE_DATE, false) param_type(::Dates.Time) = return (P.MYSQL_TYPE_TIME, false) param_type(::AbstractString) = return (P.MYSQL_TYPE_STRING, false) diff --git a/test/protocol/binary_tests.jl b/test/protocol/binary_tests.jl index e524ad9..04717c6 100644 --- a/test/protocol/binary_tests.jl +++ b/test/protocol/binary_tests.jl @@ -996,7 +996,8 @@ end @test roundtrip(Timestamp{Microsecond}, Timestamp{Nanosecond}(2024, 1, 2, 1, 2, 3, 456, 789)) === Timestamp{Microsecond}(2024, 1, 2, 1, 2, 3, 456, 789) # the wire carries microseconds: a sub-microsecond Timestamp{Nanosecond} is refused, never truncated @test_throws InexactError N.encode_param_value!(UInt8[], Timestamp{Nanosecond}(2024, 1, 2, 1, 2, 3, 456, 789, 1)) - @test N.param_signature(Any[Timestamp{Second}(2024, 1, 1), DateTime(2024, 1, 1)]) == UInt16[P.MYSQL_TYPE_DATETIME, P.MYSQL_TYPE_TIMESTAMP] + # DateTime binds as DATETIME: MariaDB nulls TIMESTAMP-typed parameters outside 1970-2106 + @test N.param_signature(Any[Timestamp{Second}(2024, 1, 1), DateTime(2024, 1, 1)]) == UInt16[P.MYSQL_TYPE_DATETIME, P.MYSQL_TYPE_DATETIME] @test roundtrip(Time, Time(13, 14, 15)) == Time(13, 14, 15) @test roundtrip(Time, Time(13, 14, 15, 250, 500)) == Time(13, 14, 15, 250, 500) diff --git a/test/user_workflow.jl b/test/user_workflow.jl index 44bbca6..f124da9 100644 --- a/test/user_workflow.jl +++ b/test/user_workflow.jl @@ -55,6 +55,16 @@ function run_user_workflow(conn) @test expected.data == [UInt8[0, 255], UInt8[2], UInt8[3]] @test Tables.columntable(DBInterface.execute(conn, "SELECT * FROM items ORDER BY id"; mysql_store_result=false)) == expected @test Tables.columntable(DBInterface.execute(conn, "SELECT * FROM items WHERE id > ? ORDER BY id", (0,))) == expected + # DateTime parameters before 1970 or after 2106: MariaDB stored NULL for them while they + # were sent as MYSQL_TYPE_TIMESTAMP. + DBInterface.execute(conn, "DROP TABLE IF EXISTS old_dates") + DBInterface.execute(conn, "CREATE TABLE old_dates (id INT PRIMARY KEY, at DATETIME(3))") + dates = [DateTime(1955, 3, 4, 5, 6, 7, 890), DateTime(1969, 12, 31, 23, 59, 59), DateTime(2200, 1, 1)] + DBInterface.executemany(conn, "INSERT INTO old_dates VALUES (?, ?)", (id=[1, 2, 3], at=dates)) + @test isequal(Tables.columntable(DBInterface.execute(conn, "SELECT at FROM old_dates ORDER BY id")).at, Timestamp{Millisecond}.(dates)) + DBInterface.execute(conn, "DROP TABLE IF EXISTS old_dates_load") + MySQL.load((id=[1], born=[DateTime(1955, 3, 4)]), conn, "old_dates_load") + @test isequal(Tables.columntable(DBInterface.execute(conn, "SELECT born FROM old_dates_load")).born, [Timestamp{Millisecond}(DateTime(1955, 3, 4))]) @test DBInterface.transaction(conn) do DBInterface.execute(conn, "UPDATE items SET label='changed' WHERE id=1") :committed