diff --git a/.gitignore b/.gitignore index dd60d96224e..dba299862bb 100644 --- a/.gitignore +++ b/.gitignore @@ -79,3 +79,5 @@ docs/specs/ # Claude Code personal/local overrides (keep shared .claude/ content tracked) .claude/*.local.* + +output/ diff --git a/src/servers/src/mysql/writer.rs b/src/servers/src/mysql/writer.rs index 049f925c650..c485a5189fc 100644 --- a/src/servers/src/mysql/writer.rs +++ b/src/servers/src/mysql/writer.rs @@ -23,7 +23,7 @@ use arrow::datatypes::{ UInt16Type, UInt32Type, UInt64Type, }; use arrow_schema::{DataType, IntervalUnit}; -use chrono::{Datelike, NaiveDateTime}; +use chrono::{Datelike, NaiveDateTime, SubsecRound}; use common_decimal::Decimal128; use common_error::ext::ErrorExt; use common_error::status_code::StatusCode; @@ -275,7 +275,8 @@ impl MysqlResultWriter { write!( &mut staged_timestamp.formatted, "{}", - datetime.format("%Y-%m-%d %H:%M:%S%.f") + // MySQL temporal types support at most microsecond precision. + datetime.trunc_subsecs(6).format("%Y-%m-%d %H:%M:%S%.f") ) .map_err(|_| { InternalSnafu { diff --git a/src/servers/tests/mysql/mysql_server_test.rs b/src/servers/tests/mysql/mysql_server_test.rs index cba667f22b1..fdbdd4fd8f9 100644 --- a/src/servers/tests/mysql/mysql_server_test.rs +++ b/src/servers/tests/mysql/mysql_server_test.rs @@ -33,7 +33,9 @@ use datafusion_expr::LogicalPlan; use datatypes::prelude::VectorRef; use datatypes::schema::{ColumnSchema, Schema}; use datatypes::value::Value; -use datatypes::vectors::{Int32Vector, TimestampMicrosecondVector, TimestampSecondVector}; +use datatypes::vectors::{ + Int32Vector, TimestampMicrosecondVector, TimestampNanosecondVector, TimestampSecondVector, +}; use mysql_async::prelude::*; use mysql_async::{Conn, Row, SslOpts}; use query::QueryEngineFactory; @@ -859,6 +861,68 @@ async fn test_mysql_timestamp_precision_slot_isolation() -> Result<()> { Ok(()) } +#[tokio::test] +async fn test_mysql_nanosecond_timestamps_use_microsecond_precision() -> Result<()> { + let schema = Arc::new(Schema::new(vec![ColumnSchema::new( + "ts", + datatypes::prelude::ConcreteDataType::timestamp_nanosecond_datatype(), + true, + )])); + let timestamps = vec![ + Some(1_792_572_935_718_870_300), + Some(123_000_789), + Some(999_999_999), + Some(999), + Some(-1), + Some(0), + None, + ]; + let batch = RecordBatch::new( + schema, + vec![Arc::new(TimestampNanosecondVector::from(timestamps)) as VectorRef], + ) + .unwrap(); + let table = MemTable::table("nanosecond_timestamps", batch); + let query = "SELECT ts FROM nanosecond_timestamps".to_string(); + let (result, health_check) = query_mysql_text_protocol(table.clone(), query.clone()).await; + let rows = result.unwrap(); + let expected = [ + Some("2026-10-21 08:55:35.718870"), + Some("1970-01-01 00:00:00.123"), + Some("1970-01-01 00:00:00.999999"), + Some("1970-01-01 00:00:00"), + Some("1969-12-31 23:59:59.999999"), + Some("1970-01-01 00:00:00"), + None, + ]; + assert_eq!(expected.len(), rows.len()); + for (row, expected) in rows.iter().zip(expected) { + assert_eq!( + vec![expected.map_or(Value::Null, |s| Value::from(s.as_bytes().to_vec()))], + row.values + ); + } + assert_eq!(Some(1), health_check.unwrap()); + + let (result, health_check) = query_mysql_binary_protocol(table, query).await; + let rows = result.unwrap(); + let expected = [ + mysql_async::Value::Date(2026, 10, 21, 8, 55, 35, 718_870), + mysql_async::Value::Date(1970, 1, 1, 0, 0, 0, 123_000), + mysql_async::Value::Date(1970, 1, 1, 0, 0, 0, 999_999), + mysql_async::Value::Date(1970, 1, 1, 0, 0, 0, 0), + mysql_async::Value::Date(1969, 12, 31, 23, 59, 59, 999_999), + mysql_async::Value::Date(1970, 1, 1, 0, 0, 0, 0), + mysql_async::Value::NULL, + ]; + assert_eq!(expected.len(), rows.len()); + for (row, expected) in rows.iter().zip(expected) { + assert_eq!(Some(&expected), row.as_ref(0)); + } + assert_eq!(Some(1), health_check.unwrap()); + Ok(()) +} + fn timestamp_table(table_name: &str, timestamp: Option) -> (TableRef, Arc) { let schema = Arc::new(Schema::new(vec![ ColumnSchema::new( diff --git a/src/servers/tests/mysql/mysql_writer_test.rs b/src/servers/tests/mysql/mysql_writer_test.rs index 473a6300263..6cfcd09e746 100644 --- a/src/servers/tests/mysql/mysql_writer_test.rs +++ b/src/servers/tests/mysql/mysql_writer_test.rs @@ -264,13 +264,8 @@ fn test_create_mysql_column_unsigned_flags() { // ── Timestamp precision tests (issue #8227) ─────────────────────────────────── -/// Timestamps displayed through the MySQL writer must respect their storage unit. -/// -/// Before the fix, `to_chrono_datetime_with_timezone` → `NaiveDateTime::Display` -/// always emitted 6 fractional-second digits, so: -/// - `TIMESTAMP(3)` showed `.195000` instead of `.195` -/// - `TIMESTAMP(9)` showed `.195123` instead of `.195123456` -/// +/// The common timestamp string formatter preserves full storage precision. +/// The MySQL writer separately caps temporal output at microseconds (issue #9828). #[test] fn test_timestamp_display_precision_utc() { // Use UTC timezone for deterministic, timezone-independent output. diff --git a/tests/cases/standalone/common/types/timestamp/timestamp_precision_display.result b/tests/cases/standalone/common/types/timestamp/timestamp_precision_display.result index 45b930336f5..2c5f66da409 100644 --- a/tests/cases/standalone/common/types/timestamp/timestamp_precision_display.result +++ b/tests/cases/standalone/common/types/timestamp/timestamp_precision_display.result @@ -1,7 +1,7 @@ -- description: Timestamp display precision over the MySQL protocol should --- respect the column's storage unit (issue #8227): +-- respect MySQL's maximum microsecond precision (issues #8227 and #9828): -- - TIMESTAMP(3) must not pad to 6 fractional digits --- - TIMESTAMP(9) must not truncate to 6 fractional digits +-- - TIMESTAMP(9) must truncate to at most 6 fractional digits -- SQLNESS PROTOCOL MYSQL CREATE TABLE ts_precision_display ( ts_s TIMESTAMP(0), @@ -54,13 +54,26 @@ affected_rows: 1 -- SQLNESS PROTOCOL MYSQL SELECT * FROM ts_precision_display ORDER BY v; -+---------------------+-------------------------+----------------------------+-------------------------------+---+ -| ts_s | ts_ms | ts_us | ts_ns | v | -+---------------------+-------------------------+----------------------------+-------------------------------+---+ -| 2026-06-02 03:50:00 | 2026-06-02 03:50:00.195 | 2026-06-02 03:50:00.195123 | 2026-06-02 03:50:00.195123456 | 1 | -| 2026-06-02 03:50:01 | 2026-06-02 03:50:01.100 | 2026-06-02 03:50:01.100 | 2026-06-02 03:50:01.100 | 2 | -| 2026-06-02 03:50:02 | 2026-06-02 03:50:02 | 2026-06-02 03:50:02 | 2026-06-02 03:50:02 | 3 | -+---------------------+-------------------------+----------------------------+-------------------------------+---+ ++---------------------+-------------------------+----------------------------+----------------------------+---+ +| ts_s | ts_ms | ts_us | ts_ns | v | ++---------------------+-------------------------+----------------------------+----------------------------+---+ +| 2026-06-02 03:50:00 | 2026-06-02 03:50:00.195 | 2026-06-02 03:50:00.195123 | 2026-06-02 03:50:00.195123 | 1 | +| 2026-06-02 03:50:01 | 2026-06-02 03:50:01.100 | 2026-06-02 03:50:01.100 | 2026-06-02 03:50:01.100 | 2 | +| 2026-06-02 03:50:02 | 2026-06-02 03:50:02 | 2026-06-02 03:50:02 | 2026-06-02 03:50:02 | 3 | ++---------------------+-------------------------+----------------------------+----------------------------+---+ + +-- Full nanosecond precision remains available as an integer or string. +-- SQLNESS PROTOCOL MYSQL +SELECT ts_ns::BIGINT AS epoch_ns, ts_ns::STRING AS timestamp_string +FROM ts_precision_display ORDER BY v; + ++---------------------+-------------------------------+ +| epoch_ns | timestamp_string | ++---------------------+-------------------------------+ +| 1780372200195123456 | 2026-06-02T03:50:00.195123456 | +| 1780372201100000000 | 2026-06-02T03:50:01.100 | +| 1780372202000000000 | 2026-06-02T03:50:02 | ++---------------------+-------------------------------+ -- SQLNESS PROTOCOL MYSQL DROP TABLE ts_precision_display; diff --git a/tests/cases/standalone/common/types/timestamp/timestamp_precision_display.sql b/tests/cases/standalone/common/types/timestamp/timestamp_precision_display.sql index 8eb82ed711d..38fd0d6aa03 100644 --- a/tests/cases/standalone/common/types/timestamp/timestamp_precision_display.sql +++ b/tests/cases/standalone/common/types/timestamp/timestamp_precision_display.sql @@ -1,7 +1,7 @@ -- description: Timestamp display precision over the MySQL protocol should --- respect the column's storage unit (issue #8227): +-- respect MySQL's maximum microsecond precision (issues #8227 and #9828): -- - TIMESTAMP(3) must not pad to 6 fractional digits --- - TIMESTAMP(9) must not truncate to 6 fractional digits +-- - TIMESTAMP(9) must truncate to at most 6 fractional digits -- SQLNESS PROTOCOL MYSQL CREATE TABLE ts_precision_display ( @@ -47,5 +47,10 @@ INSERT INTO ts_precision_display VALUES ( -- SQLNESS PROTOCOL MYSQL SELECT * FROM ts_precision_display ORDER BY v; +-- Full nanosecond precision remains available as an integer or string. +-- SQLNESS PROTOCOL MYSQL +SELECT ts_ns::BIGINT AS epoch_ns, ts_ns::STRING AS timestamp_string +FROM ts_precision_display ORDER BY v; + -- SQLNESS PROTOCOL MYSQL DROP TABLE ts_precision_display; diff --git a/tests/compatibility/cases/mysql_timestamp_precision/case.toml b/tests/compatibility/cases/mysql_timestamp_precision/case.toml new file mode 100644 index 00000000000..4ddd00e664f --- /dev/null +++ b/tests/compatibility/cases/mysql_timestamp_precision/case.toml @@ -0,0 +1,8 @@ +name = "mysql_timestamp_precision" +reason = "MySQL timestamp results must use at most microseconds while stored nanoseconds survive restart (issue #9828)." +introduced_by = "issue #9828" +topologies = ["standalone", "distributed"] +from_range = ["*"] +to_range = [">=v1.4.0-alpha.0"] +features = ["table", "mysql", "timestamp"] +owner = "servers" diff --git a/tests/compatibility/cases/mysql_timestamp_precision/setup.sql b/tests/compatibility/cases/mysql_timestamp_precision/setup.sql new file mode 100644 index 00000000000..b9a453b0bf4 --- /dev/null +++ b/tests/compatibility/cases/mysql_timestamp_precision/setup.sql @@ -0,0 +1,3 @@ +CREATE TABLE timestamps (ts TIMESTAMP(9) TIME INDEX); + +INSERT INTO timestamps VALUES ('2026-10-21 08:55:35.718870300'); diff --git a/tests/compatibility/cases/mysql_timestamp_precision/verify.result b/tests/compatibility/cases/mysql_timestamp_precision/verify.result new file mode 100644 index 00000000000..330d9c5e033 --- /dev/null +++ b/tests/compatibility/cases/mysql_timestamp_precision/verify.result @@ -0,0 +1,17 @@ +-- SQLNESS PROTOCOL MYSQL +SELECT * FROM timestamps; + ++----------------------------+ +| ts | ++----------------------------+ +| 2026-10-21 08:55:35.718870 | ++----------------------------+ + +-- SQLNESS PROTOCOL MYSQL +SELECT ts::BIGINT AS epoch_ns, ts::STRING AS timestamp_string FROM timestamps; + ++---------------------+-------------------------------+ +| epoch_ns | timestamp_string | ++---------------------+-------------------------------+ +| 1792572935718870300 | 2026-10-21T08:55:35.718870300 | ++---------------------+-------------------------------+ diff --git a/tests/compatibility/cases/mysql_timestamp_precision/verify.sql b/tests/compatibility/cases/mysql_timestamp_precision/verify.sql new file mode 100644 index 00000000000..8dc0214b50f --- /dev/null +++ b/tests/compatibility/cases/mysql_timestamp_precision/verify.sql @@ -0,0 +1,5 @@ +-- SQLNESS PROTOCOL MYSQL +SELECT * FROM timestamps; + +-- SQLNESS PROTOCOL MYSQL +SELECT ts::BIGINT AS epoch_ns, ts::STRING AS timestamp_string FROM timestamps;