This is an automated email from the ASF dual-hosted git repository.
Gabriel39 pushed a commit to branch branch-4.1
in repository https://gitbox.apache.org/repos/asf/doris.git
The following commit(s) were added to refs/heads/branch-4.1 by this push:
new 763d9855785 [branch-4.1](fix) Correct external binary partitions and
timestamp handling (#68873)
763d9855785 is described below
commit 763d98557856e3f9982da3f54608de66bb0c9676
Author: Gabriel <[email protected]>
AuthorDate: Mon Oct 12 14:46:28 2026 +0800
[branch-4.1](fix) Correct external binary partitions and timestamp handling
(#68873)
### What problem does this PR solve?
Related PR: #68786, #68532
`branch-4.1` already contains #68532. This PR adapts the applicable
correctness fixes found during #68786 review to the 4.1 executors and
scan planner.
| Area | Fix |
| --- | --- |
| Iceberg binary partitions | Distinguish SQL NULL from binary bytes
whose Base64 path is `null` when reusing writers, preserving separate
commit metadata. |
| VARBINARY literals | Compare unsigned bytes with significant trailing
zeros in both FE literal implementations. |
| MySQL / OceanBase MySQL | Project instant columns as UTC text before
driver decoding and preserve `zeroDateTimeBehavior`. |
| Oracle | Initialize each borrowed driver's session time zone and use
explicit-offset text binds with server-side conversion. |
| Presto | Support the official driver's scalar/array timestamp
representation and VARCHAR timestamp binds with explicit casts. |
| JDBC query TVFs | Preserve trailing comments, terminal delimiters,
MySQL/MariaDB quoting modes and Trino `WITH SESSION` when wrapping
timestamp projections. Capture MySQL SQL mode from the scan connection
configuration before serialization. |
| JNI | Reject UTC years outside 0–9999 before packing TIMESTAMPTZ,
including nested values, while preserving NULL and year zero. |
| Paimon | Serialize binary partition values losslessly for native
readers. |
| MaxCompute | Write nested timestamp vectors using UTC microseconds. |
Existing 4.1 type mappings are retained. The master connector framework,
native external UUID migration and unconditional file-TVF mapping
changes are outside this backport.
### Release note
Fix external binary partition correctness, VARBINARY literal
comparisons, JDBC timestamp handling and nested timestamp writes in 4.1.
### Check List (For Author)
- Test:
- [x] Unit Test: 76 focused FE tests, 44 Java extension tests and 31 BE
writer/partition-transform tests passed locally. BE tests rebuilt the
affected source and test with existing 4.1 build dependencies. The
latest FE run includes the query-wrapper lifecycle and MariaDB
bracket-identifier regressions.
- [x] Manual test: MySQL Connector/J 5.1.49 and 8.0.28 each passed all
four integration methods, covering JVM/session time zones, client/server
prepared statements, cached driver time zones and zero-date policies.
MariaDB 10.4 also passed three wrapped-query timestamp readbacks with
MSSQL bracket identifiers and scan-local mode capture.
- [x] Regression test added: Iceberg binary/NULL path collision,
including independent Spark partition-filter checks for Parquet and ORC.
The full external regression suite was not run locally. Updated the
MySQL projection expectations and adapted the Doris binary-filter
assertion to 4.1 while retaining Spark partition-pruning checks.
- [x] FE Checkstyle and clang-format 16 checks.
- Behavior changed:
- [x] Yes: correct the existing read/write and comparison behavior
described above.
- Does this need documentation?
- [x] No.
---
.../sink/writer/iceberg/viceberg_table_writer.cpp | 17 +-
.../sink/writer/iceberg/viceberg_table_writer.h | 1 +
.../writer/iceberg/viceberg_table_writer_test.cpp | 8 +-
.../apache/doris/common/jni/vec/VectorColumn.java | 9 +
.../jni/vec/VectorColumnTimestampTzTest.java | 86 ++++++
fe/be-java-extensions/jdbc-scanner/pom.xml | 6 +
.../org/apache/doris/jdbc/MySQLJdbcExecutor.java | 9 +-
.../org/apache/doris/jdbc/OracleJdbcExecutor.java | 27 +-
.../org/apache/doris/jdbc/TrinoJdbcExecutor.java | 32 ++-
.../doris/jdbc/JdbcTimestampBackportTest.java | 70 +++++
.../apache/doris/jdbc/JdbcTimestampBindTest.java | 6 +-
.../doris/jdbc/JdbcTimestampSemanticsTest.java | 4 +-
.../jdbc/JdbcZonedTimestampIntegrationTest.java | 52 +++-
.../doris/jdbc/MySqlTimestampIntegrationTest.java | 124 +++++++--
.../doris/jdbc/OracleSessionTimeZoneTest.java | 73 +++++
.../doris/maxcompute/MaxComputeJniWriter.java | 9 +
.../doris/maxcompute/MaxComputeJniWriterTest.java | 40 +++
.../apache/doris/analysis/VarBinaryLiteral.java | 30 +-
.../java/org/apache/doris/catalog/JdbcTable.java | 13 +-
.../doris/datasource/jdbc/client/JdbcClient.java | 26 ++
.../doris/datasource/jdbc/source/JdbcScanNode.java | 195 ++++++++++++-
.../apache/doris/datasource/paimon/PaimonUtil.java | 7 +-
.../expressions/literal/VarBinaryLiteral.java | 30 +-
.../analysis/VarBinaryLiteralOrderingTest.java | 34 +++
.../jdbc/source/JdbcQueryWrappingTest.java | 306 +++++++++++++++++++++
.../jdbc/source/JdbcTimestampProjectionTest.java | 7 +-
.../paimon/PaimonBinaryPartitionTest.java | 36 +++
.../nereids/parser/VarBinaryLiteralParserTest.java | 4 +-
.../trees/expressions/VarBinaryLiteralTest.java | 5 +-
.../test_iceberg_write_binary_partitions.groovy | 21 ++
.../jdbc/test_mysql_jdbc_catalog.groovy | 5 +-
31 files changed, 1188 insertions(+), 104 deletions(-)
diff --git a/be/src/exec/sink/writer/iceberg/viceberg_table_writer.cpp
b/be/src/exec/sink/writer/iceberg/viceberg_table_writer.cpp
index d2697f61e10..6b1f8318c33 100644
--- a/be/src/exec/sink/writer/iceberg/viceberg_table_writer.cpp
+++ b/be/src/exec/sink/writer/iceberg/viceberg_table_writer.cpp
@@ -481,7 +481,7 @@ Status VIcebergTableWriter::_write_prepared_block(Block&
output_block) {
}
std::string partition_name;
try {
- partition_name = _partition_to_path(partition_data.value());
+ partition_name =
_partition_to_writer_key(partition_data.value());
} catch (doris::Exception& e) {
return e.to_status();
}
@@ -656,6 +656,21 @@ std::string
VIcebergTableWriter::_partition_value_to_human_string(size_t index,
return encoded;
}
+std::string VIcebergTableWriter::_partition_to_writer_key(const
doris::iceberg::StructLike& data) {
+ std::string key = _partition_to_path(data);
+ // Iceberg paths encode both NULL and binary 0x9ee965 as "null". Keep
their writers and
+ // commit metadata separate without changing the standard, externally
visible path.
+ key.push_back('\0');
+ for (size_t i = 0; i < _iceberg_partition_columns.size(); ++i) {
+ // Static fields are constant for this writer; only dynamic fields can
collide.
+ key.push_back((_has_static_partition &&
_partition_column_is_static[i]) ||
+ data.get(i).has_value()
+ ? '1'
+ : '0');
+ }
+ return key;
+}
+
std::string VIcebergTableWriter::_partition_to_path(const
doris::iceberg::StructLike& data) {
std::stringstream ss;
for (size_t i = 0; i < _iceberg_partition_columns.size(); i++) {
diff --git a/be/src/exec/sink/writer/iceberg/viceberg_table_writer.h
b/be/src/exec/sink/writer/iceberg/viceberg_table_writer.h
index 1b98f26c86a..c66cc1a74da 100644
--- a/be/src/exec/sink/writer/iceberg/viceberg_table_writer.h
+++ b/be/src/exec/sink/writer/iceberg/viceberg_table_writer.h
@@ -131,6 +131,7 @@ private:
std::vector<IcebergPartitionColumn> _to_iceberg_partition_columns();
+ std::string _partition_to_writer_key(const doris::iceberg::StructLike&
data);
std::string _partition_to_path(const doris::iceberg::StructLike& data);
std::string _escape(const std::string& path);
std::vector<std::string> _partition_values(const
doris::iceberg::StructLike& data);
diff --git a/be/test/exec/sink/writer/iceberg/viceberg_table_writer_test.cpp
b/be/test/exec/sink/writer/iceberg/viceberg_table_writer_test.cpp
index 852adb276fd..849b55b2437 100644
--- a/be/test/exec/sink/writer/iceberg/viceberg_table_writer_test.cpp
+++ b/be/test/exec/sink/writer/iceberg/viceberg_table_writer_test.cpp
@@ -180,7 +180,7 @@ TEST(VIcebergTableWriterTest,
StaticNullBinaryPartitionsKeepNullPathsAndCommitVa
IcebergPartitionData hybrid({std::any(), Int32(7)});
ASSERT_EQ("key=null/id=7", writer._partition_to_path(hybrid));
EXPECT_EQ(std::vector<std::string>({"null", "7"}),
writer._partition_values(hybrid));
- writer._partitions_to_writers["key=null/id=7"] = recording_writer;
+ writer._partitions_to_writers[writer._partition_to_writer_key(hybrid)]
= recording_writer;
Block hybrid_block({ColumnWithTypeAndName(null_column->get_ptr(),
type, "key"),
ColumnWithTypeAndName(id_column->get_ptr(),
id_type, "id")});
ASSERT_TRUE(writer._write_prepared_block(hybrid_block).ok());
@@ -226,6 +226,12 @@ TEST(VIcebergTableWriterTest,
BinaryIdentityStaticAndDynamicRouting) {
IcebergPartitionData data({value});
EXPECT_EQ("key=3q0%3D", writer._partition_to_path(data));
EXPECT_EQ(std::vector<std::string>({"0xdead"}),
writer._partition_values(data));
+ IcebergPartitionData null_value({std::any()});
+ IcebergPartitionData null_text({std::string("\x9e\xe9\x65", 3)});
+ // Distinct values can share the standard Iceberg directory, but must
never share a writer.
+ EXPECT_EQ(writer._partition_to_path(null_value),
writer._partition_to_path(null_text));
+ EXPECT_NE(writer._partition_to_writer_key(null_value),
+ writer._partition_to_writer_key(null_text));
writer._init_static_partition_values();
EXPECT_TRUE(writer._is_full_static_partition);
EXPECT_EQ("key=3q0%3D", writer._static_partition_path);
diff --git
a/fe/be-java-extensions/java-common/src/main/java/org/apache/doris/common/jni/vec/VectorColumn.java
b/fe/be-java-extensions/java-common/src/main/java/org/apache/doris/common/jni/vec/VectorColumn.java
index 010bf7eba8f..ec66746c6dc 100644
---
a/fe/be-java-extensions/java-common/src/main/java/org/apache/doris/common/jni/vec/VectorColumn.java
+++
b/fe/be-java-extensions/java-common/src/main/java/org/apache/doris/common/jni/vec/VectorColumn.java
@@ -1187,6 +1187,15 @@ public class VectorColumn {
}
private void putTimeStampTz(int rowId, LocalDateTime v) {
+ // SQL NULL uses an out-of-range sentinel; only real values need range
validation.
+ if (isNullAt(rowId)) {
+ OffHeap.putLong(null, data + rowId * 8L, 0L);
+ return;
+ }
+ // Validate UTC components before bit packing, including values from
nested containers.
+ if (v.getYear() < 0 || v.getYear() > 9999) {
+ throw new IllegalArgumentException("TIMESTAMPTZ year must be
between 0 and 9999: " + v);
+ }
// TimeStampTz use the same storage format as DateTimeV2
long time = TypeNativeBytes.convertToDateTimeV2(v.getYear(),
v.getMonthValue(), v.getDayOfMonth(), v.getHour(),
v.getMinute(), v.getSecond(), v.getNano() / 1000);
diff --git
a/fe/be-java-extensions/java-common/src/test/java/org/apache/doris/common/jni/vec/VectorColumnTimestampTzTest.java
b/fe/be-java-extensions/java-common/src/test/java/org/apache/doris/common/jni/vec/VectorColumnTimestampTzTest.java
new file mode 100644
index 00000000000..6d0dbec503d
--- /dev/null
+++
b/fe/be-java-extensions/java-common/src/test/java/org/apache/doris/common/jni/vec/VectorColumnTimestampTzTest.java
@@ -0,0 +1,86 @@
+// 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.
+
+package org.apache.doris.common.jni.vec;
+
+import org.apache.doris.common.jni.utils.OffHeap;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.time.LocalDateTime;
+import java.util.Arrays;
+
+class VectorColumnTimestampTzTest {
+ @Test
+ void rejectsOutOfRangeScalarAndBatchValuesButPreservesNulls() {
+ OffHeap.setTesting();
+ VectorColumn column =
VectorColumn.createWritableColumn(ColumnType.parseType("ts", "timestamptz(6)"),
1);
+ try {
+ LocalDateTime min = LocalDateTime.of(0, 1, 1, 0, 0);
+ LocalDateTime max = LocalDateTime.of(9999, 12, 31, 23, 59, 59,
999999000);
+ column.appendTimeStampTz(min);
+ column.appendNull(ColumnType.Type.TIMESTAMPTZ);
+ column.appendTimeStampTz(new LocalDateTime[] {null, min, max},
true);
+ Assertions.assertArrayEquals(new LocalDateTime[] {min, null, null,
min, max},
+ column.getTimeStampTzColumn(0, 5));
+ for (LocalDateTime invalid : Arrays.asList(min.minusNanos(1000),
max.plusNanos(1000))) {
+ Assertions.assertThrows(IllegalArgumentException.class, () ->
column.appendTimeStampTz(invalid));
+ Assertions.assertThrows(IllegalArgumentException.class,
+ () -> column.appendTimeStampTz(new LocalDateTime[]
{invalid}, false));
+ }
+ } finally {
+ column.close();
+ }
+ }
+
+ @Test
+ void preservesYearZeroArrayElements() {
+ OffHeap.setTesting();
+ VectorColumn column = VectorColumn.createWritableColumn(
+ ColumnType.parseType("events", "array<timestamptz(6)>"), 1);
+ try {
+ LocalDateTime min = LocalDateTime.of(0, 1, 1, 0, 0);
+ Object[] values = column.newObjectContainerArray(1);
+ values[0] = new java.util.ArrayList<>(Arrays.asList(min, null,
min.plusNanos(1000)));
+ column.appendObjectColumn(values, true);
+ Assertions.assertEquals(values[0], column.getObjectColumn(0,
1)[0]);
+ } finally {
+ column.close();
+ }
+ }
+
+ @Test
+ void rejectsOutOfRangeNestedValues() {
+ OffHeap.setTesting();
+ for (String type : new String[] {"array<timestamptz(6)>",
"map<string,timestamptz(6)>",
+ "struct<ts:timestamptz(6)>"}) {
+ VectorColumn column =
VectorColumn.createWritableColumn(ColumnType.parseType("nested", type), 1);
+ try {
+ LocalDateTime invalid = LocalDateTime.of(10000, 1, 1, 0, 0);
+ Object value = type.startsWith("array") ? new
java.util.ArrayList<>(Arrays.asList(invalid))
+ : new
java.util.HashMap<>(java.util.Collections.singletonMap("ts", invalid));
+ Object[] batch = column.newObjectContainerArray(1);
+ batch[0] = value;
+ Assertions.assertThrows(IllegalArgumentException.class,
+ () -> column.appendObjectColumn(batch, true));
+ } finally {
+ column.close();
+ }
+ }
+ }
+}
diff --git a/fe/be-java-extensions/jdbc-scanner/pom.xml
b/fe/be-java-extensions/jdbc-scanner/pom.xml
index 84dd37e7946..fa866ddd6b2 100644
--- a/fe/be-java-extensions/jdbc-scanner/pom.xml
+++ b/fe/be-java-extensions/jdbc-scanner/pom.xml
@@ -35,6 +35,12 @@ under the License.
</properties>
<dependencies>
+ <dependency>
+ <groupId>com.oracle.database.jdbc</groupId>
+ <artifactId>ojdbc11</artifactId>
+ <version>23.26.2.0.0</version>
+ <scope>test</scope>
+ </dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
diff --git
a/fe/be-java-extensions/jdbc-scanner/src/main/java/org/apache/doris/jdbc/MySQLJdbcExecutor.java
b/fe/be-java-extensions/jdbc-scanner/src/main/java/org/apache/doris/jdbc/MySQLJdbcExecutor.java
index f0cb06163be..0cc7d0e2006 100644
---
a/fe/be-java-extensions/jdbc-scanner/src/main/java/org/apache/doris/jdbc/MySQLJdbcExecutor.java
+++
b/fe/be-java-extensions/jdbc-scanner/src/main/java/org/apache/doris/jdbc/MySQLJdbcExecutor.java
@@ -180,7 +180,14 @@ public class MySQLJdbcExecutor extends BaseJdbcExecutor {
}
case TIMESTAMPTZ: {
if (usesMySqlTimestampProtocol(config)) {
- return resultSet.getObject(columnIndex + 1,
LocalDateTime.class);
+ // Read the server's UTC text projection to bypass cached
driver time zones.
+ String text = resultSet.getString(columnIndex + 1);
+ if (text != null && text.startsWith("0000-00-00 ")) {
+ // The date decoder preserves zeroDateTimeBehavior
even for VARCHAR projections.
+ java.sql.Date date = resultSet.getDate(columnIndex +
1);
+ return date == null ? null :
date.toLocalDate().atStartOfDay();
+ }
+ return text == null ? null :
LocalDateTime.parse(text.replace(' ', 'T'));
}
Timestamp value = resultSet.getTimestamp(columnIndex + 1);
return value == null ? null :
LocalDateTime.ofInstant(value.toInstant(), ZoneOffset.UTC);
diff --git
a/fe/be-java-extensions/jdbc-scanner/src/main/java/org/apache/doris/jdbc/OracleJdbcExecutor.java
b/fe/be-java-extensions/jdbc-scanner/src/main/java/org/apache/doris/jdbc/OracleJdbcExecutor.java
index 12e8bed89ea..4332c1472f2 100644
---
a/fe/be-java-extensions/jdbc-scanner/src/main/java/org/apache/doris/jdbc/OracleJdbcExecutor.java
+++
b/fe/be-java-extensions/jdbc-scanner/src/main/java/org/apache/doris/jdbc/OracleJdbcExecutor.java
@@ -32,13 +32,15 @@ import java.nio.charset.CharacterCodingException;
import java.nio.charset.CharsetDecoder;
import java.nio.charset.StandardCharsets;
import java.sql.Clob;
+import java.sql.Connection;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.sql.Types;
import java.time.LocalDateTime;
-import java.time.ZoneOffset;
public class OracleJdbcExecutor extends BaseJdbcExecutor {
+ private static final java.time.format.DateTimeFormatter
TIMESTAMP_TZ_FORMATTER =
+ java.time.format.DateTimeFormatter.ofPattern("uuuu-MM-dd
HH:mm:ss.SSSSSS");
private static final Logger LOG =
Logger.getLogger(OracleJdbcExecutor.class);
private final CharsetDecoder utf8Decoder =
StandardCharsets.UTF_8.newDecoder();
private final boolean isNewJdbcVersion;
@@ -50,8 +52,27 @@ public class OracleJdbcExecutor extends BaseJdbcExecutor {
@Override
protected void setTimestampTz(int parameterIndex, LocalDateTime value)
throws SQLException {
- // An unzoned TIMESTAMP bind is session-local for both Oracle TZ and
LOCAL TIME ZONE columns.
- preparedStatement.setObject(parameterIndex,
value.atOffset(ZoneOffset.UTC), Types.TIMESTAMP_WITH_TIMEZONE);
+ // Older Oracle drivers reject JDBC type 2014; the sink SQL parses an
explicit UTC offset.
+ preparedStatement.setString(parameterIndex,
value.format(TIMESTAMP_TZ_FORMATTER) + " +00:00");
+ }
+
+ @Override
+ protected void setTimestampTzNull(int parameterIndex) throws SQLException {
+ preparedStatement.setNull(parameterIndex, Types.VARCHAR);
+ }
+
+ @Override
+ protected void initializeStatement(Connection conn, JdbcDataSourceConfig
config, String sql) throws SQLException {
+ // ALTER SESSION alone leaves the driver's TSLTZ zone unset;
initialize every borrowed connection.
+ try {
+ Connection physical = conn.unwrap(Connection.class);
+ Class<?> oracleConnection =
Class.forName("oracle.jdbc.OracleConnection", true,
+ physical.getClass().getClassLoader());
+ oracleConnection.getMethod("setSessionTimeZone",
String.class).invoke(conn.unwrap(oracleConnection), "UTC");
+ } catch (ReflectiveOperationException e) {
+ throw new SQLException("Failed to initialize Oracle session time
zone", e);
+ }
+ super.initializeStatement(conn, config, sql);
}
@Override
diff --git
a/fe/be-java-extensions/jdbc-scanner/src/main/java/org/apache/doris/jdbc/TrinoJdbcExecutor.java
b/fe/be-java-extensions/jdbc-scanner/src/main/java/org/apache/doris/jdbc/TrinoJdbcExecutor.java
index 49566afe350..a1304a74d88 100644
---
a/fe/be-java-extensions/jdbc-scanner/src/main/java/org/apache/doris/jdbc/TrinoJdbcExecutor.java
+++
b/fe/be-java-extensions/jdbc-scanner/src/main/java/org/apache/doris/jdbc/TrinoJdbcExecutor.java
@@ -42,6 +42,10 @@ import java.util.List;
public class TrinoJdbcExecutor extends BaseJdbcExecutor {
private static final DateTimeFormatter TIMESTAMP_TZ_WRITE_FORMATTER =
DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm:ss.SSSSSS");
+ private static final DateTimeFormatter ARRAY_TIMESTAMP_FORMATTER = new
java.time.format.DateTimeFormatterBuilder()
+ .appendPattern("uuuu-MM-dd HH:mm:ss")
+ .appendFraction(java.time.temporal.ChronoField.NANO_OF_SECOND, 0,
9, true)
+ .appendLiteral(' ').appendZoneOrOffsetId().toFormatter();
public TrinoJdbcExecutor(byte[] thriftParams) throws Exception {
super(thriftParams);
@@ -49,6 +53,11 @@ public class TrinoJdbcExecutor extends BaseJdbcExecutor {
@Override
protected void setTimestampTz(int parameterIndex, LocalDateTime value)
throws SQLException {
+ if (config != null && config.getTableType() ==
org.apache.doris.thrift.TOdbcTableType.PRESTO) {
+ // PrestoDB requires VARCHAR plus a server-side cast; its driver
rejects JDBC type 2014.
+ preparedStatement.setString(parameterIndex,
value.format(TIMESTAMP_TZ_WRITE_FORMATTER) + " UTC");
+ return;
+ }
// Trino/Presto require a string for typed zoned binds; Timestamp
drops the zone and sub-millisecond digits.
preparedStatement.setObject(parameterIndex,
value.format(TIMESTAMP_TZ_WRITE_FORMATTER) + " UTC",
Types.TIMESTAMP_WITH_TIMEZONE);
@@ -57,7 +66,9 @@ public class TrinoJdbcExecutor extends BaseJdbcExecutor {
@Override
protected void setTimestampTzNull(int parameterIndex) throws SQLException {
// These drivers reject TIMESTAMP_WITH_TIMEZONE in setNull; SQL NULL
is coerced by the target column.
- preparedStatement.setNull(parameterIndex, Types.NULL);
+ preparedStatement.setNull(parameterIndex,
+ config != null && config.getTableType() ==
org.apache.doris.thrift.TOdbcTableType.PRESTO
+ ? Types.VARCHAR : Types.NULL);
}
@Override
@@ -79,6 +90,11 @@ public class TrinoJdbcExecutor extends BaseJdbcExecutor {
protected Object getColumnValue(int columnIndex, ColumnType type, String[]
replaceStringList) throws SQLException {
switch (type.getType()) {
case TIMESTAMPTZ: {
+ if (config != null && config.getTableType() ==
org.apache.doris.thrift.TOdbcTableType.PRESTO) {
+ // PrestoDB does not implement typed getObject for zoned
timestamps.
+ Timestamp value = resultSet.getTimestamp(columnIndex + 1);
+ return value == null ? null :
LocalDateTime.ofInstant(value.toInstant(), ZoneOffset.UTC);
+ }
// JdbcScanNode projects to UTC before the driver can lose a
named-zone overlap offset.
// JNI carries instants as UTC components, not the source
zone's wall clock.
ZonedDateTime value = resultSet.getObject(columnIndex + 1,
ZonedDateTime.class);
@@ -167,7 +183,7 @@ public class TrinoJdbcExecutor extends BaseJdbcExecutor {
// Trino JDBC exposes timestamp-with-zone array elements as
java.sql.Timestamp.
for (Object element : array) {
result.add(element == null ? null
- : LocalDateTime.ofInstant(((Timestamp)
element).toInstant(), ZoneOffset.UTC));
+ : convertTimestampTzArrayElement(element));
}
return result;
}
@@ -195,4 +211,16 @@ public class TrinoJdbcExecutor extends BaseJdbcExecutor {
return array;
}
}
+
+ private static LocalDateTime convertTimestampTzArrayElement(Object
element) {
+ // PrestoDB returns zoned text for arrays, whereas Trino returns
Timestamp objects.
+ java.time.Instant instant;
+ if (element instanceof String) {
+ instant = ZonedDateTime.parse((String) element,
ARRAY_TIMESTAMP_FORMATTER).toInstant();
+ } else {
+ instant = ((Timestamp) element).toInstant();
+ }
+ return LocalDateTime.ofInstant(instant, ZoneOffset.UTC);
+ }
+
}
diff --git
a/fe/be-java-extensions/jdbc-scanner/src/test/java/org/apache/doris/jdbc/JdbcTimestampBackportTest.java
b/fe/be-java-extensions/jdbc-scanner/src/test/java/org/apache/doris/jdbc/JdbcTimestampBackportTest.java
new file mode 100644
index 00000000000..8977b53fdbe
--- /dev/null
+++
b/fe/be-java-extensions/jdbc-scanner/src/test/java/org/apache/doris/jdbc/JdbcTimestampBackportTest.java
@@ -0,0 +1,70 @@
+// 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.
+
+package org.apache.doris.jdbc;
+
+import org.apache.doris.common.jni.vec.ColumnType;
+import org.apache.doris.thrift.TOdbcTableType;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.time.LocalDateTime;
+import java.util.Arrays;
+
+class JdbcTimestampBackportTest {
+ @Test
+ void mysqlReadsProjectedTextAndHonorsZeroDates() throws Exception {
+ MySQLJdbcExecutor executor = Mockito.mock(MySQLJdbcExecutor.class,
Mockito.CALLS_REAL_METHODS);
+ executor.config = new
JdbcDataSourceConfig().setTableType(TOdbcTableType.MYSQL);
+ executor.resultSet = Mockito.mock(ResultSet.class);
+ ColumnType type = ColumnType.parseType("ts", "timestamptz(6)");
+ Mockito.when(executor.resultSet.getString(1)).thenReturn("2021-11-07
06:30:00.123456");
+
Assertions.assertEquals(LocalDateTime.parse("2021-11-07T06:30:00.123456"),
+ executor.getColumnValue(0, type, new String[0]));
+ Mockito.when(executor.resultSet.getString(1)).thenReturn("0000-00-00
00:00:00");
+ Assertions.assertNull(executor.getColumnValue(0, type, new String[0]));
+ Mockito.verify(executor.resultSet).getDate(1);
+ }
+
+ @Test
+ void prestoReadsZonedStringsInNestedArrays() {
+ TrinoJdbcExecutor executor = Mockito.mock(TrinoJdbcExecutor.class,
Mockito.CALLS_REAL_METHODS);
+ executor.config = new
JdbcDataSourceConfig().setTableType(TOdbcTableType.PRESTO);
+ ColumnType type = ColumnType.parseType("ts",
"array<array<timestamptz(6)>>");
+ Object input = Arrays.asList(Arrays.asList("1969-12-31 23:59:59.999999
UTC", null));
+ Object expected =
Arrays.asList(Arrays.asList(LocalDateTime.parse("1969-12-31T23:59:59.999999"),
null));
+ Assertions.assertEquals(expected, executor.getOutputConverter(type,
"").convert(new Object[] {input})[0]);
+ }
+
+ @Test
+ void legacyDriversBindZonedTimestampsAsText() throws Exception {
+ LocalDateTime value =
LocalDateTime.parse("2021-11-07T06:30:00.123456");
+ OracleJdbcExecutor oracle = Mockito.mock(OracleJdbcExecutor.class,
Mockito.CALLS_REAL_METHODS);
+ oracle.preparedStatement = Mockito.mock(PreparedStatement.class);
+ oracle.setTimestampTz(1, value);
+ Mockito.verify(oracle.preparedStatement).setString(1, "2021-11-07
06:30:00.123456 +00:00");
+ TrinoJdbcExecutor presto = Mockito.mock(TrinoJdbcExecutor.class,
Mockito.CALLS_REAL_METHODS);
+ presto.config = new
JdbcDataSourceConfig().setTableType(TOdbcTableType.PRESTO);
+ presto.preparedStatement = Mockito.mock(PreparedStatement.class);
+ presto.setTimestampTz(1, value);
+ Mockito.verify(presto.preparedStatement).setString(1, "2021-11-07
06:30:00.123456 UTC");
+ }
+}
diff --git
a/fe/be-java-extensions/jdbc-scanner/src/test/java/org/apache/doris/jdbc/JdbcTimestampBindTest.java
b/fe/be-java-extensions/jdbc-scanner/src/test/java/org/apache/doris/jdbc/JdbcTimestampBindTest.java
index 96f088fae52..ac90f59594f 100644
---
a/fe/be-java-extensions/jdbc-scanner/src/test/java/org/apache/doris/jdbc/JdbcTimestampBindTest.java
+++
b/fe/be-java-extensions/jdbc-scanner/src/test/java/org/apache/doris/jdbc/JdbcTimestampBindTest.java
@@ -87,6 +87,9 @@ class JdbcTimestampBindTest {
Mockito.verify(executor.preparedStatement).setObject(1,
utc.format(DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm:ss.SSSSSS")) + " UTC",
Types.TIMESTAMP_WITH_TIMEZONE);
+ } else if (dialect == OracleJdbcExecutor.class) {
+ Mockito.verify(executor.preparedStatement).setString(1,
+
utc.format(DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm:ss.SSSSSS")) + "
+00:00");
} else if (dialect == ClickHouseJdbcExecutor.class) {
Mockito.verify(executor.preparedStatement).setObject(1,
utc.atOffset(ZoneOffset.UTC));
} else {
@@ -99,7 +102,8 @@ class JdbcTimestampBindTest {
column.appendTimeStampTz(new LocalDateTime[] {null}, true);
insert.invoke(executor, 0, 0, column);
Mockito.verify(executor.preparedStatement).setNull(1,
- dialect == TrinoJdbcExecutor.class ? Types.NULL :
Types.TIMESTAMP_WITH_TIMEZONE);
+ dialect == TrinoJdbcExecutor.class ? Types.NULL
+ : dialect == OracleJdbcExecutor.class ?
Types.VARCHAR : Types.TIMESTAMP_WITH_TIMEZONE);
Mockito.clearInvocations(executor.preparedStatement);
}
} finally {
diff --git
a/fe/be-java-extensions/jdbc-scanner/src/test/java/org/apache/doris/jdbc/JdbcTimestampSemanticsTest.java
b/fe/be-java-extensions/jdbc-scanner/src/test/java/org/apache/doris/jdbc/JdbcTimestampSemanticsTest.java
index 9fd92797008..0dacaf3ce3d 100644
---
a/fe/be-java-extensions/jdbc-scanner/src/test/java/org/apache/doris/jdbc/JdbcTimestampSemanticsTest.java
+++
b/fe/be-java-extensions/jdbc-scanner/src/test/java/org/apache/doris/jdbc/JdbcTimestampSemanticsTest.java
@@ -167,7 +167,7 @@ class JdbcTimestampSemanticsTest {
MySQLJdbcExecutor executor = Mockito.mock(MySQLJdbcExecutor.class,
Mockito.CALLS_REAL_METHODS);
executor.config = new
JdbcDataSourceConfig().setTableType(org.apache.doris.thrift.TOdbcTableType.OCEANBASE);
executor.resultSet = Mockito.mock(ResultSet.class);
- Mockito.when(executor.resultSet.getObject(1,
LocalDateTime.class)).thenReturn(UTC_VALUE);
+
Mockito.when(executor.resultSet.getString(1)).thenReturn(UTC_VALUE.toString().replace('T',
' '));
Assertions.assertEquals(UTC_VALUE, executor.getColumnValue(0,
ColumnType.parseType("event_time", "timestamptz(6)"), new
String[0]));
executor.preparedStatement =
Mockito.mock(java.sql.PreparedStatement.class);
@@ -289,7 +289,7 @@ class JdbcTimestampSemanticsTest {
executor.config = new
JdbcDataSourceConfig().setTableType(org.apache.doris.thrift.TOdbcTableType.MYSQL);
ResultSet resultSet = Mockito.mock(ResultSet.class);
executor.resultSet = resultSet;
- Mockito.when(resultSet.getObject(1,
LocalDateTime.class)).thenReturn(UTC_VALUE).thenReturn(null);
+
Mockito.when(resultSet.getString(1)).thenReturn(UTC_VALUE.toString().replace('T',
' ')).thenReturn(null);
ColumnType type = ColumnType.parseType("event_time", "timestamptz(6)");
Assertions.assertEquals(UTC_VALUE, executor.getColumnValue(0, type,
new String[0]));
Assertions.assertNull(executor.getColumnValue(0, type, new String[0]));
diff --git
a/fe/be-java-extensions/jdbc-scanner/src/test/java/org/apache/doris/jdbc/JdbcZonedTimestampIntegrationTest.java
b/fe/be-java-extensions/jdbc-scanner/src/test/java/org/apache/doris/jdbc/JdbcZonedTimestampIntegrationTest.java
index 9e78d19cfc8..2ba10b0b7de 100644
---
a/fe/be-java-extensions/jdbc-scanner/src/test/java/org/apache/doris/jdbc/JdbcZonedTimestampIntegrationTest.java
+++
b/fe/be-java-extensions/jdbc-scanner/src/test/java/org/apache/doris/jdbc/JdbcZonedTimestampIntegrationTest.java
@@ -18,6 +18,7 @@
package org.apache.doris.jdbc;
import org.apache.doris.common.jni.vec.ColumnType;
+import org.apache.doris.thrift.TOdbcTableType;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
@@ -55,6 +56,9 @@ class JdbcZonedTimestampIntegrationTest {
// Both dialects must disambiguate the fold before the driver
reconstructs the instant.
withDriver(dialect, driver, connection -> {
TrinoJdbcExecutor executor = Mockito.mock(TrinoJdbcExecutor.class,
Mockito.CALLS_REAL_METHODS);
+ // Presto must exercise its untyped timestamp read and VARCHAR
bind paths.
+ executor.config = new JdbcDataSourceConfig().setTableType(
+ "presto".equals(dialect) ? TOdbcTableType.PRESTO :
TOdbcTableType.TRINO);
String values = "ARRAY[first_value, second_value, old_value,
NULL]";
String query = "WITH sample AS (SELECT "
+ "at_timezone(TIMESTAMP '2023-11-05 08:30:00.123456 UTC',
'America/Los_Angeles') first_value, "
@@ -293,12 +297,28 @@ class JdbcZonedTimestampIntegrationTest {
if (sessionSql != null) {
ddl.execute(sessionSql);
}
+ if (dialect == OracleJdbcExecutor.class) {
+ // A conflicting NLS format proves that both TZ column
kinds use the explicit format mask.
+ ddl.execute("ALTER SESSION SET NLS_TIMESTAMP_TZ_FORMAT =
'DD-MON-RR HH.MI.SSXFF AM TZR'");
+ }
ddl.execute("CREATE TABLE " + table + " (id INT, " +
String.join(", ", definitions) + ")" + suffix);
try {
BaseJdbcExecutor executor = Mockito.mock(dialect,
Mockito.CALLS_REAL_METHODS);
+ executor.config = new JdbcDataSourceConfig().setTableType(
+
TOdbcTableType.valueOf(prefix.toUpperCase(java.util.Locale.ROOT)));
String[] values = {"2020-01-02T04:01:00.111333Z",
"1969-12-31T23:59:59.999999Z",
"2023-11-05T08:30:00.123456Z",
"2023-11-05T09:30:00.123456Z", "2020-01-02T00:00:00Z", null};
- String parameters = String.join(", ",
java.util.Collections.nCopies(types.length + 1, "?"));
+ // Mirror JdbcTable's explicit conversion: these drivers
bind instants as VARCHAR.
+ String timestampParameter = "?";
+ if (dialect == OracleJdbcExecutor.class) {
+ timestampParameter = "TO_TIMESTAMP_TZ(?, 'YYYY-MM-DD
HH24:MI:SS.FF6 TZH:TZM')";
+ } else if ("presto".equals(prefix)) {
+ timestampParameter =
"io.prestosql.jdbc.PrestoDriver".equals(driver)
+ ? "CAST(? AS TIMESTAMP(6) WITH TIME ZONE)"
+ : "CAST(? AS TIMESTAMP WITH TIME ZONE)";
+ }
+ String parameters = "?, " + String.join(", ",
+ java.util.Collections.nCopies(types.length,
timestampParameter));
try (java.sql.PreparedStatement insert =
connection.prepareStatement(
"INSERT INTO " + table + " VALUES (" + parameters
+ ")")) {
executor.preparedStatement = insert;
@@ -330,6 +350,10 @@ class JdbcZonedTimestampIntegrationTest {
long micros = rows.getLong(col);
actual = rows.wasNull() ? null :
Instant.ofEpochSecond(Math.floorDiv(micros, 1_000_000),
Math.floorMod(micros, 1_000_000) *
1000);
+ } else if ("presto".equals(prefix)) {
+ // PrestoDB does not implement typed
getObject for zoned timestamps.
+ java.sql.Timestamp value =
rows.getTimestamp(col);
+ actual = value == null ? null :
value.toInstant();
} else if (dialect == TrinoJdbcExecutor.class)
{
java.time.ZonedDateTime value =
rows.getObject(col, java.time.ZonedDateTime.class);
actual = value == null ? null :
value.toInstant();
@@ -349,11 +373,33 @@ class JdbcZonedTimestampIntegrationTest {
});
}
+ @Test
+ void testOracleDriverIsIsolatedFromTestClasspath() throws Exception {
+ URL jar =
oracle.jdbc.OracleDriver.class.getProtectionDomain().getCodeSource().getLocation();
+ try (URLClassLoader loader = createDriverClassLoader(jar,
"oracle.jdbc.OracleDriver")) {
+ Class<?> selected = loader.loadClass("oracle.jdbc.OracleDriver");
+ Assertions.assertSame(loader, selected.getClassLoader());
+ Assertions.assertNotSame(oracle.jdbc.OracleDriver.class, selected);
+ Assertions.assertEquals(jar,
selected.getProtectionDomain().getCodeSource().getLocation());
+ Assertions.assertTrue(Driver.class.isAssignableFrom(selected));
+ }
+ }
+
+ private URLClassLoader createDriverClassLoader(URL jar, String
driverClass) {
+ // The Oracle mock dependency must not shadow the JAR selected for
integration testing.
+ ClassLoader parent = driverClass.startsWith("oracle.")
+ ? Driver.class.getClassLoader() : getClass().getClassLoader();
+ return new URLClassLoader(new URL[] {jar}, parent);
+ }
+
private void withDriver(String prefix, String driverClass, Check check)
throws Exception {
TimeZone original = TimeZone.getDefault();
URL jar = new File(System.getProperty(prefix +
".integration.driverJar")).toURI().toURL();
- try (URLClassLoader loader = new URLClassLoader(new URL[] {jar},
getClass().getClassLoader())) {
- Driver driver = (Driver)
loader.loadClass(driverClass).getDeclaredConstructor().newInstance();
+ try (URLClassLoader loader = createDriverClassLoader(jar,
driverClass)) {
+ Class<?> selected = loader.loadClass(driverClass);
+ Assertions.assertEquals(jar,
selected.getProtectionDomain().getCodeSource().getLocation(),
+ "The integration test must load the configured driver
JAR");
+ Driver driver = (Driver)
selected.getDeclaredConstructor().newInstance();
for (String zone : new String[] {"UTC", "Asia/Shanghai",
"America/New_York"}) {
TimeZone.setDefault(TimeZone.getTimeZone(zone));
Properties properties = new Properties();
diff --git
a/fe/be-java-extensions/jdbc-scanner/src/test/java/org/apache/doris/jdbc/MySqlTimestampIntegrationTest.java
b/fe/be-java-extensions/jdbc-scanner/src/test/java/org/apache/doris/jdbc/MySqlTimestampIntegrationTest.java
index 365aef08755..2486e499d84 100644
---
a/fe/be-java-extensions/jdbc-scanner/src/test/java/org/apache/doris/jdbc/MySqlTimestampIntegrationTest.java
+++
b/fe/be-java-extensions/jdbc-scanner/src/test/java/org/apache/doris/jdbc/MySqlTimestampIntegrationTest.java
@@ -19,8 +19,6 @@ package org.apache.doris.jdbc;
import org.apache.doris.common.jni.vec.ColumnType;
import org.apache.doris.common.jni.vec.VectorColumn;
-import org.apache.doris.thrift.TJdbcOperation;
-import org.apache.doris.thrift.TOdbcTableType;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
@@ -35,6 +33,7 @@ import java.sql.Connection;
import java.sql.Driver;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
+import java.sql.SQLException;
import java.sql.Statement;
import java.time.Instant;
import java.time.LocalDateTime;
@@ -61,6 +60,83 @@ class MySqlTimestampIntegrationTest {
runMatrix(true);
}
+ @Test
+ void testReadIgnoresCachedServerTimezoneAfterUtcReset() throws Exception {
+ TimeZone original = TimeZone.getDefault();
+ TimeZone.setDefault(TimeZone.getTimeZone("UTC"));
+ URL driverUrl = new
File(System.getProperty("mysql.integration.driverJar")).toURI().toURL();
+ try (URLClassLoader loader = new URLClassLoader(new URL[] {driverUrl},
getClass().getClassLoader())) {
+ Driver driver = (Driver)
loader.loadClass(System.getProperty("mysql.integration.driverClass",
+
"com.mysql.cj.jdbc.Driver")).getDeclaredConstructor().newInstance();
+ for (boolean serverPrepared : new boolean[] {false, true}) {
+ String baseUrl = System.getProperty("mysql.integration.url");
+ String url = baseUrl + (baseUrl.contains("?") ? "&" : "?")
+ +
"useTimezone=true&serverTimezone=Asia/Shanghai&useServerPrepStmts=" +
serverPrepared;
+ Properties properties = new Properties();
+ properties.setProperty("user",
System.getProperty("mysql.integration.user", "root"));
+ properties.setProperty("password",
System.getProperty("mysql.integration.password", ""));
+ try (Connection connection = driver.connect(url, properties)) {
+ seed(connection, "+08:00");
+ MySQLJdbcExecutor handler = handler();
+ try (PreparedStatement statement = prepare(handler,
connection,
+ "SELECT CAST(event_time AS CHAR) FROM
timestamp_roundtrip ORDER BY id", 100);
+ ResultSet rows = statement.executeQuery()) {
+ for (String value : VALUES) {
+ Assertions.assertTrue(rows.next());
+
Assertions.assertEquals(LocalDateTime.ofInstant(Instant.parse(value),
ZoneOffset.UTC),
+ read(handler, rows, 1, INSTANT_TYPE));
+ }
+ Assertions.assertTrue(rows.next());
+ Assertions.assertNull(read(handler, rows, 1,
INSTANT_TYPE));
+ Assertions.assertFalse(rows.next());
+ }
+ }
+ }
+ } finally {
+ TimeZone.setDefault(original);
+ }
+ }
+
+ @Test
+ void testZeroTimestampRespectsDriverPolicy() throws Exception {
+ URL driverUrl = new
File(System.getProperty("mysql.integration.driverJar")).toURI().toURL();
+ try (URLClassLoader loader = new URLClassLoader(new URL[] {driverUrl},
getClass().getClassLoader())) {
+ Driver driver = (Driver)
loader.loadClass(System.getProperty("mysql.integration.driverClass",
+
"com.mysql.cj.jdbc.Driver")).getDeclaredConstructor().newInstance();
+ for (String policy : new String[] {"convertToNull", "round",
"exception"}) {
+ for (boolean serverPrepared : new boolean[] {false, true}) {
+ String baseUrl =
System.getProperty("mysql.integration.url");
+ String url = baseUrl + (baseUrl.contains("?") ? "&" : "?")
+ + "zeroDateTimeBehavior=" + policy +
"&useServerPrepStmts=" + serverPrepared;
+ Properties properties = new Properties();
+ properties.setProperty("user",
System.getProperty("mysql.integration.user", "root"));
+ properties.setProperty("password",
System.getProperty("mysql.integration.password", ""));
+ try (Connection connection = driver.connect(url,
properties)) {
+ try (Statement statement =
connection.createStatement()) {
+ statement.execute("SET SESSION sql_mode = ''");
+ statement.execute("CREATE TEMPORARY TABLE
zero_timestamp (ts TIMESTAMP(6) NULL)");
+ statement.execute("INSERT INTO zero_timestamp
VALUES ('0000-00-00 00:00:00')");
+ }
+ MySQLJdbcExecutor handler = handler();
+ try (PreparedStatement statement = prepare(handler,
connection,
+ "SELECT CAST(ts AS CHAR) FROM zero_timestamp",
100);
+ ResultSet rows = statement.executeQuery()) {
+ Assertions.assertTrue(rows.next());
+ if (policy.equals("exception")) {
+ Assertions.assertThrows(SQLException.class,
+ () -> read(handler, rows, 1,
INSTANT_TYPE));
+ } else {
+ Assertions.assertEquals(policy.equals("round")
+ ? LocalDateTime.of(1, 1, 1, 0,
0) : null,
+ read(handler, rows, 1, INSTANT_TYPE));
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
private void runMatrix(boolean write) throws Exception {
TimeZone original = TimeZone.getDefault();
URL driverUrl = new
File(System.getProperty("mysql.integration.driverJar")).toURI().toURL();
@@ -114,39 +190,37 @@ class MySqlTimestampIntegrationTest {
}
}
- private MySQLJdbcExecutor executor(Connection connection, TJdbcOperation
operation, String sql) throws Exception {
- // Bypass only the JNI bootstrap; statements, result sets and timezone
decoding use the real driver.
+ private MySQLJdbcExecutor handler() {
+ // OceanBase MySQL mode shares the same wire contract as MySQL.
MySQLJdbcExecutor executor = Mockito.mock(MySQLJdbcExecutor.class,
Mockito.CALLS_REAL_METHODS);
- // The OceanBase MySQL-mode path must share this wire contract, not
just the schema mapping.
- TOdbcTableType tableType =
TOdbcTableType.valueOf(System.getProperty("mysql.integration.tableType",
"MYSQL"));
- JdbcDataSourceConfig config = new
JdbcDataSourceConfig().setOp(operation).setTableType(tableType);
- executor.config = config;
- executor.initializeStatement(connection, config, sql);
+ executor.config = new
JdbcDataSourceConfig().setTableType(org.apache.doris.thrift.TOdbcTableType.valueOf(
+ System.getProperty("mysql.integration.tableType", "MYSQL")));
return executor;
}
private void verifyRead(Connection connection) throws Exception {
- MySQLJdbcExecutor executor = executor(connection, TJdbcOperation.READ,
- "SELECT event_time, local_time FROM timestamp_roundtrip ORDER
BY id");
- try (PreparedStatement statement = (PreparedStatement) executor.stmt;
ResultSet rows = statement.executeQuery()) {
- executor.resultSet = rows;
+ MySQLJdbcExecutor executor = handler();
+ try (PreparedStatement statement = prepare(executor, connection,
+ "SELECT CAST(event_time AS CHAR), local_time FROM
timestamp_roundtrip ORDER BY id", 100);
+ ResultSet rows = statement.executeQuery()) {
for (String value : VALUES) {
Assertions.assertTrue(rows.next());
LocalDateTime expected =
LocalDateTime.ofInstant(Instant.parse(value), ZoneOffset.UTC);
- Assertions.assertEquals(expected, executor.getColumnValue(0,
INSTANT_TYPE, new String[0]));
- Assertions.assertEquals(expected, executor.getColumnValue(1,
LOCAL_TYPE, new String[0]));
+ Assertions.assertEquals(expected, read(executor, rows, 1,
INSTANT_TYPE));
+ Assertions.assertEquals(expected, read(executor, rows, 2,
LOCAL_TYPE));
}
Assertions.assertTrue(rows.next());
- Assertions.assertNull(executor.getColumnValue(0, INSTANT_TYPE, new
String[0]));
- Assertions.assertNull(executor.getColumnValue(1, LOCAL_TYPE, new
String[0]));
+ Assertions.assertNull(read(executor, rows, 1, INSTANT_TYPE));
+ Assertions.assertNull(read(executor, rows, 2, LOCAL_TYPE));
Assertions.assertFalse(rows.next());
}
}
private void verifyWrite(Connection connection) throws Exception {
- MySQLJdbcExecutor executor = executor(connection, TJdbcOperation.WRITE,
- "INSERT INTO timestamp_roundtrip (id, event_time) VALUES (4,
?)");
+ MySQLJdbcExecutor executor = handler();
Instant instant = Instant.parse(VALUES[0]);
+ executor.initializeStatement(connection,
executor.config.setOp(org.apache.doris.thrift.TJdbcOperation.WRITE),
+ "INSERT INTO timestamp_roundtrip (id, event_time) VALUES (4,
?)");
VectorColumn column = Mockito.mock(VectorColumn.class);
Mockito.when(column.getColumnPrimitiveType()).thenReturn(ColumnType.Type.TIMESTAMPTZ);
Mockito.when(column.getTimeStampTz(0)).thenReturn(LocalDateTime.ofInstant(instant,
ZoneOffset.UTC));
@@ -164,4 +238,16 @@ class MySqlTimestampIntegrationTest {
rows.getBigDecimal(1).movePointRight(6).longValueExact());
}
}
+
+ private PreparedStatement prepare(MySQLJdbcExecutor executor, Connection
connection, String sql, int batchSize)
+ throws SQLException {
+ executor.initializeStatement(connection,
+
executor.config.setOp(org.apache.doris.thrift.TJdbcOperation.READ).setBatchSize(batchSize),
sql);
+ return (PreparedStatement) executor.stmt;
+ }
+
+ private Object read(MySQLJdbcExecutor executor, ResultSet rows, int index,
ColumnType type) throws SQLException {
+ executor.resultSet = rows;
+ return executor.getColumnValue(index - 1, type, new String[0]);
+ }
}
diff --git
a/fe/be-java-extensions/jdbc-scanner/src/test/java/org/apache/doris/jdbc/OracleSessionTimeZoneTest.java
b/fe/be-java-extensions/jdbc-scanner/src/test/java/org/apache/doris/jdbc/OracleSessionTimeZoneTest.java
new file mode 100644
index 00000000000..ee525d7f1e4
--- /dev/null
+++
b/fe/be-java-extensions/jdbc-scanner/src/test/java/org/apache/doris/jdbc/OracleSessionTimeZoneTest.java
@@ -0,0 +1,73 @@
+// 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.
+
+
+package org.apache.doris.jdbc;
+
+import oracle.jdbc.OracleConnection;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.mockito.InOrder;
+import org.mockito.Mockito;
+
+import java.sql.Connection;
+import java.sql.DatabaseMetaData;
+import java.sql.PreparedStatement;
+import java.sql.SQLException;
+
+class OracleSessionTimeZoneTest {
+ @Test
+ void initializesEveryBorrowedConnectionBeforePreparingQuery() throws
Exception {
+ OracleJdbcExecutor handler = Mockito.mock(OracleJdbcExecutor.class,
Mockito.CALLS_REAL_METHODS);
+ for (int i = 0; i < 2; i++) {
+ Connection pooled = Mockito.mock(Connection.class);
+ OracleConnection physical = Mockito.mock(OracleConnection.class);
+ DatabaseMetaData metadata = Mockito.mock(DatabaseMetaData.class);
+ PreparedStatement statement =
Mockito.mock(PreparedStatement.class);
+ Mockito.when(metadata.getDriverVersion()).thenReturn("23.26.2");
+ Mockito.when(pooled.getMetaData()).thenReturn(metadata);
+ Mockito.when(pooled.unwrap(Connection.class)).thenReturn(physical);
+
Mockito.when(pooled.unwrap(OracleConnection.class)).thenReturn(physical);
+ Mockito.when(pooled.prepareStatement(Mockito.anyString(),
Mockito.anyInt(), Mockito.anyInt()))
+ .thenReturn(statement);
+ handler.initializeStatement(pooled, new
JdbcDataSourceConfig().setOp(
+
org.apache.doris.thrift.TJdbcOperation.READ).setBatchSize(100), "SELECT ts FROM
t");
+ InOrder order = Mockito.inOrder(physical, pooled);
+ order.verify(physical).setSessionTimeZone("UTC");
+ order.verify(pooled).prepareStatement(Mockito.anyString(),
Mockito.anyInt(), Mockito.anyInt());
+ }
+ }
+
+ @Test
+ void failedSessionInitializationAbortsBeforeQuery() throws Exception {
+ Connection pooled = Mockito.mock(Connection.class);
+ OracleConnection physical = Mockito.mock(OracleConnection.class);
+ DatabaseMetaData metadata = Mockito.mock(DatabaseMetaData.class);
+ Mockito.when(metadata.getDriverVersion()).thenReturn("23.26.2");
+ Mockito.when(pooled.getMetaData()).thenReturn(metadata);
+ Mockito.when(pooled.unwrap(Connection.class)).thenReturn(physical);
+
Mockito.when(pooled.unwrap(OracleConnection.class)).thenReturn(physical);
+ Mockito.when(pooled.prepareStatement(Mockito.anyString(),
Mockito.anyInt(), Mockito.anyInt()))
+ .thenReturn(Mockito.mock(PreparedStatement.class));
+ Mockito.doThrow(new SQLException("session initialization
failed")).when(physical).setSessionTimeZone("UTC");
+ Assertions.assertThrows(SQLException.class,
+ () -> Mockito.mock(OracleJdbcExecutor.class,
Mockito.CALLS_REAL_METHODS).initializeStatement(pooled,
+ new
JdbcDataSourceConfig().setOp(org.apache.doris.thrift.TJdbcOperation.READ),
"SELECT ts FROM t"));
+ Mockito.verify(pooled, Mockito.never()).prepareStatement(
+ Mockito.anyString(), Mockito.anyInt(), Mockito.anyInt());
+ }
+}
diff --git
a/fe/be-java-extensions/max-compute-connector/src/main/java/org/apache/doris/maxcompute/MaxComputeJniWriter.java
b/fe/be-java-extensions/max-compute-connector/src/main/java/org/apache/doris/maxcompute/MaxComputeJniWriter.java
index e95bb05aaf0..0bed49286d6 100644
---
a/fe/be-java-extensions/max-compute-connector/src/main/java/org/apache/doris/maxcompute/MaxComputeJniWriter.java
+++
b/fe/be-java-extensions/max-compute-connector/src/main/java/org/apache/doris/maxcompute/MaxComputeJniWriter.java
@@ -52,7 +52,10 @@ import org.apache.arrow.vector.Float4Vector;
import org.apache.arrow.vector.Float8Vector;
import org.apache.arrow.vector.IntVector;
import org.apache.arrow.vector.SmallIntVector;
+import org.apache.arrow.vector.TimeStampMicroTZVector;
+import org.apache.arrow.vector.TimeStampMicroVector;
import org.apache.arrow.vector.TimeStampMilliVector;
+import org.apache.arrow.vector.TimeStampVector;
import org.apache.arrow.vector.TinyIntVector;
import org.apache.arrow.vector.VarBinaryVector;
import org.apache.arrow.vector.VarCharVector;
@@ -960,6 +963,12 @@ public class MaxComputeJniWriter extends JniWriter {
BigDecimal bd = elem instanceof BigDecimal ? (BigDecimal) elem
: new BigDecimal(elem.toString());
((DecimalVector) vec).setSafe(idx, bd);
+ } else if (vec instanceof TimeStampMicroVector || vec instanceof
TimeStampMicroTZVector) {
+ // Nested TIMESTAMPTZ uses the same UTC microsecond carrier as
top-level TIMESTAMP writes.
+ java.time.Instant instant = elem instanceof java.sql.Timestamp
+ ? ((java.sql.Timestamp) elem).toInstant()
+ : ((LocalDateTime)
elem).toInstant(java.time.ZoneOffset.UTC);
+ ((TimeStampVector) vec).setSafe(idx, instant.getEpochSecond() *
1_000_000L + instant.getNano() / 1000);
} else if (vec instanceof StructVector) {
StructVector structVec = (StructVector) vec;
structVec.setIndexDefined(idx);
diff --git
a/fe/be-java-extensions/max-compute-connector/src/test/java/org/apache/doris/maxcompute/MaxComputeJniWriterTest.java
b/fe/be-java-extensions/max-compute-connector/src/test/java/org/apache/doris/maxcompute/MaxComputeJniWriterTest.java
index 6e1a6ad6312..cf73ca91b5f 100644
---
a/fe/be-java-extensions/max-compute-connector/src/test/java/org/apache/doris/maxcompute/MaxComputeJniWriterTest.java
+++
b/fe/be-java-extensions/max-compute-connector/src/test/java/org/apache/doris/maxcompute/MaxComputeJniWriterTest.java
@@ -262,4 +262,44 @@ public class MaxComputeJniWriterTest {
return bytes;
};
}
+
+ @Test
+ public void testNestedTimestampWriteKeepsUtcMicros() throws Exception {
+ java.util.Map<String, String> params = new java.util.HashMap<>();
+ params.put("endpoint", "http://localhost");
+ params.put("project", "test_project");
+ params.put("table", "events");
+ params.put("txn_id", "1");
+ params.put("write_session_id", "test_session");
+ params.put("fe_host", "localhost");
+ params.put("fe_port", "9020");
+ MaxComputeJniWriter writer = new MaxComputeJniWriter(1, params);
+ try {
+ for (String timezone : new String[] {null, "UTC"}) {
+ try (BufferAllocator allocator = new RootAllocator();
+ org.apache.arrow.vector.complex.ListVector vector =
+
org.apache.arrow.vector.complex.ListVector.empty("events", allocator)) {
+
vector.initializeChildrenFromFields(Collections.singletonList(new
org.apache.arrow.vector.types.pojo.Field(
+ "element",
org.apache.arrow.vector.types.pojo.FieldType.nullable(
+ new
org.apache.arrow.vector.types.pojo.ArrowType.Timestamp(
+
org.apache.arrow.vector.types.TimeUnit.MICROSECOND, timezone)), null)));
+ vector.allocateNew();
+ java.lang.reflect.Method write =
MaxComputeJniWriter.class.getDeclaredMethod(
+ "writeListElement",
org.apache.arrow.vector.FieldVector.class, int.class, Object.class);
+ write.setAccessible(true);
+ write.invoke(writer, vector, 0, java.util.Arrays.asList(
+ java.time.LocalDateTime.of(1969, 12, 31, 23, 59,
59, 999999000), null,
+ java.time.LocalDateTime.of(2021, 11, 7, 6, 30, 0,
123456000)));
+ org.apache.arrow.vector.TimeStampVector values =
+ (org.apache.arrow.vector.TimeStampVector)
vector.getDataVector();
+ Assert.assertEquals(-1L, values.get(0));
+ Assert.assertTrue(values.isNull(1));
+ Assert.assertEquals(1636266600123456L, values.get(2));
+ }
+ }
+ } finally {
+ writer.close();
+ }
+ }
+
}
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/analysis/VarBinaryLiteral.java
b/fe/fe-core/src/main/java/org/apache/doris/analysis/VarBinaryLiteral.java
index 8cc93cdd416..599692cba7d 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/analysis/VarBinaryLiteral.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/analysis/VarBinaryLiteral.java
@@ -92,33 +92,9 @@ public class VarBinaryLiteral extends LiteralExpr {
@Override
public int compareLiteral(LiteralExpr other) {
if (other instanceof VarBinaryLiteral) {
- byte[] thisBytes = this.value;
- byte[] otherBytes = ((VarBinaryLiteral) other).value;
-
- int minLength = Math.min(thisBytes.length, otherBytes.length);
- int i = 0;
- for (i = 0; i < minLength; i++) {
- if (Byte.toUnsignedInt(thisBytes[i]) <
Byte.toUnsignedInt(otherBytes[i])) {
- return -1;
- } else if (Byte.toUnsignedInt(thisBytes[i]) >
Byte.toUnsignedInt(otherBytes[i])) {
- return 1;
- }
- }
- if (thisBytes.length > otherBytes.length) {
- if (thisBytes[i] == 0x00) {
- return 0;
- } else {
- return 1;
- }
- } else if (thisBytes.length < otherBytes.length) {
- if (otherBytes[i] == 0x00) {
- return 0;
- } else {
- return -1;
- }
- } else {
- return 0;
- }
+ // VARBINARY is length-sensitive: trailing zero bytes are data,
not string padding.
+ return
com.google.common.primitives.UnsignedBytes.lexicographicalComparator()
+ .compare(value, ((VarBinaryLiteral) other).value);
}
if (other instanceof NullLiteral) {
return 1;
diff --git a/fe/fe-core/src/main/java/org/apache/doris/catalog/JdbcTable.java
b/fe/fe-core/src/main/java/org/apache/doris/catalog/JdbcTable.java
index 25a4bcefaf1..bebb13e5b3b 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/catalog/JdbcTable.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/catalog/JdbcTable.java
@@ -152,7 +152,18 @@ public class JdbcTable extends Table {
if (i != 0) {
sb.append(", ");
}
- sb.append("?");
+ Column column = getColumn(insertCols.get(i));
+ TOdbcTableType remoteType = getJdbcTableType();
+ // Legacy drivers bind zoned timestamps as VARCHAR; parse their
explicit offsets on the server.
+ if (column != null && column.getType().isTimeStampTz()
+ && (remoteType == TOdbcTableType.ORACLE || remoteType ==
TOdbcTableType.OCEANBASE_ORACLE)) {
+ sb.append("TO_TIMESTAMP_TZ(?, 'YYYY-MM-DD HH24:MI:SS.FF6
TZH:TZM')");
+ } else if (column != null && column.getType().isTimeStampTz() &&
remoteType == TOdbcTableType.PRESTO) {
+
sb.append("io.prestosql.jdbc.PrestoDriver".equals(getDriverClass())
+ ? "CAST(? AS TIMESTAMP(6) WITH TIME ZONE)" : "CAST(?
AS TIMESTAMP WITH TIME ZONE)");
+ } else {
+ sb.append("?");
+ }
}
sb.append(")");
return sb.toString();
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/datasource/jdbc/client/JdbcClient.java
b/fe/fe-core/src/main/java/org/apache/doris/datasource/jdbc/client/JdbcClient.java
index ff4cc29eb10..445bcc2d182 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/datasource/jdbc/client/JdbcClient.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/datasource/jdbc/client/JdbcClient.java
@@ -196,6 +196,32 @@ public abstract class JdbcClient {
dataSource = null;
}
+ public static Set<String> readSessionSqlMode(JdbcClientConfig config) {
+ // Mode lookup needs only one MySQL-protocol session, not metadata
discovery or dialect-specific clients.
+ JdbcClient client = new JdbcMySQLClient(config,
parseDbType(config.getJdbcUrl()));
+ try {
+ return client.getSessionSqlMode();
+ } finally {
+ client.closeClient();
+ }
+ }
+
+ public Set<String> getSessionSqlMode() {
+ // Read the connected session so URL sessionVariables and server SQL
mode are both honored.
+ try (Connection connection = getConnection();
+ java.sql.Statement statement = connection.createStatement();
+ ResultSet result = statement.executeQuery("SELECT
@@SESSION.sql_mode")) {
+ if (!result.next()) {
+ throw new JdbcClientException("MySQL did not return its
session SQL mode");
+ }
+ return java.util.Arrays.stream(result.getString(1).split(","))
+ .map(mode ->
mode.trim().toUpperCase(java.util.Locale.ROOT))
+ .collect(java.util.stream.Collectors.toSet());
+ } catch (SQLException e) {
+ throw new JdbcClientException("Failed to read MySQL session SQL
mode", e);
+ }
+ }
+
public Connection getConnection() throws JdbcClientException {
ClassLoader oldClassLoader =
Thread.currentThread().getContextClassLoader();
Connection conn;
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/datasource/jdbc/source/JdbcScanNode.java
b/fe/fe-core/src/main/java/org/apache/doris/datasource/jdbc/source/JdbcScanNode.java
index b66ccccf564..5ec952fc14f 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/datasource/jdbc/source/JdbcScanNode.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/datasource/jdbc/source/JdbcScanNode.java
@@ -40,6 +40,8 @@ import org.apache.doris.common.UserException;
import org.apache.doris.datasource.ExternalFunctionRules;
import org.apache.doris.datasource.ExternalScanNode;
import org.apache.doris.datasource.jdbc.JdbcExternalTable;
+import org.apache.doris.datasource.jdbc.client.JdbcClient;
+import org.apache.doris.datasource.jdbc.client.JdbcClientConfig;
import org.apache.doris.planner.PlanNodeId;
import org.apache.doris.planner.ScanContext;
import org.apache.doris.qe.ConnectContext;
@@ -70,6 +72,7 @@ public class JdbcScanNode extends ExternalScanNode {
private boolean isTableValuedFunction = false;
private String query = "";
private boolean projectsTimestamps;
+ private java.util.Set<String> mysqlSqlMode;
private JdbcTable tbl;
private long catalogId;
@@ -95,6 +98,18 @@ public class JdbcScanNode extends ExternalScanNode {
jdbcType = tbl.getJdbcTableType();
tableName = tbl.getExternalTableName();
catalogId = tbl.getCatalogId();
+ if (isTableValuedFunction && (jdbcType == TOdbcTableType.MYSQL ||
jdbcType == TOdbcTableType.OCEANBASE)
+ && desc.getSlots().stream().anyMatch(slot ->
slot.getColumn().getType().isTimeStampTz())) {
+ // Bind the mode to this scan's connection snapshot, not a catalog
that ALTER/DROP can replace.
+ // A private single-connection client avoids borrowing a pool that
catalog reset can close.
+ mysqlSqlMode = JdbcClient.readSessionSqlMode(new JdbcClientConfig()
+ .setCatalog(String.valueOf(catalogId))
+
.setJdbcUrl(tbl.getJdbcUrl()).setUser(tbl.getJdbcUser()).setPassword(tbl.getJdbcPasswd())
+
.setDriverClass(tbl.getDriverClass()).setDriverUrl(tbl.getDriverUrl())
+ .setConnectionPoolMinSize(0).setConnectionPoolMaxSize(1)
+
.setConnectionPoolMaxWaitTime(tbl.getConnectionPoolMaxWaitTime())
+
.setConnectionPoolMaxLifeTime(tbl.getConnectionPoolMaxLifeTime()));
+ }
}
@@ -165,7 +180,12 @@ public class JdbcScanNode extends ExternalScanNode {
while (leaf.isArrayType()) {
leaf = ((ArrayType) leaf).getItemType();
}
- if (jdbcType == TOdbcTableType.CLICKHOUSE && leaf.isTimeStampTz())
{
+ if ((jdbcType == TOdbcTableType.MYSQL || jdbcType ==
TOdbcTableType.OCEANBASE)
+ && col.getType().isTimeStampTz()) {
+ // Text projection prevents Connector/J's cached server zone
from shifting UTC session fields.
+ columns.add("CAST(" + remoteName + " AS CHAR) AS " +
remoteName);
+ projectsTimestamps = true;
+ } else if (jdbcType == TOdbcTableType.CLICKHOUSE &&
leaf.isTimeStampTz()) {
// JDBC v1 loses the offset at DST overlaps, including for
scalar ZonedDateTime.
// Send epoch microseconds for both scalar and nested instants
before JDBC decoding.
columns.add(clickHouseTimestampProjection(remoteName,
col.getType(), 0) + " AS " + remoteName);
@@ -213,13 +233,178 @@ public class JdbcScanNode extends ExternalScanNode {
if (!projectsTimestamps) {
return query;
}
- String source = query.trim();
- if (source.endsWith(";")) {
- source = source.substring(0, source.length() - 1);
+ boolean mysql = jdbcType == TOdbcTableType.MYSQL || jdbcType ==
TOdbcTableType.OCEANBASE;
+ String source = stripTerminalDelimiter(query.trim(),
+ mysql && mysqlSqlMode.contains("NO_BACKSLASH_ESCAPES"), mysql
&& mysqlSqlMode.contains("ANSI_QUOTES"),
+ mysql && mysqlSqlMode.contains("MSSQL"));
+ // WITH SESSION must stay outside the derived table; terminate
trailing line comments with a newline.
+ int start = jdbcType == TOdbcTableType.TRINO ?
trinoSessionQueryStart(source) : 0;
+ return source.substring(0, start) + "SELECT " + Joiner.on(",
").join(columns)
+ + " FROM (" + source.substring(start) + "\n)
doris_jdbc_source";
+ }
+
+ private String stripTerminalDelimiter(String sql, boolean
noBackslashEscapes, boolean ansiQuotes, boolean mssql) {
+ boolean mysql = jdbcType == TOdbcTableType.MYSQL || jdbcType ==
TOdbcTableType.OCEANBASE;
+ List<Integer> delimiters = new java.util.ArrayList<>();
+ for (int i = 0; i < sql.length();) {
+ char c = sql.charAt(i);
+ if (Character.isWhitespace(c)) {
+ i++;
+ } else if (jdbcType == TOdbcTableType.CLICKHOUSE
+ && (sql.startsWith("//", i) || sql.startsWith("#!", i) ||
sql.startsWith("# ", i))) {
+ // ClickHouse comments may contain unmatched quotes; their
contents end only at LF.
+ int newline = sql.indexOf('\n', i + 2);
+ i = newline < 0 ? sql.length() : newline + 1;
+ // MySQL's second dash needs a following whitespace/control
character; --1 is arithmetic.
+ } else if ((sql.startsWith("--", i) && (!mysql || (i + 2 <
sql.length()
+ && (Character.isWhitespace(sql.charAt(i + 2)) ||
Character.isISOControl(sql.charAt(i + 2))))))
+ || (c == '#'
+ && (jdbcType == TOdbcTableType.MYSQL || jdbcType ==
TOdbcTableType.OCEANBASE))) {
+ while (i < sql.length() && sql.charAt(i) != '\n' &&
sql.charAt(i) != '\r') {
+ i++;
+ }
+ } else if (sql.startsWith("/*", i)) {
+ i = skipBlockComment(sql, i, jdbcType ==
TOdbcTableType.CLICKHOUSE);
+ } else if (jdbcType == TOdbcTableType.CLICKHOUSE && c == '$') {
+ i = skipClickHouseHeredoc(sql, i);
+ delimiters.clear();
+ } else if (c == '\'' || c == '"' || c == '`' || (mysql && mssql &&
c == '[')) {
+ // MariaDB MSSQL identifiers use brackets; only a doubled
closing bracket escapes the delimiter.
+ char quote = c == '[' ? ']' : c;
+ for (i++; i < sql.length(); i++) {
+ // ANSI_QUOTES makes double quotes identifiers, where
backslashes never escape the closing quote.
+ // Both quoting modes belong to the remote session.
+ // ClickHouse also permits backslash escapes inside
backtick-quoted identifiers.
+ if (sql.charAt(i) == '\\' && quote != ']' && !(mysql &&
noBackslashEscapes)
+ && !(mysql && ansiQuotes && quote == '"')
+ && (quote != '`' || jdbcType ==
TOdbcTableType.CLICKHOUSE)
+ && jdbcType != TOdbcTableType.TRINO && jdbcType !=
TOdbcTableType.PRESTO) {
+ i++;
+ } else if (sql.charAt(i) == quote) {
+ if (i + 1 < sql.length() && sql.charAt(i + 1) ==
quote) {
+ i++;
+ } else {
+ i++;
+ break;
+ }
+ }
+ }
+ delimiters.clear();
+ } else {
+ if (c == ';') {
+ delimiters.add(i);
+ } else {
+ delimiters.clear();
+ }
+ i++;
+ }
+ }
+ // A statement delimiter may precede trailing comments, which must
stay outside SQL literals.
+ // Remove only the terminal delimiter run; interior delimiters still
fail as multi-statements.
+ if (delimiters.isEmpty()) {
+ return sql;
+ }
+ StringBuilder result = new StringBuilder(sql);
+ for (int i = delimiters.size() - 1; i >= 0; i--) {
+ result.deleteCharAt(delimiters.get(i));
}
- return "SELECT " + Joiner.on(", ").join(columns) + " FROM (" + source
+ ") doris_jdbc_source";
+ return result.toString();
}
+ private static int skipClickHouseHeredoc(String sql, int start) {
+ // A dollar sign inside a bare identifier is not the beginning of a
heredoc.
+ if (start > 0 && (isClickHouseWordCharacter(sql.charAt(start - 1)) ||
sql.charAt(start - 1) == '$')) {
+ return start + 1;
+ }
+ int tagEnd = start + 1;
+ while (tagEnd < sql.length() &&
isClickHouseWordCharacter(sql.charAt(tagEnd))) {
+ tagEnd++;
+ }
+ if (tagEnd == sql.length() || sql.charAt(tagEnd) != '$') {
+ return start + 1;
+ }
+ // Heredocs contain raw text: embedded quotes, comments and delimiters
must not affect scanning.
+ String tag = sql.substring(start, tagEnd + 1);
+ int end = sql.indexOf(tag, tagEnd + 1);
+ return end < 0 ? start + 1 : end + tag.length();
+ }
+
+ private static boolean isClickHouseWordCharacter(char c) {
+ return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c
<= '9' || c == '_';
+ }
+
+ private static int skipBlockComment(String sql, int start, boolean nested)
{
+ // Trino, Presto and MySQL end at the first closing marker; ClickHouse
permits nested comments.
+ int depth = 1;
+ int i = start + 2;
+ while (i < sql.length() && depth > 0) {
+ if (nested && sql.startsWith("/*", i)) {
+ depth++;
+ i += 2;
+ } else if (sql.startsWith("*/", i)) {
+ depth--;
+ i += 2;
+ } else {
+ i++;
+ }
+ }
+ return i;
+ }
+
+ private static int trinoSessionQueryStart(String sql) {
+ int depth = 0;
+ int prefixWords = 0;
+ for (int i = 0; i < sql.length();) {
+ char c = sql.charAt(i);
+ if (c == '\'' || c == '"') {
+ char quote = c;
+ for (i++; i < sql.length(); i++) {
+ if (sql.charAt(i) == quote) {
+ if (i + 1 < sql.length() && sql.charAt(i + 1) ==
quote) {
+ i++;
+ } else {
+ i++;
+ break;
+ }
+ }
+ }
+ } else if (sql.startsWith("--", i)) {
+ int newline = sql.indexOf('\n', i + 2);
+ i = newline < 0 ? sql.length() : newline + 1;
+ } else if (sql.startsWith("/*", i)) {
+ i = skipBlockComment(sql, i, false);
+ } else if (c == '(') {
+ depth++;
+ i++;
+ } else if (c == ')') {
+ depth--;
+ i++;
+ } else if (Character.isLetterOrDigit(c) || c == '_') {
+ int start = i++;
+ while (i < sql.length() &&
(Character.isLetterOrDigit(sql.charAt(i)) || sql.charAt(i) == '_')) {
+ i++;
+ }
+ if (depth != 0) {
+ continue;
+ }
+ String word = sql.substring(start, i);
+ if (prefixWords < 2) {
+ if (!word.equalsIgnoreCase(prefixWords == 0 ? "WITH" :
"SESSION")) {
+ return 0;
+ }
+ prefixWords++;
+ } else if (word.equalsIgnoreCase("SELECT") ||
word.equalsIgnoreCase("WITH")
+ || word.equalsIgnoreCase("TABLE") ||
word.equalsIgnoreCase("VALUES")) {
+ return start;
+ }
+ } else {
+ i++;
+ }
+ }
+ return 0;
+ }
+
+
private String getJdbcQueryStr() {
StringBuilder sql = new StringBuilder("SELECT ");
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonUtil.java
b/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonUtil.java
index a3506783707..2a362ebebfd 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonUtil.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonUtil.java
@@ -812,9 +812,10 @@ public class PaimonUtil {
return null;
}
return Double.toString((Double) value);
- // case binary:
- // case varbinary: should not supported, because if return string
with utf8,
- // the data maybe be corrupted
+ case BINARY:
+ case VARBINARY:
+ // Native readers reconstruct omitted partition columns from
lossless hex literals.
+ return value == null ? null : "0x" +
com.google.common.io.BaseEncoding.base16().encode((byte[]) value);
case DATE:
if (value == null) {
return null;
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/literal/VarBinaryLiteral.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/literal/VarBinaryLiteral.java
index e1f467ae2b3..0aad9d1ac58 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/literal/VarBinaryLiteral.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/literal/VarBinaryLiteral.java
@@ -118,33 +118,9 @@ public class VarBinaryLiteral extends Literal implements
ComparableLiteral {
@Override
public int compareTo(ComparableLiteral other) {
if (other instanceof VarBinaryLiteral) {
- byte[] thisBytes = this.byteValues;
- byte[] otherBytes = ((VarBinaryLiteral) other).byteValues;
-
- int minLength = Math.min(thisBytes.length, otherBytes.length);
- int i = 0;
- for (i = 0; i < minLength; i++) {
- if (Byte.toUnsignedInt(thisBytes[i]) <
Byte.toUnsignedInt(otherBytes[i])) {
- return -1;
- } else if (Byte.toUnsignedInt(thisBytes[i]) >
Byte.toUnsignedInt(otherBytes[i])) {
- return 1;
- }
- }
- if (thisBytes.length > otherBytes.length) {
- if (thisBytes[i] == 0x00) {
- return 0;
- } else {
- return 1;
- }
- } else if (thisBytes.length < otherBytes.length) {
- if (otherBytes[i] == 0x00) {
- return 0;
- } else {
- return -1;
- }
- } else {
- return 0;
- }
+ // VARBINARY is length-sensitive: trailing zero bytes are data,
not string padding.
+ return
com.google.common.primitives.UnsignedBytes.lexicographicalComparator()
+ .compare(byteValues, ((VarBinaryLiteral)
other).byteValues);
}
if (other instanceof NullLiteral) {
return 1;
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/analysis/VarBinaryLiteralOrderingTest.java
b/fe/fe-core/src/test/java/org/apache/doris/analysis/VarBinaryLiteralOrderingTest.java
new file mode 100644
index 00000000000..4c6aa541a43
--- /dev/null
+++
b/fe/fe-core/src/test/java/org/apache/doris/analysis/VarBinaryLiteralOrderingTest.java
@@ -0,0 +1,34 @@
+// 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.
+
+package org.apache.doris.analysis;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+class VarBinaryLiteralOrderingTest {
+ @Test
+ void preservesTrailingZerosAndUnsignedOrdering() throws Exception {
+ byte[][] values = { {}, {0}, {0, 0}, {1}, {1, 0}, {1, 0, 1}, {(byte)
0xff} };
+ for (int i = 0; i < values.length; i++) {
+ for (int j = 0; j < values.length; j++) {
+ Assertions.assertEquals(Integer.compare(i, j), Integer.signum(
+ new VarBinaryLiteral(values[i]).compareLiteral(new
VarBinaryLiteral(values[j]))));
+ }
+ }
+ }
+}
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/datasource/jdbc/source/JdbcQueryWrappingTest.java
b/fe/fe-core/src/test/java/org/apache/doris/datasource/jdbc/source/JdbcQueryWrappingTest.java
new file mode 100644
index 00000000000..059fbed9af5
--- /dev/null
+++
b/fe/fe-core/src/test/java/org/apache/doris/datasource/jdbc/source/JdbcQueryWrappingTest.java
@@ -0,0 +1,306 @@
+// 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.
+
+package org.apache.doris.datasource.jdbc.source;
+
+import org.apache.doris.catalog.JdbcTable;
+import org.apache.doris.datasource.jdbc.client.JdbcClientConfig;
+import org.apache.doris.datasource.jdbc.client.JdbcMySQLClient;
+import org.apache.doris.thrift.TOdbcTableType;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedConstruction;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.util.Arrays;
+
+class JdbcQueryWrappingTest {
+ @Test
+ void mariaDbMssqlBracketIdentifiersPreserveContents() throws Exception {
+ JdbcScanNode node = Mockito.mock(JdbcScanNode.class,
Mockito.CALLS_REAL_METHODS);
+ set(node, "jdbcType", TOdbcTableType.MYSQL);
+ set(node, "projectsTimestamps", true);
+ set(node, "columns", Arrays.asList("CAST(ts AS CHAR) AS ts"));
+ set(node, "mysqlSqlMode", java.util.Collections.singleton("MSSQL"));
+ Method wrap = JdbcScanNode.class.getDeclaredMethod("getTvfQuery");
+ wrap.setAccessible(true);
+ for (String query : Arrays.asList("SELECT ts AS [x'y] FROM t",
+ "SELECT ts AS [x]]'y;--z] FROM t", "SELECT ts AS [x\\] FROM
t")) {
+ set(node, "query", query + "; -- tail");
+ Assertions.assertEquals("SELECT CAST(ts AS CHAR) AS ts FROM (" +
query
+ + " -- tail\n) doris_jdbc_source", wrap.invoke(node));
+ }
+ }
+
+ @Test
+ void mysqlTvfCapturesModeBeforeCatalogReset() throws Exception {
+ org.apache.doris.catalog.Column column = new
org.apache.doris.catalog.Column("ts",
+ org.apache.doris.catalog.ScalarType.createTimeStampTzType(6));
+ JdbcTable table = new JdbcTable(1, "events", Arrays.asList(column),
+ org.apache.doris.catalog.Table.TableType.JDBC);
+ table.setJdbcTypeName("mysql");
+ table.setExternalTableName("events");
+ table.setJdbcUrl("jdbc:mysql://localhost/events");
+ table.setDriverUrl("mysql-connector.jar");
+ table.setDriverClass("com.mysql.cj.jdbc.Driver");
+ table.setResourceName("");
+ org.apache.doris.analysis.TupleDescriptor tuple = new
org.apache.doris.analysis.TupleDescriptor(
+ new org.apache.doris.analysis.TupleId(0));
+ tuple.setTable(table);
+ org.apache.doris.analysis.SlotDescriptor slot = new
org.apache.doris.analysis.SlotDescriptor(
+ new org.apache.doris.analysis.SlotId(0), tuple);
+ slot.setColumn(column);
+ tuple.addSlot(slot);
+ try (MockedConstruction<JdbcMySQLClient> clients =
Mockito.mockConstruction(JdbcMySQLClient.class,
+ (client, construction) -> {
+ JdbcClientConfig config = (JdbcClientConfig)
construction.arguments().get(0);
+ Assertions.assertEquals(table.getJdbcUrl(),
config.getJdbcUrl());
+ Assertions.assertEquals(table.getDriverUrl(),
config.getDriverUrl());
+ Assertions.assertEquals(table.getDriverClass(),
config.getDriverClass());
+ if (construction.getCount() == 1) {
+ Mockito.when(client.getSessionSqlMode())
+
.thenReturn(java.util.Collections.singleton("ANSI_QUOTES"));
+ } else {
+ Mockito.when(client.getSessionSqlMode())
+ .thenThrow(new IllegalStateException("Mode
lookup failed"));
+ }
+ })) {
+ JdbcScanNode node = new JdbcScanNode(new
org.apache.doris.planner.PlanNodeId(0), tuple, true,
+ "SELECT ts, 1 AS \"x\\\" FROM events;",
org.apache.doris.planner.ScanContext.EMPTY);
+ Assertions.assertEquals(1, clients.constructed().size());
+ JdbcMySQLClient client = clients.constructed().get(0);
+ Mockito.verify(client).getSessionSqlMode();
+ Mockito.verify(client).closeClient();
+ // A catalog reset/drop after setup must not trigger any lookup or
connection during serialization.
+ Mockito.reset(client);
+ try (MockedStatic<org.apache.doris.catalog.Env> env =
Mockito.mockStatic(
+ org.apache.doris.catalog.Env.class)) {
+ env.when(org.apache.doris.catalog.Env::getCurrentEnv)
+ .thenThrow(new IllegalStateException("Catalog is no
longer available"));
+ Method project =
JdbcScanNode.class.getDeclaredMethod("createJdbcColumns");
+ project.setAccessible(true);
+ project.invoke(node);
+ org.apache.doris.thrift.TPlanNode message = new
org.apache.doris.thrift.TPlanNode();
+ node.toThrift(message);
+ Assertions.assertEquals("SELECT CAST(`ts` AS CHAR) AS `ts`
FROM ("
+ + "SELECT ts, 1 AS \"x\\\" FROM events\n)
doris_jdbc_source",
+ message.jdbc_scan_node.getQueryString());
+ Mockito.verifyNoInteractions(client);
+ }
+ Assertions.assertThrows(IllegalStateException.class, () -> new
JdbcScanNode(
+ new org.apache.doris.planner.PlanNodeId(1), tuple, true,
"SELECT ts FROM events",
+ org.apache.doris.planner.ScanContext.EMPTY));
+ Assertions.assertEquals(2, clients.constructed().size());
+ Mockito.verify(clients.constructed().get(1)).closeClient();
+ }
+ }
+
+ @Test
+ void preservesCommentsAndTrinoStatementPrefix() throws Exception {
+ JdbcScanNode node = org.mockito.Mockito.mock(JdbcScanNode.class,
org.mockito.Mockito.CALLS_REAL_METHODS);
+ set(node, "jdbcType", TOdbcTableType.TRINO);
+ set(node, "projectsTimestamps", true);
+ set(node, "columns", Arrays.asList("at_timezone(ts, 'UTC') AS ts"));
+ Method wrap = JdbcScanNode.class.getDeclaredMethod("getTvfQuery");
+ wrap.setAccessible(true);
+ set(node, "query", "SELECT ts FROM t; -- trailing");
+ String sql = (String) wrap.invoke(node);
+ Assertions.assertTrue(sql.contains("FROM (SELECT ts FROM t --
trailing\n)"), sql);
+ set(node, "query", "WITH SESSION time_zone='UTC' SELECT ts FROM t;");
+ sql = (String) wrap.invoke(node);
+ Assertions.assertTrue(sql.startsWith("WITH SESSION time_zone='UTC'
SELECT at_timezone"), sql);
+ Assertions.assertTrue(sql.contains("FROM (SELECT ts FROM t\n)"), sql);
+ }
+
+ @Test
+ void trinoBlockCommentsEndAtFirstClosingMarker() throws Exception {
+ JdbcScanNode node = org.mockito.Mockito.mock(JdbcScanNode.class,
org.mockito.Mockito.CALLS_REAL_METHODS);
+ set(node, "jdbcType", TOdbcTableType.TRINO);
+ set(node, "projectsTimestamps", true);
+ set(node, "columns", Arrays.asList("at_timezone(ts, 'UTC') AS ts"));
+ Method wrap = JdbcScanNode.class.getDeclaredMethod("getTvfQuery");
+ wrap.setAccessible(true);
+ String prefix = "WITH SESSION query_max_execution_time='1m' /* note /*
marker */ ";
+ set(node, "query", prefix + "SELECT ts FROM t;");
+ Assertions.assertEquals(prefix + "SELECT at_timezone(ts, 'UTC') AS ts
FROM (SELECT ts FROM t\n)"
+ + " doris_jdbc_source", wrap.invoke(node));
+ set(node, "query", "SELECT ts /* note /* marker */ FROM t; /* tail
*/");
+ Assertions.assertEquals("SELECT at_timezone(ts, 'UTC') AS ts FROM ("
+ + "SELECT ts /* note /* marker */ FROM t /* tail */\n)
doris_jdbc_source", wrap.invoke(node));
+ set(node, "jdbcType", TOdbcTableType.CLICKHOUSE);
+ set(node, "columns", Arrays.asList("toUnixTimestamp64Micro(ts) AS
ts"));
+ set(node, "query", "SELECT ts /* outer /* inner */ ; still comment */
FROM t;");
+ Assertions.assertEquals("SELECT toUnixTimestamp64Micro(ts) AS ts FROM
("
+ + "SELECT ts /* outer /* inner */ ; still comment */ FROM t\n)
doris_jdbc_source", wrap.invoke(node));
+ }
+
+ @Test
+ void mysqlAnsiQuotedIdentifierEndingInBackslash() throws Exception {
+ JdbcScanNode node = org.mockito.Mockito.mock(JdbcScanNode.class,
org.mockito.Mockito.CALLS_REAL_METHODS);
+ set(node, "jdbcType", TOdbcTableType.MYSQL);
+ set(node, "projectsTimestamps", true);
+ set(node, "columns", Arrays.asList("CAST(ts AS CHAR) AS ts"));
+ set(node, "mysqlSqlMode",
java.util.Collections.singleton("ANSI_QUOTES"));
+ Method wrap = JdbcScanNode.class.getDeclaredMethod("getTvfQuery");
+ wrap.setAccessible(true);
+ String query = "SELECT ts, 1 AS \"x\\\" FROM t";
+ set(node, "query", query + ";");
+ Assertions.assertEquals("SELECT CAST(ts AS CHAR) AS ts FROM (" + query
+ "\n) doris_jdbc_source",
+ wrap.invoke(node));
+ set(node, "mysqlSqlMode", new
java.util.HashSet<>(Arrays.asList("ANSI_QUOTES", "NO_BACKSLASH_ESCAPES")));
+ Assertions.assertEquals("SELECT CAST(ts AS CHAR) AS ts FROM (" + query
+ "\n) doris_jdbc_source",
+ wrap.invoke(node));
+ // Without ANSI_QUOTES, a backslash escapes a double quote inside a
string literal.
+ set(node, "mysqlSqlMode", java.util.Collections.emptySet());
+ query = "SELECT ts, \"x\\\"; y\" AS value FROM t";
+ set(node, "query", query + ";");
+ Assertions.assertEquals("SELECT CAST(ts AS CHAR) AS ts FROM (" + query
+ "\n) doris_jdbc_source",
+ wrap.invoke(node));
+ }
+
+ @Test
+ void readsBothMysqlQuotingModesFromOneSession() throws Exception {
+ org.apache.doris.datasource.jdbc.client.JdbcClient client =
org.mockito.Mockito.mock(
+ org.apache.doris.datasource.jdbc.client.JdbcClient.class,
org.mockito.Mockito.CALLS_REAL_METHODS);
+ java.sql.Connection connection =
org.mockito.Mockito.mock(java.sql.Connection.class);
+ java.sql.Statement statement =
org.mockito.Mockito.mock(java.sql.Statement.class);
+ java.sql.ResultSet result =
org.mockito.Mockito.mock(java.sql.ResultSet.class);
+ org.mockito.Mockito.doReturn(connection).when(client).getConnection();
+
org.mockito.Mockito.when(connection.createStatement()).thenReturn(statement);
+ org.mockito.Mockito.when(statement.executeQuery("SELECT
@@SESSION.sql_mode")).thenReturn(result);
+ org.mockito.Mockito.when(result.next()).thenReturn(true);
+ org.mockito.Mockito.when(result.getString(1)).thenReturn("ansi_quotes,
NO_BACKSLASH_ESCAPES");
+ Assertions.assertEquals(new
java.util.HashSet<>(Arrays.asList("ANSI_QUOTES", "NO_BACKSLASH_ESCAPES")),
+ client.getSessionSqlMode());
+ org.mockito.Mockito.verify(connection).close();
+ org.mockito.Mockito.verify(statement).close();
+ org.mockito.Mockito.verify(result).close();
+ }
+
+ @Test
+ void legacyTimestampBindsHaveExplicitServerCasts() throws Exception {
+ for (TOdbcTableType type : Arrays.asList(TOdbcTableType.ORACLE,
TOdbcTableType.PRESTO)) {
+ JdbcTable table = new JdbcTable(1, "events", Arrays.asList(new
org.apache.doris.catalog.Column(
+ "ts",
org.apache.doris.catalog.ScalarType.createTimeStampTzType(6))),
+ org.apache.doris.catalog.Table.TableType.JDBC);
+
table.setJdbcTypeName(type.name().toLowerCase(java.util.Locale.ROOT));
+ table.setExternalTableName("events");
+ String sql = table.getInsertSql(Arrays.asList("ts"));
+ Assertions.assertTrue(sql.contains(type == TOdbcTableType.ORACLE ?
"TO_TIMESTAMP_TZ(?"
+ : "CAST(? AS TIMESTAMP WITH TIME ZONE)"), sql);
+ }
+ }
+
+ @Test
+ void mysqlWrappingHonorsSqlModeAndArithmeticDashes() throws Exception {
+ JdbcScanNode node = org.mockito.Mockito.mock(JdbcScanNode.class,
org.mockito.Mockito.CALLS_REAL_METHODS);
+ set(node, "jdbcType", TOdbcTableType.MYSQL);
+ set(node, "projectsTimestamps", true);
+ set(node, "columns", Arrays.asList("CAST(ts AS CHAR) AS ts"));
+ set(node, "mysqlSqlMode",
java.util.Collections.singleton("NO_BACKSLASH_ESCAPES"));
+ Method wrap = JdbcScanNode.class.getDeclaredMethod("getTvfQuery");
+ wrap.setAccessible(true);
+ for (String query : Arrays.asList("SELECT ts, 1--1 AS n FROM t; --
tail",
+ "SELECT ts, '\\' AS text FROM t; # tail")) {
+ set(node, "query", query);
+ String sql = (String) wrap.invoke(node);
+ Assertions.assertFalse(sql.contains("FROM t;"), sql);
+ Assertions.assertTrue(sql.endsWith("\n) doris_jdbc_source"), sql);
+ }
+ set(node, "projectsTimestamps", false);
+ set(node, "mysqlSqlMode", null);
+ set(node, "query", "SELECT 1;");
+ Assertions.assertEquals("SELECT 1;", wrap.invoke(node));
+ }
+
+ @Test
+ void mysqlProjectsOnlyInstantColumnsAsText() throws Exception {
+ org.apache.doris.catalog.Column instant = new
org.apache.doris.catalog.Column("ts",
+ org.apache.doris.catalog.ScalarType.createTimeStampTzType(6));
+ org.apache.doris.catalog.Column local = new
org.apache.doris.catalog.Column("dt",
+ org.apache.doris.catalog.ScalarType.createDatetimeV2Type(6));
+ JdbcTable table = new JdbcTable(1, "events", Arrays.asList(instant,
local),
+ org.apache.doris.catalog.Table.TableType.JDBC);
+ table.setJdbcTypeName("mysql");
+ table.setExternalTableName("events");
+ org.apache.doris.analysis.TupleDescriptor tuple = new
org.apache.doris.analysis.TupleDescriptor(
+ new org.apache.doris.analysis.TupleId(0));
+ tuple.setTable(table);
+ int id = 0;
+ for (org.apache.doris.catalog.Column column : Arrays.asList(instant,
local)) {
+ org.apache.doris.analysis.SlotDescriptor slot = new
org.apache.doris.analysis.SlotDescriptor(
+ new org.apache.doris.analysis.SlotId(id++), tuple);
+ slot.setColumn(column);
+ tuple.addSlot(slot);
+ }
+ JdbcScanNode node = new JdbcScanNode(new
org.apache.doris.planner.PlanNodeId(0), tuple, false,
+ org.apache.doris.planner.ScanContext.EMPTY);
+ Method project =
JdbcScanNode.class.getDeclaredMethod("createJdbcColumns");
+ project.setAccessible(true);
+ project.invoke(node);
+ Field columns = JdbcScanNode.class.getDeclaredField("columns");
+ columns.setAccessible(true);
+ Assertions.assertEquals(Arrays.asList("CAST(`ts` AS CHAR) AS `ts`",
"`dt`"), columns.get(node));
+ }
+
+ @Test
+ void clickHouseEscapedBackticksPreserveIdentifierContents() throws
Exception {
+ assertClickHouseWrapped("SELECT ts, 1 AS `a\\`b` FROM t");
+ assertClickHouseWrapped("SELECT ts, 1 AS `a\\`;b` FROM t");
+ }
+
+ @Test
+ void clickHouseLineCommentsDoNotChangeQuoteState() throws Exception {
+ for (String marker : Arrays.asList("//", "///", "#!", "# ")) {
+ for (String newline : Arrays.asList("\n", "\r\n")) {
+ assertClickHouseWrapped("SELECT ts FROM t " + marker + " 'note
` ;" + newline);
+ }
+ }
+ }
+
+ @Test
+ void clickHouseHeredocsPreserveRawContents() throws Exception {
+ for (String literal : Arrays.asList("$h$foo'bar$h$", "$$'\\\"`; /*
--$$",
+ "$tag_1$'text; $other$tag_1$", "$h$$h$")) {
+ assertClickHouseWrapped("SELECT ts FROM t WHERE length(" + literal
+ ") > 0");
+ }
+ // An interior statement delimiter must remain visible to the remote
parser.
+ assertClickHouseWrapped("SELECT ts, $h$'raw$h$ FROM t; SELECT ts FROM
t");
+ }
+
+ private void assertClickHouseWrapped(String query) throws Exception {
+ JdbcScanNode node = org.mockito.Mockito.mock(JdbcScanNode.class,
org.mockito.Mockito.CALLS_REAL_METHODS);
+ set(node, "jdbcType", TOdbcTableType.CLICKHOUSE);
+ set(node, "projectsTimestamps", true);
+ set(node, "columns", Arrays.asList("toUnixTimestamp64Micro(ts) AS
ts"));
+ set(node, "query", query + ";");
+ Method wrap = JdbcScanNode.class.getDeclaredMethod("getTvfQuery");
+ wrap.setAccessible(true);
+ Assertions.assertEquals("SELECT toUnixTimestamp64Micro(ts) AS ts FROM
(" + query
+ + "\n) doris_jdbc_source", wrap.invoke(node));
+ }
+
+ private static void set(Object target, String name, Object value) throws
Exception {
+ Field field = JdbcScanNode.class.getDeclaredField(name);
+ field.setAccessible(true);
+ field.set(target, value);
+ }
+}
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/datasource/jdbc/source/JdbcTimestampProjectionTest.java
b/fe/fe-core/src/test/java/org/apache/doris/datasource/jdbc/source/JdbcTimestampProjectionTest.java
index fb3623f7c38..9b05ab2acb4 100644
---
a/fe/fe-core/src/test/java/org/apache/doris/datasource/jdbc/source/JdbcTimestampProjectionTest.java
+++
b/fe/fe-core/src/test/java/org/apache/doris/datasource/jdbc/source/JdbcTimestampProjectionTest.java
@@ -95,8 +95,9 @@ class JdbcTimestampProjectionTest {
setField(node, "query", query);
Method tvfQuery = JdbcScanNode.class.getDeclaredMethod("getTvfQuery");
tvfQuery.setAccessible(true);
+ // The newline prevents a trailing source comment from swallowing the
derived-table closing parenthesis.
Assertions.assertEquals(type.isDatetimeV2() ? query : "SELECT " +
expected
- + " FROM (SELECT event_time FROM event_stream)
doris_jdbc_source", tvfQuery.invoke(node));
+ + " FROM (SELECT event_time FROM event_stream\n)
doris_jdbc_source", tvfQuery.invoke(node));
}
@Test
@@ -123,7 +124,7 @@ class JdbcTimestampProjectionTest {
Method tvfQuery = JdbcScanNode.class.getDeclaredMethod("getTvfQuery");
tvfQuery.setAccessible(true);
Assertions.assertEquals("SELECT " + columns.get(0)
- + " FROM (SELECT event_time FROM event_stream)
doris_jdbc_source", tvfQuery.invoke(node));
+ + " FROM (SELECT event_time FROM event_stream\n)
doris_jdbc_source", tvfQuery.invoke(node));
}
@Test
@@ -152,7 +153,7 @@ class JdbcTimestampProjectionTest {
Method tvfQuery = JdbcScanNode.class.getDeclaredMethod("getTvfQuery");
tvfQuery.setAccessible(true);
Assertions.assertEquals("SELECT " + columns.get(0)
- + " FROM (SELECT events FROM event_stream) doris_jdbc_source",
tvfQuery.invoke(node));
+ + " FROM (SELECT events FROM event_stream\n)
doris_jdbc_source", tvfQuery.invoke(node));
}
private static void setField(JdbcScanNode node, String name, Object value)
throws Exception {
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/datasource/paimon/PaimonBinaryPartitionTest.java
b/fe/fe-core/src/test/java/org/apache/doris/datasource/paimon/PaimonBinaryPartitionTest.java
new file mode 100644
index 00000000000..e56a3f8e4a6
--- /dev/null
+++
b/fe/fe-core/src/test/java/org/apache/doris/datasource/paimon/PaimonBinaryPartitionTest.java
@@ -0,0 +1,36 @@
+// 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.
+
+package org.apache.doris.datasource.paimon;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+class PaimonBinaryPartitionTest {
+ @Test
+ void binaryPartitionsPreserveBytesAndNull() throws Exception {
+ java.lang.reflect.Method render =
PaimonUtil.class.getDeclaredMethod("serializePartitionValue",
+ org.apache.paimon.types.DataType.class, Object.class,
String.class, boolean.class);
+ render.setAccessible(true);
+ for (org.apache.paimon.types.DataType type : new
org.apache.paimon.types.DataType[] {
+ new org.apache.paimon.types.BinaryType(3), new
org.apache.paimon.types.VarBinaryType()}) {
+ Assertions.assertEquals("0x00FF80", render.invoke(null, type, new
byte[] {0, -1, -128}, "UTC", true));
+ Assertions.assertEquals("0x", render.invoke(null, type, new
byte[0], "UTC", false));
+ Assertions.assertNull(render.invoke(null, type, null, "UTC",
true));
+ }
+ }
+}
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/nereids/parser/VarBinaryLiteralParserTest.java
b/fe/fe-core/src/test/java/org/apache/doris/nereids/parser/VarBinaryLiteralParserTest.java
index 4fb1249907b..5ef9e89590d 100644
---
a/fe/fe-core/src/test/java/org/apache/doris/nereids/parser/VarBinaryLiteralParserTest.java
+++
b/fe/fe-core/src/test/java/org/apache/doris/nereids/parser/VarBinaryLiteralParserTest.java
@@ -78,7 +78,9 @@ public class VarBinaryLiteralParserTest extends
TestWithFeService {
public void testComparison() {
VarBinaryLiteral v1 = extract("SELECT X'AB'");
VarBinaryLiteral v2 = extract("SELECT X'AB00'");
- Assertions.assertEquals(0, v1.compareTo(v2)); // trailing 00
considered equal
+ // Trailing zero bytes are part of the binary value, not padding.
+ Assertions.assertTrue(v1.compareTo(v2) < 0);
+ Assertions.assertTrue(v2.compareTo(v1) > 0);
VarBinaryLiteral v3 = extract("SELECT X'AC'");
Assertions.assertTrue(v3.compareTo(v1) > 0);
}
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/expressions/VarBinaryLiteralTest.java
b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/expressions/VarBinaryLiteralTest.java
index 89a17162545..4f26c514b97 100644
---
a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/expressions/VarBinaryLiteralTest.java
+++
b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/expressions/VarBinaryLiteralTest.java
@@ -81,9 +81,10 @@ public class VarBinaryLiteralTest {
Assertions.assertTrue(a.compareLiteral(c) < 0);
Assertions.assertTrue(c.compareLiteral(a) > 0);
- // different length with trailing zero semantics
+ // Trailing zero bytes are significant in VARBINARY, unlike string
padding.
VarBinaryLiteral withZero = new VarBinaryLiteral(new byte[] { 'a',
'b', 0x00 });
- Assertions.assertEquals(0, a.compareLiteral(withZero));
+ Assertions.assertTrue(a.compareLiteral(withZero) < 0);
+ Assertions.assertTrue(withZero.compareLiteral(a) > 0);
// null literal is treated as less than any value
Assertions.assertTrue(a.compareLiteral(new NullLiteral()) > 0);
diff --git
a/regression-test/suites/external_table_p0/iceberg/write/test_iceberg_write_binary_partitions.groovy
b/regression-test/suites/external_table_p0/iceberg/write/test_iceberg_write_binary_partitions.groovy
index 6ebc420e749..b7e7e2a5737 100644
---
a/regression-test/suites/external_table_p0/iceberg/write/test_iceberg_write_binary_partitions.groovy
+++
b/regression-test/suites/external_table_p0/iceberg/write/test_iceberg_write_binary_partitions.groovy
@@ -73,6 +73,27 @@ suite("test_iceberg_write_binary_partitions",
"p0,external,iceberg,external_dock
sql "DROP TABLE IF EXISTS ${table}"
}
}
+ String identityTable = "binary_${format}_identity"
+ sql "DROP TABLE IF EXISTS ${identityTable}"
+ try {
+ spark_iceberg """CREATE TABLE demo.${dbName}.${identityTable}
(id INT, binary_key BINARY)
+ USING iceberg PARTITIONED BY (binary_key)
+ TBLPROPERTIES ('format-version'='2',
'write.format.default'='${format}')"""
+ // The binary value and SQL NULL share the Iceberg path token
"null", but not a partition.
+ sql "INSERT INTO ${identityTable} VALUES (1, X'9EE965'), (2,
NULL), (3, X'9EE965')"
+ spark_iceberg "REFRESH TABLE demo.${dbName}.${identityTable}"
+ assertEquals([[2]], sql("SELECT id FROM ${identityTable} WHERE
binary_key IS NULL"))
+ // Doris 4.1 cannot compare VARBINARY directly; Spark below
still checks binary partition pruning.
+ assertEquals([[1], [3]], sql("SELECT id FROM ${identityTable}
WHERE from_hex(binary_key) = '9EE965' ORDER BY id"))
+ assertSparkDorisResultEquals(spark_iceberg("""
+ SELECT id FROM demo.${dbName}.${identityTable} WHERE
binary_key IS NULL
+ """), [[2]])
+ assertSparkDorisResultEquals(spark_iceberg("""
+ SELECT id FROM demo.${dbName}.${identityTable} WHERE
binary_key = X'9EE965' ORDER BY id
+ """), [[1], [3]])
+ } finally {
+ sql "DROP TABLE IF EXISTS ${identityTable}"
+ }
}
} finally {
sql "SWITCH internal"
diff --git
a/regression-test/suites/external_table_p0/jdbc/test_mysql_jdbc_catalog.groovy
b/regression-test/suites/external_table_p0/jdbc/test_mysql_jdbc_catalog.groovy
index beeddb065a3..d7c1d0086f2 100644
---
a/regression-test/suites/external_table_p0/jdbc/test_mysql_jdbc_catalog.groovy
+++
b/regression-test/suites/external_table_p0/jdbc/test_mysql_jdbc_catalog.groovy
@@ -395,13 +395,14 @@ suite("test_mysql_jdbc_catalog",
"p0,external,mysql,external_docker,external_doc
explain {
sql ("SELECT timestamp0 from dt where
DATE_TRUNC(date_sub(timestamp0,INTERVAL 9 HOUR),'hour') > '2011-03-03
17:39:05';")
- contains "QUERY: SELECT `timestamp0` FROM `doris_test`.`dt`"
+ // UTC text projection prevents Connector/J from applying its
cached connection time zone.
+ contains "QUERY: SELECT CAST(`timestamp0` AS CHAR) AS `timestamp0`
FROM `doris_test`.`dt`"
}
explain {
sql ("SELECT timestamp0 from dt where
DATE_TRUNC(date_sub(timestamp0,INTERVAL 9 HOUR),'hour') > '2011-03-03 17:39:05'
and timestamp0 > '2022-01-01';")
// The JDBC session is UTC, while the literal is interpreted in
the Doris session zone.
- contains "QUERY: SELECT `timestamp0` FROM `doris_test`.`dt`"
+ contains "QUERY: SELECT CAST(`timestamp0` AS CHAR) AS `timestamp0`
FROM `doris_test`.`dt`"
notContains "WHERE"
contains "PREDICATES:"
contains "2021-12-31 16:00:00+00:00"
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]