morningman commented on code in PR #68874:
URL: https://github.com/apache/doris/pull/68874#discussion_r4237265974


##########
fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/handle/ConnectorWriteHandle.java:
##########
@@ -75,6 +76,20 @@ default Set<String> getStaticPartitionNullKeys() {
         return Collections.emptySet();
     }
 
+    /**
+     * The static partition values cast to the types of their columns, the 
cast a written row gets, 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. 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() {

Review Comment:
   Not bumping here, on purpose. The connector SPI has not shipped in a 
release: the FE and every connector plugin are built from the same tree and 
released together, so there is no Paimon plugin built against an older 12.0 
surface for the gate to keep out. Until the SPI ships, methods are added 
without moving `connector.plugin.api.version`. The frozen surface baseline 
(`connector-plugin-surface.txt`) is refreshed in this PR, so the surface test 
still pins the new method.
   



##########
fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/PluginDrivenInsertCommandContext.java:
##########
@@ -66,16 +69,51 @@ public Set<String> getStaticPartitionNullKeys() {
     public void setStaticPartitionSpecFromExpressions(Map<String, Expression> 
partitionValues) {
         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) {
                 spec.put(entry.getKey(), ((Literal) 
entry.getValue()).getStringValue());
+                literals.put(entry.getKey(), (Literal) entry.getValue());
                 if (entry.getValue() instanceof NullLiteral) {
                     nullKeys.add(entry.getKey());
                 }
             }
         }
         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}. The cast is 
the one constant folding applies to
+     * the value BindSink materializes into each row, 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()) {
+                Expression cast = 
value.checkedCastTo(DataType.fromCatalogType(column.get().getType()));

Review Comment:
   Agreed, fixed in 1d4999b3fbb. BindSink does not cast a string written into a 
string column. With `enable_insert_value_auto_cast` on and `enable_strict_cast` 
off (the defaults) it cuts the value to the CHAR / VARCHAR length with 
`Substring`, which counts code points; otherwise it writes the value as is. 
`castStaticPartitionSpec` now does the same for a string literal and a string 
column: the session check is shared with BindSink 
(`BindSink.truncatesStringOnInsert()`) and the cut is the constant-folding 
`substring`. Other combinations keep the cast.
   
   On a local file-system Paimon catalog:
   
   | Statement | before | after |
   |---|---|---|
   | `INSERT INTO t PARTITION (p = 'abcd') VALUES (1)`, `p CHAR(2)` | `Cast 
from abcd to CHAR(2) not supported` | row written with `p = 'ab'` |
   | `INSERT OVERWRITE TABLE t PARTITION (p = 'abcd') VALUES (10)` | the same 
error | partition `ab` replaced |
   | empty overwrite `PARTITION (p = '😀x')`, `p VARCHAR(1)`, partitions `😀` and 
`?` exist | partition `?` deleted | partition `😀` deleted |
   
   
`PluginDrivenTableSinkBindingTest.staticPartitionStringValueIsCutLikeTheWrittenRows`
 covers CHAR, VARCHAR with a surrogate pair, STRING, and a session that does 
not truncate.
   



##########
fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonWriteBinding.java:
##########
@@ -78,26 +84,75 @@ static FileStoreTable configureTableForWrite(FileStoreTable 
table, boolean overw
         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 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.
+     */
+    static Map<String, String> resolveStaticPartition(FileStoreTable table, 
ConnectorWriteHandle writeHandle,
+            String sessionTimeZone) {
         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> castValues = 
writeHandle.getCastStaticPartitionSpec();
         Map<String, String> result = new LinkedHashMap<>();
-        for (Map.Entry<String, String> entry : requested.entrySet()) {
-            String canonicalName = canonicalNames.get(entry.getKey());
+        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);
+            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(value, isTimestampTz(writeHandle, 
canonicalName), sessionTimeZone);
+            }
+            if (writeHandle.isOverwrite() && 
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;
     }
 
