From ecac99691a8c6cf44c8227369eceae6afb3cabb7 Mon Sep 17 00:00:00 2001 From: zhaochangle Date: Fri, 15 May 2026 15:44:27 +0800 Subject: [PATCH] [fix](timestamptz) Report TIMESTAMPTZ as string to MySQL clients TIMESTAMPTZ result rows are serialized as timezone-aware strings in the MySQL binary protocol. FE metadata used to advertise them as MYSQL_TYPE_DATETIME, so Connector/J decoded the length-encoded string bytes as a MySQL datetime binary payload and failed with Invalid length (32) for type TIMESTAMP when ResultSet.getString read a server-prepared result. Change PrimitiveType.toMysqlType() to return MYSQL_TYPE_STRING for TIMESTAMPTZ, adjust the field packet length and decimals to string semantics, and make the FE local binary result path write TIMESTAMPTZ through the string fallback instead of the datetime binary layout. BE serialization already uses push_timestamptz -> push_string, so no BE payload change is needed. Add a field-packet unit test and a regression suite that forces ServerPreparedStatement and compares direct ResultSet.getString(ts) with CAST(ts AS STRING). Verification: - ./run-fe-ut.sh --run org.apache.doris.mysql.MysqlSerializerVarbinaryTest - ./run-regression-test.sh --run -d datatype_p0/timestamptz -s test_timestamptz_jdbc_binary_protocol --- .../apache/doris/mysql/MysqlSerializer.java | 7 +- .../org/apache/doris/qe/StmtExecutor.java | 1 - .../mysql/MysqlSerializerVarbinaryTest.java | 30 +++++++ .../apache/doris/catalog/PrimitiveType.java | 5 +- ...st_timestamptz_jdbc_binary_protocol.groovy | 85 +++++++++++++++++++ 5 files changed, 122 insertions(+), 6 deletions(-) create mode 100644 regression-test/suites/datatype_p0/timestamptz/test_timestamptz_jdbc_binary_protocol.groovy diff --git a/fe/fe-core/src/main/java/org/apache/doris/mysql/MysqlSerializer.java b/fe/fe-core/src/main/java/org/apache/doris/mysql/MysqlSerializer.java index 4c8d1824104a07..4469830f320eb3 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/mysql/MysqlSerializer.java +++ b/fe/fe-core/src/main/java/org/apache/doris/mysql/MysqlSerializer.java @@ -288,9 +288,11 @@ private int getMysqlTypeLength(Type type) { case DATEV2: case DATE: return 10; + case TIMESTAMPTZ: + // yyyy-MM-dd HH:mm:ss[.ffffff]+HH:mm + return 32; case DATETIME: - case DATETIMEV2: - case TIMESTAMPTZ: { + case DATETIMEV2: { if (type.getPrimitiveType().isTimeType()) { return 10; } else { @@ -338,7 +340,6 @@ public int getMysqlDecimals(Type type) { case DECIMAL256: case TIMEV2: case DATETIMEV2: - case TIMESTAMPTZ: return ((ScalarType) type).decimalScale(); case FLOAT: case DOUBLE: diff --git a/fe/fe-core/src/main/java/org/apache/doris/qe/StmtExecutor.java b/fe/fe-core/src/main/java/org/apache/doris/qe/StmtExecutor.java index 11d633a56445ac..3ebd2f69307c4d 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/qe/StmtExecutor.java +++ b/fe/fe-core/src/main/java/org/apache/doris/qe/StmtExecutor.java @@ -1870,7 +1870,6 @@ protected void sendBinaryResultRow(ResultSet resultSet) throws IOException { break; case DATETIME: case DATETIMEV2: - case TIMESTAMPTZ: DateTimeV2Literal datetime = new DateTimeV2Literal(item); long microSecond = datetime.getMicroSecond(); // https://dev.mysql.com/doc/dev/mysql-server/latest/page_protocol_com_query_response_text_resultset.html diff --git a/fe/fe-core/src/test/java/org/apache/doris/mysql/MysqlSerializerVarbinaryTest.java b/fe/fe-core/src/test/java/org/apache/doris/mysql/MysqlSerializerVarbinaryTest.java index 86688666feb566..9f949a566f00a8 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/mysql/MysqlSerializerVarbinaryTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/mysql/MysqlSerializerVarbinaryTest.java @@ -17,6 +17,7 @@ package org.apache.doris.mysql; +import org.apache.doris.catalog.MysqlColType; import org.apache.doris.catalog.ScalarType; import org.apache.doris.catalog.Type; @@ -118,6 +119,35 @@ public void testFieldPacketForVarcharUsesUtf8Collation() { Assertions.assertEquals(0, flags); // not BINARY } + @Test + public void testFieldPacketForTimestampTzUsesStringMetadata() { + MysqlSerializer ser = MysqlSerializer.newInstance(); + Type type = ScalarType.createTimeStampTzType(6); + ser.writeField("ts", type); + byte[] out = ser.toArray(); + + Assertions.assertEquals(MysqlColType.MYSQL_TYPE_STRING, type.getPrimitiveType().toMysqlType()); + + int off = skipFieldHeaderStrings(out); + int charset = leUInt2(out, off); + Assertions.assertEquals(33, charset); // utf8_general_ci + off += 2; + + long displayLen = leUInt4(out, off); + Assertions.assertEquals(32L, displayLen); + off += 4; + + int colType = out[off] & 0xFF; + Assertions.assertEquals(MysqlColType.MYSQL_TYPE_STRING.getCode(), colType); + off += 1; + + int flags = leUInt2(out, off); + Assertions.assertEquals(0, flags); + off += 2; + + Assertions.assertEquals(0, out[off] & 0xFF); + } + @Test public void testWriteLenEncodedBytesPreservesNullByte() { MysqlSerializer ser = MysqlSerializer.newInstance(); diff --git a/fe/fe-type/src/main/java/org/apache/doris/catalog/PrimitiveType.java b/fe/fe-type/src/main/java/org/apache/doris/catalog/PrimitiveType.java index deec4343659f72..216db92e21698a 100644 --- a/fe/fe-type/src/main/java/org/apache/doris/catalog/PrimitiveType.java +++ b/fe/fe-type/src/main/java/org/apache/doris/catalog/PrimitiveType.java @@ -427,9 +427,10 @@ public MysqlColType toMysqlType() { case DATE: case DATEV2: return MysqlColType.MYSQL_TYPE_DATE; + case TIMESTAMPTZ: + return MysqlColType.MYSQL_TYPE_STRING; case DATETIME: - case DATETIMEV2: - case TIMESTAMPTZ: { + case DATETIMEV2: { if (isTimeType) { return MysqlColType.MYSQL_TYPE_TIME; } else { diff --git a/regression-test/suites/datatype_p0/timestamptz/test_timestamptz_jdbc_binary_protocol.groovy b/regression-test/suites/datatype_p0/timestamptz/test_timestamptz_jdbc_binary_protocol.groovy new file mode 100644 index 00000000000000..c04cc51677053e --- /dev/null +++ b/regression-test/suites/datatype_p0/timestamptz/test_timestamptz_jdbc_binary_protocol.groovy @@ -0,0 +1,85 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import com.mysql.cj.jdbc.ServerPreparedStatement + +import java.sql.PreparedStatement +import java.sql.ResultSet +import java.sql.SQLException + +suite("test_timestamptz_jdbc_binary_protocol") { + String tableName = "test_timestamptz_jdbc_binary_protocol" + String dbName = "regression_test_datatype_p0_timestamptz" + def user = context.config.jdbcUser + def password = context.config.jdbcPassword + + sql "SET time_zone = '+00:00'" + sql "DROP TABLE IF EXISTS ${tableName}" + sql """ + CREATE TABLE ${tableName} ( + id INT, + ts TIMESTAMPTZ(6), + note VARCHAR(16) + ) + DUPLICATE KEY(id) + DISTRIBUTED BY HASH(id) BUCKETS 1 + PROPERTIES("replication_num" = "1") + """ + sql """ + INSERT INTO ${tableName} VALUES + (1, NULL, 'null'), + (2, '2024-01-01 08:00:00.123456 +08:00', 'equiv_utc'), + (3, '2024-01-01 00:00:01.654321 +00:00', 'micro') + """ + + String url = getServerPrepareJdbcUrl(context.config.jdbcUrl, dbName) + + "&emulateUnsupportedPstmts=true&useLocalSessionState=true" + logger.info("jdbc prepare statement url: ${url}") + + connect(user, password, url) { + sql "SET time_zone = '+00:00'" + + PreparedStatement stmt = prepareStatement(""" + SELECT id, ts, CAST(ts AS STRING) AS ts_text, note + FROM ${tableName} + ORDER BY id + """) + assertEquals(ServerPreparedStatement, stmt.class) + + ResultSet rs = stmt.executeQuery() + int rowCount = 0 + try { + while (rs.next()) { + rowCount++ + String direct + try { + direct = rs.getString(2) + } catch (SQLException e) { + logger.info("failed to read TIMESTAMPTZ directly with ResultSet.getString", e) + throw e + } + + String castText = rs.getString(3) + assertEquals(castText, direct) + } + assertEquals(3, rowCount) + } finally { + rs.close() + stmt.close() + } + } +}