github-actions[bot] commented on code in PR #68874:
URL: https://github.com/apache/doris/pull/68874#discussion_r4237037980


##########
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:
   [P1] Derive the static partition value from the bound row's actual string 
coercion. Under default non-strict inserts, BindSink applies Substring to a 
bounded CHAR/VARCHAR row, while this separate checkedCastTo does something 
else: CHAR(2) with p='abcd' binds a row value of 'ab' but throws here even for 
an append; VARCHAR(1) with p='😀x' writes the whole emoji, but 
VarcharLiteral(value, 1) keeps one UTF-16 surrogate and Paimon encodes it as 
'?'. An empty overwrite can then delete a preexisting '?' partition instead of 
the emoji partition. FE constant folding and BE substring both count code 
points, so they do not remove this mismatch.



##########
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:
   [P2] Use generated query output for these deterministic results. The new 
fixed-row assertEquals checks here and at lines 309 and 323 contradict root 
AGENTS.md Testing Standards item 6, which requires qt_sql-style queries with 
script-generated .out files for determined expected results. These queries 
already ORDER BY id, so they can be converted directly to named ordered query 
tests and their outputs generated by the regression runner.



##########
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:
   [P1] Resolve a spring DST gap the same way as the BE row writer, or reject 
the nonexistent local time. For session America/Los_Angeles, UTC FE JVM, and 
static LTZ value 2024-03-10 02:30:45, Java atZone shifts the value to 03:30:45 
PDT (10:30:45Z). The BE DateTimeV2 Arrow path uses CCTZ convert, which resolves 
this skipped civil time to the transition instant (10:00:00Z). The overwrite 
target and written row differ, so an empty overwrite can delete an unrelated 
partition. This source-zone gap is separate from the destination-zone overlap 
above. See the [CCTZ v2.5 conversion 
rule](https://github.com/google/cctz/blob/v2.5/include/cctz/time_zone.h#L234-L244).



##########
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:
   [P1] Bump the connector plugin API major for this new SPI method. The frozen 
surface now includes getCastStaticPartitionSpec, but 
connector.plugin.api.version and its pinned test remain 12.0. ApiVersionGate 
checks the major only, and the FE parent-loads the SPI, so a new Paimon plugin 
can be admitted by an older 12.0 FE whose interface lacks this method; the call 
in PaimonWriteBinding then fails with NoSuchMethodError. Advance the manifest 
version and test pin together with the surface change.



##########
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:
   [P1] Preserve or validate the instant at a fall DST overlap. With an 
America/Los_Angeles FE JVM, UTC session, and static LTZ value 2024-11-03 
09:30:45, the written row is 09:30:45Z (the second 01:30 occurrence). This 
conversion emits offset-free 01:30:45; Paimon 1.4.2 parses it via 
LocalDateTime.atZone and chooses the first occurrence, 08:30:45Z. withOverwrite 
can therefore target and, for an empty overwrite, delete the other partition. 
Round-trip against Paimon's parser and reject unrepresentable instants, or pass 
a typed/offset-preserving target. See [Paimon's LTZ 
parser](https://github.com/apache/paimon/blob/release-1.4.2/paimon-common/src/main/java/org/apache/paimon/utils/DateTimeUtils.java#L504-L509).



-- 
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