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 20d2b4c6f12 [fix](paimon) Cast static overwrite partition values to
their column types (#68874)
20d2b4c6f12 is described below
commit 20d2b4c6f1286d604132280c7382f3d74112deb5
Author: Mingyu Chen (Rayner) <[email protected]>
AuthorDate: Wed Oct 14 22:30:22 2026 +0800
[fix](paimon) Cast static overwrite partition values to their column types
(#68874)
### What problem does this PR solve?
Issue Number: None
Related PR: #65868
Problem Summary:
**In short.** This ports the static-partition part of branch-4.1 #65868
to the Paimon connector. An `INSERT OVERWRITE ... PARTITION (...)` named
the Paimon partition by the literal as the user wrote it, while the rows
carry that literal cast to the column type. Overwrites whose literal is
not spelled the column type's way failed, and overwrites of a TIMESTAMP
WITH LOCAL TIME ZONE partition failed whenever the session zone differed
from the FE JVM zone; when no rows were written to trip Paimon's check,
the overwrite silently deleted the wrong partition. Connectors now get
the cast values through a new write-handle getter, and Paimon uses them
for an overwrite.
**Background.** On a Paimon table, the bind step materializes the
literal of `PARTITION (col = <literal>)` into every row with
`castIfNotSameType`, so the rows carry the value in the column's type.
The static partition spec handed to connectors is built separately:
`PluginDrivenInsertCommandContext` keeps `Literal.getStringValue()` of
the literal as written, and `PaimonWriteBinding` passed that string to
Paimon's `withOverwrite`. Paimon parses the string with the column type
to find the partition to replace, parses an LTZ value as local time in
the FE JVM zone, and reads the table's `partition.default-name` as the
NULL partition. branch-4.1 #65868 casts each literal to its column type
first, moves an LTZ value from the session zone to the JVM zone, and
rejects a value equal to `partition.default-name`; master has none of
this.
**The problem, and what it cost.** On a local FE + BE with a filesystem
Paimon catalog, session zone Asia/Shanghai, FE JVM zone Europe/London:
| Statement | master |
|---|---|
| `INSERT OVERWRITE t PARTITION (p = true) VALUES (...)`, `p INT` |
fails: `NumberFormatException: For input string: "true"` |
| `... PARTITION (dt = 20240101) ...`, `dt DATE` | fails: `the changes
in dt=19723/ does not belong to this partition` (Paimon reads 20240101
as a day number) |
| `... PARTITION (ts = '2024-01-15 08:30:45') ...`, LTZ `ts` | fails:
`the changes in ts=2024-01-15T00%3A30%3A45/ does not belong to this
partition` |
| the same LTZ overwrite with no rows (`SELECT ... LIMIT 0`) | succeeds,
deletes the partition `2024-01-15 16:30:45` (session time) and keeps
`08:30:45` |
| an overwrite with no rows of `region = '<partition.default-name>'` |
succeeds, deletes the NULL partition |
The two empty overwrites are silent data loss: the statement reports
success and the deleted partition is not the one the user named. These
were measured before #68786, which now binds every Paimon LTZ column as
TIMESTAMPTZ; it leaves the static partition spec as written, so the LTZ
rows still apply.
**How this PR fixes it.**
- `ConnectorWriteHandle#getCastStaticPartitionSpec` (new): the static
partition values cast to the types of their columns, keyed like
`getStaticPartitionSpec`, which still carries the literals as written.
SQL NULL keys stay in `getStaticPartitionNullKeys` only; a key that
names no column (an Iceberg partition field) keeps its literal. The
default, for a handle that does not cast, is the spec as written without
its null keys.
- fe-core: `InsertIntoTableCommand` records the sink's bound target
schema on `PluginDrivenInsertCommandContext`, and the write handle
computes the cast values on demand with `checkedCastTo`, the cast
constant folding applies to the row value. A string written into a
string column follows BindSink instead, which does not cast it: the
value is cut to a CHAR / VARCHAR length in code points when the session
truncates strings on insert (`BindSink.truncatesStringOnInsert()`,
shared with BindSink), and kept as written otherwise. Only a connector
that asks pays for the cast and can fail on it, so Iceberg, MaxCompute
and Hive, which keep reading the spec as written, see no change. A value
that does not fit its column type fails the cast instead of becoming
NULL, as on branch-4.x. A binary value is spelled `0x` and its bytes in
hex, as in the spec as written.
- Paimon: `PaimonWriteBinding` resolves the static partition of an
overwrite from the cast values. An INSERT writes each row to the
partition its own values name and never reads the static partition, so
Paimon asks for the cast values only for an overwrite, and an INSERT is
planned as before (branch-4.x casts for an INSERT too). Doris binds an
LTZ column as TIMESTAMPTZ (#68786), so the cast value of an LTZ
partition is the instant in UTC with its offset; it is moved to the FE
JVM zone and written with a space between date and time, which Paimon's
parser needs. An overwrite value equal to `partition.default-name` is
rejected. An overwrite also rejects an LTZ value whose local time in the
JVM zone falls in a DST overlap, since both instants format to the same
value and Paimon reads it as the earlier one (as branch-4.2 does,
#68532). A static overwrite that gives a binary partition column a value
other than NULL is rejected: Paimon reads a static value as text and
takes its UTF-8 bytes, so neither the cast value nor the `0x4142` that
master passes names the partition, and an overwrite with no rows
committed against another one.
**Results.** The table above, with this PR:
| Statement | this PR |
|---|---|
| `PARTITION (p = true)` | replaces `p = 1` |
| `PARTITION (dt = 20240101)` | replaces `dt = 2024-01-01` |
| LTZ overwrite | replaces the named partition |
| LTZ overwrite with no rows | deletes the named partition only |
| `region = '<partition.default-name>'`, with or without rows | fails:
`... cannot be represented in a static overwrite`, no data changes |
| `INSERT OVERWRITE ... PARTITION (dt = '2024-13-45')` | fails:
`date/datetime literal [2024-13-45T00:00:00] is invalid` |
| `INSERT INTO ... PARTITION (dt = '2024-13-45')` | unchanged: writes
`dt = NULL` |
| `PARTITION (p = 'abcd')` on a CHAR(2) column, INSERT or OVERWRITE |
writes and replaces `p = 'ab'`, where the rows go |
| overwrite with no rows of `p = '😀x'` on a VARCHAR(1) column | deletes
the partition `😀` only |
| LTZ overwrite whose value is repeated in the FE JVM zone (UTC session,
`2024-10-27 01:30:45` with the JVM in Europe/London) | fails: `... is
ambiguous in FE JVM time zone Europe/London ...`, no data changes |
| LTZ overwrite of a value skipped in the session zone (`2024-03-31
01:30:45` in a Europe/London session) | replaces the partition its rows
are written to, the instant of the DST change (01:00:00Z); an empty one
deletes that partition |
| `PARTITION (p = X'4142')` on a BYTES column, with or without rows |
fails: `Static overwrite of binary partition column 'p' is not
supported`, no data changes (before, an empty one committed and left the
partition `AB` in place) |
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
---
.../doris/connector/paimon/PaimonWriteBinding.java | 91 ++++++--
.../connector/paimon/PaimonWritePlanProvider.java | 3 +-
.../paimon/PaimonBackendBoundTableTest.java | 46 +++-
.../paimon/PaimonConnectorTransactionTest.java | 30 ++-
.../connector/paimon/PaimonWriteBindingTest.java | 255 +++++++++++++++++++--
.../connector/spi/handle/ConnectorWriteHandle.java | 17 ++
.../spi/handle/ConnectorWriteHandleTest.java | 26 ++-
.../test/resources/connector-plugin-surface.txt | 1 +
.../doris/nereids/rules/analysis/BindSink.java | 15 +-
.../commands/insert/InsertIntoTableCommand.java | 1 +
.../insert/PluginDrivenInsertCommandContext.java | 78 ++++++-
.../doris/planner/PluginDrivenTableSink.java | 17 +-
.../planner/PluginDrivenTableSinkBindingTest.java | 169 +++++++++++++-
.../write/test_paimon_write_transaction.groovy | 49 ++++
14 files changed, 740 insertions(+), 58 deletions(-)
diff --git
a/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonWriteBinding.java
b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonWriteBinding.java
index 26865c542c0..6328de70355 100644
---
a/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonWriteBinding.java
+++
b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonWriteBinding.java
@@ -18,17 +18,23 @@
package org.apache.doris.connector.paimon;
import org.apache.doris.connector.spi.DorisConnectorException;
+import org.apache.doris.connector.spi.handle.ConnectorWriteHandle;
import org.apache.paimon.CoreOptions;
import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.types.DataTypeRoot;
import org.apache.paimon.utils.InstantiationUtil;
import java.io.IOException;
+import java.time.LocalDateTime;
+import java.time.OffsetDateTime;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
import java.util.Base64;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
-import java.util.Set;
+import java.util.Objects;
import java.util.TreeMap;
/** Statement-scoped Paimon write target shared by sink planning and
transaction commit. */
@@ -55,10 +61,12 @@ final class PaimonWriteBinding {
}
static PaimonWriteBinding create(PaimonTableHandle handle, FileStoreTable
table,
- Map<String, String> hadoopConfig, boolean overwrite,
- Map<String, String> requestedStaticPartition, Set<String>
staticPartitionNullKeys) {
- Map<String, String> staticPartition = resolveStaticPartition(
- table, requestedStaticPartition, staticPartitionNullKeys);
+ Map<String, String> hadoopConfig, ConnectorWriteHandle
writeHandle) {
+ boolean overwrite = writeHandle.isOverwrite();
+ // Only a static overwrite reads the partition values. An INSERT
writes each row to the partition its own
+ // values name, so it leaves them alone: casting them could only fail
a write the rows would accept.
+ Map<String, String> staticPartition = overwrite
+ ? resolveStaticPartition(table, writeHandle) :
Collections.emptyMap();
FileStoreTable writeTable = configureTableForWrite(table, overwrite,
staticPartition);
return new PaimonWriteBinding(handle.getDatabaseName() + "." +
handle.getTableName(),
writeTable, hadoopConfig, overwrite, staticPartition);
@@ -78,26 +86,81 @@ final class PaimonWriteBinding {
return table.copy(Collections.singletonMap(dynamicOverwriteKey,
Boolean.FALSE.toString()));
}
- private static Map<String, String> resolveStaticPartition(FileStoreTable
table,
- Map<String, String> requested, Set<String> nullKeys) {
+ /**
+ * The static partition of an overwrite, as Paimon's static overwrite
parses it: SQL NULL becomes the table's
+ * {@code partition.default-name}, and every other value is the one the
written rows carry, cast to the
+ * column type. A binary column can only be statically overwritten with
NULL.
+ */
+ static Map<String, String> resolveStaticPartition(FileStoreTable table,
ConnectorWriteHandle writeHandle) {
Map<String, String> canonicalNames = new
TreeMap<>(String.CASE_INSENSITIVE_ORDER);
for (String partitionKey : table.partitionKeys()) {
canonicalNames.put(partitionKey, partitionKey);
}
- String defaultPartitionName =
CoreOptions.fromMap(table.options()).partitionDefaultName();
- Map<String, String> result = new LinkedHashMap<>();
- for (Map.Entry<String, String> entry : requested.entrySet()) {
- String canonicalName = canonicalNames.get(entry.getKey());
+ Map<String, String> requestedColumns = new LinkedHashMap<>();
+ for (String key : writeHandle.getStaticPartitionSpec().keySet()) {
+ String canonicalName = canonicalNames.get(key);
if (canonicalName == null) {
- throw new DorisConnectorException("Column '" + entry.getKey()
+ throw new DorisConnectorException("Column '" + key
+ "' is not a partition column of Paimon table");
}
- String value = entry.getValue();
- result.put(canonicalName, nullKeys.contains(entry.getKey()) ?
defaultPartitionName : value);
+ DataTypeRoot typeRoot =
table.rowType().getField(canonicalName).type().getTypeRoot();
+ if ((typeRoot == DataTypeRoot.BINARY || typeRoot ==
DataTypeRoot.VARBINARY)
+ &&
!writeHandle.getStaticPartitionNullKeys().contains(key)) {
+ // Paimon's static overwrite takes the UTF-8 bytes of a
value's string, which cannot name every
+ // binary partition, and a wrong name overwrites another
partition without an error. Checked
+ // before the values are cast.
+ throw new DorisConnectorException("Static overwrite of binary
partition column '"
+ + canonicalName + "' is not supported");
+ }
+ requestedColumns.put(key, canonicalName);
+ }
+ String defaultPartitionName =
CoreOptions.fromMap(table.options()).partitionDefaultName();
+ Map<String, String> castValues =
writeHandle.getCastStaticPartitionSpec();
+ Map<String, String> result = new LinkedHashMap<>();
+ for (Map.Entry<String, String> requested :
requestedColumns.entrySet()) {
+ String key = requested.getKey();
+ String canonicalName = requested.getValue();
+ if (writeHandle.getStaticPartitionNullKeys().contains(key)) {
+ result.put(canonicalName, defaultPartitionName);
+ continue;
+ }
+ String value = Objects.requireNonNull(castValues.get(key),
+ () -> "missing the cast value of static partition column "
+ key);
+ if (table.rowType().getField(canonicalName).type().getTypeRoot()
+ == DataTypeRoot.TIMESTAMP_WITH_LOCAL_TIME_ZONE) {
+ value = toSdkLocalTime(canonicalName, value);
+ }
+ if (defaultPartitionName.equals(value)) {
+ // Paimon's static overwrite reads this string as the NULL
partition of any partition type, so
+ // a value equal to it would overwrite the NULL partition
instead.
+ throw new DorisConnectorException("Static partition value for
column '" + canonicalName
+ + "' equals Paimon partition.default-name '" +
defaultPartitionName
+ + "' and cannot be represented in a static overwrite");
+ }
+ result.put(canonicalName, value);
}
return result;
}
+ /**
+ * Paimon parses a static overwrite value of a TIMESTAMP WITH LOCAL TIME
ZONE column as local time in the FE
+ * JVM's default zone. Doris binds such a column as TIMESTAMPTZ, so the
cast value is the instant in UTC with
+ * its offset; it is moved to the JVM zone, with a space between the date
and the time as Paimon's parser
+ * needs. An instant whose local time a DST change repeats there is
rejected: both instants of the overlap
+ * format to the same value, which Paimon reads as the earlier one.
+ */
+ private static String toSdkLocalTime(String column, String value) {
+ ZoneId sdkZone = ZoneId.systemDefault();
+ LocalDateTime sdkLocal = OffsetDateTime.parse(value.replace(' ', 'T'),
DateTimeFormatter.ISO_OFFSET_DATE_TIME)
+ .atZoneSameInstant(sdkZone).toLocalDateTime();
+ if (sdkZone.getRules().getValidOffsets(sdkLocal).size() > 1) {
+ throw new DorisConnectorException("Static LTZ partition value for
column '" + column
+ + "' is ambiguous in FE JVM time zone " + sdkZone
+ + " and cannot be represented in a static overwrite");
+ }
+ return
sdkLocal.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME).replace('T', ' ');
+ }
+
private static String serialize(FileStoreTable table) {
try {
return
Base64.getEncoder().encodeToString(InstantiationUtil.serializeObject(table));
diff --git
a/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonWritePlanProvider.java
b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonWritePlanProvider.java
index f5d793dd5aa..620439e7cab 100644
---
a/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonWritePlanProvider.java
+++
b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonWritePlanProvider.java
@@ -152,8 +152,7 @@ public class PaimonWritePlanProvider implements
ConnectorWritePlanProvider {
}
PaimonConnectorTransaction transaction = currentTransaction(session);
PaimonWriteBinding binding = PaimonWriteBinding.create(
- tableHandle, table, buildHadoopConfig(), handle.isOverwrite(),
- handle.getStaticPartitionSpec(),
handle.getStaticPartitionNullKeys());
+ tableHandle, table, buildHadoopConfig(), handle);
transaction.bind(binding);
TPaimonTableSink sink = new TPaimonTableSink();
diff --git
a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonBackendBoundTableTest.java
b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonBackendBoundTableTest.java
index bca0c1544e0..f618ba4d816 100644
---
a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonBackendBoundTableTest.java
+++
b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonBackendBoundTableTest.java
@@ -17,7 +17,10 @@
package org.apache.doris.connector.paimon;
+import org.apache.doris.connector.spi.ConnectorColumn;
import org.apache.doris.connector.spi.DorisConnectorException;
+import org.apache.doris.connector.spi.handle.ConnectorTableHandle;
+import org.apache.doris.connector.spi.handle.ConnectorWriteHandle;
import org.apache.paimon.CoreOptions;
import org.apache.paimon.Snapshot;
@@ -63,6 +66,7 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
+import java.util.Set;
/**
* The table {@link PaimonScanPlanProvider#getScanNodeProperties} serializes
to the BE must carry no
@@ -116,8 +120,8 @@ public class PaimonBackendBoundTableTest {
FileStoreTable table = newTable(warehouse,
catalogEnvironment(catalog), C1);
PaimonWriteBinding binding = PaimonWriteBinding.create(
- dataHandle(), table, Collections.emptyMap(), false,
- Collections.emptyMap(), Collections.emptySet());
+ dataHandle(), table, Collections.emptyMap(),
+ writeHandle(false, Collections.emptyMap(),
Collections.emptySet()));
FileStoreTable backendTable =
deserializeTable(binding.getSerializedTable());
Assertions.assertNotNull(binding.getTable().catalogEnvironment().catalogLoader());
@@ -138,9 +142,9 @@ public class PaimonBackendBoundTableTest {
Map<String, String> requested = Collections.singletonMap("pt", "NULL");
PaimonWriteBinding literal = PaimonWriteBinding.create(dataHandle(),
table,
- Collections.emptyMap(), false, requested,
Collections.emptySet());
+ Collections.emptyMap(), writeHandle(true, requested,
Collections.emptySet()));
PaimonWriteBinding sqlNull = PaimonWriteBinding.create(dataHandle(),
table,
- Collections.emptyMap(), false, requested,
Collections.singleton("pt"));
+ Collections.emptyMap(), writeHandle(true, requested,
Collections.singleton("pt")));
Assertions.assertEquals("NULL",
literal.getStaticPartition().get("pt"));
Assertions.assertEquals(table.coreOptions().partitionDefaultName(),
@@ -817,6 +821,40 @@ public class PaimonBackendBoundTableTest {
return new PaimonTableHandle("db", "tbl", Collections.emptyList(),
Collections.emptyList());
}
+ /**
+ * A write whose static partition values need no cast, so the handle's
default cast spec applies. Only an
+ * overwrite resolves the static partition.
+ */
+ private static ConnectorWriteHandle writeHandle(boolean overwrite,
Map<String, String> staticPartition,
+ Set<String> nullKeys) {
+ return new ConnectorWriteHandle() {
+ @Override
+ public ConnectorTableHandle getTableHandle() {
+ return null;
+ }
+
+ @Override
+ public List<ConnectorColumn> getColumns() {
+ return Collections.emptyList();
+ }
+
+ @Override
+ public boolean isOverwrite() {
+ return overwrite;
+ }
+
+ @Override
+ public Map<String, String> getStaticPartitionSpec() {
+ return staticPartition;
+ }
+
+ @Override
+ public Set<String> getStaticPartitionNullKeys() {
+ return nullKeys;
+ }
+ };
+ }
+
private static PaimonTableHandle sysHandle(String sysTableType,
FileStoreTable base) {
PaimonTableHandle handle = PaimonTableHandle.forSystemTable("db",
"tbl", sysTableType,
PaimonScanParams.requiresPaimonReader(sysTableType));
diff --git
a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonConnectorTransactionTest.java
b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonConnectorTransactionTest.java
index 34b46e29fcf..cb601a6ceba 100644
---
a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonConnectorTransactionTest.java
+++
b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonConnectorTransactionTest.java
@@ -17,7 +17,10 @@
package org.apache.doris.connector.paimon;
+import org.apache.doris.connector.spi.ConnectorColumn;
import org.apache.doris.connector.spi.DorisConnectorException;
+import org.apache.doris.connector.spi.handle.ConnectorTableHandle;
+import org.apache.doris.connector.spi.handle.ConnectorWriteHandle;
import org.apache.doris.thrift.TPaimonCommitMessage;
import org.apache.paimon.CoreOptions;
@@ -204,10 +207,35 @@ public class PaimonConnectorTransactionTest {
new PaimonConnectorTransaction(TRANSACTION_ID, new
RecordingConnectorContext());
transaction.bind(PaimonWriteBinding.create(
new PaimonTableHandle("db", "tbl", Collections.emptyList(),
Collections.emptyList()),
- target, Collections.emptyMap(), false, Collections.emptyMap(),
Collections.emptySet()));
+ target, Collections.emptyMap(), plainInsert()));
return transaction;
}
+ /** An INSERT without a PARTITION clause. */
+ private static ConnectorWriteHandle plainInsert() {
+ return new ConnectorWriteHandle() {
+ @Override
+ public ConnectorTableHandle getTableHandle() {
+ return null;
+ }
+
+ @Override
+ public List<ConnectorColumn> getColumns() {
+ return Collections.emptyList();
+ }
+
+ @Override
+ public boolean isOverwrite() {
+ return false;
+ }
+
+ @Override
+ public Map<String, String> getStaticPartitionSpec() {
+ return Collections.emptyMap();
+ }
+ };
+ }
+
/** A DPCM-framed payload with one empty commit message, as the BE writer
reports it. */
private static byte[] commitPayload(int bucket) throws Exception {
CommitMessage message = new CommitMessageImpl(BinaryRow.EMPTY_ROW,
bucket, 2,
diff --git
a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonWriteBindingTest.java
b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonWriteBindingTest.java
index 6cb05d51810..ca2f37d8776 100644
---
a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonWriteBindingTest.java
+++
b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonWriteBindingTest.java
@@ -17,38 +17,73 @@
package org.apache.doris.connector.paimon;
+import org.apache.doris.connector.spi.ConnectorColumn;
+import org.apache.doris.connector.spi.DorisConnectorException;
+import org.apache.doris.connector.spi.handle.ConnectorTableHandle;
+import org.apache.doris.connector.spi.handle.ConnectorWriteHandle;
+
import org.apache.paimon.CoreOptions;
+import org.apache.paimon.data.Timestamp;
import org.apache.paimon.fs.local.LocalFileIO;
import org.apache.paimon.schema.TableSchema;
import org.apache.paimon.table.FileStoreTable;
import org.apache.paimon.table.FileStoreTableFactory;
import org.apache.paimon.types.DataField;
+import org.apache.paimon.types.DataType;
import org.apache.paimon.types.DataTypes;
+import org.apache.paimon.utils.TypeUtils;
+import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
+import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
+import java.time.Instant;
+import java.time.OffsetDateTime;
import java.util.Arrays;
import java.util.Collections;
+import java.util.List;
import java.util.Map;
+import java.util.Set;
+import java.util.TimeZone;
+import java.util.function.Supplier;
/**
- * Pins how {@link PaimonWriteBinding#configureTableForWrite} maps a Doris
INSERT OVERWRITE onto Paimon's
- * {@code dynamic-partition-overwrite} option. Paimon defaults that option to
true, which replaces only the
- * partitions the new rows reach; a Doris full-table overwrite replaces the
whole table and a static-partition
- * overwrite replaces exactly the named partition.
+ * How a static partition reaches Paimon's static overwrite. The written rows
carry each PARTITION literal cast
+ * to its column type, so the overwrite must name the partition by that cast
value, in the form Paimon parses:
+ * a value spelled differently selects another partition, or none, and the
commit then fails or replaces the
+ * wrong rows.
*/
public class PaimonWriteBindingTest {
+ private static final String DEFAULT_NAME = "__CUSTOM_DEFAULT__";
private static final String DYNAMIC_OVERWRITE =
CoreOptions.DYNAMIC_PARTITION_OVERWRITE.key();
- @TempDir
- Path warehouse;
+ private TimeZone jvmZone;
+
+ @BeforeEach
+ public void pinJvmZone() {
+ // Paimon parses a static LTZ value in the JVM zone. Keep it away from
UTC, the zone of the cast values,
+ // so a value passed through unchanged cannot pass by accident.
+ jvmZone = TimeZone.getDefault();
+ TimeZone.setDefault(TimeZone.getTimeZone("America/Los_Angeles"));
+ }
+
+ @AfterEach
+ public void restoreJvmZone() {
+ TimeZone.setDefault(jvmZone);
+ }
+
+ // configureTableForWrite maps a Doris INSERT OVERWRITE onto Paimon's
dynamic-partition-overwrite option. Paimon
+ // defaults that option to true, which replaces only the partitions the
new rows reach; a Doris full-table
+ // overwrite replaces the whole table and a static-partition overwrite
replaces exactly the named partition.
@Test
- public void fullOverwriteUsesStaticSemanticsByDefault() {
- FileStoreTable table = partitionedTable(Collections.emptyMap());
+ public void fullOverwriteUsesStaticSemanticsByDefault(@TempDir Path
warehouse) {
+ FileStoreTable table = partitionedTable(warehouse,
Collections.emptyMap(),
+ new DataField(1, "pt", DataTypes.STRING()));
FileStoreTable configured = PaimonWriteBinding.configureTableForWrite(
table, true, Collections.emptyMap());
@@ -59,20 +94,22 @@ public class PaimonWriteBindingTest {
}
@Test
- public void fullOverwriteHonorsExplicitDynamicSemantics() {
+ public void fullOverwriteHonorsExplicitDynamicSemantics(@TempDir Path
warehouse) {
// A table that sets the option itself asked for dynamic overwrite, so
the binding keeps the
// loaded table instead of copying it with the static override.
- FileStoreTable table =
partitionedTable(Collections.singletonMap(DYNAMIC_OVERWRITE, "true"));
+ FileStoreTable table = partitionedTable(warehouse,
Collections.singletonMap(DYNAMIC_OVERWRITE, "true"),
+ new DataField(1, "pt", DataTypes.STRING()));
Assertions.assertSame(table, PaimonWriteBinding.configureTableForWrite(
table, true, Collections.emptyMap()));
}
@Test
- public void staticPartitionOverwriteAlwaysUsesStaticSemantics() {
+ public void staticPartitionOverwriteAlwaysUsesStaticSemantics(@TempDir
Path warehouse) {
// A static partition names the partition to replace. Dynamic
semantics would leave it intact
// when the query produces no row for it, so the table option does not
apply here.
- FileStoreTable table =
partitionedTable(Collections.singletonMap(DYNAMIC_OVERWRITE, "true"));
+ FileStoreTable table = partitionedTable(warehouse,
Collections.singletonMap(DYNAMIC_OVERWRITE, "true"),
+ new DataField(1, "pt", DataTypes.STRING()));
FileStoreTable configured = PaimonWriteBinding.configureTableForWrite(
table, true, Collections.singletonMap("pt", "1"));
@@ -81,11 +118,197 @@ public class PaimonWriteBindingTest {
Assertions.assertFalse(configured.coreOptions().dynamicPartitionOverwrite());
}
- private FileStoreTable partitionedTable(Map<String, String> options) {
- TableSchema schema = new TableSchema(0L,
- Arrays.asList(new DataField(0, "id", DataTypes.INT()), new
DataField(1, "pt", DataTypes.STRING())),
- 1, Collections.singletonList("pt"), Collections.emptyList(),
options, "");
+ @Test
+ public void staticPartitionUsesTheValueCastToTheColumnType(@TempDir Path
warehouse) {
+ // PARTITION (part = true) on an INT column and PARTITION (dt =
20240101) on a DATE column: Paimon reads
+ // "true" as no INT and "20240101" as the 20240101st day after the
epoch. MUTATION: resolving from the spec
+ // as written -> both red.
+ FileStoreTable intTable = partitionedTable(warehouse,
Collections.emptyMap(),
+ new DataField(1, "part", DataTypes.INT()));
+ Assertions.assertEquals("1",
PaimonWriteBinding.resolveStaticPartition(intTable,
+ write(true, "part", "true", "1")).get("part"));
+
+ FileStoreTable dateTable = partitionedTable(warehouse,
Collections.emptyMap(),
+ new DataField(1, "dt", DataTypes.DATE()));
+ Assertions.assertEquals("2024-01-01",
PaimonWriteBinding.resolveStaticPartition(dateTable,
+ write(true, "dt", "20240101", "2024-01-01")).get("dt"));
+ }
+
+ @Test
+ public void staticOverwriteRejectsTheDefaultPartitionName(@TempDir Path
warehouse) {
+ // Paimon's static overwrite reads partition.default-name as the NULL
partition of every type, so this
+ // value would replace the NULL partition. MUTATION: dropping the
check -> red.
+ FileStoreTable table = partitionedTable(warehouse,
+ Collections.singletonMap("partition.default-name",
DEFAULT_NAME),
+ new DataField(1, "part", DataTypes.STRING()));
+
+ DorisConnectorException error =
Assertions.assertThrows(DorisConnectorException.class,
+ () -> PaimonWriteBinding.resolveStaticPartition(table,
+ write(true, "part", DEFAULT_NAME, DEFAULT_NAME)));
+ Assertions.assertTrue(error.getMessage().contains("cannot be
represented"), error.getMessage());
+ }
+
+ @Test
+ public void staticLtzPartitionMovesTheInstantToTheSdkZone(@TempDir Path
warehouse) {
+ // Doris binds an LTZ column as TIMESTAMPTZ, so the cast value is the
instant in UTC with its offset,
+ // which Paimon's parser does not take: it reads an offset-free value
as local time in the JVM zone.
+ // MUTATION: passing the value through, or dropping its offset without
moving it -> red.
+ FileStoreTable table = partitionedTable(warehouse,
Collections.emptyMap(),
+ new DataField(1, "part",
DataTypes.TIMESTAMP_WITH_LOCAL_TIME_ZONE(6)));
+
+ String value = PaimonWriteBinding.resolveStaticPartition(table,
+ write(true, "part", "2024-01-15 08:30:45.123456", "2024-01-15
00:30:45.123456+00:00")).get("part");
+
+ Assertions.assertEquals("2024-01-14 16:30:45.123456", value);
+ Assertions.assertEquals(Instant.parse("2024-01-15T00:30:45.123456Z"),
parsedByPaimon(table, value));
+ }
+
+ @Test
+ public void staticLtzOverwriteRejectsAnInstantTheSdkZoneRepeats(@TempDir
Path warehouse) {
+ // 2023-11-05 08:30Z and 09:30Z are both 01:30 in Los Angeles, the JVM
zone here. Either value would name
+ // the same partition, which Paimon reads as the earlier instant.
MUTATION: dropping the check -> red.
+ FileStoreTable table = partitionedTable(warehouse,
Collections.emptyMap(),
+ new DataField(1, "part",
DataTypes.TIMESTAMP_WITH_LOCAL_TIME_ZONE(6)));
+ for (String instant : Arrays.asList("2023-11-05 08:30:00.123456+00:00",
+ "2023-11-05 09:30:00.123456+00:00")) {
+ DorisConnectorException error =
Assertions.assertThrows(DorisConnectorException.class,
+ () -> PaimonWriteBinding.resolveStaticPartition(table,
write(true, "part", instant, instant)));
+ Assertions.assertTrue(error.getMessage().contains("ambiguous"),
error.getMessage());
+
Assertions.assertTrue(error.getMessage().contains("America/Los_Angeles"),
error.getMessage());
+ }
+ }
+
+ @Test
+ public void
staticLtzPartitionOutsideAnOverlapRoundTripsThroughPaimon(@TempDir Path
warehouse) {
+ // Both instants that overlap in Los Angeles are distinct in UTC, and
an instant after the overlap is
+ // distinct in Los Angeles. MUTATION: rejecting every instant near a
DST change -> red.
+ FileStoreTable table = partitionedTable(warehouse,
Collections.emptyMap(),
+ new DataField(1, "part",
DataTypes.TIMESTAMP_WITH_LOCAL_TIME_ZONE(6)));
+ TimeZone.setDefault(TimeZone.getTimeZone("UTC"));
+ for (String instant : Arrays.asList("2023-11-05 08:30:00.123456+00:00",
+ "2023-11-05 09:30:00.123456+00:00")) {
+ assertStaticLtzRoundTrip(table, instant);
+ }
+ TimeZone.setDefault(TimeZone.getTimeZone("America/Los_Angeles"));
+ assertStaticLtzRoundTrip(table, "2023-11-05 10:30:00.123456+00:00");
+ }
+
+ @Test
+ public void staticOverwriteRejectsABinaryPartitionValue(@TempDir Path
warehouse) {
+ // Paimon's static overwrite takes the UTF-8 bytes of a value's
string: "0x4142" names the partition of
+ // those six bytes, not of the two bytes 41 42 the rows carry, so an
overwrite without rows would report
+ // success and leave the partition it was given in place. The check
comes before the values are cast,
+ // which a binary value may not survive. MUTATION: dropping the check
-> red.
+ Assertions.assertArrayEquals("0x4142".getBytes(StandardCharsets.UTF_8),
+ (byte[]) TypeUtils.castFromString("0x4142",
DataTypes.BYTES()));
+ for (DataType type : Arrays.asList(DataTypes.BYTES(),
DataTypes.VARBINARY(2), DataTypes.BINARY(2))) {
+ FileStoreTable table = partitionedTable(warehouse,
Collections.emptyMap(),
+ new DataField(1, "part", type));
+ ConnectorWriteHandle overwrite = handle(true,
Collections.singletonMap("part", "0x4142"), () -> {
+ throw new AssertionError("a binary static partition value must
be rejected before it is cast");
+ }, Collections.emptySet());
+
+ DorisConnectorException error =
Assertions.assertThrows(DorisConnectorException.class,
+ () -> PaimonWriteBinding.resolveStaticPartition(table,
overwrite));
+ Assertions.assertTrue(error.getMessage().contains("binary
partition column 'part'"),
+ error.getMessage());
+ }
+ }
+
+ @Test
+ public void staticPartitionNullUsesTheDefaultPartitionName(@TempDir Path
warehouse) {
+ // A binary column too: SQL NULL names its partition by
partition.default-name, not by bytes.
+ for (DataType type : Arrays.asList(DataTypes.INT(),
DataTypes.BYTES())) {
+ FileStoreTable table = partitionedTable(warehouse,
+ Collections.singletonMap("partition.default-name",
DEFAULT_NAME),
+ new DataField(1, "part", type));
+ ConnectorWriteHandle write = handle(true,
Collections.singletonMap("part", "NULL"),
+ Collections::emptyMap, Collections.singleton("part"));
+
+ Assertions.assertEquals(DEFAULT_NAME,
+ PaimonWriteBinding.resolveStaticPartition(table,
write).get("part"), type.toString());
+ }
+ }
+
+ @Test
+ public void insertLeavesTheStaticPartitionValuesAlone(@TempDir Path
warehouse) {
+ // Only a static overwrite reads the static partition. An INSERT
writes each row to the partition its own
+ // values name, so a value the cast rejects (a row keeps it as NULL,
or as a fitting binary) or an
+ // overwrite cannot represent must not fail it. MUTATION: resolving
the static partition of an INSERT
+ // -> red.
+ FileStoreTable table = partitionedTable(warehouse,
+ Collections.singletonMap("partition.default-name",
DEFAULT_NAME),
+ new DataField(1, "part", DataTypes.STRING()));
+ ConnectorWriteHandle insert = handle(false,
Collections.singletonMap("part", DEFAULT_NAME), () -> {
+ throw new AssertionError("an INSERT must not cast its static
partition values");
+ }, Collections.emptySet());
+
+ PaimonWriteBinding binding = PaimonWriteBinding.create(
+ new PaimonTableHandle("db", "tbl", Collections.emptyList(),
Collections.emptyList()),
+ table, Collections.emptyMap(), insert);
+
+ Assertions.assertFalse(binding.isOverwrite());
+ Assertions.assertTrue(binding.getStaticPartition().isEmpty(),
binding.getStaticPartition().toString());
+ }
+
+ private static void assertStaticLtzRoundTrip(FileStoreTable table, String
instant) {
+ String value = PaimonWriteBinding.resolveStaticPartition(table,
write(true, "part", instant, instant))
+ .get("part");
+ Assertions.assertEquals(OffsetDateTime.parse(instant.replace(' ',
'T')).toInstant(),
+ parsedByPaimon(table, value), instant);
+ }
+
+ private static Instant parsedByPaimon(FileStoreTable table, String value) {
+ return ((Timestamp) TypeUtils.castFromString(value,
table.rowType().getTypeAt(1))).toInstant();
+ }
+
+ private static FileStoreTable partitionedTable(Path warehouse, Map<String,
String> options,
+ DataField partition) {
+ List<DataField> fields = Arrays.asList(new DataField(0, "id",
DataTypes.INT()), partition);
+ TableSchema schema = new TableSchema(0L, fields, 1,
Collections.singletonList(partition.name()),
+ Collections.emptyList(), options, "");
return FileStoreTableFactory.create(LocalFileIO.create(),
new org.apache.paimon.fs.Path("file://" + warehouse +
"/db.db/tbl"), schema);
}
+
+ private static ConnectorWriteHandle write(boolean overwrite, String name,
String writtenValue,
+ String castValue) {
+ return handle(overwrite, Collections.singletonMap(name, writtenValue),
+ () -> Collections.singletonMap(name, castValue),
Collections.emptySet());
+ }
+
+ private static ConnectorWriteHandle handle(boolean overwrite, Map<String,
String> spec,
+ Supplier<Map<String, String>> castSpec, Set<String> nullKeys) {
+ return new ConnectorWriteHandle() {
+ @Override
+ public ConnectorTableHandle getTableHandle() {
+ return null;
+ }
+
+ @Override
+ public List<ConnectorColumn> getColumns() {
+ return Collections.emptyList();
+ }
+
+ @Override
+ public boolean isOverwrite() {
+ return overwrite;
+ }
+
+ @Override
+ public Map<String, String> getStaticPartitionSpec() {
+ return spec;
+ }
+
+ @Override
+ public Set<String> getStaticPartitionNullKeys() {
+ return nullKeys;
+ }
+
+ @Override
+ public Map<String, String> getCastStaticPartitionSpec() {
+ return castSpec.get();
+ }
+ };
+ }
}
diff --git
a/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/handle/ConnectorWriteHandle.java
b/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/handle/ConnectorWriteHandle.java
index 79b0d489ccc..db7234d92a2 100644
---
a/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/handle/ConnectorWriteHandle.java
+++
b/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/handle/ConnectorWriteHandle.java
@@ -21,6 +21,7 @@ import org.apache.doris.connector.spi.ConnectorColumn;
import org.apache.doris.thrift.TSortInfo;
import java.util.Collections;
+import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@@ -75,6 +76,22 @@ public interface ConnectorWriteHandle {
return Collections.emptySet();
}
+ /**
+ * The static partition values cast to the types of their columns, as the
written rows carry them (a string
+ * longer than its CHAR / VARCHAR column is cut as the INSERT cuts it), in
the string form of the cast value
+ * and keyed like {@link #getStaticPartitionSpec}, which keeps each
literal as written:
+ * {@code PARTITION (p = true)} on an INT column is {@code "true"} there
and {@code "1"} here. A binary value
+ * is {@code 0x} followed by its bytes in hex, as in {@link
#getStaticPartitionSpec}. The
+ * {@link #getStaticPartitionNullKeys null keys} are absent, and a key
that names no column (an Iceberg
+ * partition field) keeps its literal. A value that cannot be cast to its
column type fails the call. The
+ * default, for a handle that does not cast, is the spec as written
without its null keys.
+ */
+ default Map<String, String> getCastStaticPartitionSpec() {
+ Map<String, String> spec = new
LinkedHashMap<>(getStaticPartitionSpec());
+ spec.keySet().removeAll(getStaticPartitionNullKeys());
+ return spec;
+ }
+
/**
* The kind of DML write (INSERT / OVERWRITE / DELETE / UPDATE / MERGE). A
single
* {@code planWrite} dispatches on this to pick the connector's Thrift
sink dialect, and a
diff --git
a/fe/fe-connector/fe-connector-spi/src/test/java/org/apache/doris/connector/spi/handle/ConnectorWriteHandleTest.java
b/fe/fe-connector/fe-connector-spi/src/test/java/org/apache/doris/connector/spi/handle/ConnectorWriteHandleTest.java
index b4bff4173fc..1027c0f8a30 100644
---
a/fe/fe-connector/fe-connector-spi/src/test/java/org/apache/doris/connector/spi/handle/ConnectorWriteHandleTest.java
+++
b/fe/fe-connector/fe-connector-spi/src/test/java/org/apache/doris/connector/spi/handle/ConnectorWriteHandleTest.java
@@ -23,9 +23,11 @@ import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import java.util.Collections;
+import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
+import java.util.Set;
/**
* Pins the {@link ConnectorWriteHandle#getWriteOperation()} default
(P6.3-T03, deferred from T01).
@@ -39,7 +41,7 @@ import java.util.Optional;
public class ConnectorWriteHandleTest {
/** Minimal handle that overrides nothing write-op related, to read the
SPI default. */
- private static final class BareWriteHandle implements ConnectorWriteHandle
{
+ private static class BareWriteHandle implements ConnectorWriteHandle {
@Override
public ConnectorTableHandle getTableHandle() {
return null;
@@ -99,4 +101,26 @@ public class ConnectorWriteHandleTest {
Assertions.assertEquals(Optional.empty(), new
BareWriteHandle().getBranchName(),
"a write handle that declares no branch must default to
Optional.empty()");
}
+
+ @Test
+ public void castStaticPartitionSpecDefaultsToTheSpecWithoutNullKeys() {
+ // A handle that does not cast hands its values on as written, and a
SQL NULL value is a null key only.
+ Map<String, String> spec = new LinkedHashMap<>();
+ spec.put("pt", "20240101");
+ spec.put("region", "NULL");
+ ConnectorWriteHandle handle = new BareWriteHandle() {
+ @Override
+ public Map<String, String> getStaticPartitionSpec() {
+ return spec;
+ }
+
+ @Override
+ public Set<String> getStaticPartitionNullKeys() {
+ return Collections.singleton("region");
+ }
+ };
+
+ Assertions.assertEquals(Collections.singletonMap("pt", "20240101"),
handle.getCastStaticPartitionSpec());
+ Assertions.assertEquals(2, spec.size(), "the default must not modify
the spec");
+ }
}
diff --git
a/fe/fe-connector/fe-connector-spi/src/test/resources/connector-plugin-surface.txt
b/fe/fe-connector/fe-connector-spi/src/test/resources/connector-plugin-surface.txt
index f0ae0af27d1..3f9e7f2703d 100644
---
a/fe/fe-connector/fe-connector-spi/src/test/resources/connector-plugin-surface.txt
+++
b/fe/fe-connector/fe-connector-spi/src/test/resources/connector-plugin-surface.txt
@@ -118,6 +118,7 @@
org.apache.doris.connector.spi.handle.ConnectorWriteHandle#getBeExecVersion():in
org.apache.doris.connector.spi.handle.ConnectorWriteHandle#getBoundTargetColumns():java.util.List
org.apache.doris.connector.spi.handle.ConnectorWriteHandle#getBoundWriteMetadataIdentity():java.lang.String
org.apache.doris.connector.spi.handle.ConnectorWriteHandle#getBranchName():java.util.Optional
+org.apache.doris.connector.spi.handle.ConnectorWriteHandle#getCastStaticPartitionSpec():java.util.Map
org.apache.doris.connector.spi.handle.ConnectorWriteHandle#getColumns():java.util.List
org.apache.doris.connector.spi.handle.ConnectorWriteHandle#getSortInfo():org.apache.doris.thrift.TSortInfo
org.apache.doris.connector.spi.handle.ConnectorWriteHandle#getStaticPartitionNullKeys():java.util.Set
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/BindSink.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/BindSink.java
index a426a76dddc..5a235fd995b 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/BindSink.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/BindSink.java
@@ -312,10 +312,7 @@ public class BindSink implements AnalysisRuleFactory {
// add cast project
List<NamedExpression> castExprs = Lists.newArrayList();
- ConnectContext connCtx = ConnectContext.get();
- final boolean truncateString = needTruncateStringWhenInsert
- && (connCtx == null ||
connCtx.getSessionVariable().enableInsertValueAutoCast)
- && !SessionVariable.enableStrictCast();
+ final boolean truncateString = needTruncateStringWhenInsert &&
truncatesStringOnInsert();
for (int i = 0; i < tableSchema.size(); ++i) {
Column col = tableSchema.get(i);
NamedExpression expr = columnToOutput.get(col.getName()); //
relative outputExpr
@@ -361,6 +358,16 @@ public class BindSink implements AnalysisRuleFactory {
return fullOutputProject;
}
+ /**
+ * Whether the session lets an INSERT write a string into a shorter CHAR /
VARCHAR column cut to the column
+ * length, counted in code points (the {@link Substring} above), instead
of as is.
+ */
+ public static boolean truncatesStringOnInsert() {
+ ConnectContext connCtx = ConnectContext.get();
+ return (connCtx == null ||
connCtx.getSessionVariable().enableInsertValueAutoCast)
+ && !SessionVariable.enableStrictCast();
+ }
+
@VisibleForTesting
static Expression coerceSinkExpression(Expression expression, DataType
targetType) {
return TypeCoercionUtils.castIfNotSameType(expression, targetType);
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/InsertIntoTableCommand.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/InsertIntoTableCommand.java
index ea963183659..db001e34998 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/InsertIntoTableCommand.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/InsertIntoTableCommand.java
@@ -631,6 +631,7 @@ public class InsertIntoTableCommand extends Command
// commit at the branch (iceberg validates it in beginWrite).
The guard above already
// rejected @branch for connectors without
supportsWriteBranch().
branchName.ifPresent(notUsed ->
pluginCtx.setBranchName(branchName));
+
pluginCtx.setBoundTargetSchema(((PhysicalConnectorTableSink<?>)
physicalSink).getBoundTargetSchema());
if (pluginCtx.getStaticPartitionSpec().isEmpty()
&& originLogicalQuery instanceof
UnboundConnectorTableSink) {
UnboundConnectorTableSink<?> pluginSink =
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/PluginDrivenInsertCommandContext.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/PluginDrivenInsertCommandContext.java
index e0921f7d4d5..7d96149086a 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/PluginDrivenInsertCommandContext.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/PluginDrivenInsertCommandContext.java
@@ -19,14 +19,23 @@ package
org.apache.doris.nereids.trees.plans.commands.insert;
import org.apache.doris.catalog.Column;
import
org.apache.doris.datasource.connector.converter.ConnectorWriteValueConverter;
+import org.apache.doris.nereids.rules.analysis.BindSink;
import org.apache.doris.nereids.trees.expressions.Expression;
+import
org.apache.doris.nereids.trees.expressions.functions.executable.StringArithmetic;
+import org.apache.doris.nereids.trees.expressions.literal.IntegerLiteral;
import org.apache.doris.nereids.trees.expressions.literal.Literal;
import org.apache.doris.nereids.trees.expressions.literal.NullLiteral;
+import org.apache.doris.nereids.trees.expressions.literal.StringLikeLiteral;
import org.apache.doris.nereids.trees.expressions.literal.VarBinaryLiteral;
+import org.apache.doris.nereids.types.DataType;
+import org.apache.doris.nereids.types.coercion.CharacterType;
+
+import com.google.common.base.Preconditions;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
+import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@@ -50,18 +59,15 @@ public class PluginDrivenInsertCommandContext extends
BaseExternalTableInsertCom
private Map<String, String> staticPartitionSpec = Collections.emptyMap();
private Set<String> staticPartitionNullKeys = Collections.emptySet();
+ private Map<String, Literal> staticPartitionLiterals =
Collections.emptyMap();
+ // The target schema the sink was bound to, whose column types the static
partition values are cast to.
+ private List<Column> boundTargetSchema = Collections.emptyList();
private Optional<String> branchName = Optional.empty();
public Map<String, String> getStaticPartitionSpec() {
return staticPartitionSpec;
}
- public void setStaticPartitionSpec(Map<String, String>
staticPartitionSpec) {
- this.staticPartitionSpec =
- staticPartitionSpec == null ? Collections.emptyMap() :
staticPartitionSpec;
- this.staticPartitionNullKeys = Collections.emptySet();
- }
-
public Set<String> getStaticPartitionNullKeys() {
return staticPartitionNullKeys;
}
@@ -75,6 +81,7 @@ public class PluginDrivenInsertCommandContext extends
BaseExternalTableInsertCom
public void setStaticPartitionSpecFromExpressions(Map<String, Expression>
partitionValues, List<Column> schema) {
Map<String, String> spec = new HashMap<>();
Set<String> nullKeys = new HashSet<>();
+ Map<String, Literal> literals = new LinkedHashMap<>();
for (Map.Entry<String, Expression> entry : partitionValues.entrySet())
{
if (entry.getValue() instanceof Literal) {
Literal literal = (Literal) entry.getValue();
@@ -84,9 +91,8 @@ public class PluginDrivenInsertCommandContext extends
BaseExternalTableInsertCom
// Commit metadata must use the same semantic bytes as the
materialized partition row.
literal = (Literal)
ConnectorWriteValueConverter.convert(column, literal);
}
- // Binary partition keys must bypass character decoding,
including empty and non-UTF-8 bytes.
- spec.put(entry.getKey(), literal instanceof VarBinaryLiteral
- ? "0x" + literal.toString() :
literal.getStringValue());
+ spec.put(entry.getKey(), specValue(literal));
+ literals.put(entry.getKey(), literal);
if (entry.getValue() instanceof NullLiteral) {
nullKeys.add(entry.getKey());
}
@@ -94,6 +100,60 @@ public class PluginDrivenInsertCommandContext extends
BaseExternalTableInsertCom
}
this.staticPartitionSpec = spec;
this.staticPartitionNullKeys = nullKeys;
+ this.staticPartitionLiterals = literals;
+ }
+
+ public void setBoundTargetSchema(List<Column> boundTargetSchema) {
+ this.boundTargetSchema = boundTargetSchema;
+ }
+
+ /**
+ * Casts the static partition values to the types of their columns in the
bound target schema; see
+ * {@code ConnectorWriteHandle#getCastStaticPartitionSpec}. Each value is
the one BindSink materializes into
+ * the rows, after constant folding, but a value that does not fit its
type fails here instead of becoming
+ * NULL.
+ */
+ public Map<String, String> castStaticPartitionSpec() {
+ Preconditions.checkState(staticPartitionLiterals.isEmpty() ||
!boundTargetSchema.isEmpty(),
+ "static partition values are cast before the sink is bound to
a target schema");
+ Map<String, String> spec = new LinkedHashMap<>();
+ for (Map.Entry<String, Literal> entry :
staticPartitionLiterals.entrySet()) {
+ if (staticPartitionNullKeys.contains(entry.getKey())) {
+ continue;
+ }
+ Literal value = entry.getValue();
+ Optional<Column> column = boundTargetSchema.stream()
+ .filter(candidate ->
candidate.getName().equalsIgnoreCase(entry.getKey()))
+ .findFirst();
+ if (column.isPresent()) {
+ value = writtenValue(value,
DataType.fromCatalogType(column.get().getType()));
+ }
+ spec.put(entry.getKey(), specValue(value));
+ }
+ return spec;
+ }
+
+ private static String specValue(Literal literal) {
+ // Binary partition keys must bypass character decoding, including
empty and non-UTF-8 bytes.
+ return literal instanceof VarBinaryLiteral ? "0x" + literal.toString()
: literal.getStringValue();
+ }
+
+ private static Literal writtenValue(Literal value, DataType columnType) {
+ if (!value.getDataType().isStringLikeType() ||
!columnType.isStringLikeType()) {
+ Expression cast = value.checkedCastTo(columnType);
+ Preconditions.checkState(cast instanceof Literal, "cast of literal
%s is not a literal", value);
+ return (Literal) cast;
+ }
+ // BindSink does not cast a string written into a string column. It
keeps the value, cut to the length of
+ // a CHAR / VARCHAR column in code points when the session truncates
strings on insert; a cast would cut
+ // UTF-16 units instead, or reject a CHAR value that is too long.
+ int length = ((CharacterType) columnType).getLen();
+ if (length < 0 || !BindSink.truncatesStringOnInsert()) {
+ return value;
+ }
+ Preconditions.checkState(value instanceof StringLikeLiteral, "string
literal %s has no string value", value);
+ return (Literal)
StringArithmetic.substringVarcharIntInt((StringLikeLiteral) value,
+ new IntegerLiteral(1), new IntegerLiteral(length));
}
public Optional<String> getBranchName() {
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/planner/PluginDrivenTableSink.java
b/fe/fe-core/src/main/java/org/apache/doris/planner/PluginDrivenTableSink.java
index fbbc7bfcaa3..32fe4c24e54 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/planner/PluginDrivenTableSink.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/planner/PluginDrivenTableSink.java
@@ -46,6 +46,7 @@ import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
+import java.util.function.Supplier;
/**
* Generic data sink for plugin-driven external tables.
@@ -147,7 +148,7 @@ public class PluginDrivenTableSink extends
BaseExternalTableDataSink {
// EXPLAIN), so the connector derives the detail from the write handle.
ConnectorWriteHandle handle = new PluginDrivenWriteHandle(
tableHandle, connectorColumns, boundTargetColumns, false,
Collections.emptyMap(),
- Collections.emptySet(), null, null, Optional.empty(),
writeOperation,
+ Collections.emptySet(), Collections::emptyMap, null, null,
Optional.empty(), writeOperation,
writesDataFiles, requireMergeCardinalityCheck, beExecVersion);
writePlanProvider.appendExplainInfo(sb, prefix, connectorSession,
handle);
return sb.toString();
@@ -175,12 +176,14 @@ public class PluginDrivenTableSink extends
BaseExternalTableDataSink {
boolean overwrite = false;
Map<String, String> writeContext = Collections.emptyMap();
Set<String> staticPartitionNullKeys = Collections.emptySet();
+ Supplier<Map<String, String>> castStaticPartitionSpec =
Collections::emptyMap;
Optional<String> branchName = Optional.empty();
if (insertCtx.isPresent() && insertCtx.get() instanceof
PluginDrivenInsertCommandContext) {
PluginDrivenInsertCommandContext ctx =
(PluginDrivenInsertCommandContext) insertCtx.get();
overwrite = ctx.isOverwrite();
writeContext = ctx.getStaticPartitionSpec();
staticPartitionNullKeys = ctx.getStaticPartitionNullKeys();
+ castStaticPartitionSpec = ctx::castStaticPartitionSpec;
branchName = ctx.getBranchName();
}
ConnectorTableHandle boundTableHandle = tableHandle;
@@ -195,7 +198,7 @@ public class PluginDrivenTableSink extends
BaseExternalTableDataSink {
}
ConnectorWriteHandle handle = new PluginDrivenWriteHandle(
boundTableHandle, connectorColumns, boundTargetColumns,
overwrite, writeContext,
- staticPartitionNullKeys, writeSortInfo,
+ staticPartitionNullKeys, castStaticPartitionSpec,
writeSortInfo,
boundWriteMetadataIdentity, branchName, writeOperation,
writesDataFiles,
requireMergeCardinalityCheck, beExecVersion);
ConnectorSinkPlan sinkPlan =
writePlanProvider.planWrite(connectorSession, handle);
@@ -230,6 +233,9 @@ public class PluginDrivenTableSink extends
BaseExternalTableDataSink {
private final boolean overwrite;
private final Map<String, String> writeContext;
private final Set<String> staticPartitionNullKeys;
+ // Computed on demand, so only a connector that reads the cast values
can fail on a value that does not
+ // fit its column type.
+ private final Supplier<Map<String, String>> castStaticPartitionSpec;
private final TSortInfo sortInfo;
private final String boundWriteMetadataIdentity;
private final Optional<String> branchName;
@@ -241,6 +247,7 @@ public class PluginDrivenTableSink extends
BaseExternalTableDataSink {
private PluginDrivenWriteHandle(ConnectorTableHandle tableHandle,
List<ConnectorColumn> columns,
List<ConnectorColumn> boundTargetColumns, boolean overwrite,
Map<String, String> writeContext, Set<String>
staticPartitionNullKeys,
+ Supplier<Map<String, String>> castStaticPartitionSpec,
TSortInfo sortInfo, String boundWriteMetadataIdentity,
Optional<String> branchName, WriteOperation writeOperation,
boolean writesDataFiles, boolean requireMergeCardinalityCheck,
@@ -251,6 +258,7 @@ public class PluginDrivenTableSink extends
BaseExternalTableDataSink {
this.overwrite = overwrite;
this.writeContext = writeContext;
this.staticPartitionNullKeys = staticPartitionNullKeys;
+ this.castStaticPartitionSpec = castStaticPartitionSpec;
this.sortInfo = sortInfo;
this.boundWriteMetadataIdentity = boundWriteMetadataIdentity;
this.branchName = branchName == null ? Optional.empty() :
branchName;
@@ -320,6 +328,11 @@ public class PluginDrivenTableSink extends
BaseExternalTableDataSink {
return staticPartitionNullKeys;
}
+ @Override
+ public Map<String, String> getCastStaticPartitionSpec() {
+ return castStaticPartitionSpec.get();
+ }
+
@Override
public WriteOperation getWriteOperation() {
return writeOperation;
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/planner/PluginDrivenTableSinkBindingTest.java
b/fe/fe-core/src/test/java/org/apache/doris/planner/PluginDrivenTableSinkBindingTest.java
index 120bfbb2c96..2da58e50325 100644
---
a/fe/fe-core/src/test/java/org/apache/doris/planner/PluginDrivenTableSinkBindingTest.java
+++
b/fe/fe-core/src/test/java/org/apache/doris/planner/PluginDrivenTableSinkBindingTest.java
@@ -18,6 +18,9 @@
package org.apache.doris.planner;
import org.apache.doris.analysis.TableScanParams;
+import org.apache.doris.catalog.Column;
+import org.apache.doris.catalog.ScalarType;
+import org.apache.doris.catalog.Type;
import org.apache.doris.common.AnalysisException;
import org.apache.doris.common.Config;
import org.apache.doris.connector.ConnectorSessionBuilder;
@@ -32,7 +35,16 @@ import
org.apache.doris.connector.spi.write.ConnectorWritePlanProvider;
import org.apache.doris.datasource.mvcc.MvccUtil;
import org.apache.doris.datasource.mvcc.PluginDrivenMvccSnapshot;
import org.apache.doris.datasource.plugin.PluginDrivenExternalTable;
+import org.apache.doris.nereids.trees.expressions.Expression;
+import org.apache.doris.nereids.trees.expressions.literal.BooleanLiteral;
+import org.apache.doris.nereids.trees.expressions.literal.DecimalV3Literal;
+import org.apache.doris.nereids.trees.expressions.literal.IntegerLiteral;
+import org.apache.doris.nereids.trees.expressions.literal.NullLiteral;
+import org.apache.doris.nereids.trees.expressions.literal.StringLiteral;
+import org.apache.doris.nereids.trees.expressions.literal.VarBinaryLiteral;
+import org.apache.doris.nereids.trees.expressions.literal.VarcharLiteral;
import
org.apache.doris.nereids.trees.plans.commands.insert.PluginDrivenInsertCommandContext;
+import org.apache.doris.qe.ConnectContext;
import org.apache.doris.thrift.TDataSink;
import org.junit.jupiter.api.Assertions;
@@ -40,8 +52,11 @@ import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
+import java.math.BigDecimal;
+import java.util.Arrays;
import java.util.Collections;
-import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
import java.util.Map;
import java.util.Optional;
@@ -69,9 +84,7 @@ public class PluginDrivenTableSinkBindingTest {
PluginDrivenInsertCommandContext ctx = new
PluginDrivenInsertCommandContext();
ctx.setOverwrite(true);
- Map<String, String> staticSpec = new HashMap<>();
- staticSpec.put("pt", "20240101");
- ctx.setStaticPartitionSpec(staticSpec);
+
ctx.setStaticPartitionSpecFromExpressions(Collections.singletonMap("pt", new
StringLiteral("20240101")));
sink.bindDataSink(Optional.of(ctx));
@@ -79,10 +92,145 @@ public class PluginDrivenTableSinkBindingTest {
Assertions.assertNotNull(handle, "planWrite must be invoked with a
bound write handle");
Assertions.assertTrue(handle.isOverwrite(),
"INSERT OVERWRITE must propagate ctx.isOverwrite()=true to the
connector write handle");
- Assertions.assertEquals(staticSpec, handle.getStaticPartitionSpec(),
+ Assertions.assertEquals(Collections.singletonMap("pt", "20240101"),
handle.getStaticPartitionSpec(),
"PARTITION(col=val) must propagate the static partition spec
to the write handle");
}
+ @Test
+ public void staticPartitionValuesCastToTheirColumnTypesFlowToWriteHandle()
throws AnalysisException {
+ // The rows carry each PARTITION literal cast to its column type; a
connector that names the partition
+ // by value (Paimon's static overwrite) must get that value, not the
literal as written. MUTATION:
+ // returning the spec as written from getCastStaticPartitionSpec ->
red.
+ RecordingWritePlanProvider provider = new RecordingWritePlanProvider();
+ PluginDrivenTableSink sink = newPlanProviderSink(provider);
+ PluginDrivenInsertCommandContext ctx = new
PluginDrivenInsertCommandContext();
+ Map<String, Expression> partition = new LinkedHashMap<>();
+ partition.put("P", BooleanLiteral.TRUE);
+ partition.put("dt", new IntegerLiteral(20240101));
+ partition.put("amount", new DecimalV3Literal(new BigDecimal("1.5")));
+ partition.put("region", new NullLiteral());
+ partition.put("p_bucket", new StringLiteral("x"));
+ ctx.setStaticPartitionSpecFromExpressions(partition);
+ ctx.setBoundTargetSchema(Arrays.asList(new Column("p", Type.INT), new
Column("dt", Type.DATEV2),
+ new Column("amount", ScalarType.createDecimalV3Type(10, 2)),
new Column("region", Type.STRING)));
+
+ sink.bindDataSink(Optional.of(ctx));
+
+ ConnectorWriteHandle handle = provider.capturedHandle;
+ Assertions.assertEquals("20240101",
handle.getStaticPartitionSpec().get("dt"));
+ Map<String, String> expected = new LinkedHashMap<>();
+ expected.put("P", "1");
+ expected.put("dt", "2024-01-01");
+ expected.put("amount", "1.50");
+ // A key that names no column, such as an Iceberg partition field,
keeps its literal; SQL NULL stays a null key.
+ expected.put("p_bucket", "x");
+ Assertions.assertEquals(expected, handle.getCastStaticPartitionSpec());
+ Assertions.assertEquals(Collections.singleton("region"),
handle.getStaticPartitionNullKeys());
+ }
+
+ @Test
+ public void staticPartitionDateTimeValuesCastInTheSessionZone() throws
AnalysisException {
+ // A DATETIMEV2 value stays local time in the session zone, and a
TIMESTAMPTZ value becomes the instant
+ // in UTC with its offset; Paimon moves either one to the zone its
overwrite parser uses.
+ ConnectContext previous = ConnectContext.get();
+ ConnectContext context = new ConnectContext();
+ context.getSessionVariable().setTimeZone("Asia/Shanghai");
+ context.setThreadLocalInfo();
+ try {
+ RecordingWritePlanProvider provider = new
RecordingWritePlanProvider();
+ PluginDrivenTableSink sink = newPlanProviderSink(provider);
+ PluginDrivenInsertCommandContext ctx = new
PluginDrivenInsertCommandContext();
+ Map<String, Expression> partition = new LinkedHashMap<>();
+ partition.put("ts", new StringLiteral("2024-01-15
08:30:45.123456"));
+ partition.put("tz", new StringLiteral("2024-01-15
08:30:45.123456"));
+ ctx.setStaticPartitionSpecFromExpressions(partition);
+ ctx.setBoundTargetSchema(Arrays.asList(new Column("ts",
ScalarType.createDatetimeV2Type(6)),
+ new Column("tz", ScalarType.createTimeStampTzType(6))));
+
+ sink.bindDataSink(Optional.of(ctx));
+
+ Map<String, String> expected = new LinkedHashMap<>();
+ expected.put("ts", "2024-01-15 08:30:45.123456");
+ expected.put("tz", "2024-01-15 00:30:45.123456+00:00");
+ Assertions.assertEquals(expected,
provider.capturedHandle.getCastStaticPartitionSpec());
+ } finally {
+ if (previous == null) {
+ ConnectContext.remove();
+ } else {
+ previous.setThreadLocalInfo();
+ }
+ }
+ }
+
+ @Test
+ public void staticPartitionStringValueIsCutLikeTheWrittenRows() throws
AnalysisException {
+ // BindSink writes a string into a shorter CHAR / VARCHAR column cut
to the column length in code points,
+ // not cast: a cast rejects the CHAR value and splits the emoji's
surrogate pair, so the overwrite would
+ // fail or name another partition. MUTATION: casting string values
like the others -> red.
+ ConnectContext previous = ConnectContext.get();
+ ConnectContext context = new ConnectContext();
+ context.setThreadLocalInfo();
+ try {
+ // One code point, two UTF-16 units.
+ String emoji = new String(Character.toChars(0x1F600));
+ Map<String, Expression> partition = new LinkedHashMap<>();
+ partition.put("c", new VarcharLiteral("abcd"));
+ partition.put("v", new VarcharLiteral(emoji + "x"));
+ partition.put("s", new VarcharLiteral("abcd"));
+ List<Column> schema = Arrays.asList(new Column("c",
ScalarType.createCharType(2)),
+ new Column("v", ScalarType.createVarcharType(1)), new
Column("s", Type.STRING));
+
+ Map<String, String> expected = new LinkedHashMap<>();
+ expected.put("c", "ab");
+ expected.put("v", emoji);
+ expected.put("s", "abcd");
+ Assertions.assertEquals(expected,
castStaticPartitionSpec(partition, schema));
+
+ // A session that does not truncate writes the strings as they
are, and they stay so here.
+ context.getSessionVariable().enableInsertValueAutoCast = false;
+ Map<String, String> asWritten = new LinkedHashMap<>();
+ asWritten.put("c", "abcd");
+ asWritten.put("v", emoji + "x");
+ asWritten.put("s", "abcd");
+ Assertions.assertEquals(asWritten,
castStaticPartitionSpec(partition, schema));
+ } finally {
+ if (previous == null) {
+ ConnectContext.remove();
+ } else {
+ previous.setThreadLocalInfo();
+ }
+ }
+ }
+
+ @Test
+ public void staticPartitionBinaryValueKeepsItsBytesWhenCast() throws
AnalysisException {
+ // A binary value is 0x and its bytes in hex in both maps. Its string
value would be the identity of the
+ // byte array ([B@...), which names no partition. MUTATION:
getStringValue() for a cast binary value -> red.
+ Map<String, Expression> partition = Collections.singletonMap("b",
+ new VarBinaryLiteral(new byte[] {0x41, 0x42}));
+ // The type Paimon's VARBINARY maps to.
+ List<Column> schema = Collections.singletonList(
+ new Column("b",
ScalarType.createVarbinaryType(ScalarType.MAX_VARBINARY_LENGTH)));
+
+ Assertions.assertEquals(Collections.singletonMap("b", "0x4142"),
castStaticPartitionSpec(partition, schema));
+ }
+
+ @Test
+ public void staticPartitionValueThatDoesNotFitItsTypeFailsOnlyWhenCast()
throws AnalysisException {
+ // Only a connector that reads the cast values may fail on them; the
others keep reading the literal.
+ RecordingWritePlanProvider provider = new RecordingWritePlanProvider();
+ PluginDrivenTableSink sink = newPlanProviderSink(provider);
+ PluginDrivenInsertCommandContext ctx = new
PluginDrivenInsertCommandContext();
+
ctx.setStaticPartitionSpecFromExpressions(Collections.singletonMap("dt", new
StringLiteral("2024-13-45")));
+ ctx.setBoundTargetSchema(Collections.singletonList(new Column("dt",
Type.DATEV2)));
+
+ sink.bindDataSink(Optional.of(ctx));
+
+ ConnectorWriteHandle handle = provider.capturedHandle;
+ Assertions.assertEquals(Collections.singletonMap("dt", "2024-13-45"),
handle.getStaticPartitionSpec());
+ Assertions.assertThrows(RuntimeException.class,
handle::getCastStaticPartitionSpec);
+ }
+
@Test
public void absentContextDefaultsToNonOverwriteEmptySpec() throws
AnalysisException {
RecordingWritePlanProvider provider = new RecordingWritePlanProvider();
@@ -133,6 +281,17 @@ public class PluginDrivenTableSinkBindingTest {
Assertions.assertSame(pinnedHandle,
provider.capturedHandle.getTableHandle());
}
+ private static Map<String, String> castStaticPartitionSpec(Map<String,
Expression> partition,
+ List<Column> boundTargetSchema) throws AnalysisException {
+ RecordingWritePlanProvider provider = new RecordingWritePlanProvider();
+ PluginDrivenTableSink sink = newPlanProviderSink(provider);
+ PluginDrivenInsertCommandContext ctx = new
PluginDrivenInsertCommandContext();
+ ctx.setStaticPartitionSpecFromExpressions(partition);
+ ctx.setBoundTargetSchema(boundTargetSchema);
+ sink.bindDataSink(Optional.of(ctx));
+ return provider.capturedHandle.getCastStaticPartitionSpec();
+ }
+
private static PluginDrivenTableSink
newPlanProviderSink(ConnectorWritePlanProvider provider) {
ConnectorSession session =
ConnectorSessionBuilder.create().withCatalogName("mc_cat").build();
ConnectorTableHandle tableHandle = new ConnectorTableHandle() { };
diff --git
a/regression-test/suites/external_table_p0/paimon/write/test_paimon_write_transaction.groovy
b/regression-test/suites/external_table_p0/paimon/write/test_paimon_write_transaction.groovy
index 0cdbee408e9..6a0db1e9dbc 100644
---
a/regression-test/suites/external_table_p0/paimon/write/test_paimon_write_transaction.groovy
+++
b/regression-test/suites/external_table_p0/paimon/write/test_paimon_write_transaction.groovy
@@ -91,6 +91,18 @@ suite("test_paimon_write_transaction", "p0,external,paimon")
{
'partition.default-name' = '__CUSTOM_DEFAULT_PARTITION__'
);
+ DROP TABLE IF EXISTS paimon.${dbName}.t_static_cast;
+ CREATE TABLE paimon.${dbName}.t_static_cast (
+ id INT, name STRING, p INT, dt DATE
+ ) USING paimon
+ PARTITIONED BY (p, dt);
+
+ DROP TABLE IF EXISTS paimon.${dbName}.t_static_ltz;
+ CREATE TABLE paimon.${dbName}.t_static_ltz (
+ id INT, ts TIMESTAMP
+ ) USING paimon
+ PARTITIONED BY (ts);
+
DROP TABLE IF EXISTS paimon.${dbName}.t_dynamic_multi;
CREATE TABLE paimon.${dbName}.t_dynamic_multi (
id INT, name STRING, region STRING
@@ -273,6 +285,43 @@ suite("test_paimon_write_transaction",
"p0,external,paimon") {
FROM t_static_default ORDER BY id"""
assertTableEquals("t_static_default", "ORDER BY id")
+ // Paimon's static overwrite reads partition.default-name as the NULL
+ // partition, so the same string as a value cannot be overwritten.
+ test {
+ sql """INSERT OVERWRITE TABLE t_static_default
+ PARTITION (region = '__CUSTOM_DEFAULT_PARTITION__') SELECT 1,
'unused' LIMIT 0"""
+ exception "cannot be represented in a static overwrite"
+ }
+ assertTableEquals("t_static_default", "ORDER BY id")
+
+ // A static partition value is cast to its column type, as the written
+ // rows are: PARTITION (p = true) names p = 1 and dt = 20240101 names
+ // 2024-01-01.
+ sql """INSERT INTO t_static_cast VALUES
+ (1, 'p1_d1', 1, '2024-01-01'),
+ (2, 'p1_d2', 1, '2024-01-02'),
+ (3, 'p2_d1', 2, '2024-01-01')"""
+ sql """INSERT OVERWRITE TABLE t_static_cast
+ PARTITION (p = true) VALUES (10, 'p1_new', '2024-01-01')"""
+ assertEquals([[3], [10]], sql("""SELECT id FROM t_static_cast ORDER BY
id"""))
+ sql """INSERT OVERWRITE TABLE t_static_cast
+ PARTITION (p = 2, dt = 20240101) VALUES (20, 'p2_new')"""
+ assertEquals([[10], [20]], sql("""SELECT id FROM t_static_cast ORDER
BY id"""))
+ assertTableEquals("t_static_cast", "ORDER BY id")
+
+ // An LTZ static partition value is local time in the session zone,
+ // while Paimon parses the overwrite value in the FE JVM zone; keep the
+ // session zone away from the JVM zone so a value left unconverted
+ // names another partition.
+ sql """set time_zone = 'Asia/Kolkata'"""
+ sql """INSERT INTO t_static_ltz VALUES
+ (1, '2024-01-15 08:30:45'), (2, '2024-01-15 16:30:45')"""
+ sql """INSERT OVERWRITE TABLE t_static_ltz
+ PARTITION (ts = '2024-01-15 08:30:45') VALUES (10)"""
+ def staticLtzIds = sql """SELECT id FROM t_static_ltz ORDER BY id"""
+ sql """set time_zone = default"""
+ assertEquals([[2], [10]], staticLtzIds)
+
// A partial static specification must use typed partition identity.
// NULL, blank, the literal "null", escaped path characters, and DATE
// subpartitions must remain distinct even when display paths overlap.
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]