This is an automated email from the ASF dual-hosted git repository.
morningman pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris.git
The following commit(s) were added to refs/heads/master by this push:
new 1c1f3e51bb9 [test](external) Cover the external binary and timestamp
mapping cases from branch-4.2 (#68918)
1c1f3e51bb9 is described below
commit 1c1f3e51bb91b042dac53492a7bf501f4eadecf3
Author: Mingyu Chen (Rayner) <[email protected]>
AuthorDate: Mon Oct 12 14:04:05 2026 +0800
[test](external) Cover the external binary and timestamp mapping cases from
branch-4.2 (#68918)
### What problem does this PR solve?
Issue Number: None
Related PR: #68532, #68786
Problem Summary:
**In short.** This ports the tests of branch-4.2 #68532 that master does
not have yet. Master re-implemented #68532 in #68786; a hunk-by-hunk
comparison found all of its product behavior on master, but about twenty
of its assertions had no master equivalent. This PR adds them, fixes
three Paimon tests that set a misspelled option, and changes no product
code.
**Background.** #68532 (merged into branch-4.1 and synced to branch-4.2)
maps external binary columns to VARBINARY and instant timestamps to
TIMESTAMPTZ across Hive, Iceberg, Paimon, JDBC, Hudi, MaxCompute and the
Trino connector. On master the external catalogs live in
`fe/fe-connector/*`, so #68786 re-implemented the change on the
connector plugins rather than cherry-picking it, and moved most of the
#68532 tests along. The ones listed below were not moved.
**The problem, and what it cost.** Without these tests nothing on master
fails if one of these behaviors regresses:
| Area | Behavior with no test on master |
| --- | --- |
| Iceberg partitions | 0x-hex BINARY/FIXED/UUID commit values are
decoded, and wrong lengths or malformed hex are rejected; transform
ranges carry `+00:00` bounds and the proleptic year zero; zoned and
unzoned partition timestamps round-trip in every session zone, including
both instants of a DST fold; commit values honor their explicit offsets
|
| Iceberg writes | zoned write defaults keep the instant and nested
binary defaults stay bytes with the old flags off; binary/UUID commit
values become typed partitions; a NULL static overwrite on a
binary/fixed/uuid key replaces only the NULL partition (full and hybrid
specs) and reaches the sink as a NULL marker distinct from bytes; MODIFY
to TIMESTAMPTZ on a zoned field is a no-op while DATETIMEV2 is rejected;
Iceberg does not use Hive's INT96 time zone |
| fe-core | TIMESTAMPTZ range partitions keep UTC bounds and a non-empty
NULL-min range in UTC, Asia/Shanghai and America/New_York sessions; the
legacy JDBC clients that CDC still uses map binary and instant types
regardless of the old flags, and PostgreSQL `timestamptz` becomes
nullable |
| Paimon | nested LTZ write columns stay TIMESTAMPTZ; LTZ partition
values keep pre-epoch and DST-fold instants in every zone; legacy ORC
LTZ files route to the SDK for MAP/MULTISET/ROW leaves and mixed splits
|
| JDBC / HMS / Trino | per-dialect binary and timestamp mappings;
calendar functions on instants, TIMESTAMPTZ literals, PostgreSQL decoded
NULLs and dialect-specific binary literals stay local; Hive binary
partition keys keep their text type; Trino domains encode UTC micros and
millisecond-aligned zoned bounds; the JDBC writer sets the MySQL session
to UTC before it prepares the INSERT |
| Regression | a Paimon LTZ INSERT SELECT of two instants that fold to
the same Los Angeles time; a Spark cross-check of LTZ rows written in a
non-UTC session, including a pre-epoch instant; a native V1 read of the
Paimon timestamp-scale tables |
Separately, three `PaimonScanPlanProviderTest` cases set
`orc.timestamp-ltz.legacy-type`; the real key is
`orc.timestamp-ltz.legacy.type`, so the setting was ignored and the
tests passed only because the default is `true`.
**How this PR fixes it.** It ports each missing #68532 assertion to the
master class that now owns the behavior (mostly connector tests),
adapted to the connector APIs; follows master's regression style
(`order_qt_` blocks plus Spark/Doris cross-checks instead of literal
asserts); and corrects the misspelled option key.
**Results.** The 19 changed test classes pass locally. To check that the
new tests are not vacuous, six product-code mutations were applied
across five modules: dropping the binary-literal pushdown guard, an
always-true NULL static overwrite filter, no FIXED length check, parsing
the NULL-min sentinel as a date, ignoring MULTISET when routing legacy
ORC LTZ, and initializing the MySQL UTC session after the prepare. Each
mutation failed exactly the corresponding new test and nothing else.
Both Paimon regression suites pass against the iceberg docker
environment, with the new `.out` blocks generated by the framework and
matching #68532's expected values (for example epoch micros
`1699173000123456` and `1699176600123456` for the fold).
Co-authored-by: Gabriel <[email protected]>
---
.../doris/jdbc/JdbcTimestampSemanticsTest.java | 32 ++++++
.../hms/ThriftHmsClientBinaryPartitionTest.java | 56 +++++++++
.../iceberg/IcebergConnectorTransactionTest.java | 60 ++++++++++
.../iceberg/IcebergNestedColumnEvolutionTest.java | 21 ++++
.../iceberg/IcebergPartitionUtilsTest.java | 93 +++++++++++++++
.../iceberg/IcebergScanPlanProviderTest.java | 7 ++
.../connector/iceberg/IcebergSchemaUtilsTest.java | 9 ++
.../iceberg/IcebergTimestampMappingTest.java | 31 +++--
.../iceberg/IcebergWritePlanProviderTest.java | 48 ++++++++
.../connector/iceberg/IcebergWriterHelperTest.java | 32 ++++++
.../jdbc/JdbcTimestampProjectionTest.java | 111 ++++++++++++++++++
.../jdbc/client/JdbcLogicalTypeMappingTest.java | 120 +++++++++++++++++++
.../paimon/PaimonLegacyOrcTimestampTest.java | 45 ++++++++
.../paimon/PaimonPartitionValueRenderTest.java | 19 +++
.../paimon/PaimonScanPlanProviderTest.java | 6 +-
.../paimon/PaimonWritePlanProviderTest.java | 21 ++++
.../connector/trino/TrinoTimestampMappingTest.java | 40 +++++++
.../datasource/jdbc/client/JdbcClientTest.java | 127 +++++++++++++++++++++
.../mvcc/PluginDrivenMvccExternalTableTest.java | 37 +++++-
.../paimon/paimon_timestamp_types.out | 6 +
.../paimon/write/test_paimon_write_types.out | 14 +++
.../paimon/paimon_timestamp_types.groovy | 5 +
.../paimon/write/test_paimon_write_types.groovy | 34 +++++-
23 files changed, 958 insertions(+), 16 deletions(-)
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 d7c97c0cbc3..6279a667008 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
@@ -106,6 +106,38 @@ class JdbcTimestampSemanticsTest {
order.verify(connection).prepareStatement("INSERT");
}
+ @Test
+ void
testMySqlWriterInitializesUtcBeforePreparingInsert(@org.junit.jupiter.api.io.TempDir
java.nio.file.Path dir)
+ throws Exception {
+ // Drive the production open path through the shared data-source cache
instead of a test-only seam.
+ String driverUrl =
java.nio.file.Files.createFile(dir.resolve("driver.jar")).toUri().toString();
+ java.util.Map<String, String> params = new java.util.HashMap<>();
+ params.put("table_type", "MYSQL");
+ params.put("jdbc_url", "jdbc:mysql://localhost/test");
+ params.put("jdbc_driver_url", driverUrl);
+ params.put("insert_sql", "INSERT INTO t VALUES (?)");
+ JdbcJniWriter writer = new JdbcJniWriter(1, params);
+ com.zaxxer.hikari.HikariDataSource dataSource =
Mockito.mock(com.zaxxer.hikari.HikariDataSource.class);
+ java.sql.Connection connection =
Mockito.mock(java.sql.Connection.class);
+ java.sql.Statement timezoneStatement =
Mockito.mock(java.sql.Statement.class);
+ Mockito.when(dataSource.getConnection()).thenReturn(connection);
+
Mockito.when(connection.createStatement()).thenReturn(timezoneStatement);
+ Mockito.when(connection.prepareStatement(Mockito.anyString()))
+ .thenReturn(Mockito.mock(java.sql.PreparedStatement.class));
+ String cacheKey = JdbcDataSource.createCacheKey(0L,
"jdbc:mysql://localhost/test", "", "", driverUrl,
+ "", "", 1, 10, 1800000, 5000, false);
+ JdbcDataSource.getDataSource().putSource(cacheKey, dataSource);
+ try {
+ writer.openInternal();
+ org.mockito.InOrder order = Mockito.inOrder(connection,
timezoneStatement);
+ order.verify(connection).createStatement();
+ order.verify(timezoneStatement).execute("SET SESSION time_zone =
'+00:00'");
+ order.verify(connection).prepareStatement("INSERT INTO t VALUES
(?)");
+ } finally {
+ JdbcDataSource.getDataSource().getSourcesMap().remove(cacheKey);
+ }
+ }
+
@Test
void testMySqlTimestampWriteUsesUtcSessionFields() throws Exception {
MySQLTypeHandler executor = new MySQLTypeHandler("MYSQL");
diff --git
a/fe/fe-connector/fe-connector-hms/src/test/java/org/apache/doris/connector/hms/ThriftHmsClientBinaryPartitionTest.java
b/fe/fe-connector/fe-connector-hms/src/test/java/org/apache/doris/connector/hms/ThriftHmsClientBinaryPartitionTest.java
new file mode 100644
index 00000000000..38f83fdfe03
--- /dev/null
+++
b/fe/fe-connector/fe-connector-hms/src/test/java/org/apache/doris/connector/hms/ThriftHmsClientBinaryPartitionTest.java
@@ -0,0 +1,56 @@
+// 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.connector.hms;
+
+import org.apache.hadoop.hive.metastore.IMetaStoreClient;
+import org.apache.hadoop.hive.metastore.api.FieldSchema;
+import org.apache.hadoop.hive.metastore.api.StorageDescriptor;
+import org.apache.hadoop.hive.metastore.api.Table;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Proxy;
+import java.util.Collections;
+
+class ThriftHmsClientBinaryPartitionTest {
+ @Test
+ void binaryPartitionKeyKeepsTextWhileDataColumnKeepsBytes() {
+ Table remote = new Table();
+ remote.setDbName("db");
+ remote.setTableName("tbl");
+ StorageDescriptor sd = new StorageDescriptor();
+ sd.setCols(Collections.singletonList(new FieldSchema("payload",
"binary", "")));
+ remote.setSd(sd);
+ remote.setPartitionKeys(Collections.singletonList(new
FieldSchema("part_key", "binary", "")));
+ IMetaStoreClient fake = (IMetaStoreClient) Proxy.newProxyInstance(
+ IMetaStoreClient.class.getClassLoader(), new Class<?>[]
{IMetaStoreClient.class},
+ (proxy, method, args) -> {
+ if ("getTable".equals(method.getName())) {
+ return remote;
+ }
+ return method.getReturnType() == boolean.class ? false :
null;
+ });
+ ThriftHmsClient client = new ThriftHmsClient(new
HmsClientConfig(Collections.emptyMap(), 0), null,
+ hiveConf -> fake, HmsTypeMapping.Options.DEFAULT);
+
+ HmsTableInfo table = client.getTable("db", "tbl");
+ // Partition values come from directory names, so existing partitions
must keep their text contract.
+ Assertions.assertEquals("STRING",
table.getPartitionKeys().get(0).getType().getTypeName());
+ Assertions.assertEquals("VARBINARY",
table.getColumns().get(0).getType().getTypeName());
+ }
+}
diff --git
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorTransactionTest.java
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorTransactionTest.java
index eb30afcddaf..35a4ae5ad29 100644
---
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorTransactionTest.java
+++
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorTransactionTest.java
@@ -73,6 +73,7 @@ import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
+import java.util.Set;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
@@ -907,6 +908,65 @@ public class IcebergConnectorTransactionTest {
"a stale static overwrite must fail at begin before it can
degrade to an always-true filter");
}
+ @Test
+ public void overwriteStaticNullBinaryKeyReplacesOnlyTheNullPartition() {
+ // A NULL static key must become key IS NULL for full and hybrid
specs; matching bytes or text
+ // instead would replace the wrong partition.
+ for (org.apache.iceberg.types.Type keyType : Arrays.asList(
+ Types.BinaryType.get(), Types.FixedType.ofLength(16),
Types.UUIDType.get())) {
+ for (boolean hybrid : new boolean[] {false, true}) {
+ InMemoryCatalog catalog = freshCatalog();
+ TableIdentifier id = TableIdentifier.of("db1", "t1");
+ Schema schema = new Schema(Types.NestedField.optional(1,
"key", keyType),
+ Types.NestedField.optional(2, "region",
Types.StringType.get()));
+ PartitionSpec.Builder builder =
PartitionSpec.builderFor(schema).identity("key");
+ if (hybrid) {
+ builder.identity("region");
+ }
+ PartitionSpec spec = builder.build();
+ Table table = catalog.createTable(id, schema, spec,
props("write.format.default", "parquet"));
+ Object nonNullKey = keyType.typeId() ==
org.apache.iceberg.types.Type.TypeID.UUID
+ ?
java.util.UUID.fromString("00112233-4455-6677-8899-aabbccddeeff")
+ : ByteBuffer.wrap(new byte[16]);
+ table.newAppend()
+ .appendFile(binaryKeyFile(spec,
"s3://b/db1/t1/null-key.parquet", null))
+ .appendFile(binaryKeyFile(spec,
"s3://b/db1/t1/non-null-key.parquet", nonNullKey))
+ .commit();
+
+ IcebergConnectorTransaction txn = txnFor(
+ opsReturning(catalog.loadTable(id)), new
RecordingConnectorContext());
+ txn.beginWrite(SESSION, "db1", "t1",
+ overwriteStaticCtx(table,
Collections.singletonMap("key", null)));
+
txn.addCommitData(commitBytes(dataFileItem("s3://b/db1/t1/replacement.parquet",
1L, 1024L,
+ hybrid ? Arrays.asList("null", "us") :
Collections.singletonList("null"))));
+ txn.commit();
+
+ Set<String> live = new HashSet<>();
+ for (DataFile file : currentDataFiles(catalog.loadTable(id))) {
+ live.add(file.path().toString());
+ }
+ Assertions.assertEquals(new HashSet<>(Arrays.asList(
+ "s3://b/db1/t1/non-null-key.parquet",
"s3://b/db1/t1/replacement.parquet")),
+ live, keyType + " hybrid=" + hybrid);
+ }
+ }
+ }
+
+ private static DataFile binaryKeyFile(PartitionSpec spec, String path,
Object key) {
+ org.apache.iceberg.PartitionData partition = new
org.apache.iceberg.PartitionData(spec.partitionType());
+ partition.set(0, key);
+ if (spec.fields().size() > 1) {
+ partition.set(1, "us");
+ }
+ return DataFiles.builder(spec)
+ .withPath(path)
+ .withFileSizeInBytes(1024)
+ .withRecordCount(1)
+ .withFormat(FileFormat.PARQUET)
+ .withPartition(partition)
+ .build();
+ }
+
@Test
public void overwriteStaticRejectsConcurrentDataInTargetPartition() {
InMemoryCatalog catalog = freshCatalog();
diff --git
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergNestedColumnEvolutionTest.java
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergNestedColumnEvolutionTest.java
index c2029243c3a..6a069de0992 100644
---
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergNestedColumnEvolutionTest.java
+++
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergNestedColumnEvolutionTest.java
@@ -733,6 +733,27 @@ public class IcebergNestedColumnEvolutionTest {
Assertions.assertEquals(3, amount.scale());
}
+ @Test
+ public void
testModifyNestedTimestamptzKeepsInstantTypeAndRejectsWallClock() {
+ // Restating the mapped TIMESTAMPTZ type is a no-op; DATETIMEV2 would
drop the instant semantics.
+ Schema schema = new Schema(Types.NestedField.optional(1, "info",
Types.StructType.of(
+ Types.NestedField.optional(2, "tz_value",
Types.TimestampType.withZone(), "tz doc"))));
+ createTable("m_tz", schema);
+ ops.modifyNestedColumn("db1", "m_tz", path("info", "tz_value"),
+ change("tz_value",
IcebergSchemaBuilder.buildColumnType(ConnectorType.of("TIMESTAMPTZ", 6, 0)),
+ null, true), false, false, null);
+ Types.NestedField tzValue =
reload("m_tz").findField("info").type().asStructType().field("tz_value");
+ Assertions.assertEquals(Types.TimestampType.withZone(),
tzValue.type());
+ Assertions.assertEquals("tz doc", tzValue.doc());
+
+ assertFailsLoud(() -> ops.modifyNestedColumn("db1", "m_tz",
path("info", "tz_value"),
+ change("tz_value",
IcebergSchemaBuilder.buildColumnType(
+ ConnectorType.of("DATETIMEV2", 6, 0)), null,
true), false, false, null),
+ "Cannot change column type: info.tz_value");
+ Assertions.assertEquals(Types.TimestampType.withZone(),
+
reload("m_tz").findField("info").type().asStructType().field("tz_value").type());
+ }
+
@Test
public void testModifyNestedPrimitivePromotionDisallowedFailsLoud() {
// BIGINT -> INT is not an iceberg-representable promotion.
diff --git
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergPartitionUtilsTest.java
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergPartitionUtilsTest.java
index 28b95d18e56..323d28a73d8 100644
---
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergPartitionUtilsTest.java
+++
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergPartitionUtilsTest.java
@@ -1353,4 +1353,97 @@ public class IcebergPartitionUtilsTest {
}
}
+ @Test
+ public void timestampPartitionsRoundTripInEverySessionZone() {
+ for (ZoneId zone : Arrays.asList(ZoneOffset.UTC, SHANGHAI,
ZoneId.of("America/New_York"))) {
+ for (Types.TimestampType type : Arrays.asList(
+ Types.TimestampType.withZone(),
Types.TimestampType.withoutZone())) {
+ // 1636263000123456 and 1636266600123456 are both
01:30:00.123456 in New York.
+ for (long micros : new long[] {-1L, -1_000_001L,
1636263000123456L, 1636266600123456L}) {
+ String encoded =
IcebergPartitionUtils.serializePartitionValue(type, micros, zone);
+ Assertions.assertEquals(micros,
IcebergPartitionUtils.parsePartitionValueFromString(
+ encoded, type, zone), type + " " + zone + " " +
micros);
+ }
+ }
+ }
+ }
+
+ @Test
+ public void parseCommittedTimestampsHonorTheirExplicitOffsets() {
+ for (ZoneId zone : Arrays.asList(ZoneOffset.UTC, SHANGHAI,
ZoneId.of("America/New_York"))) {
+ // BE commits carry an offset, so the session zone must not
reinterpret either fold instant.
+ for (String value : Arrays.asList("2021-11-07
01:30:00.123456-04:00",
+ "2021-11-07 01:30:00.123456-05:00", "1969-12-31
23:59:59.999999+00:00")) {
+ java.time.Instant instant =
java.time.OffsetDateTime.parse(value.replace(' ', 'T')).toInstant();
+ long expected = instant.getEpochSecond() * 1_000_000L +
instant.getNano() / 1000;
+ Assertions.assertEquals(expected,
IcebergPartitionUtils.parsePartitionValueFromString(
+ value, Types.TimestampType.withZone(), zone), value +
" " + zone);
+ }
+ Assertions.assertEquals(-1L,
IcebergPartitionUtils.parsePartitionValueFromString(
+ "1969-12-31 23:59:59.999999",
Types.TimestampType.withoutZone(), zone));
+ }
+ }
+
+ @Test
+ public void parseBinaryAndUuidPartitionValuesFromCommits() {
+ byte[] bytes = new byte[] {0, (byte) 0xde, (byte) 0xad, (byte) 0xff};
+ for (org.apache.iceberg.types.Type type : Arrays.asList(
+ Types.BinaryType.get(),
Types.FixedType.ofLength(bytes.length))) {
+ Assertions.assertEquals(ByteBuffer.wrap(bytes),
IcebergPartitionUtils.parsePartitionValueFromString(
+ "0x00deadff", type, ZoneOffset.UTC));
+ Assertions.assertEquals("0x00deadff",
IcebergPartitionUtils.serializePartitionValue(
+ type, ByteBuffer.wrap(bytes), ZoneOffset.UTC));
+ }
+ Assertions.assertEquals(ByteBuffer.allocate(0),
IcebergPartitionUtils.parsePartitionValueFromString(
+ "0x", Types.BinaryType.get(), ZoneOffset.UTC));
+ UUID uuid = UUID.fromString("00112233-4455-6677-8899-aabbccddeeff");
+ Assertions.assertEquals(uuid,
IcebergPartitionUtils.parsePartitionValueFromString(
+ "0x00112233445566778899aabbccddeeff", Types.UUIDType.get(),
ZoneOffset.UTC));
+ Assertions.assertEquals(uuid,
IcebergPartitionUtils.parsePartitionValueFromString(
+ uuid.toString(), Types.UUIDType.get(), ZoneOffset.UTC));
+ // A wrong byte count or a value without the 0x prefix must not become
a different partition.
+ Assertions.assertThrows(IllegalArgumentException.class, () ->
+ IcebergPartitionUtils.parsePartitionValueFromString(
+ "0xdead", Types.FixedType.ofLength(4),
ZoneOffset.UTC));
+ Assertions.assertThrows(IllegalArgumentException.class, () ->
+ IcebergPartitionUtils.parsePartitionValueFromString(
+ "0xdead", Types.UUIDType.get(), ZoneOffset.UTC));
+ for (String malformed : Arrays.asList("DEAD", "0xD", "0xGG")) {
+ Assertions.assertThrows(IllegalArgumentException.class, () ->
+ IcebergPartitionUtils.parsePartitionValueFromString(
+ malformed, Types.BinaryType.get(),
ZoneOffset.UTC), malformed);
+ }
+ }
+
+ @Test
+ public void buildRangeTimestamptzBoundsCarryUtcOffset() {
+ // Transform ordinals describe UTC boundaries; the offset keeps
fe-core from reading them in the session zone.
+ Map<String, String> uppers = new HashMap<>();
+ uppers.put("hour", "1970-01-01 01:00:00+00:00");
+ uppers.put("day", "1970-01-02 00:00:00+00:00");
+ uppers.put("month", "1970-02-01 00:00:00+00:00");
+ uppers.put("year", "1971-01-01 00:00:00+00:00");
+ for (Map.Entry<String, String> entry : uppers.entrySet()) {
+ IcebergPartitionUtils.RangeBuild rb =
IcebergPartitionUtils.buildRange(
+ "ts_" + entry.getKey() + "=0", "0", entry.getKey(),
Types.TimestampType.withZone(), 1L, 1L);
+ Assertions.assertEquals(Collections.singletonList("1970-01-01
00:00:00+00:00"), rb.getLowerBound());
+
Assertions.assertEquals(Collections.singletonList(entry.getValue()),
rb.getUpperBound());
+ }
+ }
+
+ @Test
+ public void buildRangeYearZeroUsesProlepticYear() {
+ // Year-of-era formatting would render both year zero and year one as
0001.
+ Map<org.apache.iceberg.types.Type, String> suffixes = new HashMap<>();
+ suffixes.put(Types.DateType.get(), "");
+ suffixes.put(Types.TimestampType.withoutZone(), " 00:00:00");
+ suffixes.put(Types.TimestampType.withZone(), " 00:00:00+00:00");
+ for (Map.Entry<org.apache.iceberg.types.Type, String> entry :
suffixes.entrySet()) {
+ IcebergPartitionUtils.RangeBuild rb =
IcebergPartitionUtils.buildRange(
+ "y=-1970", "-1970", "year", entry.getKey(), 1L, 1L);
+ Assertions.assertEquals(Collections.singletonList("0000-01-01" +
entry.getValue()), rb.getLowerBound());
+ Assertions.assertEquals(Collections.singletonList("0001-01-01" +
entry.getValue()), rb.getUpperBound());
+ }
+ }
+
}
diff --git
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergScanPlanProviderTest.java
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergScanPlanProviderTest.java
index 3e39b6aa47a..f0d616d7431 100644
---
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergScanPlanProviderTest.java
+++
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergScanPlanProviderTest.java
@@ -310,6 +310,13 @@ public class IcebergScanPlanProviderTest {
Assertions.assertTrue(provider.ignorePartitionPruneShortCircuit());
}
+ @Test
+ public void doesNotUseHiveParquetInt96TimeZone() {
+ // Iceberg INT96 values are UTC instants; Hive's session-zone rule
would shift them.
+ Assertions.assertFalse(new
IcebergScanPlanProvider(IcebergCatalogProperties.of(Collections.emptyMap()),
+ new
RecordingIcebergCatalogOps()).usesHiveParquetInt96TimeZone());
+ }
+
@Test
public void supportsSystemTableTimeTravelIsTrue() {
IcebergScanPlanProvider provider =
diff --git
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergSchemaUtilsTest.java
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergSchemaUtilsTest.java
index 7d6c790b927..0e7e3173678 100644
---
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergSchemaUtilsTest.java
+++
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergSchemaUtilsTest.java
@@ -398,6 +398,15 @@ public class IcebergSchemaUtilsTest {
Assertions.assertEquals("key", nested.get(1).getFieldPtr().getName());
}
+ @Test
+ public void timestamptzInitialDefaultKeepsUtcOffsetWithoutLegacyFlag() {
+ // The offset lets BE read a missing column's default as an instant in
every session zone.
+ for (boolean flag : new boolean[] {false, true}) {
+ Assertions.assertEquals("2025-01-18 01:02:03.654321+00:00",
IcebergSchemaUtils.serializeInitialDefault(
+ Types.TimestampType.withZone(), 1_737_162_123_654_321L,
flag));
+ }
+ }
+
@Test
public void fieldsWithoutInitialDefaultOmitTheCarrier() {
// A field with no initial default must NOT set initial_default_value
(BE then backfills NULL, the legacy
diff --git
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergTimestampMappingTest.java
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergTimestampMappingTest.java
index e1ca9b1521d..84ae712d36e 100644
---
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergTimestampMappingTest.java
+++
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergTimestampMappingTest.java
@@ -43,17 +43,26 @@ class IcebergTimestampMappingTest {
org.apache.iceberg.types.Types.NestedField.optional(1, "ts",
org.apache.iceberg.types.Types.TimestampType.withZone()));
ConnectorType type = ConnectorType.of("TIMESTAMPTZ", 6, 0);
- java.time.LocalDateTime utc = java.time.LocalDateTime.of(1969, 12, 31,
23, 59, 59, 999999000);
- org.apache.doris.connector.spi.pushdown.ConnectorComparison filter =
- new
org.apache.doris.connector.spi.pushdown.ConnectorComparison(
-
org.apache.doris.connector.spi.pushdown.ConnectorComparison.Operator.GE,
- new
org.apache.doris.connector.spi.pushdown.ConnectorColumnRef("ts", type),
- new
org.apache.doris.connector.spi.pushdown.ConnectorLiteral(type, utc));
- for (IcebergPredicateConverter.Mode mode :
IcebergPredicateConverter.Mode.values()) {
- org.apache.iceberg.expressions.Expression expression = new
IcebergPredicateConverter(
- schema, java.time.ZoneId.of("Asia/Shanghai"),
mode).convert(filter).get(0);
- Assertions.assertEquals(-1L,
- ((org.apache.iceberg.expressions.UnboundPredicate<?>)
expression).literal().value());
+ // TIMESTAMPTZ literals already carry UTC fields, including both
instants of a New York DST overlap.
+ for (java.time.LocalDateTime utc : java.util.Arrays.asList(
+ java.time.LocalDateTime.of(1969, 12, 31, 23, 59, 59,
999999000),
+ java.time.LocalDateTime.of(2021, 11, 7, 5, 30, 0, 123456000),
+ java.time.LocalDateTime.of(2021, 11, 7, 6, 30, 0, 123456000)))
{
+ long micros = utc.toEpochSecond(java.time.ZoneOffset.UTC) *
1_000_000L + utc.getNano() / 1000;
+ org.apache.doris.connector.spi.pushdown.ConnectorComparison filter
=
+ new
org.apache.doris.connector.spi.pushdown.ConnectorComparison(
+
org.apache.doris.connector.spi.pushdown.ConnectorComparison.Operator.GE,
+ new
org.apache.doris.connector.spi.pushdown.ConnectorColumnRef("ts", type),
+ new
org.apache.doris.connector.spi.pushdown.ConnectorLiteral(type, utc));
+ for (String zone : java.util.Arrays.asList("UTC", "Asia/Shanghai",
"America/New_York")) {
+ for (IcebergPredicateConverter.Mode mode :
IcebergPredicateConverter.Mode.values()) {
+ org.apache.iceberg.expressions.Expression expression = new
IcebergPredicateConverter(
+ schema, java.time.ZoneId.of(zone),
mode).convert(filter).get(0);
+ Assertions.assertEquals(micros,
+
((org.apache.iceberg.expressions.UnboundPredicate<?>)
expression).literal().value(),
+ utc + " " + zone + " " + mode);
+ }
+ }
}
}
diff --git
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergWritePlanProviderTest.java
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergWritePlanProviderTest.java
index d917e809378..37e5734bf6f 100644
---
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergWritePlanProviderTest.java
+++
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergWritePlanProviderTest.java
@@ -366,6 +366,19 @@ public class IcebergWritePlanProviderTest {
IcebergWriteSchemaContext.toDorisSql(nestedType,
nestedDefault, false, false));
}
+ @Test
+ public void zonedAndNestedBinaryWriteDefaultsIgnoreLegacyMappingFlags() {
+ // A zoned default keeps its instant, and a nested binary leaf stays
bytes, with the old flags off.
+ Assertions.assertEquals("'2025-01-18 01:02:03.654321+00:00'",
IcebergWriteSchemaContext.toDorisSql(
+ Types.TimestampType.withZone(), 1_737_162_123_654_321L, false,
false));
+ Types.StructType structType = Types.StructType.of(
+ Types.NestedField.required(101, "payload",
Types.BinaryType.get()));
+ GenericRecord structDefault = GenericRecord.create(structType);
+ structDefault.setField("payload", ByteBuffer.wrap(new byte[] {(byte)
0x80, 0x00, (byte) 0xff}));
+ Assertions.assertEquals("named_struct('payload', X'8000FF')",
+ IcebergWriteSchemaContext.toDorisSql(structType,
structDefault, false, false));
+ }
+
@Test
public void branchWritePinsBranchHeadSchema() {
InMemoryCatalog catalog = freshCatalog();
@@ -997,6 +1010,41 @@ public class IcebergWritePlanProviderTest {
Assertions.assertEquals("null",
sink.getStaticPartitionValues().get("id"));
}
+ @Test
+ public void
planWriteKeepsNullBinaryKeysDistinctFromBytesForFullAndHybridSpecs() {
+ String bytes = "0x00112233445566778899aabbccddeeff";
+ for (org.apache.iceberg.types.Type keyType : Arrays.asList(
+ Types.BinaryType.get(), Types.FixedType.ofLength(16),
Types.UUIDType.get())) {
+ for (boolean hybrid : new boolean[] {false, true}) {
+ Schema schema = new Schema(Types.NestedField.optional(1,
"key", keyType),
+ Types.NestedField.optional(2, "region",
Types.StringType.get()));
+ PartitionSpec.Builder spec =
PartitionSpec.builderFor(schema).identity("key");
+ if (hybrid) {
+ spec.identity("region");
+ }
+ Table table =
freshCatalog().createTable(TableIdentifier.of("db1", "t1"), schema,
spec.build(),
+ Collections.singletonMap("write.format.default",
"parquet"));
+ String message = keyType + " hybrid=" + hybrid;
+
+ Map<String, String> nullValues = new
HashMap<>(Collections.singletonMap("key", "NULL"));
+ WriteHandle nullHandle = new WriteHandle(new
IcebergTableHandle("db1", "t1"))
+ .overwrite(true).writeContext(nullValues);
+ nullHandle.staticPartitionNullKeys =
Collections.singleton("key");
+ TIcebergTableSink nullSink = planSink(table,
contextWithStorage(), nullHandle);
+ Assertions.assertEquals(Collections.singleton("key"),
nullSink.getStaticPartitionNullKeys(), message);
+ Assertions.assertEquals("null",
nullSink.getStaticPartitionValues().get("key"), message);
+ // Encoding the wire marker must not rewrite the engine's
static partition spec.
+ Assertions.assertEquals("NULL", nullValues.get("key"),
message);
+
+ TIcebergTableSink bytesSink = planSink(table,
contextWithStorage(),
+ new WriteHandle(new IcebergTableHandle("db1", "t1"))
+
.overwrite(true).writeContext(Collections.singletonMap("key", bytes)));
+
Assertions.assertTrue(bytesSink.getStaticPartitionNullKeys().isEmpty(),
message);
+ Assertions.assertEquals(bytes,
bytesSink.getStaticPartitionValues().get("key"), message);
+ }
+ }
+ }
+
@Test
public void planWriteThreadsBranchFromHandleToTransaction() {
// WHY: INSERT INTO t@branch threads the target branch onto the write
handle; planWrite must hand it
diff --git
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergWriterHelperTest.java
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergWriterHelperTest.java
index 441ca3d5bc9..7a7c61de093 100644
---
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergWriterHelperTest.java
+++
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergWriterHelperTest.java
@@ -210,6 +210,38 @@ public class IcebergWriterHelperTest {
Assertions.assertEquals(FileFormat.PARQUET, df.format());
}
+ @Test
+ public void convertToWriterResultDecodesBinaryAndUuidPartitionValues() {
+ // BE commits binary-like identity partitions as 0x hex; UUID may also
arrive as canonical text.
+ Schema binarySchema = new Schema(
+ Types.NestedField.optional(1, "key", Types.BinaryType.get()),
+ Types.NestedField.optional(2, "fixed_key",
Types.FixedType.ofLength(2)),
+ Types.NestedField.optional(3, "uuid_key",
Types.UUIDType.get()));
+ InMemoryCatalog catalog = new InMemoryCatalog();
+ catalog.initialize("test", Collections.emptyMap());
+ catalog.createNamespace(Namespace.of("db1"));
+ Table table = catalog.createTable(TableIdentifier.of("db1",
"binary_t"), binarySchema,
+
PartitionSpec.builderFor(binarySchema).identity("key").identity("fixed_key").identity("uuid_key")
+ .build(),
+ Collections.singletonMap("write.format.default", "parquet"));
+ java.util.UUID uuid =
java.util.UUID.fromString("00112233-4455-6677-8899-aabbccddeeff");
+ for (String uuidValue :
Arrays.asList("0x00112233445566778899aabbccddeeff", uuid.toString())) {
+ TIcebergCommitData commit = new TIcebergCommitData();
+ commit.setFilePath("s3://b/db1/binary_t/binary.parquet");
+ commit.setPartitionValues(Arrays.asList("0xdead", "0xdead",
uuidValue));
+ commit.setRowCount(1L);
+ commit.setFileSize(128L);
+ commit.setFileContent(TFileContent.DATA);
+ DataFile file = IcebergWriterHelper.convertToWriterResult(
+ table, Collections.singletonList(commit),
ZoneOffset.UTC).dataFiles()[0];
+ Assertions.assertEquals(ByteBuffer.wrap(new byte[] {(byte) 0xde,
(byte) 0xad}),
+ file.partition().get(0, ByteBuffer.class));
+ Assertions.assertEquals(file.partition().get(0, ByteBuffer.class),
+ file.partition().get(1, ByteBuffer.class));
+ Assertions.assertEquals(uuid, file.partition().get(2,
java.util.UUID.class), uuidValue);
+ }
+ }
+
@Test
public void convertToWriterResultCarriesColumnMetrics() {
Table table = tableWith("write.format.default", "parquet");
diff --git
a/fe/fe-connector/fe-connector-jdbc/src/test/java/org/apache/doris/connector/jdbc/JdbcTimestampProjectionTest.java
b/fe/fe-connector/fe-connector-jdbc/src/test/java/org/apache/doris/connector/jdbc/JdbcTimestampProjectionTest.java
index 27d267f0e2c..b6c8c553da1 100644
---
a/fe/fe-connector/fe-connector-jdbc/src/test/java/org/apache/doris/connector/jdbc/JdbcTimestampProjectionTest.java
+++
b/fe/fe-connector/fe-connector-jdbc/src/test/java/org/apache/doris/connector/jdbc/JdbcTimestampProjectionTest.java
@@ -18,10 +18,25 @@
package org.apache.doris.connector.jdbc;
import org.apache.doris.connector.spi.ConnectorType;
+import org.apache.doris.connector.spi.handle.ConnectorColumnHandle;
+import org.apache.doris.connector.spi.pushdown.ConnectorColumnRef;
+import org.apache.doris.connector.spi.pushdown.ConnectorComparison;
+import org.apache.doris.connector.spi.pushdown.ConnectorExpression;
+import org.apache.doris.connector.spi.pushdown.ConnectorFunctionCall;
+import org.apache.doris.connector.spi.pushdown.ConnectorIn;
+import org.apache.doris.connector.spi.pushdown.ConnectorIsNull;
+import org.apache.doris.connector.spi.pushdown.ConnectorLiteral;
+import org.apache.doris.connector.spi.pushdown.ConnectorOr;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
+import java.time.LocalDateTime;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+
class JdbcTimestampProjectionTest {
@Test
void trinoSessionClauseRemainsOutsideTimestampProjection() {
@@ -201,4 +216,100 @@ class JdbcTimestampProjectionTest {
}
}
+ private static final ConnectorType INSTANT =
ConnectorType.of("TIMESTAMPTZ", 6, 0);
+ private static final ConnectorType LOCAL = ConnectorType.of("DATETIMEV2",
6, 0);
+ private static final ConnectorType BYTES = ConnectorType.of("VARBINARY",
16, 0);
+ private static final List<ConnectorColumnHandle> FILTER_COLUMNS =
Arrays.asList(
+ new JdbcColumnHandle("ts", "ts", INSTANT), new
JdbcColumnHandle("other_ts", "other_ts", INSTANT),
+ new JdbcColumnHandle("dt", "dt", LOCAL), new
JdbcColumnHandle("payload", "payload", BYTES),
+ new JdbcColumnHandle("id", "id", ConnectorType.of("INT")));
+
+ private static boolean pushedDown(JdbcQueryBuilder builder,
ConnectorExpression filter) {
+ return builder.buildQuery("db", "tbl", FILTER_COLUMNS,
Optional.of(filter), -1).contains("WHERE");
+ }
+
+ private static ConnectorExpression idIsOne() {
+ return new ConnectorComparison(ConnectorComparison.Operator.EQ,
+ new ConnectorColumnRef("id", ConnectorType.of("INT")), new
ConnectorLiteral(ConnectorType.of("INT"), 1));
+ }
+
+ @Test
+ void calendarFunctionsOnInstantsStayLocal() {
+ JdbcQueryBuilder mysql = new JdbcQueryBuilder(JdbcDbType.MYSQL,
+ JdbcFunctionPushdownConfig.create(JdbcDbType.MYSQL, null,
true), Collections.emptyMap());
+ // year() of an instant depends on the zone; the remote UTC session
differs from the Doris session.
+ Assertions.assertFalse(pushedDown(mysql, new
ConnectorComparison(ConnectorComparison.Operator.EQ,
+ new ConnectorFunctionCall("year", ConnectorType.of("INT"),
+ Collections.singletonList(new ConnectorColumnRef("ts",
INSTANT))),
+ new ConnectorLiteral(ConnectorType.of("INT"), 2020))));
+ Assertions.assertTrue(pushedDown(mysql, new
ConnectorComparison(ConnectorComparison.Operator.EQ,
+ new ConnectorFunctionCall("year", ConnectorType.of("INT"),
+ Collections.singletonList(new ConnectorColumnRef("dt",
LOCAL))),
+ new ConnectorLiteral(ConnectorType.of("INT"), 2020))));
+ // A direct null check is zone-independent.
+ Assertions.assertTrue(pushedDown(mysql, new ConnectorIsNull(new
ConnectorColumnRef("ts", INSTANT), false)));
+ }
+
+ @Test
+ void instantLiteralsStayLocalInEveryDialect() {
+ ConnectorLiteral literal = new ConnectorLiteral(INSTANT,
LocalDateTime.of(2020, 1, 2, 4, 1, 0, 111333000));
+ for (JdbcDbType dialect : JdbcDbType.values()) {
+ JdbcQueryBuilder builder = new JdbcQueryBuilder(dialect);
+ Assertions.assertFalse(pushedDown(builder, new
ConnectorComparison(ConnectorComparison.Operator.EQ,
+ new ConnectorColumnRef("ts", INSTANT), literal)),
dialect.name());
+ Assertions.assertFalse(pushedDown(builder, new ConnectorIn(new
ConnectorColumnRef("ts", INSTANT),
+ Collections.singletonList(literal), false)),
dialect.name());
+ }
+ }
+
+ @Test
+ void postgresInstantPredicatesObserveDecodedNulls() {
+ // Out-of-range PostgreSQL instants become NULL only after JDBC
decoding.
+ JdbcQueryBuilder postgres = new
JdbcQueryBuilder(JdbcDbType.POSTGRESQL);
+ ConnectorColumnRef ts = new ConnectorColumnRef("ts", INSTANT);
+ ConnectorColumnRef other = new ConnectorColumnRef("other_ts", INSTANT);
+ for (ConnectorExpression predicate : Arrays.asList(new
ConnectorIsNull(ts, false),
+ new ConnectorIsNull(ts, true),
+ new ConnectorComparison(ConnectorComparison.Operator.EQ, ts,
other),
+ new
ConnectorComparison(ConnectorComparison.Operator.EQ_FOR_NULL, ts, other),
+ new ConnectorOr(Arrays.asList(new ConnectorIsNull(ts, false),
idIsOne())))) {
+ Assertions.assertFalse(pushedDown(postgres, predicate),
predicate.toString());
+ }
+ Assertions.assertTrue(pushedDown(new
JdbcQueryBuilder(JdbcDbType.MYSQL), new ConnectorIsNull(ts, false)));
+ Assertions.assertTrue(pushedDown(postgres, new ConnectorIsNull(new
ConnectorColumnRef("dt", LOCAL), false)));
+ Assertions.assertTrue(pushedDown(postgres, idIsOne()));
+ }
+
+ @Test
+ void binaryLiteralsStayLocalWhereTheDialectReadsThemDifferently() {
+ ConnectorColumnRef payload = new ConnectorColumnRef("payload", BYTES);
+ for (JdbcDbType dialect : Arrays.asList(JdbcDbType.POSTGRESQL,
JdbcDbType.ORACLE, JdbcDbType.SQLSERVER,
+ JdbcDbType.DB2, JdbcDbType.OCEANBASE_ORACLE)) {
+ JdbcQueryBuilder builder = new JdbcQueryBuilder(dialect);
+ for (ConnectorComparison.Operator operator :
Arrays.asList(ConnectorComparison.Operator.EQ,
+ ConnectorComparison.Operator.NE,
ConnectorComparison.Operator.LT)) {
+ Assertions.assertFalse(pushedDown(builder, new
ConnectorComparison(operator, payload,
+ binaryLiteral((byte) 0, (byte) 0xff))), dialect + " "
+ operator);
+ }
+ for (boolean notIn : new boolean[] {false, true}) {
+ ConnectorExpression in = new ConnectorIn(payload,
Arrays.asList(
+ binaryLiteral((byte) 0xde, (byte) 0, (byte) 0xff),
binaryLiteral()), notIn);
+ Assertions.assertFalse(pushedDown(builder, in), dialect + "
notIn=" + notIn);
+ Assertions.assertFalse(pushedDown(builder, new
ConnectorOr(Arrays.asList(in, idIsOne()))),
+ dialect + " notIn=" + notIn);
+ }
+ Assertions.assertTrue(pushedDown(builder, new ConnectorIn(new
ConnectorColumnRef("id",
+ ConnectorType.of("INT")), Collections.singletonList(
+ new ConnectorLiteral(ConnectorType.of("INT"), 1)),
false)), dialect.name());
+ }
+ String mysql = new JdbcQueryBuilder(JdbcDbType.MYSQL).buildQuery("db",
"tbl", FILTER_COLUMNS, Optional.of(
+ new ConnectorIn(payload,
Collections.singletonList(binaryLiteral((byte) 0, (byte) 0xff)), false)), -1);
+ Assertions.assertTrue(mysql.contains("IN (X'00ff')"), mysql);
+ }
+
+ // VARBINARY literals cross the SPI as a lossless Latin-1 string carrier.
+ private static ConnectorLiteral binaryLiteral(byte... bytes) {
+ return new ConnectorLiteral(BYTES, new String(bytes,
java.nio.charset.StandardCharsets.ISO_8859_1));
+ }
+
}
diff --git
a/fe/fe-connector/fe-connector-jdbc/src/test/java/org/apache/doris/connector/jdbc/client/JdbcLogicalTypeMappingTest.java
b/fe/fe-connector/fe-connector-jdbc/src/test/java/org/apache/doris/connector/jdbc/client/JdbcLogicalTypeMappingTest.java
new file mode 100644
index 00000000000..a156d2f0002
--- /dev/null
+++
b/fe/fe-connector/fe-connector-jdbc/src/test/java/org/apache/doris/connector/jdbc/client/JdbcLogicalTypeMappingTest.java
@@ -0,0 +1,120 @@
+// 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.connector.jdbc.client;
+
+import org.apache.doris.connector.jdbc.JdbcDbType;
+import org.apache.doris.connector.spi.ConnectorType;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.sql.Types;
+import java.util.Collections;
+import java.util.Optional;
+
+/**
+ * The binary and instant mappings are determined by source semantics; the
deprecated
+ * {@code enable.mapping.*} flags must not select a different external logical
type.
+ */
+class JdbcLogicalTypeMappingTest {
+
+ @Test
+ void binaryTypesKeepBytesAndDeclaredLength() {
+ for (boolean flag : new boolean[] {false, true}) {
+ assertVarbinary(new JdbcMySQLConnectorClient("test",
JdbcDbType.MYSQL, "jdbc:mysql://localhost/test",
+ false, Collections.emptyMap(), Collections.emptyMap(),
flag, flag),
+ "BINARY", "VARBINARY", "TINYBLOB", "BLOB", "MEDIUMBLOB",
"LONGBLOB");
+ assertVarbinary(postgres(flag), "bytea");
+ assertVarbinary(new JdbcOracleConnectorClient("test",
JdbcDbType.ORACLE, "jdbc:oracle:thin:@localhost",
+ false, Collections.emptyMap(), Collections.emptyMap(),
flag, flag), "BLOB");
+ assertVarbinary(sqlServer(flag), "binary", "varbinary", "image");
+ assertVarbinary(new JdbcDB2ConnectorClient("test", JdbcDbType.DB2,
"jdbc:db2://localhost/test",
+ false, Collections.emptyMap(), Collections.emptyMap(),
flag, flag), "BLOB", "BINARY", "VARBINARY");
+ }
+ }
+
+ @Test
+ void instantTimestampsMapToTimestamptzWhileLocalTimestampsStayDatetime() {
+ for (boolean flag : new boolean[] {false, true}) {
+ JdbcConnectorClient mysql = new JdbcMySQLConnectorClient("test",
JdbcDbType.MYSQL,
+ "jdbc:mysql://localhost/test", false,
Collections.emptyMap(), Collections.emptyMap(), flag, flag);
+ assertTimestamp(mysql, "TIMESTAMP", true);
+ assertTimestamp(mysql, "DATETIME", false);
+ assertTimestamp(postgres(flag), "timestamptz", true);
+ assertTimestamp(postgres(flag), "timestamp", false);
+ JdbcConnectorClient oracle = new JdbcOracleConnectorClient("test",
JdbcDbType.ORACLE,
+ "jdbc:oracle:thin:@localhost", false,
Collections.emptyMap(), Collections.emptyMap(), flag, flag);
+ assertTimestamp(oracle, "TIMESTAMP(6) WITH LOCAL TIME ZONE", true);
+ assertTimestamp(oracle, "TIMESTAMP(6) WITH TIME ZONE", true);
+ assertTimestamp(oracle, "TIMESTAMP(6)", false);
+ JdbcConnectorClient trino = new JdbcTrinoConnectorClient("test",
JdbcDbType.TRINO,
+ "jdbc:trino://localhost", false, Collections.emptyMap(),
Collections.emptyMap(), flag, flag);
+ assertTimestamp(trino, "timestamp(6) with time zone", true);
+ assertTimestamp(trino, "timestamp(6)", false);
+ assertTimestamp(sqlServer(flag), "datetimeoffset", true);
+ assertTimestamp(sqlServer(flag), "datetime2", false);
+ assertTimestamp(new JdbcClickHouseConnectorClient("test",
JdbcDbType.CLICKHOUSE,
+ "jdbc:clickhouse://localhost/test", false,
Collections.emptyMap(), Collections.emptyMap(),
+ flag, flag), "DateTime64(6, 'UTC')", true);
+ }
+ }
+
+ @Test
+ void postgresInstantsAllowDecoderCreatedNulls() {
+ // Out-of-range and infinite timestamptz values decode to NULL, so a
source NOT NULL cannot hold.
+ JdbcFieldInfo instant = timestampField("timestamptz");
+ instant.setAllowNull(false);
+ postgres(false).jdbcTypeToConnectorType(instant);
+ Assertions.assertTrue(instant.isAllowNull());
+
+ JdbcFieldInfo local = timestampField("timestamp");
+ local.setAllowNull(false);
+ postgres(false).jdbcTypeToConnectorType(local);
+ Assertions.assertFalse(local.isAllowNull());
+ }
+
+ private static JdbcConnectorClient postgres(boolean flag) {
+ return new JdbcPostgreSQLConnectorClient("test",
JdbcDbType.POSTGRESQL, "jdbc:postgresql://localhost/test",
+ false, Collections.emptyMap(), Collections.emptyMap(), flag,
flag);
+ }
+
+ private static JdbcConnectorClient sqlServer(boolean flag) {
+ return new JdbcSQLServerConnectorClient("test", JdbcDbType.SQLSERVER,
"jdbc:sqlserver://localhost",
+ false, Collections.emptyMap(), Collections.emptyMap(), flag,
flag);
+ }
+
+ private static JdbcFieldInfo timestampField(String typeName) {
+ return new JdbcFieldInfo("ts", Optional.of(typeName), Types.TIMESTAMP,
+ Optional.of(26), Optional.of(6), Optional.empty());
+ }
+
+ private static void assertVarbinary(JdbcConnectorClient client, String...
typeNames) {
+ for (String typeName : typeNames) {
+ ConnectorType type = client.jdbcTypeToConnectorType(new
JdbcFieldInfo("b", Optional.of(typeName),
+ Types.VARBINARY, Optional.of(32), Optional.of(0),
Optional.empty()));
+ Assertions.assertEquals("VARBINARY", type.getTypeName(), typeName);
+ Assertions.assertEquals(32, type.getPrecision(), typeName);
+ }
+ }
+
+ private static void assertTimestamp(JdbcConnectorClient client, String
typeName, boolean instant) {
+ ConnectorType type =
client.jdbcTypeToConnectorType(timestampField(typeName));
+ Assertions.assertEquals(instant ? "TIMESTAMPTZ" : "DATETIMEV2",
type.getTypeName(), typeName);
+ Assertions.assertEquals(6, type.getPrecision(), typeName);
+ }
+}
diff --git
a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonLegacyOrcTimestampTest.java
b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonLegacyOrcTimestampTest.java
index a0840317cc3..7d0221494b8 100644
---
a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonLegacyOrcTimestampTest.java
+++
b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonLegacyOrcTimestampTest.java
@@ -33,11 +33,14 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
+import java.util.Set;
class PaimonLegacyOrcTimestampTest {
@Test
@@ -75,4 +78,46 @@ class PaimonLegacyOrcTimestampTest {
Collections.singleton(1), new HashMap<>()));
}
}
+
+ @Test
+ void routeEveryNestedLtzShapeAndMixedSplitToSdk(@TempDir Path warehouse)
throws Exception {
+ org.apache.paimon.types.DataType ltz =
DataTypes.TIMESTAMP_WITH_LOCAL_TIME_ZONE(6);
+ List<org.apache.paimon.types.DataType> shapes = Arrays.asList(ltz,
DataTypes.ARRAY(ltz),
+ DataTypes.MULTISET(ltz), DataTypes.MAP(ltz, DataTypes.INT()),
DataTypes.MAP(DataTypes.INT(), ltz),
+ DataTypes.ROW(DataTypes.FIELD(1, "ts", ltz)));
+ try (Catalog catalog = new FileSystemCatalog(LocalFileIO.create(),
+ new org.apache.paimon.fs.Path(warehouse.toUri()))) {
+ catalog.createDatabase("db", false);
+ for (int i = 0; i < shapes.size(); i++) {
+ // An absent option means Paimon's legacy default, so both
spellings must route to the SDK.
+ for (boolean explicit : new boolean[] {false, true}) {
+ Identifier id = Identifier.create("db", "t" + i +
(explicit ? "_explicit" : "_default"));
+ Schema.Builder schema = Schema.newBuilder()
+ .column("id", DataTypes.INT())
+ .column("c", shapes.get(i));
+ if (explicit) {
+
schema.option(OrcOptions.ORC_TIMESTAMP_LTZ_LEGACY_TYPE.key(), "true");
+ }
+ catalog.createTable(id, schema.build(), false);
+ FileStoreTable table = (FileStoreTable)
catalog.getTable(id);
+ Set<Integer> readC =
Collections.singleton(table.rowType().getField("c").id());
+ String message = shapes.get(i) + (explicit ? " explicit" :
" default");
+ Assertions.assertTrue(requiresSdk(table, readC,
"data.orc"), message);
+ // One legacy ORC file is enough to route a mixed split
away from the native reader.
+ Assertions.assertTrue(requiresSdk(table, readC,
"data.parquet", "data.orc"), message);
+ Assertions.assertFalse(requiresSdk(table, readC,
"data.parquet"), message);
+ }
+ }
+ }
+ }
+
+ private static boolean requiresSdk(FileStoreTable table, Set<Integer>
readFieldIds, String... paths) {
+ List<RawFile> files = new ArrayList<>();
+ for (String path : paths) {
+ files.add(new RawFile(path, 0L, 100L, 1L,
path.substring(path.lastIndexOf('.') + 1),
+ table.schema().id(), 0L));
+ }
+ return PaimonScanPlanProvider.requiresLegacyOrcTimestampReader(
+ table, Optional.of(files), readFieldIds, new HashMap<>());
+ }
}
diff --git
a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonPartitionValueRenderTest.java
b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonPartitionValueRenderTest.java
index 0da0c76c093..1af43861c8c 100644
---
a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonPartitionValueRenderTest.java
+++
b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonPartitionValueRenderTest.java
@@ -24,6 +24,9 @@ import org.junit.jupiter.api.Test;
import java.time.LocalDate;
import java.time.LocalDateTime;
+import java.time.OffsetDateTime;
+import java.time.ZoneOffset;
+import java.util.Arrays;
/**
* Unit tests for {@link PaimonScanPlanProvider#serializePartitionValue}, the
FIX-NATIVE-PARTVAL
@@ -66,6 +69,22 @@ public class PaimonPartitionValueRenderTest {
Assertions.assertEquals("2024-01-01T01:02:03Z", utc);
}
+ @Test
+ public void ltzRetainsPreEpochAndDstFoldInstantsInEverySessionZone() {
+ // Both New York fold instants display as 01:30; only the explicit
offset keeps them apart.
+ for (LocalDateTime utc : Arrays.asList(
+ LocalDateTime.of(1969, 12, 31, 23, 59, 59, 999999000),
+ LocalDateTime.of(2021, 11, 7, 5, 30, 0, 123456000),
+ LocalDateTime.of(2021, 11, 7, 6, 30, 0, 123456000))) {
+ for (String zone : Arrays.asList("UTC", "Asia/Shanghai",
"America/New_York")) {
+ String value = PaimonScanPlanProvider.serializePartitionValue(
+ DataTypes.TIMESTAMP_WITH_LOCAL_TIME_ZONE(6),
Timestamp.fromLocalDateTime(utc), zone);
+ Assertions.assertEquals(utc.toInstant(ZoneOffset.UTC),
+ OffsetDateTime.parse(value).toInstant(), utc + " " +
zone);
+ }
+ }
+ }
+
@Test
public void ntzRendersIsoNoZoneShift() {
Timestamp ts = Timestamp.fromLocalDateTime(LocalDateTime.of(2024, 1,
1, 1, 2, 3));
diff --git
a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonScanPlanProviderTest.java
b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonScanPlanProviderTest.java
index 70fe9cc4993..9223305efbb 100644
---
a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonScanPlanProviderTest.java
+++
b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonScanPlanProviderTest.java
@@ -2791,7 +2791,7 @@ public class PaimonScanPlanProviderTest {
.column("id", DataTypes.INT().notNull())
.column("event_time", new
org.apache.paimon.types.LocalZonedTimestampType(6))
.primaryKey("id").option("bucket", "1")
- .option("file.format",
"orc").option("orc.timestamp-ltz.legacy-type", "true")
+ .option("file.format",
"orc").option("orc.timestamp-ltz.legacy.type", "true")
.build(), false);
Table table = catalog.getTable(id);
BatchWriteBuilder builder = table.newBatchWriteBuilder();
@@ -2857,7 +2857,7 @@ public class PaimonScanPlanProviderTest {
catalog.createTable(id, Schema.newBuilder()
.column("id", DataTypes.INT().notNull())
.primaryKey("id").option("bucket", "1")
- .option("file.format",
"orc").option("orc.timestamp-ltz.legacy-type", "true")
+ .option("file.format",
"orc").option("orc.timestamp-ltz.legacy.type", "true")
.build(), false);
Table table = catalog.getTable(id);
BatchWriteBuilder builder = table.newBatchWriteBuilder();
@@ -2972,7 +2972,7 @@ public class PaimonScanPlanProviderTest {
.column("id", DataTypes.INT())
.column("event_time", new
org.apache.paimon.types.LocalZonedTimestampType(6))
.option("file.format", "orc")
- .option("orc.timestamp-ltz.legacy-type", "true")
+ .option("orc.timestamp-ltz.legacy.type", "true")
.build(), false);
Table table = catalog.getTable(id);
BatchWriteBuilder builder = table.newBatchWriteBuilder();
diff --git
a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonWritePlanProviderTest.java
b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonWritePlanProviderTest.java
index 8995f4fc5c0..cd321088f72 100644
---
a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonWritePlanProviderTest.java
+++
b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonWritePlanProviderTest.java
@@ -110,6 +110,27 @@ public class PaimonWritePlanProviderTest {
2, 3, false, CoreOptions.MergeEngine.FIRST_ROW));
}
+ @Test
+ public void
writeColumnsKeepInstantSemanticsInsideContainersRegardlessOfLegacyFlags() {
+ // A civil DATETIMEV2 write boundary would collapse both instants of a
DST fold.
+ List<DataField> fields = Arrays.asList(
+ DataTypes.FIELD(0, "instant",
DataTypes.TIMESTAMP_WITH_LOCAL_TIME_ZONE(6)),
+ DataTypes.FIELD(1, "local_time", DataTypes.TIMESTAMP(6)),
+ DataTypes.FIELD(2, "items",
DataTypes.ARRAY(DataTypes.TIMESTAMP_WITH_LOCAL_TIME_ZONE(3))),
+ DataTypes.FIELD(3, "record", DataTypes.ROW(
+ DataTypes.FIELD(4, "instant",
DataTypes.TIMESTAMP_WITH_LOCAL_TIME_ZONE(6)))));
+ for (boolean flag : new boolean[] {false, true}) {
+ List<ConnectorColumn> columns =
PaimonWritePlanProvider.mapWriteColumns(
+ fields, Collections.emptyList(), new
PaimonTypeMapping.Options(flag, flag));
+ Assertions.assertEquals(ConnectorType.of("TIMESTAMPTZ", 6, 0),
columns.get(0).getType());
+ Assertions.assertEquals(ConnectorType.of("DATETIMEV2", 6, 0),
columns.get(1).getType());
+ Assertions.assertEquals(ConnectorType.of("TIMESTAMPTZ", 3, 0),
+ columns.get(2).getType().getChildren().get(0));
+ Assertions.assertEquals(ConnectorType.of("TIMESTAMPTZ", 6, 0),
+ columns.get(3).getType().getChildren().get(0));
+ }
+ }
+
@Test
public void
writeColumnsConvertPaimonDefaultsToDorisSqlAndPreserveSchemaProperties() {
List<ConnectorColumn> columns =
PaimonWritePlanProvider.mapWriteColumns(
diff --git
a/fe/fe-connector/fe-connector-trino/src/test/java/org/apache/doris/connector/trino/TrinoTimestampMappingTest.java
b/fe/fe-connector/fe-connector-trino/src/test/java/org/apache/doris/connector/trino/TrinoTimestampMappingTest.java
index 616215e6257..6e05570e11c 100644
---
a/fe/fe-connector/fe-connector-trino/src/test/java/org/apache/doris/connector/trino/TrinoTimestampMappingTest.java
+++
b/fe/fe-connector/fe-connector-trino/src/test/java/org/apache/doris/connector/trino/TrinoTimestampMappingTest.java
@@ -62,6 +62,46 @@ class TrinoTimestampMappingTest {
}
}
+ @Test
+ void domainsEncodeUtcMicrosAndMillisecondAlignedZonedBounds() {
+ for (java.time.LocalDateTime utc : java.util.Arrays.asList(
+ java.time.LocalDateTime.of(2020, 1, 2, 4, 1, 0, 111333000),
+ java.time.LocalDateTime.of(1969, 12, 31, 23, 59, 59,
999999000))) {
+ long millis =
utc.toInstant(java.time.ZoneOffset.UTC).toEpochMilli();
+ // TIMESTAMPTZ(6) keeps the sub-millisecond part as picoseconds of
the UTC millisecond.
+
Assertions.assertEquals(io.trino.spi.type.LongTimestampWithTimeZone.fromEpochMillisAndFraction(
+ millis, (utc.getNano() % 1_000_000) * 1000,
io.trino.spi.type.TimeZoneKey.UTC_KEY),
+
singleDomainValue(io.trino.spi.type.TimestampWithTimeZoneType.createTimestampWithTimeZoneType(6),
+ "TIMESTAMPTZ", 6, utc));
+ // TIMESTAMPTZ(3) accepts a millisecond-aligned bound as a packed
UTC value.
+ java.time.LocalDateTime aligned = utc.withNano(utc.getNano() /
1_000_000 * 1_000_000);
+ long packed = (long) singleDomainValue(
+
io.trino.spi.type.TimestampWithTimeZoneType.createTimestampWithTimeZoneType(3),
+ "TIMESTAMPTZ", 3, aligned);
+
Assertions.assertEquals(aligned.toInstant(java.time.ZoneOffset.UTC).toEpochMilli(),
+
io.trino.spi.type.DateTimeEncoding.unpackMillisUtc(packed));
+ // TIMESTAMP(6) carries the wall-clock fields as epoch
microseconds without double counting millis.
+
Assertions.assertEquals(utc.toEpochSecond(java.time.ZoneOffset.UTC) *
1_000_000L + utc.getNano() / 1000,
+
singleDomainValue(io.trino.spi.type.TimestampType.createTimestampType(6),
"DATETIMEV2", 6, utc));
+ }
+ }
+
+ private static Object singleDomainValue(io.trino.spi.type.Type trinoType,
String dorisTypeName, int precision,
+ java.time.LocalDateTime value) {
+ io.trino.spi.connector.ColumnHandle handle = new
io.trino.spi.connector.ColumnHandle() { };
+ org.apache.doris.connector.spi.ConnectorType dorisType =
+ org.apache.doris.connector.spi.ConnectorType.of(dorisTypeName,
precision, 0);
+ org.apache.doris.connector.spi.pushdown.ConnectorExpression filter =
+ new
org.apache.doris.connector.spi.pushdown.ConnectorComparison(
+
org.apache.doris.connector.spi.pushdown.ConnectorComparison.Operator.EQ,
+ new
org.apache.doris.connector.spi.pushdown.ConnectorColumnRef("ts", dorisType),
+ new
org.apache.doris.connector.spi.pushdown.ConnectorLiteral(dorisType, value));
+ TrinoPredicateConverter converter = new TrinoPredicateConverter(
+ java.util.Collections.singletonMap("ts", handle),
+ java.util.Collections.singletonMap("ts", new
io.trino.spi.connector.ColumnMetadata("ts", trinoType)));
+ return
converter.convert(filter).getDomains().orElseThrow(AssertionError::new).get(handle).getSingleValue();
+ }
+
@Test
void subMicrosecondLocalTimestampDomainStaysLocal() {
io.trino.spi.connector.ColumnHandle handle = new
io.trino.spi.connector.ColumnHandle() { };
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/datasource/jdbc/client/JdbcClientTest.java
b/fe/fe-core/src/test/java/org/apache/doris/datasource/jdbc/client/JdbcClientTest.java
new file mode 100644
index 00000000000..30f11fee297
--- /dev/null
+++
b/fe/fe-core/src/test/java/org/apache/doris/datasource/jdbc/client/JdbcClientTest.java
@@ -0,0 +1,127 @@
+// 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.client;
+
+import org.apache.doris.catalog.ArrayType;
+import org.apache.doris.catalog.Column;
+import org.apache.doris.catalog.ScalarType;
+import org.apache.doris.datasource.jdbc.util.JdbcFieldSchema;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+import java.sql.ResultSet;
+import java.sql.Types;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * The legacy fe-core clients (still used by CDC schema discovery) map binary
and instant types by source
+ * semantics; the deprecated mapping flags must not restore the STRING or
unzoned DATETIME contract.
+ */
+public class JdbcClientTest {
+
+ @Test
+ public void testBinaryMappingsKeepBytesAndDeclaredLength() throws
Exception {
+ assertBinaryMapping(JdbcMySQLClient.class, "BINARY", "VARBINARY",
"TINYBLOB", "BLOB", "MEDIUMBLOB", "LONGBLOB");
+ assertBinaryMapping(JdbcPostgreSQLClient.class, "bytea");
+ assertBinaryMapping(JdbcOracleClient.class, "BLOB");
+ assertBinaryMapping(JdbcSQLServerClient.class, "binary", "varbinary",
"image");
+ assertBinaryMapping(JdbcDB2Client.class, "BLOB");
+ JdbcClientConfig config = new
JdbcClientConfig().setEnableMappingVarbinary(false);
+ Assertions.assertTrue(config.isEnableMappingVarbinary());
+ }
+
+ private void assertBinaryMapping(Class<? extends JdbcClient> clientClass,
String... names) throws Exception {
+ JdbcClient client = Mockito.mock(clientClass,
Mockito.CALLS_REAL_METHODS);
+ for (String name : names) {
+ JdbcFieldSchema field = new
JdbcFieldSchema(Mockito.mock(ResultSet.class));
+ field.setDataTypeName(Optional.of(name));
+ field.setColumnSize(Optional.of(32));
+ Assertions.assertEquals(ScalarType.createVarbinaryType(32),
client.jdbcTypeToDoris(field), name);
+ }
+ }
+
+ @Test
+ public void testTimestampMappingsIgnoreLegacyFlag() throws Exception {
+ assertTimestampMapping(JdbcMySQLClient.class, "TIMESTAMP", true);
+ assertTimestampMapping(JdbcMySQLClient.class, "DATETIME", false);
+ assertTimestampMapping(JdbcPostgreSQLClient.class, "timestamptz",
true);
+ assertTimestampMapping(JdbcPostgreSQLClient.class, "timestamp", false);
+ assertTimestampMapping(JdbcOracleClient.class, "TIMESTAMP(6) WITH
LOCAL TIME ZONE", true);
+ assertTimestampMapping(JdbcOracleClient.class, "TIMESTAMP(6)", false);
+ }
+
+ @Test
+ public void testExplicitZonedJdbcTimestampMappings() throws Exception {
+ assertTimestampMapping(JdbcTrinoClient.class, "timestamp(6) with time
zone", true);
+ assertTimestampMapping(JdbcTrinoClient.class, "timestamp(6)", false);
+ assertTimestampMapping(JdbcSQLServerClient.class, "datetimeoffset",
true);
+ assertTimestampMapping(JdbcSQLServerClient.class, "datetime2", false);
+ assertTimestampMapping(JdbcOracleClient.class, "TIMESTAMP(6) WITH TIME
ZONE", true);
+ assertTimestampMapping(JdbcClickHouseClient.class, "DateTime64(6,
'UTC')", true);
+ }
+
+ private void assertTimestampMapping(Class<? extends JdbcClient>
clientClass, String typeName,
+ boolean withZone) throws Exception {
+ // Avoid opening remote connections; execute the real metadata
conversion with either legacy flag.
+ JdbcClient client = Mockito.mock(clientClass,
Mockito.CALLS_REAL_METHODS);
+ JdbcFieldSchema field = new
JdbcFieldSchema(Mockito.mock(ResultSet.class));
+ field.setDataTypeName(Optional.of(typeName));
+ field.setColumnSize(Optional.of(26));
+ field.setDecimalDigits(Optional.of(6));
+ for (boolean flag : new boolean[] {false, true}) {
+ client.enableMappingTimestampTz = flag;
+ Assertions.assertEquals(withZone ?
ScalarType.createTimeStampTzType(6)
+ : ScalarType.createDatetimeV2Type(6),
client.jdbcTypeToDoris(field), typeName);
+ }
+ }
+
+ @Test
+ public void testPostgresTimestampMappingAllowsDecoderCreatedNulls() throws
Exception {
+ JdbcPostgreSQLClient client = Mockito.mock(JdbcPostgreSQLClient.class,
Mockito.CALLS_REAL_METHODS);
+ JdbcFieldSchema instant = new
JdbcFieldSchema(Mockito.mock(ResultSet.class));
+ instant.setColumnName("event_time");
+ instant.setDataTypeName(Optional.of("timestamptz"));
+ instant.setDecimalDigits(Optional.of(6));
+ instant.setAllowNull(false);
+ JdbcFieldSchema local = new JdbcFieldSchema(instant);
+ local.setColumnName("local_time");
+ local.setDataTypeName(Optional.of("timestamp"));
+ JdbcFieldSchema array = new JdbcFieldSchema(instant);
+ array.setColumnName("events");
+ array.setDataTypeName(Optional.of("_timestamptz"));
+ array.setDataType(Types.ARRAY);
+ array.setArrayDimensions(Optional.of(2));
+ Mockito.doReturn(Arrays.asList(instant, local, array)).when(client)
+ .getJdbcColumnsInfo("sample_schema", "sample_table");
+
+ List<Column> columns = client.getColumnsFromJdbc("sample_schema",
"sample_table");
+
+ // Source NOT NULL cannot prevent the timestamp range conversion from
creating NULL.
+ Assertions.assertTrue(columns.get(0).isAllowNull());
+ Assertions.assertTrue(columns.get(0).getType().isTimeStampTz());
+ Assertions.assertFalse(columns.get(1).isAllowNull());
+ Assertions.assertFalse(columns.get(2).isAllowNull());
+ ArrayType arrayType = (ArrayType) columns.get(2).getType();
+ Assertions.assertTrue(arrayType.getContainsNull());
+ Assertions.assertTrue(((ArrayType)
arrayType.getItemType()).getContainsNull());
+ }
+}
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/datasource/mvcc/PluginDrivenMvccExternalTableTest.java
b/fe/fe-core/src/test/java/org/apache/doris/datasource/mvcc/PluginDrivenMvccExternalTableTest.java
index 4496694c284..f232aa9f0cc 100644
---
a/fe/fe-core/src/test/java/org/apache/doris/datasource/mvcc/PluginDrivenMvccExternalTableTest.java
+++
b/fe/fe-core/src/test/java/org/apache/doris/datasource/mvcc/PluginDrivenMvccExternalTableTest.java
@@ -1443,6 +1443,35 @@ public class PluginDrivenMvccExternalTableTest {
"the NULL-min partition's exclusive upper must be
lowerKey.successor()");
}
+ @Test
+ public void testRangeViewTimestamptzBoundsStayUtcInEverySessionZone()
throws AnalysisException {
+ // Transform bounds are UTC instants, and the NULL-min sentinel must
stay below them: parsing it as a
+ // wall-clock date would underflow year zero in a positive-offset
session.
+ Type timestamptz =
org.apache.doris.catalog.ScalarType.createTimeStampTzType(6);
+ for (String zone : Arrays.asList("UTC", "Asia/Shanghai",
"America/New_York")) {
+ ConnectContext ctx = new ConnectContext();
+ ctx.getSessionVariable().setTimeZone(zone);
+ ctx.setThreadLocalInfo();
+ try {
+ Fixture f = Fixture.rangeView(rangeView(
+ rangePart("pnull", "0000-01-01", null, FRESH_555),
+ rangePart("p0", "1970-01-01 00:00:00+00:00",
"1970-01-02 00:00:00+00:00", FRESH_777)),
+ timestamptz);
+ Map<String, PartitionItem> items =
f.table.getNameToPartitionItems(Optional.empty());
+ com.google.common.collect.Range<PartitionKey> nullRange =
+ ((RangePartitionItem) items.get("pnull")).getItems();
+ com.google.common.collect.Range<PartitionKey> dayZero =
+ ((RangePartitionItem) items.get("p0")).getItems();
+ Assertions.assertFalse(nullRange.isEmpty(), zone);
+
Assertions.assertTrue(nullRange.upperEndpoint().compareTo(dayZero.lowerEndpoint())
< 0, zone);
+ Assertions.assertEquals(0,
dayZero.lowerEndpoint().getKeys().get(0).compareLiteral(
+ new org.apache.doris.analysis.DateLiteral(1970, 1, 1,
0, 0, 0, 0, timestamptz)), zone);
+ } finally {
+ ConnectContext.remove();
+ }
+ }
+ }
+
@Test
public void testRangeViewPartitionSnapshotIsSnapshotId() throws
AnalysisException {
Fixture f = Fixture.rangeView(rangeView(
@@ -1709,7 +1738,13 @@ public class PluginDrivenMvccExternalTableTest {
* the snapshot-consistent enumeration. The partition column is the
default DATEV2 {@code dt}.
*/
static Fixture rangeView(ConnectorMvccPartitionView view) {
- Fixture f = partitioned();
+ return rangeView(view, Type.DATEV2);
+ }
+
+ static Fixture rangeView(ConnectorMvccPartitionView view, Type
partitionColType) {
+ Fixture f = with(Arrays.asList(
+ cpi("dt=2024-01-01", TS_2024_01_01),
+ cpi("dt=2024-02-02", TS_2024_02_02)), partitionColType);
Mockito.when(f.metadata.applySnapshot(Mockito.eq(f.session),
Mockito.eq(f.handle), Mockito.any()))
.thenReturn(f.pinnedHandle);
Mockito.when(f.metadata.getMvccPartitionView(f.session,
f.pinnedHandle))
diff --git
a/regression-test/data/external_table_p0/paimon/paimon_timestamp_types.out
b/regression-test/data/external_table_p0/paimon/paimon_timestamp_types.out
index 32eaea02ed1..f4734e460f6 100644
--- a/regression-test/data/external_table_p0/paimon/paimon_timestamp_types.out
+++ b/regression-test/data/external_table_p0/paimon/paimon_timestamp_types.out
@@ -97,6 +97,12 @@
-- !ts9_ne_parquet --
1
+-- !c1_v1 --
+1 2024-01-02T10:04:05.100 2024-01-02T10:04:05.120 2024-01-02T10:04:05.123
2024-01-02T10:04:05.123400 2024-01-02T10:04:05.123450
2024-01-02T10:04:05.123456 2024-01-02T10:04:05.123456
2024-01-02T10:04:05.123456 2024-01-02T10:04:05.123456 2024-01-02
10:04:05.1+08:00 2024-01-02 10:04:05.12+08:00 2024-01-02
10:04:05.123+08:00 2024-01-02 10:04:05.1234+08:00 2024-01-02
10:04:05.12345+08:00 2024-01-02 10:04:05.123456+08:00 2024-01-02
10:04:05.123456+08:00 2024-01-02 10:04:05.123456+08:00 2024-01
[...]
+
+-- !c2_v1 --
+1 2024-01-02T10:04:05.100 2024-01-02T10:04:05.120 2024-01-02T10:04:05.123
2024-01-02T10:04:05.123400 2024-01-02T10:04:05.123450
2024-01-02T10:04:05.123456 2024-01-02T10:04:05.123456
2024-01-02T10:04:05.123456 2024-01-02T10:04:05.123456 2024-01-02
10:04:05.1+08:00 2024-01-02 10:04:05.12+08:00 2024-01-02
10:04:05.123+08:00 2024-01-02 10:04:05.1234+08:00 2024-01-02
10:04:05.12345+08:00 2024-01-02 10:04:05.123456+08:00 2024-01-02
10:04:05.123456+08:00 2024-01-02 10:04:05.123456+08:00 2024-01
[...]
+
-- !ltz_ntz_simple2 --
1 {"2024-01-01 10:12:34.123456":"2024-01-02 10:12:34.123456", "2024-01-03
10:12:34.123456":"2024-01-04 10:12:34.123456"} {"2024-01-03
10:12:34.123456+08:00":"2024-01-04 10:12:34.123456+08:00", "2024-01-01
10:12:34.123456+08:00":"2024-01-02 10:12:34.123456+08:00"} ["2024-01-01
10:12:34.123456", "2024-01-02 10:12:34.123456", "2024-01-03 10:12:34.123456"]
["2024-01-01 10:12:34.123456+08:00", "2024-01-02 10:12:34.123456+08:00",
"2024-01-03 10:12:34.123456+08:00"] {"crow1":"2024-01-01 10:12:3 [...]
diff --git
a/regression-test/data/external_table_p0/paimon/write/test_paimon_write_types.out
b/regression-test/data/external_table_p0/paimon/write/test_paimon_write_types.out
index 9125b18a52b..e4a254f9bf7 100644
---
a/regression-test/data/external_table_p0/paimon/write/test_paimon_write_types.out
+++
b/regression-test/data/external_table_p0/paimon/write/test_paimon_write_types.out
@@ -26,12 +26,26 @@ event_time timestamptz(6) Yes true \N
WITH_TIMEZONE
-- !types_timezone_utc --
1 2024-01-15 02:30:00.123456+00:00
2 2024-01-15 02:30:00.654321+00:00
+3 1969-12-31 16:00:00.000000+00:00
-- !types_timezone_shanghai --
1 2024-01-15 10:30:00.123456+08:00
2 2024-01-15 10:30:00.654321+08:00
+3 1970-01-01 00:00:00.000000+08:00
-- !types_ntz --
1 2024-03-10T02:30:00.123456
2 2024-01-15T10:30:00.654321
+-- !types_fold_micros --
+1 1699173000123456
+2 1699176600123456
+
+-- !types_fold_jni_false --
+1 2023-11-05 08:30:00.123456+00:00
+2 2023-11-05 09:30:00.123456+00:00
+
+-- !types_fold_jni_true --
+1 2023-11-05 08:30:00.123456+00:00
+2 2023-11-05 09:30:00.123456+00:00
+
diff --git
a/regression-test/suites/external_table_p0/paimon/paimon_timestamp_types.groovy
b/regression-test/suites/external_table_p0/paimon/paimon_timestamp_types.groovy
index 118e79b0598..fe3f3155141 100644
---
a/regression-test/suites/external_table_p0/paimon/paimon_timestamp_types.groovy
+++
b/regression-test/suites/external_table_p0/paimon/paimon_timestamp_types.groovy
@@ -164,6 +164,11 @@ suite("paimon_timestamp_types", "p0,external") {
test_scale("2024-01-02 10:04:05.123457")
// Keep testing legacy LTZ fallback with V2 disabled independently of
the NTZ predicate tests.
sql """set enable_file_scanner_v2=false"""
+ // V1 must still decode every timestamp scale. Legacy ORC LTZ files
need Paimon's
+ // compatibility conversion even when JNI is not forced, while Parquet
stays on the
+ // native V1 reader. The ts9 predicates above stay on V2 only.
+ order_qt_c1_v1 """select * from ts_scale_orc"""
+ order_qt_c2_v1 """select * from ts_scale_parquet"""
// test_ltz_ntz("test_timestamp_ntz_ltz_orc")
// test_ltz_ntz("test_timestamp_ntz_ltz_parquet")
test_ltz_ntz_simple("test_timestamp_ntz_ltz_simple_orc", "2024-01-02
10:12:34.123456+08:00")
diff --git
a/regression-test/suites/external_table_p0/paimon/write/test_paimon_write_types.groovy
b/regression-test/suites/external_table_p0/paimon/write/test_paimon_write_types.groovy
index a32f8810dd4..b9394e8bc79 100644
---
a/regression-test/suites/external_table_p0/paimon/write/test_paimon_write_types.groovy
+++
b/regression-test/suites/external_table_p0/paimon/write/test_paimon_write_types.groovy
@@ -83,6 +83,11 @@ suite("test_paimon_write_types", "p0,external,paimon") {
CREATE TABLE paimon.${dbName}.t_types_ntz (
id INT, event_time TIMESTAMP_NTZ
) USING paimon;
+
+ DROP TABLE IF EXISTS paimon.${dbName}.t_types_fold;
+ CREATE TABLE paimon.${dbName}.t_types_fold (
+ id INT, event_time TIMESTAMP
+ ) USING paimon;
"""
sql """drop catalog if exists ${catalogName}"""
@@ -182,13 +187,19 @@ suite("test_paimon_write_types", "p0,external,paimon") {
sql """SET time_zone = 'Asia/Shanghai'"""
sql """INSERT INTO t_types_timezone VALUES
(1, TIMESTAMP '2024-01-15 10:30:00.123456')"""
+ // Shanghai's epoch midnight is a pre-epoch UTC instant.
+ sql """INSERT INTO t_types_timezone VALUES
+ (3, TIMESTAMP '1970-01-01 00:00:00')"""
sql """SET time_zone = 'UTC'"""
sql """INSERT INTO t_types_timezone VALUES
(2, TIMESTAMP '2024-01-15 02:30:00.654321')"""
order_qt_types_timezone_utc """SELECT id, event_time FROM
t_types_timezone ORDER BY id"""
+ // Spark reads the instants independently of the Doris reader.
+ assertTableEquals("t_types_timezone", "id, unix_micros(event_time)",
"ORDER BY id",
+ "id, " + epochMicros("event_time"))
// Reading the same snapshot in Asia/Shanghai must apply the session
offset
- // to both rows without losing their microsecond fractions.
+ // to every row without losing their microsecond fractions.
sql """SET time_zone = 'Asia/Shanghai'"""
order_qt_types_timezone_shanghai """SELECT id, event_time FROM
t_types_timezone ORDER BY id"""
@@ -204,6 +215,27 @@ suite("test_paimon_write_types", "p0,external,paimon") {
sql """SET time_zone = 'UTC'"""
order_qt_types_ntz """SELECT id, event_time FROM t_types_ntz ORDER BY
id"""
assertTableEquals("t_types_ntz", "*", "ORDER BY id")
+
+ // Both UTC instants render as 01:30 in Los Angeles. INSERT SELECT
must reach the
+ // LTZ writer without a lossy intermediate civil-time cast.
+ sql """SET time_zone = 'America/Los_Angeles'"""
+ sql """INSERT INTO t_types_fold
+ SELECT 1, CAST('2023-11-05 08:30:00.123456+00:00' AS
TIMESTAMPTZ(6))
+ UNION ALL
+ SELECT 2, CAST('2023-11-05 09:30:00.123456+00:00' AS
TIMESTAMPTZ(6))"""
+ sql """SET time_zone = 'UTC'"""
+ order_qt_types_fold_micros """SELECT id, ${epochMicros("event_time")}
FROM t_types_fold ORDER BY id"""
+ assertTableEquals("t_types_fold", "id, unix_micros(event_time)",
"ORDER BY id",
+ "id, " + epochMicros("event_time"))
+ def originalJniScanner = sql """SELECT @@force_jni_scanner"""
+ try {
+ for (boolean jni : [false, true]) {
+ sql """SET force_jni_scanner = ${jni}"""
+ "order_qt_types_fold_jni_${jni}" """SELECT id, event_time FROM
t_types_fold ORDER BY id"""
+ }
+ } finally {
+ sql """SET force_jni_scanner = ${originalJniScanner[0][0]}"""
+ }
} finally {
sql """SET time_zone = '${originalTimeZone[0][0]}'"""
}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]