This is an automated email from the ASF dual-hosted git repository.

yiguolei pushed a commit to branch branch-4.1
in repository https://gitbox.apache.org/repos/asf/doris.git


The following commit(s) were added to refs/heads/branch-4.1 by this push:
     new 0191a305520 branch-4.1:[fix](iceberg) Preserve nullability in schema 
metadata (#67856)
0191a305520 is described below

commit 0191a305520c8c3d2d51ee22308fea372652f95b
Author: daidai <[email protected]>
AuthorDate: Wed Sep 16 10:54:29 2026 +0800

    branch-4.1:[fix](iceberg) Preserve nullability in schema metadata (#67856)
    
    ### What problem does this PR solve?
    Problem Summary:
    
    Iceberg columns declared `NOT NULL` are reported as nullable by `DESC`,
    `SHOW CREATE TABLE`, `SHOW [FULL] COLUMNS`, and
    `information_schema.columns`. For example, `id BIGINT NOT NULL` appears
    as `Null: Yes` / `IS_NULLABLE: YES` or `bigint NULL`, even though
    inserting NULL is rejected.
    
    Build independent display columns from one retained Iceberg metadata
    generation and preserve their top-level required/optional flags. These
    metadata-only paths use the display schema, including `SHOW COLUMNS`
    with `WHERE` through `information_schema.columns`. Metadata enumeration
    retains per-table schema-load failure handling. Scan columns keep their
    existing nullable semantics for historical files and schema evolution;
    nested-type and write-validation behavior is preserved.
    
    ### Release note
    
    Correct top-level Iceberg column nullability in `DESC`, `SHOW CREATE
    TABLE`, `SHOW [FULL] COLUMNS`, and `information_schema.columns`.
    
    ### Check List (For Author)
    
    - Test
        - [x] Regression test
        - [x] Unit Test
        - [ ] Manual test (add detailed scripts or steps below)
        - [ ] No need to test or manual test. Explain why:
    - [ ] This is a refactor/code format and no logic has been changed.
            - [ ] Previous test can cover this change.
            - [ ] No code files have been changed.
            - [ ] Other reason
    - Behavior changed:
        - [ ] No.
        - [x] Yes.
    
    - Does this need documentation?
        - [x] No.
        - [ ] Yes.
    
    ### Check List (For Reviewer who merge this PR)
    
    - [ ] Confirm the release note
    - [ ] Confirm test cases
    - [ ] Confirm document
    - [ ] Add branch pick label
---
 .../main/java/org/apache/doris/catalog/Env.java    |   3 +-
 .../apache/doris/common/proc/IndexInfoProcDir.java |   3 +
 .../datasource/iceberg/IcebergExternalTable.java   |  41 ++-
 .../doris/datasource/iceberg/IcebergUtils.java     |  10 +
 .../trees/plans/commands/ShowColumnsCommand.java   |   4 +-
 .../apache/doris/service/FrontendServiceImpl.java  |  16 +-
 .../iceberg/IcebergSchemaDisplayTest.java          | 297 +++++++++++++++++++++
 .../iceberg/iceberg_schema_change_ddl.out          |  72 ++---
 .../iceberg/test_iceberg_show_nullable.groovy      | 117 ++++++++
 9 files changed, 517 insertions(+), 46 deletions(-)

diff --git a/fe/fe-core/src/main/java/org/apache/doris/catalog/Env.java 
b/fe/fe-core/src/main/java/org/apache/doris/catalog/Env.java
index 2fc29c44c0e..43969050b50 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/catalog/Env.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/catalog/Env.java
@@ -4552,7 +4552,8 @@ public class Env {
 
         sb.append(" (\n");
         int idx = 0;
-        List<Column> columns = table.getBaseSchema(false);
+        List<Column> columns = table instanceof IcebergExternalTable
+                ? ((IcebergExternalTable) 
table).getBaseSchemaForDisplay(false) : table.getBaseSchema(false);
         for (Column column : columns) {
             if (idx++ != 0) {
                 sb.append(",\n");
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/common/proc/IndexInfoProcDir.java 
b/fe/fe-core/src/main/java/org/apache/doris/common/proc/IndexInfoProcDir.java
index 51dd6d0dc1a..d1600b2b622 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/common/proc/IndexInfoProcDir.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/common/proc/IndexInfoProcDir.java
@@ -23,6 +23,7 @@ import org.apache.doris.catalog.MaterializedIndexMeta;
 import org.apache.doris.catalog.OlapTable;
 import org.apache.doris.catalog.TableIf;
 import org.apache.doris.common.AnalysisException;
+import org.apache.doris.datasource.iceberg.IcebergExternalTable;
 import org.apache.doris.qe.SessionVariable;
 
 import com.google.common.base.Joiner;
@@ -132,6 +133,8 @@ public class IndexInfoProcDir implements ProcDirInterface {
                                     && 
SessionVariable.enableDescribeExtendVariantColumn()) {
                     return new RemoteIndexSchemaProcDir(table, schema, 
bfColumns);
                 }
+            } else if (table instanceof IcebergExternalTable) {
+                schema = ((IcebergExternalTable) 
table).getBaseSchemaForDisplay();
             } else {
                 schema = table.getBaseSchema();
             }
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergExternalTable.java
 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergExternalTable.java
index 9c9ee6d53b6..aabf3195676 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergExternalTable.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergExternalTable.java
@@ -55,6 +55,7 @@ import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.Maps;
 import com.google.common.collect.Sets;
 import org.apache.commons.lang3.StringUtils;
+import org.apache.commons.lang3.exception.ExceptionUtils;
 import org.apache.iceberg.PartitionField;
 import org.apache.iceberg.PartitionSpec;
 import org.apache.iceberg.Table;
@@ -303,18 +304,44 @@ public class IcebergExternalTable extends ExternalTable 
implements MTMVRelatedTa
         List<Column> schema = IcebergUtils.getIcebergSchema(this, snapshot);
         schema = new ArrayList<>(schema);
 
-        if (Util.showHiddenColumns() || needInternalHiddenColumns()) {
-            schema.add(createIcebergRowIdColumn());
-        }
-
         Optional<Table> snapshotTable = snapshot
                 .filter(IcebergMvccSnapshot.class::isInstance)
                 .map(IcebergMvccSnapshot.class::cast)
                 .flatMap(value -> 
value.getSnapshotCacheValue().getIcebergTable());
         // Row-lineage fields are part of the pinned schema generation, not 
the refreshable table.
-        schema = IcebergUtils.appendRowLineageColumnsForV3(
-                schema, snapshotTable.orElseGet(this::getIcebergTable));
-        return schema;
+        return appendHiddenColumns(schema, 
snapshotTable.orElseGet(this::getIcebergTable));
+    }
+
+    public List<Column> getBaseSchemaForDisplay() {
+        return getBaseSchemaForDisplay(Util.showHiddenColumns() || 
needInternalHiddenColumns());
+    }
+
+    /** Schema display uses declared nullability, independently of scan 
nullability. */
+    public List<Column> getBaseSchemaForDisplay(boolean full) {
+        if (isView()) {
+            return getBaseSchema(full);
+        }
+        try {
+            return catalog.getExecutionAuthenticator().execute(() -> {
+                // Schema-only changes need not advance the current snapshot. 
Resolve the current
+                // table schema and hidden columns from one retained metadata 
generation.
+                Table table = 
IcebergSnapshotCacheValue.retainTableGeneration(getIcebergTable());
+                List<Column> schema = 
IcebergUtils.parseSchemaForDisplay(table.schema(),
+                        catalog.getEnableMappingVarbinary(), 
catalog.getEnableMappingTimestampTz());
+                new SchemaCacheValue(schema).validateSchema();
+                schema = appendHiddenColumns(schema, table);
+                return full ? schema : 
schema.stream().filter(Column::isVisible).collect(Collectors.toList());
+            });
+        } catch (Exception e) {
+            throw new RuntimeException(ExceptionUtils.getRootCauseMessage(e), 
e);
+        }
+    }
+
+    private List<Column> appendHiddenColumns(List<Column> schema, Table table) 
{
+        if (Util.showHiddenColumns() || needInternalHiddenColumns()) {
+            schema.add(createIcebergRowIdColumn());
+        }
+        return IcebergUtils.appendRowLineageColumnsForV3(schema, table);
     }
 
     private Column createIcebergRowIdColumn() {
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergUtils.java
 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergUtils.java
index da4513ff8a1..76b731b410b 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergUtils.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergUtils.java
@@ -1423,6 +1423,16 @@ public class IcebergUtils {
         return resSchema;
     }
 
+    /** Build independent display columns without changing the nullable 
columns used by scans. */
+    public static List<Column> parseSchemaForDisplay(Schema schema, boolean 
enableMappingVarbinary,
+            boolean enableMappingTimestampTz) {
+        List<Column> columns = parseSchema(schema, enableMappingVarbinary, 
enableMappingTimestampTz);
+        for (Column column : columns) {
+            
column.setIsAllowNull(schema.findField(column.getUniqueId()).isOptional());
+        }
+        return columns;
+    }
+
     /** Convert one Iceberg field to a Doris column without using the generic 
Doris default-value channel. */
     public static Column parseField(Types.NestedField field, boolean 
enableMappingVarbinary,
             boolean enableMappingTimestampTz) {
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/ShowColumnsCommand.java
 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/ShowColumnsCommand.java
index 221482dba7d..65705b2af0c 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/ShowColumnsCommand.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/ShowColumnsCommand.java
@@ -28,6 +28,7 @@ import org.apache.doris.common.ErrorCode;
 import org.apache.doris.common.ErrorReport;
 import org.apache.doris.common.PatternMatcher;
 import org.apache.doris.common.PatternMatcherWrapper;
+import org.apache.doris.datasource.iceberg.IcebergExternalTable;
 import org.apache.doris.info.TableNameInfo;
 import org.apache.doris.mysql.privilege.PrivPredicate;
 import org.apache.doris.nereids.analyzer.UnboundSlot;
@@ -184,7 +185,8 @@ public class ShowColumnsCommand extends ShowCommand {
         }
         table.readLock();
         try {
-            List<Column> columns = table.getBaseSchema();
+            List<Column> columns = table instanceof IcebergExternalTable
+                    ? ((IcebergExternalTable) table).getBaseSchemaForDisplay() 
: table.getBaseSchema();
             for (Column col : columns) {
                 if (matcher != null && !matcher.match(col.getName())) {
                     continue;
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/service/FrontendServiceImpl.java 
b/fe/fe-core/src/main/java/org/apache/doris/service/FrontendServiceImpl.java
index f05cf689b53..54101ad5640 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/service/FrontendServiceImpl.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/service/FrontendServiceImpl.java
@@ -93,6 +93,7 @@ import org.apache.doris.datasource.ExternalCatalog;
 import org.apache.doris.datasource.ExternalDatabase;
 import org.apache.doris.datasource.InternalCatalog;
 import org.apache.doris.datasource.SplitSource;
+import org.apache.doris.datasource.iceberg.IcebergExternalTable;
 import org.apache.doris.datasource.maxcompute.MCTransaction;
 import org.apache.doris.encryption.EncryptionKey;
 import org.apache.doris.ha.FrontendNodeType;
@@ -985,7 +986,7 @@ public class FrontendServiceImpl implements 
FrontendService.Iface {
                 if (table != null && !table.isTemporary()) {
                     table.readLock();
                     try {
-                        List<Column> baseSchema = table.getBaseSchemaOrEmpty();
+                        List<Column> baseSchema = 
getBaseSchemaForDisplayOrEmpty(table);
                         for (Column column : baseSchema) {
                             final TColumnDesc desc = getColumnDesc(column);
                             final TColumnDef colDef = new TColumnDef(desc);
@@ -1017,6 +1018,19 @@ public class FrontendServiceImpl implements 
FrontendService.Iface {
         return result;
     }
 
+    private List<Column> getBaseSchemaForDisplayOrEmpty(TableIf table) {
+        if (!(table instanceof IcebergExternalTable)) {
+            return table.getBaseSchemaOrEmpty();
+        }
+        try {
+            return ((IcebergExternalTable) table).getBaseSchemaForDisplay();
+        } catch (Exception e) {
+            // Keep the per-table failure handling of getBaseSchemaOrEmpty for 
metadata enumeration.
+            LOG.warn("failed to get display schema for table {}", 
table.getName(), e);
+            return Lists.newArrayList();
+        }
+    }
+
     private TColumnDesc getColumnDesc(Column column) {
         final TColumnDesc desc = new TColumnDesc(column.getName(), 
column.getDataType().toThrift());
         final Integer precision = column.getOriginType().getPrecision();
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergSchemaDisplayTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergSchemaDisplayTest.java
new file mode 100644
index 00000000000..d33799d361b
--- /dev/null
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergSchemaDisplayTest.java
@@ -0,0 +1,297 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package org.apache.doris.datasource.iceberg;
+
+import org.apache.doris.analysis.UserIdentity;
+import org.apache.doris.catalog.Column;
+import org.apache.doris.catalog.Env;
+import org.apache.doris.catalog.TableIf;
+import org.apache.doris.common.proc.IndexInfoProcDir;
+import org.apache.doris.common.security.authentication.ExecutionAuthenticator;
+import org.apache.doris.datasource.CatalogMgr;
+import org.apache.doris.info.TableNameInfo;
+import org.apache.doris.mysql.privilege.AccessControllerManager;
+import org.apache.doris.mysql.privilege.PrivPredicate;
+import org.apache.doris.nereids.trees.expressions.SlotReference;
+import org.apache.doris.nereids.trees.expressions.StatementScopeIdGenerator;
+import org.apache.doris.nereids.trees.plans.commands.ShowColumnsCommand;
+import org.apache.doris.qe.ConnectContext;
+import org.apache.doris.service.FrontendServiceImpl;
+import org.apache.doris.thrift.TDescribeTablesParams;
+import org.apache.doris.thrift.TDescribeTablesResult;
+
+import org.apache.iceberg.BaseTable;
+import org.apache.iceberg.PartitionSpec;
+import org.apache.iceberg.Schema;
+import org.apache.iceberg.TableMetadata;
+import org.apache.iceberg.TableOperations;
+import org.apache.iceberg.TableProperties;
+import org.apache.iceberg.io.FileIO;
+import org.apache.iceberg.io.LocationProvider;
+import org.apache.iceberg.types.Types;
+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.mockito.MockedStatic;
+import org.mockito.Mockito;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+class IcebergSchemaDisplayTest {
+    private IcebergExternalCatalog catalog;
+    private IcebergExternalDatabase database;
+    private IcebergExternalTable table;
+    private TableOperations operations;
+    private TableMetadata metadata;
+    private Schema schema;
+    private List<Column> scanColumns;
+    private ConnectContext context;
+
+    @BeforeEach
+    void setUp() {
+        context = new ConnectContext();
+        context.setThreadLocalInfo();
+        schema = new Schema(
+                Types.NestedField.required(1, "id", Types.LongType.get()),
+                Types.NestedField.required(2, "value", Types.StringType.get(), 
"value doc"),
+                Types.NestedField.optional(3, "event_time", 
Types.TimestampType.withoutZone()),
+                Types.NestedField.required(4, "payload", Types.StructType.of(
+                        Types.NestedField.required(5, "child", 
Types.IntegerType.get(), "nested doc"))),
+                Types.NestedField.optional(6, "zoned_time", 
Types.TimestampType.withZone()),
+                Types.NestedField.optional(7, "bytes", 
Types.BinaryType.get()));
+        metadata = TableMetadata.newTableMetadata(schema, 
PartitionSpec.unpartitioned(),
+                "file:/tmp/iceberg-schema-display", 
Collections.singletonMap(TableProperties.FORMAT_VERSION, "2"));
+        operations = Mockito.mock(TableOperations.class);
+        Mockito.when(operations.current()).thenReturn(metadata);
+        Mockito.when(operations.io()).thenReturn(Mockito.mock(FileIO.class));
+        
Mockito.when(operations.locationProvider()).thenReturn(Mockito.mock(LocationProvider.class));
+
+        catalog = Mockito.mock(IcebergExternalCatalog.class);
+        Mockito.when(catalog.getExecutionAuthenticator()).thenReturn(new 
ExecutionAuthenticator() { });
+        Mockito.when(catalog.getEnableMappingVarbinary()).thenReturn(true);
+        database = Mockito.spy(new IcebergExternalDatabase(catalog, 2L, "db", 
"db"));
+        table = Mockito.spy(new IcebergExternalTable(3, "required_tbl", 
"required_tbl", catalog, database));
+        Mockito.doNothing().when(table).makeSureInitialized();
+        Mockito.doReturn(new BaseTable(operations, 
"required_tbl")).when(table).getIcebergTable();
+        // Model the cached scan schema. Displaying a table must never mutate 
these shared columns.
+        scanColumns = IcebergUtils.parseSchema(schema, true, false);
+        Mockito.doReturn(scanColumns).when(table).getFullSchema();
+    }
+
+    @AfterEach
+    void tearDown() {
+        ConnectContext.remove();
+    }
+
+    @Test
+    void 
testDescribeAndDdlPreserveDeclaredNullabilityWithoutChangingScanColumns() 
throws Exception {
+        context.getSessionVariable().showColumnCommentInDescribe = true;
+        List<List<String>> rows = describe();
+        Assertions.assertEquals("No", rows.get(0).get(2));
+        Assertions.assertEquals("No", rows.get(1).get(2));
+        Assertions.assertEquals("Yes", rows.get(2).get(2));
+        Assertions.assertEquals("No", rows.get(3).get(2));
+        Assertions.assertEquals("value doc", rows.get(1).get(6));
+        Assertions.assertTrue(rows.get(3).get(1).contains("child:int not null 
comment 'nested doc'"));
+        Assertions.assertEquals("WITH_TIMEZONE", rows.get(4).get(5));
+        Assertions.assertTrue(rows.get(5).get(1).startsWith("varbinary"));
+
+        String ddl = showCreate();
+        Assertions.assertTrue(ddl.contains("`id` bigint NOT NULL"));
+        Assertions.assertTrue(ddl.contains("`value` text NOT NULL"));
+        Assertions.assertTrue(ddl.contains("`event_time` datetimev2(6) NULL"));
+        Assertions.assertTrue(ddl.contains("struct<child:int not null comment 
'nested doc'> NOT NULL"));
+        Assertions.assertEquals(rows, describe());
+        Assertions.assertEquals(ddl, showCreate());
+        
Assertions.assertTrue(scanColumns.stream().allMatch(Column::isAllowNull));
+        
Assertions.assertTrue(scanColumns.get(3).getChildren().get(0).isAllowNull());
+        
Assertions.assertTrue(SlotReference.fromColumn(StatementScopeIdGenerator.newExprId(),
+                table, scanColumns.get(0), 
Collections.emptyList()).nullable());
+    }
+
+    @Test
+    void testSchemaOnlyChangeUsesCurrentTableSchema() throws Exception {
+        Assertions.assertNull(metadata.currentSnapshot());
+        Assertions.assertEquals("No", describe().get(1).get(2));
+        List<Types.NestedField> fields = new ArrayList<>(schema.columns());
+        fields.set(1, Types.NestedField.optional(2, "value", 
Types.StringType.get(), "value doc"));
+        TableMetadata evolved = metadata.updateSchema(new Schema(fields));
+        Mockito.when(operations.current()).thenReturn(evolved);
+
+        Assertions.assertNull(evolved.currentSnapshot());
+        Assertions.assertNotEquals(metadata.currentSchemaId(), 
evolved.currentSchemaId());
+        Assertions.assertEquals("Yes", describe().get(1).get(2));
+        Assertions.assertTrue(showCreate().contains("`value` text NULL"));
+        Assertions.assertTrue(showCreate().contains("`id` bigint NOT NULL"));
+    }
+
+    @Test
+    void testDisplayRetainsOneMetadataGeneration() {
+        List<Types.NestedField> fields = new ArrayList<>(schema.columns());
+        fields.set(1, Types.NestedField.optional(2, "renamed_value", 
Types.StringType.get()));
+        TableMetadata evolved = metadata.updateSchema(new Schema(fields))
+                .upgradeToFormatVersion(3);
+        Mockito.when(operations.current()).thenReturn(metadata, evolved);
+        Mockito.clearInvocations(operations);
+
+        List<Column> displayed = table.getBaseSchemaForDisplay(true);
+        Assertions.assertEquals(schema.columns().size(), displayed.size());
+        Assertions.assertEquals("value", displayed.get(1).getName());
+        Assertions.assertFalse(displayed.get(1).isAllowNull());
+        Mockito.verify(operations, Mockito.times(1)).current();
+    }
+
+    @Test
+    void testHiddenColumnsFollowExistingVisibilityRules() throws Exception {
+        
Mockito.when(operations.current()).thenReturn(metadata.upgradeToFormatVersion(3));
+        Assertions.assertEquals(schema.columns().size(), describe().size());
+        context.getSessionVariable().setShowHiddenColumns(true);
+        List<List<String>> rows = describe();
+        Assertions.assertEquals(schema.columns().size() + 3, rows.size());
+        Assertions.assertTrue(rows.stream().anyMatch(row -> 
row.get(0).equals(IcebergUtils.ICEBERG_ROW_ID_COL)));
+        Assertions.assertTrue(rows.stream().anyMatch(row -> row.get(0).equals(
+                IcebergUtils.ICEBERG_LAST_UPDATED_SEQUENCE_NUMBER_COL)));
+        Assertions.assertEquals(schema.columns().size(), 
table.getBaseSchemaForDisplay(false).size());
+        Assertions.assertFalse(showCreate().contains("`" + 
IcebergUtils.ICEBERG_ROW_ID_COL + "`"));
+        Assertions.assertFalse(showCreate().contains("`" + 
IcebergRowId.createHiddenColumn().getName() + "`"));
+    }
+
+    @Test
+    void testViewsKeepTheirExistingSchemaPath() throws Exception {
+        Mockito.doReturn(true).when(table).isView();
+        Assertions.assertEquals("Yes", describe().get(0).get(2));
+        Mockito.verify(table, Mockito.never()).getIcebergTable();
+    }
+
+    @Test
+    void testOtherTablesKeepTheirExistingSchemaPath() throws Exception {
+        TableIf otherTable = Mockito.mock(TableIf.class);
+        Mockito.when(otherTable.getBaseSchema()).thenReturn(scanColumns);
+        List<List<String>> rows = new IndexInfoProcDir(database, 
otherTable).lookup("4").fetchResult().getRows();
+        Assertions.assertEquals("Yes", rows.get(0).get(2));
+        Mockito.verify(otherTable).getBaseSchema();
+    }
+
+    @Test
+    void testShowColumnsUsesDeclaredNullability() throws Exception {
+        try (MockedStatic<Env> ignored = mockMetadataEnv()) {
+            for (boolean full : new boolean[] {false, true}) {
+                int nullIndex = full ? 3 : 2;
+                ShowColumnsCommand command = new ShowColumnsCommand(full,
+                        new TableNameInfo("iceberg", "db", "required_tbl"), 
null, null, null);
+                List<List<String>> rows = command.doRun(context, 
null).getResultRows();
+                Assertions.assertEquals("NO", rows.get(0).get(nullIndex));
+                Assertions.assertEquals("NO", rows.get(1).get(nullIndex));
+                Assertions.assertEquals("YES", rows.get(2).get(nullIndex));
+                ShowColumnsCommand filtered = new ShowColumnsCommand(full,
+                        new TableNameInfo("iceberg", "db", "required_tbl"), 
null, "id", null);
+                List<List<String>> filteredRows = filtered.doRun(context, 
null).getResultRows();
+                Assertions.assertEquals(1, filteredRows.size());
+                Assertions.assertEquals("id", filteredRows.get(0).get(0));
+                Assertions.assertEquals("NO", 
filteredRows.get(0).get(nullIndex));
+            }
+        }
+        
Assertions.assertTrue(scanColumns.stream().allMatch(Column::isAllowNull));
+    }
+
+    @Test
+    void testDescribeTablesUsesDeclaredNullabilityWithoutSessionContext() 
throws Exception {
+        try (MockedStatic<Env> ignored = mockMetadataEnv()) {
+            // information_schema requests arrive on an RPC thread, without a 
SQL session context.
+            ConnectContext.remove();
+            TDescribeTablesResult result = describeTables("required_tbl");
+            
Assertions.assertEquals(Collections.singletonList(scanColumns.size()), 
result.getTablesOffset());
+            
Assertions.assertFalse(result.getColumns().get(0).getColumnDesc().isIsAllowNull());
+            
Assertions.assertFalse(result.getColumns().get(1).getColumnDesc().isIsAllowNull());
+            
Assertions.assertTrue(result.getColumns().get(2).getColumnDesc().isIsAllowNull());
+            Assertions.assertEquals("value doc", 
result.getColumns().get(1).getComment());
+        }
+        
Assertions.assertTrue(scanColumns.stream().allMatch(Column::isAllowNull));
+        
Assertions.assertTrue(scanColumns.get(3).getChildren().get(0).isAllowNull());
+    }
+
+    @Test
+    void testDescribeTablesKeepsOffsetsWhenDisplaySchemaFails() throws 
Exception {
+        try (MockedStatic<Env> ignored = mockMetadataEnv()) {
+            IcebergExternalTable unavailable = 
Mockito.mock(IcebergExternalTable.class);
+            Mockito.when(unavailable.getBaseSchemaForDisplay()).thenThrow(new 
RuntimeException("schema unavailable"));
+            
Mockito.doReturn(unavailable).when(database).getTableNullableIfException("unavailable");
+            TDescribeTablesResult result = describeTables("unavailable", 
"required_tbl");
+            Assertions.assertEquals(Arrays.asList(0, scanColumns.size()), 
result.getTablesOffset());
+            Assertions.assertEquals(scanColumns.size(), 
result.getColumns().size());
+            
Assertions.assertFalse(result.getColumns().get(0).getColumnDesc().isIsAllowNull());
+        }
+    }
+
+    @Test
+    void testDescribeTablesChecksPrivilegesBeforeLoadingSchema() throws 
Exception {
+        try (MockedStatic<Env> ignored = mockMetadataEnv()) {
+            
Mockito.when(Env.getCurrentEnv().getAccessManager().checkTblPriv(Mockito.any(UserIdentity.class),
+                    Mockito.eq("iceberg"), Mockito.eq("db"), 
Mockito.eq("required_tbl"),
+                    Mockito.eq(PrivPredicate.SHOW))).thenReturn(false);
+            TDescribeTablesResult result = describeTables("required_tbl");
+            Assertions.assertTrue(result.getColumns().isEmpty());
+            Mockito.verify(table, Mockito.never()).getBaseSchemaForDisplay();
+        }
+    }
+
+    private MockedStatic<Env> mockMetadataEnv() throws Exception {
+        Env env = Mockito.mock(Env.class);
+        CatalogMgr catalogMgr = Mockito.mock(CatalogMgr.class);
+        AccessControllerManager accessManager = 
Mockito.mock(AccessControllerManager.class);
+        Mockito.when(env.getCatalogMgr()).thenReturn(catalogMgr);
+        Mockito.when(env.getAccessManager()).thenReturn(accessManager);
+        
Mockito.doReturn(catalog).when(catalogMgr).getCatalogOrAnalysisException("iceberg");
+        
Mockito.doReturn(catalog).when(catalogMgr).getCatalogOrException(Mockito.eq("iceberg"),
 Mockito.any());
+        
Mockito.doReturn(database).when(catalog).getDbOrAnalysisException("db");
+        Mockito.doReturn(database).when(catalog).getDbNullable("db");
+        
Mockito.doReturn(table).when(database).getTableOrAnalysisException("required_tbl");
+        
Mockito.doReturn(table).when(database).getTableNullableIfException("required_tbl");
+        
Mockito.when(accessManager.checkTblPriv(Mockito.any(ConnectContext.class), 
Mockito.anyString(),
+                Mockito.anyString(), Mockito.anyString(), 
Mockito.eq(PrivPredicate.SHOW))).thenReturn(true);
+        
Mockito.when(accessManager.checkTblPriv(Mockito.any(UserIdentity.class), 
Mockito.anyString(),
+                Mockito.anyString(), Mockito.anyString(), 
Mockito.eq(PrivPredicate.SHOW))).thenReturn(true);
+        MockedStatic<Env> mocked = Mockito.mockStatic(Env.class);
+        mocked.when(Env::getCurrentEnv).thenReturn(env);
+        return mocked;
+    }
+
+    private TDescribeTablesResult describeTables(String... names) throws 
Exception {
+        TDescribeTablesParams params = new TDescribeTablesParams();
+        params.setCatalog("iceberg");
+        params.setDb("db");
+        params.setTablesName(Arrays.asList(names));
+        params.setCurrentUserIdent(UserIdentity.ROOT.toThrift());
+        // The RPC handler does not need the background report thread started 
by its constructor.
+        return Mockito.mock(FrontendServiceImpl.class, 
Mockito.CALLS_REAL_METHODS).describeTables(params);
+    }
+
+    private List<List<String>> describe() throws Exception {
+        return new IndexInfoProcDir(database, 
table).lookup("3").fetchResult().getRows();
+    }
+
+    private String showCreate() {
+        List<String> statements = new ArrayList<>();
+        Env.getDdlStmt(table, statements, null, null, false, true, -1L);
+        return statements.get(0);
+    }
+}
diff --git 
a/regression-test/data/external_table_p0/iceberg/iceberg_schema_change_ddl.out 
b/regression-test/data/external_table_p0/iceberg/iceberg_schema_change_ddl.out
index 4565871aa8a..a67ef7f8a16 100644
--- 
a/regression-test/data/external_table_p0/iceberg/iceberg_schema_change_ddl.out
+++ 
b/regression-test/data/external_table_p0/iceberg/iceberg_schema_change_ddl.out
@@ -1,9 +1,9 @@
 -- This file is automatically generated. You should know what you did if you 
want to edit this
 -- !init_1 --
-id     int     Yes     true    \N              
-name   text    Yes     true    \N              
-age    int     Yes     true    \N              
-score  double  Yes     true    \N              
+id     int     No      true    \N              
+name   text    No      true    \N              
+age    int     No      true    \N              
+score  double  No      true    \N              
 
 -- !init_2 --
 1      Alice   25      95.5
@@ -11,11 +11,11 @@ score       double  Yes     true    \N
 3      Charlie 22      92.8
 
 -- !add_1 --
-id     int     Yes     true    \N              
-name   text    Yes     true    \N              
-age    int     Yes     true    \N              
+id     int     No      true    \N              
+name   text    No      true    \N              
+age    int     No      true    \N              
 phone  text    Yes     true    \N              User phone number
-score  double  Yes     true    \N              
+score  double  No      true    \N              
 email  text    Yes     true    \N              
 
 -- !add_2 --
@@ -33,11 +33,11 @@ email       text    Yes     true    \N
 3      \N
 
 -- !add_multi_1 --
-id     int     Yes     true    \N              
-name   text    Yes     true    \N              
-age    int     Yes     true    \N              
+id     int     No      true    \N              
+name   text    No      true    \N              
+age    int     No      true    \N              
 phone  text    Yes     true    \N              User phone number
-score  double  Yes     true    \N              
+score  double  No      true    \N              
 email  text    Yes     true    \N              
 address        struct<city:text,country:text>  Yes     true    \N              
 
@@ -52,11 +52,11 @@ address     struct<city:text,country:text>  Yes     true    
\N
 5      {"city":"New York", "country":"USA"}
 
 -- !rename_1 --
-id     int     Yes     true    \N              
-name   text    Yes     true    \N              
-age    int     Yes     true    \N              
+id     int     No      true    \N              
+name   text    No      true    \N              
+age    int     No      true    \N              
 phone  text    Yes     true    \N              User phone number
-grade  double  Yes     true    \N              
+grade  double  No      true    \N              
 email  text    Yes     true    \N              
 address        struct<city:text,country:text>  Yes     true    \N              
 
@@ -73,10 +73,10 @@ address     struct<city:text,country:text>  Yes     true    
\N
 5      Eve     26      223-345-132     91.3    [email protected]        
{"city":"New York", "country":"USA"}
 
 -- !drop_1 --
-id     int     Yes     true    \N              
-age    int     Yes     true    \N              
+id     int     No      true    \N              
+age    int     No      true    \N              
 phone  text    Yes     true    \N              User phone number
-grade  double  Yes     true    \N              
+grade  double  No      true    \N              
 email  text    Yes     true    \N              
 address        struct<city:text,country:text>  Yes     true    \N              
 
@@ -93,10 +93,10 @@ address     struct<city:text,country:text>  Yes     true    
\N
 5      26
 
 -- !add_columns_1 --
-id     int     Yes     true    \N              
-age    int     Yes     true    \N              
+id     int     No      true    \N              
+age    int     No      true    \N              
 phone  text    Yes     true    \N              User phone number
-grade  double  Yes     true    \N              
+grade  double  No      true    \N              
 email  text    Yes     true    \N              
 address        struct<city:text,country:text>  Yes     true    \N              
 col1   float   Yes     true    \N              User defined column1
@@ -111,9 +111,9 @@ col2        text    Yes     true    \N              User 
defined column2
 
 -- !modify_1 --
 age    bigint  Yes     true    \N              
-id     int     Yes     true    \N              
+id     int     No      true    \N              
 phone  text    Yes     true    \N              User phone number
-grade  double  Yes     true    \N              
+grade  double  No      true    \N              
 email  text    Yes     true    \N              
 address        struct<city:text,country:text>  Yes     true    \N              
 col1   double  Yes     true    \N              Updated column1 type
@@ -134,9 +134,9 @@ col2        text    Yes     true    \N              User 
defined column2
 
 -- !before_no_comment --
 age    bigint  Yes     true    \N              
-id     int     Yes     true    \N              
+id     int     No      true    \N              
 phone  text    Yes     true    \N              User phone number
-grade  double  Yes     true    \N              
+grade  double  No      true    \N              
 email  text    Yes     true    \N              
 address        struct<city:text,country:text>  Yes     true    \N              
 col1   double  Yes     true    \N              Updated column1 type
@@ -144,9 +144,9 @@ col2        text    Yes     true    \N              User 
defined column2
 
 -- !after_no_comment --
 age    bigint  Yes     true    \N              
-id     int     Yes     true    \N              
+id     int     No      true    \N              
 phone  text    Yes     true    \N              User phone number
-grade  double  Yes     true    \N              
+grade  double  No      true    \N              
 email  text    Yes     true    \N              
 address        struct<city:text,country:text>  Yes     true    \N              
 col1   double  Yes     true    \N              Updated column1 type
@@ -154,9 +154,9 @@ col2        text    Yes     true    \N              User 
defined column2
 
 -- !after_no_comment --
 age    bigint  Yes     true    \N              
-id     int     Yes     true    \N              
+id     int     No      true    \N              
 phone  text    Yes     true    \N              User phone number
-grade  double  Yes     true    \N              
+grade  double  No      true    \N              
 email  text    Yes     true    \N              
 address        struct<city:text,country:text>  Yes     true    \N              
 col1   double  Yes     true    \N              Updated column1 type
@@ -164,9 +164,9 @@ col2        text    Yes     true    \N              User 
defined column2
 
 -- !modify_positive_1 --
 age    bigint  Yes     true    \N              
-id     int     Yes     true    \N              
+id     int     No      true    \N              
 phone  text    Yes     true    \N              User phone number
-grade  double  Yes     true    \N              
+grade  double  No      true    \N              
 email  text    Yes     true    \N              
 address        struct<city:text,country:text>  Yes     true    \N              
 col1   double  Yes     true    \N              Updated column1 type
@@ -178,11 +178,11 @@ test_decimal      decimal(10,2)   Yes     true    \N
 7      3.140000104904175       123.45
 
 -- !reorder_1 --
-id     int     Yes     true    \N              
+id     int     No      true    \N              
 age    bigint  Yes     true    \N              
 col1   double  Yes     true    \N              Updated column1 type
 col2   text    Yes     true    \N              User defined column2
-grade  double  Yes     true    \N              
+grade  double  No      true    \N              
 phone  text    Yes     true    \N              User phone number
 email  text    Yes     true    \N              
 address        struct<city:text,country:text>  Yes     true    \N              
@@ -209,11 +209,11 @@ iceberg_ddl_test
 iceberg_ddl_test_renamed
 
 -- !rename_table_1 --
-id     int     Yes     true    \N              
+id     int     No      true    \N              
 age    bigint  Yes     true    \N              
 col1   double  Yes     true    \N              Updated column1 type
 col2   text    Yes     true    \N              User defined column2
-grade  double  Yes     true    \N              
+grade  double  No      true    \N              
 phone  text    Yes     true    \N              User phone number
 email  text    Yes     true    \N              
 address        struct<city:text,country:text>  Yes     true    \N              
diff --git 
a/regression-test/suites/external_table_p0/iceberg/test_iceberg_show_nullable.groovy
 
b/regression-test/suites/external_table_p0/iceberg/test_iceberg_show_nullable.groovy
new file mode 100644
index 00000000000..c92787a4d98
--- /dev/null
+++ 
b/regression-test/suites/external_table_p0/iceberg/test_iceberg_show_nullable.groovy
@@ -0,0 +1,117 @@
+// 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.
+
+suite("test_iceberg_show_nullable", 
"p0,external,doris,external_docker,external_docker_doris") {
+    String enabled = context.config.otherConfigs.get("enableIcebergTest")
+    if (enabled == null || !enabled.equalsIgnoreCase("true")) {
+        logger.info("disable iceberg test.")
+        return
+    }
+
+    String catalogName = "test_iceberg_show_nullable"
+    String suffix = UUID.randomUUID().toString().replace("-", "")
+    String dbName = "iceberg_show_nullable_db_" + suffix
+    String tblName = "required_tbl_" + suffix
+    String externalEnvIp = context.config.otherConfigs.get("externalEnvIp")
+    String hmsPort = context.config.otherConfigs.get("hive2HmsPort")
+    String hdfsPort = context.config.otherConfigs.get("hive2HdfsPort")
+    String defaultFs = "hdfs://${externalEnvIp}:${hdfsPort}"
+    String tableName = "${catalogName}.${dbName}.${tblName}"
+
+    sql """drop catalog if exists ${catalogName}"""
+    sql """create catalog ${catalogName} properties (
+        'type' = 'iceberg',
+        'iceberg.catalog.type' = 'hms',
+        'hive.metastore.uris' = 'thrift://${externalEnvIp}:${hmsPort}',
+        'fs.defaultFS' = '${defaultFs}',
+        'warehouse' = '${defaultFs}/warehouse'
+    )"""
+
+    try {
+        sql """create database ${catalogName}.${dbName}"""
+        sql """create table ${tableName} (
+            id bigint not null,
+            value string not null comment 'required value',
+            event_time datetime
+        ) properties ('format-version' = '2', 'write.format.default' = 
'parquet')"""
+
+        def checkSchema = { boolean valueIsRequired ->
+            def nullable = sql("desc ${tableName}").collectEntries { row -> 
[(row[0]): row[2]] }
+            assertEquals("No", nullable["id"])
+            assertEquals(valueIsRequired ? "No" : "Yes", nullable["value"])
+            assertEquals("Yes", nullable["event_time"])
+            String ddl = sql("show create table ${tableName}")[0][1]
+            assertTrue(ddl.contains("`id` bigint NOT NULL"))
+            assertTrue(ddl.contains("`value` text " + (valueIsRequired ? "NOT 
NULL" : "NULL")))
+            assertTrue(ddl.contains("`event_time` datetimev2(6) NULL"))
+            assertTrue(ddl.contains("required value"))
+
+            def expected = [id: "NO", value: valueIsRequired ? "NO" : "YES", 
event_time: "YES"]
+            [false, true].each { full ->
+                String command = full ? "show full columns" : "show columns"
+                int nullIndex = full ? 3 : 2
+                def columns = sql("${command} from ${tableName}")
+                assertEquals(expected, columns.collectEntries { row -> 
[(row[0]): row[nullIndex]] })
+                def likeColumns = sql("${command} from ${tableName} like 'id'")
+                assertEquals([id: "NO"], likeColumns.collectEntries { row -> 
[(row[0]): row[nullIndex]] })
+                // WHERE is rewritten to information_schema.columns, unlike 
the direct and LIKE paths.
+                ["NO", "YES"].each { nullableFlag ->
+                    def filtered = sql("${command} from ${tableName} where 
`Null` = '${nullableFlag}'")
+                    assertEquals(expected.findAll { name, flag -> flag == 
nullableFlag },
+                            filtered.collectEntries { row -> [(row[0]): 
row[nullIndex]] })
+                }
+            }
+
+            String metadataQuery = """select COLUMN_NAME, IS_NULLABLE
+                from ${catalogName}.information_schema.columns
+                where TABLE_SCHEMA in ('${dbName}', 
'${catalogName}.${dbName}') and TABLE_NAME = '${tblName}'"""
+            assertEquals(expected, sql(metadataQuery).collectEntries { row -> 
[(row[0]): row[1]] })
+            assertEquals(expected.findAll { name, flag -> flag == "NO" },
+                    sql(metadataQuery + " and IS_NULLABLE = 
'NO'").collectEntries { row -> [(row[0]): row[1]] })
+        }
+
+        // Empty tables have no snapshot yet, but still have a declared schema.
+        checkSchema(true)
+        sql """insert into ${tableName} values (1, 'ok', '2026-01-01 
00:00:00'), (2, 'optional time', null)"""
+        checkSchema(true)
+        assertEquals(1L, sql("select count(*) from ${tableName} where 
event_time is null")[0][0])
+
+        test {
+            sql """insert into ${tableName} values (null, 'bad', '2026-01-02 
00:00:00')"""
+            exception "Column 'id' is declared non-nullable but contains nulls"
+        }
+        test {
+            sql """insert into ${tableName} values (3, null, '2026-01-02 
00:00:00')"""
+            exception "Column 'value' is declared non-nullable but contains 
nulls"
+        }
+        assertEquals(2L, sql("select count(*) from ${tableName}")[0][0])
+
+        // A schema-only change must be visible even though it creates no new 
data snapshot.
+        def snapshotBefore = sql("select snapshot_id from 
${tableName}\$snapshots order by snapshot_id")
+        sql """alter table ${tableName} modify column value string null"""
+        sql """refresh table ${tableName}"""
+        assertEquals(snapshotBefore, sql("select snapshot_id from 
${tableName}\$snapshots order by snapshot_id"))
+        checkSchema(false)
+        sql """insert into ${tableName} values (3, null, null)"""
+        assertEquals(1L, sql("select count(*) from ${tableName} where value is 
null")[0][0])
+        assertEquals(0L, sql("select count(*) from ${tableName} where id is 
null")[0][0])
+        checkSchema(false)
+    } finally {
+        sql """drop database if exists ${catalogName}.${dbName} force"""
+        sql """drop catalog if exists ${catalogName}"""
+    }
+}


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

Reply via email to