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


##########
docs/iceberg-v3-default-values-design.md:
##########
@@ -0,0 +1,221 @@
+# Iceberg V3 Default Values in Doris
+
+## Background
+
+Iceberg V3 defines two different default-value contracts:
+
+- `initial-default` is a read-time value. A reader uses it when a selected 
data file was written
+  before a field was added and therefore does not contain that field ID.
+- `write-default` is a write-time value. A writer uses it when a known field 
is omitted or when a
+  statement explicitly requests `DEFAULT`.
+
+Adding a field with a default initially records the same value in both 
properties. A later default
+update changes only `write-default`. Doris must therefore keep the two 
concepts separate instead
+of representing both as one generic column default.
+
+The implementation has three ordered phases: read `initial-default`, consume 
`write-default`, and
+author/evolve default metadata. The current pull request combines the first 
two execution phases so
+that Doris can both read old files and write new rows with the correct 
defaults. Metadata authoring
+remains a later pull request.
+
+## Spark-Iceberg reference model
+
+The executable reference is Spark 4.0.0 with Iceberg 1.10.1, matching the 
repository Docker
+fixture. Iceberg keeps defaults as typed values in `NestedField`; its Spark 
Parquet reader converts
+the typed `initialDefault` to Spark's internal representation when a projected 
field is absent.
+The Iceberg schema-update API also preserves the distinction between adding a 
field with its
+initial default and later updating only its write default.
+
+Iceberg 1.10.1's ORC projection still rejects a physically missing field whose 
`initialDefault` is
+non-NULL. That is an upstream implementation gap, not the V3 contract Doris 
should reproduce.
+Doris therefore uses Spark-Iceberg as the reference for typed default 
conversion and implements the
+same field-ID/default semantics independently in both its Parquet and ORC scan 
paths.
+
+Doris follows that typed-constant model, with an explicit FE/BE transport 
boundary:
+
+1. FE reads `NestedField.initialDefault()` from the query-bound Iceberg schema.
+2. FE serializes primitive non-binary values through Iceberg's type-aware 
identity transform and
+   complex values through Iceberg `SingleValueParser` JSON. UUID, FIXED, and 
BINARY use Base64 as
+   direct defaults because a Thrift string cannot safely carry arbitrary 
bytes; recursive metadata
+   also retains their Iceberg type identity for binary values inside complex 
JSON.
+3. Each scanner has its own recursive parser and constructs an owning typed 
constant before
+   materializing rows. V1 and V2 do not delegate missing-column 
materialization to each other.
+
+This is intentionally different from feeding metadata text to the SQL parser. 
In particular,
+strings containing quotes, binary zero bytes, decimals, dates, and timestamps 
retain their typed
+value rather than being reinterpreted as SQL syntax.
+
+## Implementation roadmap
+
+### Phase 1: read `initial-default`
+
+The read phase adds support for every Iceberg type already mapped by Doris. It 
does not add any new
+Iceberg-to-Doris type mapping.
+
+The reader selects defaults from the schema bound to the query:
+
+| Query | Schema used for `initial-default` |
+| --- | --- |
+| Ordinary current-table read | `table.schema()` |
+| Explicit snapshot/time travel | Schema recorded by the selected snapshot |
+| Explicit branch | Current table schema over the branch-head snapshot lineage 
|
+| Explicit tag/ref snapshot | Schema recorded by the selected snapshot |
+
+Using `currentSnapshot.schemaId()` for an ordinary read is incorrect because a 
schema-only commit
+can update `table.schema()` without creating a snapshot.
+
+FE transports default metadata recursively by Iceberg field ID through the 
existing external
+schema Thrift structure. The metadata includes the serialized value, a 
lossless Base64 marker for
+UUID/FIXED/BINARY, and the Iceberg optional/required flag. Nested defaults do 
not depend on
+`Column.defaultValue`. The read phase deliberately leaves that generic Doris 
field unset for
+Iceberg schema columns so existing INSERT/MERGE code cannot mistake 
`initial-default` for
+`write-default`. The write phase uses a separate schema-pinned write-default 
path in the same pull
+request. Consequently, `DESC` and `SHOW CREATE TABLE` do not present either 
Iceberg value as a
+generic Doris column default.
+
+File Scanner V1 and File Scanner V2 consume the same metadata contract but 
implement missing-field
+materialization independently:
+
+- V1 applies defaults in its Parquet and ORC schema-change readers.
+- V2 applies defaults through `ColumnDefinition`, `TableColumnMapper`, and 
`TableReader`.
+
+Both implementations follow the same rules:
+
+1. A physically present field is read as stored, including an explicit NULL.
+2. A physically absent field with `initial-default` is materialized as a typed 
constant.
+3. A physically absent optional field without a default is NULL.
+4. A physically absent required field without a default is an error.
+5. A NULL parent struct, list element, or map value remains NULL. A nested 
default is applied only
+   inside a present parent value.
+6. A non-NULL struct default is the Iceberg V3 empty-object sentinel `{}`; its 
effective value is
+   built recursively from the struct fields' own `initial-default` metadata. 
List and map defaults
+   use Iceberg's single-value JSON (`[...]` and `{"keys": [...], "values": 
[...]}`), including nested
+   primitive and complex values.
+
+Complex-column coverage includes defaults on newly added children of structs, 
list-element
+structs, and map-value structs, plus recursive decoding of a whole 
complex-field default. Iceberg
+Java 1.10.1's public `Types.NestedField`/`UpdateSchema` path rejects every 
non-NULL nested-type
+default, even though the V3 format specification permits the empty struct 
sentinel. The Docker
+fixture therefore uses only metadata the official API can commit: it covers 
child defaults under
+physical complex parents and wholly missing optional complex parents with NULL 
parent defaults.
+Focused V1 and V2 unit tests cover the spec-level `{}`/list/map JSON decoder. 
The fixture never
+hand-edits table metadata to bypass Iceberg validation.
+
+Spark-Iceberg Docker generates the fixtures. It writes Parquet and ORC files 
using the old schema,
+then uses the Iceberg Java API to add defaulted fields. The fixture changes 
`write-default` after
+the add so an old file returning `initial-default` proves that the read path 
did not accidentally
+consume the current write default. Regression tests force all four paths:
+
+- File Scanner V1 with Parquet
+- File Scanner V1 with ORC
+- File Scanner V2 with Parquet
+- File Scanner V2 with ORC
+
+The type matrix is exactly the set already supported before this change: 
BOOLEAN, INTEGER, LONG, FLOAT,
+DOUBLE, DECIMAL, DATE, TIMESTAMP with and without zone, STRING, UUID, FIXED, 
BINARY, and their
+existing ARRAY/MAP/STRUCT compositions. TIME, TIMESTAMP_NANO, VARIANT, and any 
other currently
+unsupported mapping remain out of scope.
+
+The read phase verifies predicates for fields absent from old files in 
forced-path regression
+tests. Focused V1 and V2 BE tests verify equality-delete keys, including a key 
that is retained only
+in schema history after being dropped. Iceberg 1.10.1 cannot plan that 
dropped-key sequence end to
+end; its planner fix is upstream Iceberg #15268, so this change does not 
broaden scope with an
+Iceberg dependency upgrade. Binary-like defaults are checked byte-for-byte, 
and regression output
+is generated only by the Doris regression test runner.
+
+### Phase 2: consume `write-default`
+
+The write phase adds write-time consumption without changing Iceberg default 
metadata. It
+introduces a statement-scoped, field-ID keyed context pinned to the target 
schema ID and format
+version.
+
+The following write paths consume the statement-pinned `write-default`:
+
+- omitted columns in INSERT
+- explicit `DEFAULT`
+- reordered and multi-row VALUES
+- MERGE `NOT MATCHED INSERT`
+- `DEFAULT(column)` in INSERT, UPDATE, and matched MERGE
+
+Explicit NULL and explicit values are never replaced. A bare `DEFAULT` in an 
INSERT position uses
+that destination field's write default, while `DEFAULT(column)` resolves the 
referenced field by
+Iceberg field ID. Analyzer projection and the schema sent to the Iceberg sink 
must use the same
+schema ID; schema skew fails before data is dispatched.
+
+The write phase does not add CREATE/ALTER syntax and does not author or evolve 
default metadata.
+
+### Later PR: author and evolve default metadata
+
+The later PR adds DDL support after the read and write execution paths are 
stable:
+
+- CREATE COLUMN DEFAULT records the appropriate initial write default for a 
new table schema.
+- ADD COLUMN DEFAULT records equal `initial-default` and `write-default` 
values.
+- Adding a required field succeeds only with a valid non-NULL initial default.
+- `ALTER COLUMN ... SET DEFAULT` changes only `write-default`.
+- `ALTER COLUMN ... DROP DEFAULT` removes only `write-default`.
+
+DDL validates the Iceberg format version, converts Doris constant expressions 
to typed Iceberg
+literals, and exposes current write defaults through user-visible schema 
output. Nested ALTER
+syntax is not expanded beyond syntax Doris already supports when the later DDL 
PR is implemented.
+
+## Read-path acceptance criteria
+
+1. Every Iceberg type mapped by Doris before this change has an 
`initial-default` read test, and the
+   mapping switch itself is unchanged.
+2. Struct children, list-element struct children, and map-value struct 
children receive defaults by
+   field ID in both scanners; focused tests also cover `{}` struct, list, and 
map default decoding.
+3. Parent NULL values and explicitly stored child NULL values remain NULL.
+4. UUID, FIXED, and BINARY defaults match their exact bytes in both mapping 
modes.
+5. Ordinary reads observe schema-only evolution, while explicit historical 
reads use their bound
+   historical schema.
+6. Parquet and ORC fixtures are generated through Spark-Iceberg Docker without 
hand-edited Iceberg
+   metadata.
+7. V1 and V2 each pass independent unit tests and forced-path regression tests.
+8. Regression predicates and focused V1/V2 equality-delete tests agree with 
the value materialized
+   for an absent field; dropped equality-key metadata is recovered from schema 
history.
+9. The read implementation never consumes `write-default` and never stores 
either Iceberg default
+   in generic `Column.defaultValue`. DDL implementation, dependency upgrades, 
and new type mappings
+   remain out of scope.
+
+## Write-path acceptance criteria
+
+1. Omitted INSERT columns, explicit `DEFAULT`, reordered/multi-row VALUES, 
supported
+   `DEFAULT(column)`, and MERGE `NOT MATCHED INSERT` consume the typed 
`write-default` from the
+   statement-pinned current table schema. A branch target selects the snapshot 
lineage that receives
+   the commit; schema evolution remains table-global, so columns added or 
renamed after the branch

Review Comment:
   [P2] Align the branch-schema contract with the implementation
   
   The current provider now pins `SnapshotUtil.schemaFor(table, branchHead)`, 
and the changed regression intentionally rejects current-only `zone` until the 
first successful old-schema branch write advances the ref. These criteria 
instead promise that post-branch additions/renames are immediately writable and 
that branch choice affects only the commit target. Please update this section 
and the read matrix/release note to state the branch-head schema behavior; 
otherwise this durable design documents the opposite of the behavior the PR 
implements and tests.



##########
fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergScanPlanProvider.java:
##########
@@ -1728,57 +1759,405 @@ private static List<String> 
requestedLowerNames(List<ConnectorColumnHandle> colu
         return names;
     }
 
+    private static boolean mayHaveEqualityDeletes(Snapshot snapshot) {
+        if (snapshot == null) {
+            return false;
+        }
+        String equalityDeletes = 
snapshot.summary().get(TOTAL_EQUALITY_DELETES);
+        // A missing counter is unknown (replace/cherry-pick snapshots can 
omit it), so retain the bounded
+        // schema-history carrier. The exact task/delete binding is still 
decided by Iceberg during split planning.
+        return equalityDeletes == null || !equalityDeletes.equals("0");
+    }
+
     /**
-     * Ensure the schema-evolution dict carries the table's equality-delete 
KEY columns even when the query
-     * does not project them (#65502). Equality-delete keys are hidden scan 
dependencies: BE resolves a key
-     * that is missing from an OLD data file by looking its field id up in 
this dict to get the column type +
-     * iceberg initial default; without the entry BE materializes the key as 
NULL and mis-applies the delete.
-     * The keys are the table's declared identifier fields (what 
equality-delete writers key on) -> a few
-     * columns, DCHECK-safe superset (BE looks up only its own scan slots; the 
pin/top-N branches already ship
-     * the full schema). If the table declares NO identifier yet the scan 
carries equality deletes (whose
-     * equality_ids we cannot cheaply enumerate here), fall back to the full 
schema. Non-identifier /
-     * append-only / position-delete-only tables are unaffected (the pruned 
dict is returned verbatim).
+     * Build a schema carrier that can resolve any equality key reachable 
before the selected schema without
+     * enumerating data files, manifests, or byte-split tasks. Its retained 
state is bounded by table schema
+     * history rather than scan cardinality. At execution time BE looks fields 
up by the exact IDs on each
+     * {@link FileScanTask#deletes()}; unrelated carrier fields never 
participate in delete matching.
+     *
+     * <p>The selected snapshot lineage wins when a field was renamed. The 
metadata schema list, in its actual
+     * chronology up to the selected schema (schema IDs are identifiers, not a 
sequence), fills schema-only
+     * changes and expired ancestors. Current fields remain first, so a 
dropped/re-added name still resolves the
+     * projected current field by name while a historical equality key 
resolves by its stable field ID.</p>
      */
-    private List<String> withEqualityDeleteKeyColumns(Table table, 
List<String> requested) {
-        if (requested.isEmpty()) {
-            // An empty requested list already makes buildCurrentSchema fall 
back to the FULL schema (every
-            // top-level column) — a superset that covers every 
equality-delete key — so there is nothing to
-            // force-include. Returning early also preserves that all-columns 
fallback (a non-empty identifier
-            // set would otherwise prune it to identifier-only) and skips the 
table.schema()/currentSnapshot()
-            // probe when it cannot change the result.
-            return requested;
-        }
-        Schema schema = table.schema();
-        Set<Integer> identifierFieldIds = schema.identifierFieldIds();
-        if (identifierFieldIds.isEmpty()) {
-            return hasEqualityDeletes(table) ? Collections.emptyList() : 
requested;
-        }
-        Set<String> present = new HashSet<>();
-        for (String name : requested) {
-            present.add(name.toLowerCase(Locale.ROOT));
-        }
-        List<String> result = new ArrayList<>(requested);
-        for (int fieldId : identifierFieldIds) {
-            Types.NestedField field = schema.findField(fieldId);
+    private static Schema schemaForPotentialEqualityDeletes(
+            Table table, TableScan scan, Schema scanSchema) {
+        List<Schema> history = potentialEqualityDeleteSchemaHistory(table, 
scan, scanSchema);
+        Set<Integer> missing = new HashSet<>();
+        for (Schema schema : history) {
+            for (NestedField field : 
TypeUtil.indexById(schema.asStruct()).values()) {
+                if (field.type().isPrimitiveType()) {
+                    missing.add(field.fieldId());
+                }
+            }
+        }
+        missing.removeAll(TypeUtil.indexById(scanSchema.asStruct()).keySet());
+        if (missing.isEmpty()) {
+            return scanSchema;
+        }
+
+        List<NestedField> fields = new ArrayList<>(scanSchema.columns());
+        for (Schema historicalSchema : history) {
+            addHistoricalEqualityFields(fields, missing, historicalSchema);
+        }
+        if (!missing.isEmpty()) {
+            throw new IllegalStateException(
+                    "Iceberg historical primitive fields are absent from 
schema history: " + missing);
+        }
+        return new Schema(scanSchema.schemaId(), fields);
+    }
+
+    private static List<Schema> potentialEqualityDeleteSchemaHistory(
+            Table table, TableScan scan, Schema scanSchema) {
+        List<Schema> history = new ArrayList<>();
+        Set<Integer> seenSchemaIds = new HashSet<>();
+        addSchemaIfAbsent(history, seenSchemaIds, scanSchema);
+
+        Snapshot snapshot = scan.snapshot();
+        while (snapshot != null) {
+            Integer schemaId = snapshot.schemaId();
+            if (schemaId != null) {
+                Schema historicalSchema = table.schemas().get(schemaId);
+                if (historicalSchema == null) {
+                    throw new IllegalStateException(
+                            "Iceberg snapshot schema " + schemaId + " is 
absent from table metadata");
+                }
+                addSchemaIfAbsent(history, seenSchemaIds, historicalSchema);
+            }
+            Long parentId = snapshot.parentId();
+            snapshot = parentId == null ? null : table.snapshot(parentId);
+        }
+
+        List<Schema> metadataSchemas = metadataSchemaHistory(table);
+        int selectedSchemaIndex = -1;
+        for (int index = 0; index < metadataSchemas.size(); index++) {
+            if (metadataSchemas.get(index).schemaId() == 
scanSchema.schemaId()) {
+                selectedSchemaIndex = index;
+            }
+        }
+        int lastRelevantIndex = selectedSchemaIndex >= 0
+                ? selectedSchemaIndex : metadataSchemas.size() - 1;
+        for (int index = lastRelevantIndex; index >= 0; index--) {
+            addSchemaIfAbsent(history, seenSchemaIds, 
metadataSchemas.get(index));
+        }
+        return history;
+    }
+
+    private static void addSchemaIfAbsent(
+            List<Schema> schemas, Set<Integer> seenSchemaIds, Schema schema) {
+        if (seenSchemaIds.add(schema.schemaId())) {
+            schemas.add(schema);
+        }
+    }
+
+    private static List<Schema> metadataSchemaHistory(Table table) {
+        if (table instanceof HasTableOperations) {
+            return ((HasTableOperations) 
table).operations().current().schemas();
+        }
+        return new ArrayList<>(table.schemas().values());
+    }
+
+    private static void addHistoricalEqualityFields(
+            List<NestedField> fields, Set<Integer> missingFieldIds, Schema 
historicalSchema) {
+        Map<Integer, NestedField> historicalFields = 
TypeUtil.indexById(historicalSchema.asStruct());
+        Set<Integer> selectedFieldIds = new HashSet<>();
+        for (Integer fieldId : missingFieldIds) {
+            NestedField field = historicalFields.get(fieldId);
+            if (field != null) {
+                if (!field.type().isPrimitiveType()) {
+                    throw new IllegalStateException(
+                            "Iceberg equality-delete field " + fieldId + " 
must be primitive");
+                }
+                selectedFieldIds.add(fieldId);
+            }
+        }
+        if (selectedFieldIds.isEmpty()) {
+            return;
+        }
+        Schema selectedSchema = TypeUtil.select(historicalSchema, 
selectedFieldIds);
+        mergeHistoricalEqualityFields(fields, selectedSchema.columns());
+        missingFieldIds.removeAll(selectedFieldIds);
+    }
+
+    private static void mergeHistoricalEqualityFields(
+            List<NestedField> fields, List<NestedField> historicalFields) {
+        for (NestedField historicalField : historicalFields) {
+            int currentIndex = -1;
+            for (int index = 0; index < fields.size(); index++) {
+                if (fields.get(index).fieldId() == historicalField.fieldId()) {
+                    currentIndex = index;
+                    break;
+                }
+            }
+            if (currentIndex < 0) {
+                fields.add(historicalField);
+                continue;
+            }
+            NestedField currentField = fields.get(currentIndex);
+            Type mergedType = mergeHistoricalEqualityType(currentField.type(), 
historicalField.type());
+            if (mergedType != currentField.type()) {
+                fields.set(currentIndex,
+                        
Types.NestedField.from(currentField).ofType(mergedType).build());
+            }
+        }
+    }
+
+    private static Type mergeHistoricalEqualityType(Type currentType, Type 
historicalType) {
+        if (currentType.typeId() != historicalType.typeId()) {
+            throw new IllegalStateException("Iceberg equality-delete ancestor 
type changed from "
+                    + historicalType + " to " + currentType);
+        }
+        switch (currentType.typeId()) {
+            case STRUCT:
+                List<NestedField> mergedFields =
+                        new ArrayList<>(currentType.asStructType().fields());
+                mergeHistoricalEqualityFields(mergedFields, 
historicalType.asStructType().fields());
+                return mergedFields.equals(currentType.asStructType().fields())
+                        ? currentType : Types.StructType.of(mergedFields);
+            case LIST:
+                Types.ListType currentList = currentType.asListType();
+                Types.ListType historicalList = historicalType.asListType();
+                if (currentList.elementId() != historicalList.elementId()) {
+                    throw new IllegalStateException(
+                            "Iceberg equality-delete list element field ID 
changed");
+                }
+                Type mergedElement = mergeHistoricalEqualityType(
+                        currentList.elementType(), 
historicalList.elementType());
+                if (mergedElement == currentList.elementType()) {
+                    return currentType;
+                }
+                return currentList.isElementOptional()
+                        ? Types.ListType.ofOptional(currentList.elementId(), 
mergedElement)
+                        : Types.ListType.ofRequired(currentList.elementId(), 
mergedElement);
+            case MAP:
+                Types.MapType currentMap = currentType.asMapType();
+                Types.MapType historicalMap = historicalType.asMapType();
+                if (currentMap.keyId() != historicalMap.keyId()
+                        || currentMap.valueId() != historicalMap.valueId()) {
+                    throw new IllegalStateException(
+                            "Iceberg equality-delete map field IDs changed");
+                }
+                Type mergedKey = mergeHistoricalEqualityType(
+                        currentMap.keyType(), historicalMap.keyType());
+                Type mergedValue = mergeHistoricalEqualityType(
+                        currentMap.valueType(), historicalMap.valueType());
+                if (mergedKey == currentMap.keyType() && mergedValue == 
currentMap.valueType()) {
+                    return currentType;
+                }
+                return currentMap.isValueOptional()
+                        ? Types.MapType.ofOptional(currentMap.keyId(), 
currentMap.valueId(),
+                                mergedKey, mergedValue)
+                        : Types.MapType.ofRequired(currentMap.keyId(), 
currentMap.valueId(),
+                                mergedKey, mergedValue);
+            default:
+                if (!currentType.equals(historicalType)) {
+                    throw new IllegalStateException("Iceberg equality-delete 
field type changed from "
+                            + historicalType + " to " + currentType);
+                }
+                return currentType;
+        }
+    }
+
+    private static boolean requiresCurrentScanSemantics(
+            Table table, TableScan scan, Schema scanSchema, 
List<ConnectorColumnHandle> columns,
+            boolean mayHaveEqualityDeletes,
+            Optional<Map<Integer, List<String>>> nameMapping) {
+        if (mayHaveEqualityDeletes) {
+            return true;
+        }
+        Set<Integer> projectedFieldIds = projectedFieldIds(scanSchema, 
columns);
+        Set<Integer> topLevelFieldIds = new HashSet<>();
+        for (NestedField field : scanSchema.columns()) {
+            topLevelFieldIds.add(field.fieldId());
+        }
+        Map<Integer, NestedField> fields = 
TypeUtil.indexById(scanSchema.asStruct());
+        for (Integer fieldId : projectedFieldIds) {
+            NestedField field = fields.get(fieldId);
+            if (field != null && field.initialDefault() != null
+                    && (!topLevelFieldIds.contains(fieldId) || 
field.type().isNestedType())) {
+                return true;
+            }
+        }
+        if (hasProjectedNameAliasCollision(scanSchema, projectedFieldIds, 
nameMapping)) {
+            return true;
+        }
+        Optional<List<Schema>> history = requiredFieldSchemaHistory(table, 
scanSchema, scan.snapshot());
+        return !history.isPresent()
+                || requiresMissingRequiredFieldRejection(scanSchema, 
projectedFieldIds, history.get());
+    }
+
+    @VisibleForTesting
+    static Set<Integer> projectedFieldIds(
+            Schema scanSchema, List<ConnectorColumnHandle> columns) {
+        Set<Integer> projected = new HashSet<>();
+        if (columns == null || columns.isEmpty()) {
+            
projected.addAll(TypeUtil.indexById(scanSchema.asStruct()).keySet());
+            return projected;
+        }
+        for (ConnectorColumnHandle column : columns) {
+            NestedField field = scanSchema.findField(((IcebergColumnHandle) 
column).getFieldId());
             if (field == null) {
                 continue;
             }
-            String lower = field.name().toLowerCase(Locale.ROOT);
-            if (present.add(lower)) {
-                result.add(lower);
+            projected.add(field.fieldId());
+            // Iceberg's type visitor returns null for a primitive root; 
getProjectedIds(Type) then passes
+            // that null to ImmutableSet.copyOf. The top-level id is already 
present, and only nested types
+            // have descendant ids to add.
+            if (field.type().isNestedType()) {
+                projected.addAll(TypeUtil.getProjectedIds(field.type()));

Review Comment:
   [P1] Keep the smooth-upgrade gate projection-aware
   
   `PluginDrivenScanNode` reduces each selected slot to an 
`IcebergColumnHandle` containing only the top-level field ID; it drops the 
slot's pruned type/access path. This line then expands that field to every 
descendant, so `SELECT s.a` is rejected whenever an unselected sibling `s.b` 
has an initial default and any source BE remains, even though BE never reads 
`b`. The legacy scan path was already fixed for this availability regression. 
Please carry the resolved nested projection into this provider and add a 
planner-level unselected-sibling test.



##########
be/src/format/table/iceberg_default_value.h:
##########
@@ -0,0 +1,493 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+#pragma once
+
+#include <gen_cpp/ExternalTableSchema_types.h>
+#include <rapidjson/document.h>
+#include <rapidjson/stringbuffer.h>
+#include <rapidjson/writer.h>
+
+#include <cstddef>
+#include <deque>
+#include <string>
+#include <string_view>
+#include <unordered_map>
+#include <utility>
+
+#include "common/status.h"
+#include "core/assert_cast.h"
+#include "core/column/column.h"
+#include "core/data_type/data_type.h"
+#include "core/data_type/data_type_array.h"
+#include "core/data_type/data_type_map.h"
+#include "core/data_type/data_type_nullable.h"
+#include "core/data_type/data_type_struct.h"
+#include "core/data_type/primitive_type.h"
+#include "core/field.h"
+#include "util/string_util.h"
+#include "util/url_coding.h"
+
+namespace doris::iceberg {
+
+namespace detail {
+
+inline const schema::external::TField* get_field_ptr(const 
schema::external::TFieldPtr& field_ptr) {
+    if (!field_ptr.__isset.field_ptr || field_ptr.field_ptr == nullptr) {
+        return nullptr;
+    }
+    return field_ptr.field_ptr.get();
+}
+
+inline const schema::external::TField* find_struct_child(
+        const schema::external::TStructField& struct_field, const std::string& 
name) {
+    if (!struct_field.__isset.fields) {
+        return nullptr;
+    }
+    for (const auto& child_ptr : struct_field.fields) {
+        const auto* child = get_field_ptr(child_ptr);
+        if (child != nullptr && child->__isset.name && iequal(child->name, 
name)) {
+            return child;
+        }
+    }
+    for (const auto& child_ptr : struct_field.fields) {
+        const auto* child = get_field_ptr(child_ptr);
+        if (child == nullptr || !child->__isset.name_mapping) {
+            continue;
+        }
+        for (const auto& alias : child->name_mapping) {
+            if (iequal(alias, name)) {
+                return child;
+            }
+        }
+    }
+    return nullptr;
+}
+
+inline int hex_value(char c) {
+    if (c >= '0' && c <= '9') {
+        return c - '0';
+    }
+    if (c >= 'a' && c <= 'f') {
+        return c - 'a' + 10;
+    }
+    if (c >= 'A' && c <= 'F') {
+        return c - 'A' + 10;
+    }
+    return -1;
+}
+
+inline Status decode_hex(std::string_view encoded, std::string* decoded) {
+    DORIS_CHECK(decoded != nullptr);
+    if ((encoded.size() & 1U) != 0) {
+        return Status::InvalidArgument("Invalid odd-length Iceberg binary 
default");
+    }
+    decoded->resize(encoded.size() / 2);
+    for (size_t index = 0; index < encoded.size(); index += 2) {
+        const int high = hex_value(encoded[index]);
+        const int low = hex_value(encoded[index + 1]);
+        if (high < 0 || low < 0) {
+            return Status::InvalidArgument("Invalid hexadecimal Iceberg binary 
default");
+        }
+        (*decoded)[index / 2] = static_cast<char>((high << 4) | low);
+    }
+    return Status::OK();
+}
+
+inline Status decode_json_binary(std::string_view encoded, std::string* 
decoded) {
+    DORIS_CHECK(decoded != nullptr);
+    const bool is_uuid = encoded.size() == 36 && encoded[8] == '-' && 
encoded[13] == '-' &&
+                         encoded[18] == '-' && encoded[23] == '-';
+    if (is_uuid) {
+        std::string uuid_hex;
+        uuid_hex.reserve(32);
+        for (size_t index = 0; index < encoded.size(); ++index) {
+            if (index != 8 && index != 13 && index != 18 && index != 23) {
+                uuid_hex.push_back(encoded[index]);
+            }
+        }
+        return decode_hex(uuid_hex, decoded);
+    }
+    return decode_hex(encoded, decoded);
+}
+
+inline std::string json_scalar_text(const rapidjson::Value& value) {
+    if (value.IsString()) {
+        return {value.GetString(), value.GetStringLength()};
+    }
+    rapidjson::StringBuffer buffer;
+    rapidjson::Writer<rapidjson::StringBuffer> writer(buffer);
+    value.Accept(writer);
+    return {buffer.GetString(), buffer.GetSize()};
+}
+
+inline void normalize_timestamp_for_doris(PrimitiveType primitive_type, 
std::string* value) {
+    if (primitive_type != TYPE_DATETIME && primitive_type != TYPE_DATETIMEV2 &&
+        primitive_type != TYPE_TIMESTAMPTZ) {
+        return;
+    }
+    if (const size_t separator = value->find('T'); separator != 
std::string::npos) {
+        (*value)[separator] = ' ';
+    }
+    if (primitive_type == TYPE_TIMESTAMPTZ) {
+        return;
+    }
+    if (value->ends_with('Z')) {
+        value->pop_back();
+        return;
+    }
+    const size_t time_start = value->find(' ');
+    if (time_start == std::string::npos) {
+        return;
+    }
+    const size_t offset = value->find_first_of("+-", time_start + 1);
+    if (offset != std::string::npos) {
+        value->erase(offset);
+    }
+}
+
+inline Status make_null_field(const schema::external::TField& field, const 
DataTypePtr& data_type,
+                              Field* result) {
+    DORIS_CHECK(data_type != nullptr);
+    DORIS_CHECK(result != nullptr);
+    if (field.__isset.is_optional && !field.is_optional) {
+        return Status::InvalidArgument("Required Iceberg field '{}' has a null 
default",
+                                       field.name);
+    }
+    if (!data_type->is_nullable()) {
+        return Status::InternalError(
+                "Optional Iceberg field '{}' has a null default, but its Doris 
type '{}' is not "
+                "nullable",
+                field.name, data_type->get_name());
+    }
+    *result = Field();
+    return Status::OK();
+}
+
+inline Status build_initial_default_field(const schema::external::TField& 
field,
+                                          const DataTypePtr& data_type,
+                                          std::deque<std::string>* 
binary_storage, Field* result);
+
+inline Status build_json_default_field(const schema::external::TField& field,
+                                       const DataTypePtr& data_type,
+                                       const rapidjson::Value& json_value,
+                                       std::deque<std::string>* 
binary_storage, Field* result);
+
+inline Status build_json_struct_default(const schema::external::TField& field,
+                                        const DataTypePtr& value_type,
+                                        const rapidjson::Value& json_value,
+                                        std::deque<std::string>* 
binary_storage, Field* result) {
+    if (!json_value.IsObject() || !field.__isset.nestedField ||
+        !field.nestedField.__isset.struct_field || 
!field.nestedField.struct_field.__isset.fields) {
+        return Status::InvalidArgument("Invalid Iceberg struct default for 
field '{}'", field.name);
+    }
+
+    const auto& struct_type = assert_cast<const DataTypeStruct&>(*value_type);
+    Struct struct_value;
+    struct_value.reserve(struct_type.get_elements().size());
+    for (size_t index = 0; index < struct_type.get_elements().size(); ++index) 
{
+        const auto& child_name = struct_type.get_element_name(index);
+        const auto* child = find_struct_child(field.nestedField.struct_field, 
child_name);
+        if (child == nullptr || !child->__isset.id) {
+            return Status::InvalidArgument(
+                    "Iceberg struct default for field '{}' is missing metadata 
for projected "
+                    "child '{}'",
+                    field.name, child_name);
+        }
+
+        const std::string child_id = std::to_string(child->id);
+        const auto member = json_value.FindMember(child_id.c_str());
+        Field child_value;
+        if (member == json_value.MemberEnd()) {
+            RETURN_IF_ERROR(build_initial_default_field(*child, 
struct_type.get_element(index),
+                                                        binary_storage, 
&child_value));
+        } else {
+            RETURN_IF_ERROR(build_json_default_field(*child, 
struct_type.get_element(index),
+                                                     member->value, 
binary_storage, &child_value));
+        }
+        struct_value.push_back(std::move(child_value));
+    }
+    *result = Field::create_field<TYPE_STRUCT>(std::move(struct_value));
+    return Status::OK();
+}
+
+// The recursive item TField describes the element schema and its field-level 
default metadata. It
+// cannot represent a particular list literal's length or per-position values, 
so the parent
+// initial-default keeps those values in Iceberg's single-value JSON array.
+inline Status build_json_array_default(const schema::external::TField& field,
+                                       const DataTypePtr& value_type,
+                                       const rapidjson::Value& json_value,
+                                       std::deque<std::string>* 
binary_storage, Field* result) {
+    if (!json_value.IsArray() || !field.__isset.nestedField ||
+        !field.nestedField.__isset.array_field ||
+        !field.nestedField.array_field.__isset.item_field) {
+        return Status::InvalidArgument("Invalid Iceberg list default for field 
'{}'", field.name);
+    }
+    const auto* element = 
get_field_ptr(field.nestedField.array_field.item_field);
+    if (element == nullptr) {
+        return Status::InvalidArgument(
+                "Iceberg list default for field '{}' has incomplete element 
metadata", field.name);
+    }
+
+    const auto& array_type = assert_cast<const DataTypeArray&>(*value_type);
+    Array array_value;
+    array_value.reserve(json_value.Size());
+    for (const auto& json_element : json_value.GetArray()) {
+        Field element_value;
+        RETURN_IF_ERROR(build_json_default_field(*element, 
array_type.get_nested_type(),
+                                                 json_element, binary_storage, 
&element_value));
+        array_value.push_back(std::move(element_value));
+    }
+    *result = Field::create_field<TYPE_ARRAY>(std::move(array_value));
+    return Status::OK();
+}
+
+// The recursive key/value TFields describe entry schemas and field-level 
default metadata. They
+// cannot represent the number, order, or concrete values of map entries, so 
the parent
+// initial-default keeps the entries in Iceberg's single-value JSON key/value 
arrays.
+inline Status build_json_map_default(const schema::external::TField& field,
+                                     const DataTypePtr& value_type,
+                                     const rapidjson::Value& json_value,
+                                     std::deque<std::string>* binary_storage, 
Field* result) {
+    if (!json_value.IsObject() || !json_value.HasMember("keys") || 
!json_value["keys"].IsArray() ||
+        !json_value.HasMember("values") || !json_value["values"].IsArray() ||
+        !field.__isset.nestedField || !field.nestedField.__isset.map_field ||
+        !field.nestedField.map_field.__isset.key_field ||
+        !field.nestedField.map_field.__isset.value_field) {
+        return Status::InvalidArgument("Invalid Iceberg map default for field 
'{}'", field.name);
+    }
+    const auto& keys = json_value["keys"];
+    const auto& values = json_value["values"];
+    if (keys.Size() != values.Size()) {
+        return Status::InvalidArgument(
+                "Iceberg map default for field '{}' has {} keys but {} 
values", field.name,
+                keys.Size(), values.Size());
+    }
+
+    const auto* key = get_field_ptr(field.nestedField.map_field.key_field);
+    const auto* value = get_field_ptr(field.nestedField.map_field.value_field);
+    if (key == nullptr || value == nullptr) {
+        return Status::InvalidArgument(
+                "Iceberg map default for field '{}' has incomplete key/value 
metadata", field.name);
+    }
+
+    const auto& map_type = assert_cast<const DataTypeMap&>(*value_type);
+    Array key_fields;
+    Array value_fields;
+    key_fields.reserve(keys.Size());
+    value_fields.reserve(values.Size());
+    for (rapidjson::SizeType index = 0; index < keys.Size(); ++index) {
+        Field key_value;
+        Field mapped_value;
+        RETURN_IF_ERROR(build_json_default_field(*key, 
map_type.get_key_type(), keys[index],
+                                                 binary_storage, &key_value));
+        RETURN_IF_ERROR(build_json_default_field(*value, 
map_type.get_value_type(), values[index],
+                                                 binary_storage, 
&mapped_value));
+        key_fields.push_back(std::move(key_value));
+        value_fields.push_back(std::move(mapped_value));
+    }
+    Map map_value;
+    
map_value.push_back(Field::create_field<TYPE_ARRAY>(std::move(key_fields)));
+    
map_value.push_back(Field::create_field<TYPE_ARRAY>(std::move(value_fields)));
+    *result = Field::create_field<TYPE_MAP>(std::move(map_value));
+    return Status::OK();
+}
+
+inline Status build_json_scalar_default(const schema::external::TField& field,
+                                        const DataTypePtr& value_type,
+                                        const rapidjson::Value& json_value,
+                                        std::deque<std::string>* 
binary_storage, Field* result) {
+    const auto primitive_type = value_type->get_primitive_type();
+    std::string serialized_value = json_scalar_text(json_value);
+    const bool binary_like = (field.__isset.initial_default_value_is_base64 &&
+                              field.initial_default_value_is_base64) ||
+                             primitive_type == TYPE_VARBINARY;
+    if (binary_like) {
+        if (!json_value.IsString()) {
+            return Status::InvalidArgument(
+                    "Iceberg binary default for field '{}' is not a JSON 
string", field.name);
+        }
+        binary_storage->emplace_back();
+        RETURN_IF_ERROR(decode_json_binary(serialized_value, 
&binary_storage->back()));
+        if (primitive_type == TYPE_VARBINARY) {
+            *result = 
Field::create_field<TYPE_VARBINARY>(StringView(binary_storage->back()));
+        } else if (is_string_type(primitive_type)) {
+            *result = Field::create_field<TYPE_STRING>(binary_storage->back());
+        } else {
+            return Status::InvalidArgument(
+                    "Iceberg binary default for field '{}' has incompatible 
Doris type '{}'",
+                    field.name, value_type->get_name());
+        }
+        return Status::OK();
+    }
+
+    if (is_string_type(primitive_type)) {
+        if (!json_value.IsString()) {
+            return Status::InvalidArgument("Iceberg string default for field 
'{}' is not a string",
+                                           field.name);
+        }
+        *result = 
Field::create_field<TYPE_STRING>(std::move(serialized_value));
+        return Status::OK();
+    }
+    normalize_timestamp_for_doris(primitive_type, &serialized_value);
+    RETURN_IF_ERROR(value_type->get_serde()->from_fe_string(serialized_value, 
*result));
+    return Status::OK();
+}
+
+inline Status build_json_default_field(const schema::external::TField& field,
+                                       const DataTypePtr& data_type,
+                                       const rapidjson::Value& json_value,
+                                       std::deque<std::string>* 
binary_storage, Field* result) {
+    DORIS_CHECK(data_type != nullptr);
+    DORIS_CHECK(binary_storage != nullptr);
+    DORIS_CHECK(result != nullptr);
+    if (json_value.IsNull()) {
+        return make_null_field(field, data_type, result);
+    }
+
+    const auto value_type = remove_nullable(data_type);
+    switch (value_type->get_primitive_type()) {
+    case TYPE_STRUCT:
+        return build_json_struct_default(field, value_type, json_value, 
binary_storage, result);
+    case TYPE_ARRAY:
+        return build_json_array_default(field, value_type, json_value, 
binary_storage, result);
+    case TYPE_MAP:
+        return build_json_map_default(field, value_type, json_value, 
binary_storage, result);
+    default:
+        return build_json_scalar_default(field, value_type, json_value, 
binary_storage, result);
+    }
+}
+
+inline Status build_initial_default_field(const schema::external::TField& 
field,
+                                          const DataTypePtr& data_type,
+                                          std::deque<std::string>* 
binary_storage, Field* result) {
+    DORIS_CHECK(data_type != nullptr);
+    DORIS_CHECK(binary_storage != nullptr);
+    DORIS_CHECK(result != nullptr);
+    if (!field.__isset.initial_default_value) {
+        if (field.__isset.is_optional && !field.is_optional) {
+            return Status::InvalidArgument(
+                    "Required Iceberg field '{}' is missing from the data file 
and has no initial "
+                    "default",
+                    field.name);
+        }
+        return make_null_field(field, data_type, result);
+    }
+
+    const auto value_type = remove_nullable(data_type);
+    const auto primitive_type = value_type->get_primitive_type();
+    if (is_complex_type(primitive_type)) {
+        rapidjson::Document document;
+        document.Parse(field.initial_default_value.data(), 
field.initial_default_value.size());

Review Comment:
   [P1] Send a complete typed complex-default carrier
   
   This decoder assumes the parent `initial_default_value` is 
`SingleValueParser` JSON, but FE still sends every non-binary value through 
`identity(...).toHumanString()`, so complex defaults do not reach it in the 
required form. Legacy STRING/CHAR mapping also decodes binary leaves only when 
their child `TField` has `initial_default_value_is_base64`, while FE sets that 
flag only if the child itself has a default; list/map leaves normally do not. 
The new BE test manually sets the flag, masking production. Please serialize 
complex parents as Iceberg JSON, mark every UUID/FIXED/BINARY schema leaf by 
type, and add an FE-to-reader test.



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