+    private static boolean isTimestampTz(ConnectorWriteHandle writeHandle, 
String columnName) {
+        for (ConnectorColumn column : writeHandle.getBoundTargetColumns()) {
+            if (column.getName().equalsIgnoreCase(columnName)) {
+                return 
"TIMESTAMPTZ".equalsIgnoreCase(column.getType().getTypeName());
+            }
+        }
+        throw new DorisConnectorException("Paimon partition column is missing 
from the write schema: "
+                + columnName);
+    }
+
+    /**
+     * Paimon parses a static overwrite value of a TIMESTAMP WITH LOCAL TIME 
ZONE column as local time in the FE
+     * JVM's default zone, so the value is moved there from the zone it was 
written in: the session zone for a
+     * DATETIMEV2 value, UTC for a TIMESTAMPTZ value, which carries its 
offset. Paimon's parser takes a space,
+     * not ISO's 'T', between the date and the time.
+     */
+    private static String toSdkLocalTime(String value, boolean timestampTz, 
String sessionTimeZone) {
+        String isoValue = value.replace(' ', 'T');
+        ZonedDateTime instant = timestampTz
+                ? OffsetDateTime.parse(isoValue, 
DateTimeFormatter.ISO_OFFSET_DATE_TIME).toZonedDateTime()
+                : LocalDateTime.parse(isoValue, 
DateTimeFormatter.ISO_LOCAL_DATE_TIME)
+                        .atZone(ZoneId.of(sessionTimeZone, 
PaimonConnectorMetadata.SESSION_TIME_ZONE_ALIASES));
+        return 
instant.withZoneSameInstant(ZoneId.systemDefault()).toLocalDateTime()

Review Comment:
   Agreed, fixed in 1d4999b3fbb the way branch-4.x does it (#68532): an 
overwrite rejects an LTZ value whose local time in the FE JVM zone falls in a 
DST overlap, because both instants of the overlap format to the same 
offset-free value. An INSERT keeps the value, since it overwrites nothing.
   
   Reproduced on a local catalog with the FE JVM in Europe/London, a UTC 
session, and partitions at 2024-10-27 00:30:45Z and 01:30:45Z (both 01:30:45 in 
London). Before, an empty overwrite of `ts = '2024-10-27 01:30:45'` deleted the 
00:30:45Z partition and kept the one it named. Now it fails with `Static LTZ 
partition value for column 'ts' is ambiguous in FE JVM time zone Europe/London 
and cannot be represented in a static overwrite`, and both partitions stay. 
`PaimonWriteBindingTest.staticLtzOverwriteRejectsAnInstantTheSdkZoneRepeats` 
covers both instants of an overlap for a TIMESTAMPTZ value and your DATETIMEV2 
example; `staticLtzPartitionOutsideAnOverlapRoundTripsThroughPaimon` checks 
with Paimon's parser that the same instants still pass where they are 
unambiguous.
   



##########
fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonWriteBinding.java:
##########
@@ -78,26 +84,75 @@ static FileStoreTable configureTableForWrite(FileStoreTable 
table, boolean overw
         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 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.
+     */
+    static Map<String, String> resolveStaticPartition(FileStoreTable table, 
ConnectorWriteHandle writeHandle,
+            String sessionTimeZone) {
         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> castValues = 
writeHandle.getCastStaticPartitionSpec();
         Map<String, String> result = new LinkedHashMap<>();
-        for (Map.Entry<String, String> entry : requested.entrySet()) {
-            String canonicalName = canonicalNames.get(entry.getKey());
+        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);
+            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(value, isTimestampTz(writeHandle, 
canonicalName), sessionTimeZone);
+            }
+            if (writeHandle.isOverwrite() && 
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;
     }
 
+    private static boolean isTimestampTz(ConnectorWriteHandle writeHandle, 
String columnName) {
+        for (ConnectorColumn column : writeHandle.getBoundTargetColumns()) {
+            if (column.getName().equalsIgnoreCase(columnName)) {
+                return 
"TIMESTAMPTZ".equalsIgnoreCase(column.getType().getTypeName());
+            }
+        }
+        throw new DorisConnectorException("Paimon partition column is missing 
from the write schema: "
+                + columnName);
+    }
+
+    /**
+     * Paimon parses a static overwrite value of a TIMESTAMP WITH LOCAL TIME 
ZONE column as local time in the FE
+     * JVM's default zone, so the value is moved there from the zone it was 
written in: the session zone for a
+     * DATETIMEV2 value, UTC for a TIMESTAMPTZ value, which carries its 
offset. Paimon's parser takes a space,
+     * not ISO's 'T', between the date and the time.
+     */
+    private static String toSdkLocalTime(String value, boolean timestampTz, 
String sessionTimeZone) {
+        String isoValue = value.replace(' ', 'T');
+        ZonedDateTime instant = timestampTz
+                ? OffsetDateTime.parse(isoValue, 
DateTimeFormatter.ISO_OFFSET_DATE_TIME).toZonedDateTime()
+                : LocalDateTime.parse(isoValue, 
DateTimeFormatter.ISO_LOCAL_DATE_TIME)
+                        .atZone(ZoneId.of(sessionTimeZone, 
PaimonConnectorMetadata.SESSION_TIME_ZONE_ALIASES));

Review Comment:
   Agreed. `DateV2Value::unix_timestamp` goes through `cctz::convert`, which 
returns the transition instant for a skipped civil time, while 
`LocalDateTime.atZone` moves it forward by the length of the gap. Fixed in 
1d4999b3fbb by rejecting the value instead of copying cctz's rule into the 
connector: an overwrite whose DATETIMEV2 value does not exist in the session 
zone fails with `... does not exist in session time zone ...`. A repeated 
session-local time needs nothing, because both sides take the earlier offset 
there (cctz returns `pre`, and Java takes the earlier offset of an overlap).
   
   Reproduced with a Europe/London session: the row written with `2024-03-31 
01:30:45` lands at 01:00:00Z, and before this change an empty overwrite of that 
value deleted the 01:30:45Z partition (the row written with `02:30:45`) 
instead. Now the overwrite is rejected and both partitions stay. Covered by 
`PaimonWriteBindingTest.staticLtzOverwriteRejectsASessionTimeADstChangeSkips`.
   



##########
regression-test/suites/external_table_p0/paimon/write/test_paimon_write_transaction.groovy:
##########
@@ -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"""))

Review Comment:
   The rule is right, but these cases cannot get a generated `.out` from this 
branch. The suite creates its tables through Spark in the Paimon docker 
environment, which only the External pipeline has, so a `qt_` block here would 
have to be written by hand, which the same rule forbids. I kept explicit 
assertions, as the suite already does with `assertTableEquals`: the expected 
ids are the point of each case (which partition the overwrite replaced), and 
the External run checks them.
   



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to