This is an automated email from the ASF dual-hosted git repository.
Gabriel39 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris.git
The following commit(s) were added to refs/heads/master by this push:
new c7ffbda3877 [fix](iceberg) Preserve nullability in schema metadata
(#68640)
c7ffbda3877 is described below
commit c7ffbda38772c84c41139bab930a2e704866f50e
Author: daidai <[email protected]>
AuthorDate: Tue Oct 20 10:57:02 2026 +0800
[fix](iceberg) Preserve nullability in schema metadata (#68640)
### What problem does this PR solve?
Related PR: #67856
Problem Summary:
Iceberg columns declared `NOT NULL` are shown as nullable by `DESC`,
`SHOW CREATE TABLE`, `SHOW [FULL] COLUMNS` and
`information_schema.columns`. The connector reads every top-level column
as nullable on purpose, since old data files can miss a column, and the
display paths reuse that read schema.
`ConnectorColumn` now also carries the nullability the source declares.
The Iceberg connector sets it from the field's required flag, and these
metadata commands use it. The columns used for reads and writes stay
nullable.
### Release note
Show the declared nullability of Iceberg columns in `DESC`, `SHOW CREATE
TABLE`, `SHOW COLUMNS` and `information_schema.columns`.
### Check List (For Author)
- Test
- [x] Regression test
- [x] Unit Test
- Behavior changed:
- [x] Yes. Required Iceberg columns are now shown as `NOT NULL`.
- Does this need documentation?
- [x] No.
---
.../iceberg/IcebergConnectorMetadata.java | 4 +-
.../iceberg/IcebergConnectorMetadataTest.java | 19 ++++++
.../doris/connector/spi/ConnectorColumn.java | 53 +++++++++++++---
.../connector/spi/ConnectorPluginSurfaceTest.java | 5 +-
.../test/resources/connector-plugin-surface.txt | 2 +
fe/fe-connector/pom.xml | 2 +-
.../main/java/org/apache/doris/catalog/Env.java | 2 +-
.../java/org/apache/doris/catalog/TableIf.java | 25 ++++++++
.../apache/doris/common/proc/IndexInfoProcDir.java | 2 +-
.../plugin/PluginDrivenExternalTable.java | 69 ++++++++++++++++++++-
.../plugin/PluginDrivenSchemaCacheValue.java | 32 ++++++++++
.../doris/httpv2/rest/TableSchemaAction.java | 14 ++++-
.../doris/httpv2/restv2/MetaInfoActionV2.java | 7 ++-
.../org/apache/doris/mysql/MysqlSerializer.java | 18 +++++-
.../doris/mysql/protocol/MysqlResultSender.java | 4 +-
.../trees/plans/commands/ShowColumnsCommand.java | 2 +-
.../org/apache/doris/qe/MysqlConnectProcessor.java | 4 +-
.../apache/doris/service/FrontendServiceImpl.java | 2 +-
.../catalog/EnvShowCreatePluginTableTest.java | 4 ++
.../plugin/PluginDrivenExternalTableTest.java | 64 +++++++++++++++++++
.../rest/TableSchemaActionColumnInfoTest.java | 22 +++++++
.../mysql/protocol/MysqlResultSenderTest.java | 55 +++++++++++++++++
.../iceberg/iceberg_schema_change_ddl.out | 72 +++++++++++-----------
.../iceberg/test_iceberg_initial_defaults.out | 8 +--
.../iceberg/test_iceberg_show_nullable.out | 29 +++++++++
.../iceberg/test_iceberg_show_nullable.groovy | 71 +++++++++++++++++++++
26 files changed, 527 insertions(+), 64 deletions(-)
diff --git
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadata.java
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadata.java
index 1e3aa164e26..43901064454 100644
---
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadata.java
+++
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadata.java
@@ -2487,6 +2487,8 @@ public class IcebergConnectorMetadata implements
ConnectorMetadata {
// isKey is always true (external-table semantics: DESC shows
Key=true),
// and isAllowNull is always true regardless of the Iceberg
required/optional flag (rows can
// still read NULL under schema-evolution default-fill; do NOT
propagate the NOT NULL constraint).
+ // The required/optional flag travels separately as the SOURCE
nullability, which is what DESC /
+ // SHOW CREATE TABLE / SHOW COLUMNS / information_schema display
instead of the read nullability.
// Iceberg write defaults are statement-scoped writer metadata,
not catalog display metadata.
// IcebergWritePlanProvider pins them before write analysis;
keeping the cached default null prevents
// DESCRIBE/SHOW CREATE from exposing them and avoids using a
stale default after schema evolution.
@@ -2497,7 +2499,7 @@ public class IcebergConnectorMetadata implements
ConnectorMetadata {
field.doc() != null ? field.doc() : "",
true,
null,
- true);
+ true).withSourceNullable(field.isOptional());
// Carry the stable iceberg field-id as the column's uniqueId
(legacy
// IcebergUtils.updateIcebergColumnUniqueId set the top-level
Column.uniqueId = field.fieldId()).
// fe-core's ConnectorColumnConverter re-applies it (>= 0); the BE
field-id scan path keys the
diff --git
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadataTest.java
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadataTest.java
index a8975d561ad..4b28492c4ff 100644
---
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadataTest.java
+++
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadataTest.java
@@ -888,6 +888,25 @@ public class IcebergConnectorMetadataTest {
"a doc-less Iceberg field must yield an empty (not null)
comment");
}
+ @Test
+ public void getTableSchemaDeclaresRequiredFieldsNotNull() {
+ RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps();
+ ops.table = new FakeIcebergTable(
+ "t1", idNameSchema(), PartitionSpec.unpartitioned(),
+ "s3://bucket/db1/t1", Collections.emptyMap());
+
+ ConnectorTableSchema schema =
+ metadataWith(ops).getTableSchema(null, new
IcebergTableHandle("db1", "t1"));
+
+ // Required fields are read as nullable but displayed as NOT NULL.
+ ConnectorColumn id = schema.getColumns().get(0);
+ Assertions.assertTrue(id.isNullable());
+ Assertions.assertFalse(id.isSourceNullable());
+ ConnectorColumn name = schema.getColumns().get(1);
+ Assertions.assertTrue(name.isNullable());
+ Assertions.assertTrue(name.isSourceNullable());
+ }
+
@Test
public void getTableSchemaCopiesTablePropertiesAndLocation() {
RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps();
diff --git
a/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorColumn.java
b/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorColumn.java
index 007f2d12fc9..8b678e02535 100644
---
a/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorColumn.java
+++
b/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorColumn.java
@@ -74,6 +74,22 @@ public final class ConnectorColumn {
private final String defaultValueSql;
// Optional semantic type for textual write input, separate from the
exposed binary column type.
private final ConnectorType stringWriteType;
+ // The nullability the SOURCE schema declares (its required/optional
flag), which is what the engine's
+ // metadata statements show: DESC, SHOW CREATE TABLE, SHOW [FULL] COLUMNS,
information_schema.columns and
+ // the field list a MySQL client asks for. It is deliberately separate
from the read nullability above:
+ // a connector may read a source-required column as nullable
(iceberg/paimon do, because a row written
+ // before the column existed reads back NULL under schema-evolution
default-fill) while the source
+ // contract still declares it NOT NULL. Execution — scans, the optimizer,
write validation, the BE
+ // descriptor — always follows `nullable`, never this field.
+ //
+ // Defaults to `nullable`, so every connector that does not opt in keeps
display == read and this field
+ // changes nothing. Set via withSourceNullable().
+ //
+ // Excluded from equals()/hashCode() like the "specified" markers above:
it is a presentation fact that
+ // never round-trips (a column converted back from a Doris Column carries
no source contract), so it must
+ // not become part of column identity — otherwise a MODIFY COLUMN would
look like a change just because
+ // the request-side column lacks the display flag.
+ private final boolean sourceNullable;
public ConnectorColumn(String name, ConnectorType type, String comment,
boolean nullable, String defaultValue) {
@@ -94,14 +110,14 @@ public final class ConnectorColumn {
boolean nullable, String defaultValue, boolean isKey, boolean
isAutoInc,
boolean isAggregated) {
this(name, type, comment, nullable, defaultValue, isKey, isAutoInc,
isAggregated, false, true,
- UNSET_UNIQUE_ID, false, false, false, null, null);
+ UNSET_UNIQUE_ID, false, false, false, null, null, nullable);
}
private ConnectorColumn(String name, ConnectorType type, String comment,
boolean nullable, String defaultValue, boolean isKey, boolean
isAutoInc,
boolean isAggregated, boolean withTimeZone, boolean visible, int
uniqueId,
boolean reservedPassthrough, boolean nullableSpecified, boolean
commentSpecified,
- String defaultValueSql, ConnectorType stringWriteType) {
+ String defaultValueSql, ConnectorType stringWriteType, boolean
sourceNullable) {
this.name = Objects.requireNonNull(name, "name");
this.type = Objects.requireNonNull(type, "type");
this.comment = comment;
@@ -118,6 +134,7 @@ public final class ConnectorColumn {
this.commentSpecified = commentSpecified;
this.defaultValueSql = defaultValueSql;
this.stringWriteType = stringWriteType;
+ this.sourceNullable = sourceNullable;
}
/**
@@ -128,7 +145,7 @@ public final class ConnectorColumn {
public ConnectorColumn withTimeZone() {
return new ConnectorColumn(name, type, comment, nullable, defaultValue,
isKey, isAutoInc, isAggregated, true, visible, uniqueId,
reservedPassthrough,
- nullableSpecified, commentSpecified, defaultValueSql,
stringWriteType);
+ nullableSpecified, commentSpecified, defaultValueSql,
stringWriteType, sourceNullable);
}
/**
@@ -138,7 +155,7 @@ public final class ConnectorColumn {
public ConnectorColumn invisible() {
return new ConnectorColumn(name, type, comment, nullable, defaultValue,
isKey, isAutoInc, isAggregated, withTimeZone, false, uniqueId,
reservedPassthrough,
- nullableSpecified, commentSpecified, defaultValueSql,
stringWriteType);
+ nullableSpecified, commentSpecified, defaultValueSql,
stringWriteType, sourceNullable);
}
/**
@@ -150,7 +167,7 @@ public final class ConnectorColumn {
public ConnectorColumn reservedPassthrough() {
return new ConnectorColumn(name, type, comment, nullable, defaultValue,
isKey, isAutoInc, isAggregated, withTimeZone, visible,
uniqueId, true,
- nullableSpecified, commentSpecified, defaultValueSql,
stringWriteType);
+ nullableSpecified, commentSpecified, defaultValueSql,
stringWriteType, sourceNullable);
}
/**
@@ -161,7 +178,7 @@ public final class ConnectorColumn {
public ConnectorColumn withSpecified(boolean nullableSpecified, boolean
commentSpecified) {
return new ConnectorColumn(name, type, comment, nullable, defaultValue,
isKey, isAutoInc, isAggregated, withTimeZone, visible,
uniqueId, reservedPassthrough,
- nullableSpecified, commentSpecified, defaultValueSql,
stringWriteType);
+ nullableSpecified, commentSpecified, defaultValueSql,
stringWriteType, sourceNullable);
}
/**
@@ -173,7 +190,7 @@ public final class ConnectorColumn {
public ConnectorColumn withUniqueId(int uniqueId) {
return new ConnectorColumn(name, type, comment, nullable, defaultValue,
isKey, isAutoInc, isAggregated, withTimeZone, visible,
uniqueId, reservedPassthrough,
- nullableSpecified, commentSpecified, defaultValueSql,
stringWriteType);
+ nullableSpecified, commentSpecified, defaultValueSql,
stringWriteType, sourceNullable);
}
/**
@@ -182,7 +199,7 @@ public final class ConnectorColumn {
public ConnectorColumn withDefaultValueSql(String defaultValueSql) {
return new ConnectorColumn(name, type, comment, nullable, defaultValue,
isKey, isAutoInc, isAggregated, withTimeZone, visible,
uniqueId, reservedPassthrough,
- nullableSpecified, commentSpecified, defaultValueSql,
stringWriteType);
+ nullableSpecified, commentSpecified, defaultValueSql,
stringWriteType, sourceNullable);
}
/**
@@ -193,13 +210,23 @@ public final class ConnectorColumn {
public ConnectorColumn withStringWriteType(ConnectorType stringWriteType) {
return new ConnectorColumn(name, type, comment, nullable, defaultValue,
isKey, isAutoInc, isAggregated, withTimeZone, visible,
uniqueId, reservedPassthrough,
- nullableSpecified, commentSpecified, defaultValueSql,
stringWriteType);
+ nullableSpecified, commentSpecified, defaultValueSql,
stringWriteType, sourceNullable);
}
public ConnectorType getStringWriteType() {
return stringWriteType;
}
+ /**
+ * Returns a copy carrying the nullability the source schema declares, for
metadata statements only.
+ * See {@link #isSourceNullable()}; execution keeps following {@link
#isNullable()}.
+ */
+ public ConnectorColumn withSourceNullable(boolean sourceNullable) {
+ return new ConnectorColumn(name, type, comment, nullable, defaultValue,
+ isKey, isAutoInc, isAggregated, withTimeZone, visible,
uniqueId, reservedPassthrough,
+ nullableSpecified, commentSpecified, defaultValueSql,
stringWriteType, sourceNullable);
+ }
+
public String getName() {
return name;
}
@@ -262,6 +289,14 @@ public final class ConnectorColumn {
return defaultValueSql;
}
+ /**
+ * The nullability the source schema declares, used by metadata statements
only; see the field comment.
+ * Defaults to {@link #isNullable()}. Execution always follows {@link
#isNullable()}.
+ */
+ public boolean isSourceNullable() {
+ return sourceNullable;
+ }
+
@Override
public boolean equals(Object o) {
if (this == o) {
diff --git
a/fe/fe-connector/fe-connector-spi/src/test/java/org/apache/doris/connector/spi/ConnectorPluginSurfaceTest.java
b/fe/fe-connector/fe-connector-spi/src/test/java/org/apache/doris/connector/spi/ConnectorPluginSurfaceTest.java
index 7a28ab6aa69..cce4173053f 100644
---
a/fe/fe-connector/fe-connector-spi/src/test/java/org/apache/doris/connector/spi/ConnectorPluginSurfaceTest.java
+++
b/fe/fe-connector/fe-connector-spi/src/test/java/org/apache/doris/connector/spi/ConnectorPluginSurfaceTest.java
@@ -88,8 +88,9 @@ public class ConnectorPluginSurfaceTest {
Assertions.assertNotNull(in, "missing connector plugin API version
resource");
version.load(in);
}
- // Major 18 adds DiagnosticException to the major 17 scoped streaming
surface.
- Assertions.assertEquals("18.0", version.getProperty("api.version"));
+ // Major 19 adds
ConnectorColumn#isSourceNullable()/withSourceNullable(boolean) - the
nullability the
+ // source declares, kept separate from the nullability the engine
reads the column with.
+ Assertions.assertEquals("19.0", version.getProperty("api.version"));
}
/** Root entry points plus provider/handle types returned to connector
plugins. */
diff --git
a/fe/fe-connector/fe-connector-spi/src/test/resources/connector-plugin-surface.txt
b/fe/fe-connector/fe-connector-spi/src/test/resources/connector-plugin-surface.txt
index aa67d2b9468..81918e6daa1 100644
---
a/fe/fe-connector/fe-connector-spi/src/test/resources/connector-plugin-surface.txt
+++
b/fe/fe-connector/fe-connector-spi/src/test/resources/connector-plugin-surface.txt
@@ -52,11 +52,13 @@
org.apache.doris.connector.spi.ConnectorColumn#isKey():boolean
org.apache.doris.connector.spi.ConnectorColumn#isNullable():boolean
org.apache.doris.connector.spi.ConnectorColumn#isNullableSpecified():boolean
org.apache.doris.connector.spi.ConnectorColumn#isReservedPassthrough():boolean
+org.apache.doris.connector.spi.ConnectorColumn#isSourceNullable():boolean
org.apache.doris.connector.spi.ConnectorColumn#isVisible():boolean
org.apache.doris.connector.spi.ConnectorColumn#isWithTimeZone():boolean
org.apache.doris.connector.spi.ConnectorColumn#reservedPassthrough():org.apache.doris.connector.spi.ConnectorColumn
org.apache.doris.connector.spi.ConnectorColumn#toString():java.lang.String
org.apache.doris.connector.spi.ConnectorColumn#withDefaultValueSql(java.lang.String):org.apache.doris.connector.spi.ConnectorColumn
+org.apache.doris.connector.spi.ConnectorColumn#withSourceNullable(boolean):org.apache.doris.connector.spi.ConnectorColumn
org.apache.doris.connector.spi.ConnectorColumn#withSpecified(boolean,boolean):org.apache.doris.connector.spi.ConnectorColumn
org.apache.doris.connector.spi.ConnectorColumn#withStringWriteType(org.apache.doris.connector.spi.ConnectorType):org.apache.doris.connector.spi.ConnectorColumn
org.apache.doris.connector.spi.ConnectorColumn#withTimeZone():org.apache.doris.connector.spi.ConnectorColumn
diff --git a/fe/fe-connector/pom.xml b/fe/fe-connector/pom.xml
index eab1885c677..50adfde9967 100644
--- a/fe/fe-connector/pom.xml
+++ b/fe/fe-connector/pom.xml
@@ -55,7 +55,7 @@ under the License.
of the latter two means bumping this property as well (and
fe-extension-spi means bumping
all five families).
-->
- <connector.plugin.api.version>18.0</connector.plugin.api.version>
+ <connector.plugin.api.version>19.0</connector.plugin.api.version>
</properties>
<modules>
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 46a11792b6f..2c041999427 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
@@ -4735,7 +4735,7 @@ public class Env {
sb.append(" (\n");
int idx = 0;
- List<Column> columns = table.getBaseSchema(false);
+ List<Column> columns = table.getBaseSchemaForDisplay(false);
for (Column column : columns) {
if (idx++ != 0) {
sb.append(",\n");
diff --git a/fe/fe-core/src/main/java/org/apache/doris/catalog/TableIf.java
b/fe/fe-core/src/main/java/org/apache/doris/catalog/TableIf.java
index 154fa21cc03..3bcebc6e9bf 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/catalog/TableIf.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/catalog/TableIf.java
@@ -173,8 +173,33 @@ public interface TableIf {
}
}
+ /** {@link #getBaseSchemaOrEmpty()} over the metadata view; see {@link
#getBaseSchemaForDisplay()}. */
+ default List<Column> getBaseSchemaForDisplayOrEmpty() {
+ try {
+ return getBaseSchemaForDisplay();
+ } catch (Exception e) {
+ LOG.warn("failed to get base schema for table {}", getName(), e);
+ return Lists.newArrayList();
+ }
+ }
+
List<Column> getBaseSchema(boolean full);
+ /**
+ * The base schema shown by metadata statements (DESC, SHOW CREATE TABLE,
SHOW COLUMNS and
+ * information_schema.columns). It differs from {@link #getBaseSchema()}
only for a table whose source
+ * declares columns NOT NULL that are still read as nullable. Execution
(scans, the optimizer, write
+ * validation, the BE descriptor) must keep using {@link #getBaseSchema()}.
+ */
+ default List<Column> getBaseSchemaForDisplay() {
+ return getBaseSchema();
+ }
+
+ /** Same as {@link #getBaseSchemaForDisplay()}, with the hidden-column
choice of {@link #getBaseSchema(boolean)}. */
+ default List<Column> getBaseSchemaForDisplay(boolean full) {
+ return getBaseSchema(full);
+ }
+
void setNewFullSchema(List<Column> newSchema);
Column getColumn(String name);
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 8d6d57bdeb4..71912a1916b 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
@@ -144,7 +144,7 @@ public class IndexInfoProcDir implements ProcDirInterface {
return new RemoteIndexSchemaProcDir(table, schema,
bfColumns);
}
} else {
- schema = table.getBaseSchema();
+ schema = table.getBaseSchemaForDisplay();
}
return new IndexSchemaProcNode(schema, bfColumns);
} finally {
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/datasource/plugin/PluginDrivenExternalTable.java
b/fe/fe-core/src/main/java/org/apache/doris/datasource/plugin/PluginDrivenExternalTable.java
index cc95d843239..6cbea40af75 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/datasource/plugin/PluginDrivenExternalTable.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/datasource/plugin/PluginDrivenExternalTable.java
@@ -719,6 +719,10 @@ public class PluginDrivenExternalTable extends
ExternalTable {
}
List<Column> columns =
ConnectorColumnConverter.convertColumns(mappedColumns);
+ // The display view is projected HERE, from the same mapped columns,
so the read and display schemas
+ // always belong to one schema generation (a connector may
deliberately read a column as nullable
+ // while its source contract declares it NOT NULL; only metadata
statements show the latter).
+ List<Column> displayColumns = toDisplayColumns(columns, mappedColumns);
// Identify partition columns from the connector's reserved
PARTITION_COLUMNS_KEY property (a CSV of
// RAW remote column names; producer:
hive/hudi/iceberg/paimon/maxcompute). We keep two aligned
@@ -749,7 +753,40 @@ public class PluginDrivenExternalTable extends
ExternalTable {
}
return new PluginDrivenSchemaCacheValue(columns, partitionColumns,
partitionColumnRemoteNames,
tableSchema.getProperties(),
tableSchema.getTableCapabilities(),
- tableSchema.getWriteMetadataIdentity());
+ tableSchema.getWriteMetadataIdentity(), displayColumns);
+ }
+
+ /**
+ * Projects {@code readColumns} onto the nullability the connector's
source declares
+ * ({@link ConnectorColumn#isSourceNullable()}), index-aligned with and
the same size as {@code readColumns}
+ * (the converter maps connector columns 1:1 and in order). A column whose
declared nullability equals its
+ * read nullability is shared, not copied, so a connector that declares no
divergence — and every
+ * never-opted-in connector — keeps a display view that is literally the
read schema. A copied column
+ * re-applies the one display marker {@link Column#Column(Column)} does
not carry (the WITH_TIMEZONE
+ * "Extra" marker the connector declared), so a required TIMESTAMP WITH
TIME ZONE column keeps showing it.
+ */
+ private static List<Column> toDisplayColumns(List<Column> readColumns,
List<ConnectorColumn> connectorColumns) {
+ List<Column> displayColumns = new ArrayList<>(readColumns.size());
+ for (int i = 0; i < readColumns.size(); i++) {
+ Column readColumn = readColumns.get(i);
+ ConnectorColumn connectorColumn = connectorColumns.get(i);
+ boolean sourceNullable = connectorColumn.isSourceNullable();
+ if (sourceNullable == readColumn.isAllowNull()) {
+ displayColumns.add(readColumn);
+ continue;
+ }
+ Column displayColumn = new Column(readColumn);
+ displayColumn.setIsAllowNull(sourceNullable);
+ // Column's copy constructor does not carry `extraInfo`, so the
WITH_TIMEZONE marker
+ // (ConnectorColumn.withTimeZone(), re-applied by
ConnectorColumnConverter on the read column)
+ // has to be re-applied here as well — otherwise DESC would show
"Extra: NONE" for exactly the
+ // required TZ columns this projection touches.
+ if (connectorColumn.isWithTimeZone()) {
+ displayColumn.setWithTZExtraInfo();
+ }
+ displayColumns.add(displayColumn);
+ }
+ return displayColumns;
}
@Override
@@ -885,6 +922,36 @@ public class PluginDrivenExternalTable extends
ExternalTable {
return appendSyntheticWriteColumns(super.getFullSchema());
}
+ @Override
+ public List<Column> getBaseSchemaForDisplay() {
+ boolean showHidden = Util.showHiddenColumns();
+ if (!showHidden && needInternalHiddenColumns()) {
+ showHidden = true;
+ }
+ return getBaseSchemaForDisplay(showHidden);
+ }
+
+ /**
+ * The metadata view of this table's base schema: the same columns and
order, with the nullability the
+ * connector's source declares. Mirrors {@link
ExternalTable#getBaseSchema(boolean)} exactly — {@code full}
+ * only decides whether invisible columns are kept, and request-scoped
synthetic write columns join the
+ * hidden view — but serves one schema-cache generation and never the read
nullability.
+ */
+ @Override
+ public List<Column> getBaseSchemaForDisplay(boolean full) {
+ // ONE cache read: the columns and the declared-nullability projection
were produced together at
+ // schema load, so this can never mix generations or hit the connector
twice (which it would under
+ // a bypassed schema cache, e.g. a per-user delegated credential).
Mirrors getWriteSchemaSnapshot().
+ List<Column> displayColumns = getSchemaCacheValue()
+ .map(value -> ((PluginDrivenSchemaCacheValue)
value).getDisplayColumns())
+ .orElse(null);
+ if (displayColumns == null) {
+ return null;
+ }
+ List<Column> schema = appendSyntheticWriteColumns(displayColumns);
+ return full ? schema :
schema.stream().filter(Column::isVisible).collect(Collectors.toList());
+ }
+
/**
* Same as {@link #getFullSchema()}, but the BASE schema is resolved AS OF
{@code snapshot} (this
* reference's own pin). The synthetic write columns are request-scoped,
not version-scoped, so they are
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/datasource/plugin/PluginDrivenSchemaCacheValue.java
b/fe/fe-core/src/main/java/org/apache/doris/datasource/plugin/PluginDrivenSchemaCacheValue.java
index f89662035e2..4e7650aa2a8 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/datasource/plugin/PluginDrivenSchemaCacheValue.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/datasource/plugin/PluginDrivenSchemaCacheValue.java
@@ -46,6 +46,12 @@ import java.util.Set;
* the raw-keyed partition-value maps returned by the connector SPI
* ({@code ConnectorPartitionInfo.getPartitionValues()}).</li>
* </ul>
+ *
+ * <p>It also caches the DISPLAY view of the schema next to the read schema
+ * ({@link #getDisplayColumns()}): the same columns with the nullability the
source
+ * declares rather than the nullability the engine reads them with. Metadata
statements
+ * use it; everything that executes uses {@link #getSchema()}. See the field
comment for
+ * why both views are projected together, once per generation.</p>
*/
public class PluginDrivenSchemaCacheValue extends SchemaCacheValue {
@@ -61,6 +67,15 @@ public class PluginDrivenSchemaCacheValue extends
SchemaCacheValue {
// carrier. Empty for every connector that does not refine per table.
private final Set<ConnectorCapability> tableCapabilities;
private final String writeMetadataIdentity;
+ // The same columns with the nullability the SOURCE declares
(ConnectorColumn.isSourceNullable()) instead
+ // of the read nullability, index-aligned with the full schema above.
Metadata statements (DESC, SHOW
+ // CREATE TABLE, SHOW [FULL] COLUMNS, information_schema.columns,
COM_FIELD_LIST) serve THIS view;
+ // scans, the optimizer, write binding and the BE descriptor keep serving
getSchema(), which stays as
+ // nullable as the connector declared it to be read. Projected once per
schema generation from the same
+ // ConnectorTableSchema, so the two views can never straddle a refresh;
entries are shared with the read
+ // schema (same Column instance) whenever the connector declares no
divergence, and are full copies
+ // otherwise. Identical to the read schema for every connector that does
not opt in.
+ private final List<Column> displayColumns;
public PluginDrivenSchemaCacheValue(List<Column> schema, List<Column>
partitionColumns,
List<String> partitionColumnRemoteNames) {
@@ -82,12 +97,21 @@ public class PluginDrivenSchemaCacheValue extends
SchemaCacheValue {
public PluginDrivenSchemaCacheValue(List<Column> schema, List<Column>
partitionColumns,
List<String> partitionColumnRemoteNames, Map<String, String>
tableProperties,
Set<ConnectorCapability> tableCapabilities, String
writeMetadataIdentity) {
+ this(schema, partitionColumns, partitionColumnRemoteNames,
tableProperties, tableCapabilities,
+ writeMetadataIdentity, schema);
+ }
+
+ public PluginDrivenSchemaCacheValue(List<Column> schema, List<Column>
partitionColumns,
+ List<String> partitionColumnRemoteNames, Map<String, String>
tableProperties,
+ Set<ConnectorCapability> tableCapabilities, String
writeMetadataIdentity,
+ List<Column> displayColumns) {
super(schema);
this.partitionColumns = partitionColumns;
this.partitionColumnRemoteNames = partitionColumnRemoteNames;
this.tableProperties = tableProperties == null ?
Collections.emptyMap() : tableProperties;
this.tableCapabilities = tableCapabilities == null ?
Collections.emptySet() : tableCapabilities;
this.writeMetadataIdentity = writeMetadataIdentity;
+ this.displayColumns = displayColumns == null ? schema : displayColumns;
}
public List<Column> getPartitionColumns() {
@@ -109,4 +133,12 @@ public class PluginDrivenSchemaCacheValue extends
SchemaCacheValue {
public String getWriteMetadataIdentity() {
return writeMetadataIdentity;
}
+
+ /**
+ * The columns as the source declares them, index-aligned with {@link
#getSchema()}. Only metadata
+ * statements read this; see the field comment for why the two views must
come from one generation.
+ */
+ public List<Column> getDisplayColumns() {
+ return displayColumns;
+ }
}
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/httpv2/rest/TableSchemaAction.java
b/fe/fe-core/src/main/java/org/apache/doris/httpv2/rest/TableSchemaAction.java
index 6761e7fdc6f..eda09df97a3 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/httpv2/rest/TableSchemaAction.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/httpv2/rest/TableSchemaAction.java
@@ -67,6 +67,15 @@ import java.util.stream.Collectors;
@RestController
public class TableSchemaAction extends RestBaseController {
+ /**
+ * The columns {@code /_schema} reports: the display view, so its {@code
is_nullable} agrees with DESC /
+ * SHOW COLUMNS. Split out so the choice of view is pinned by a unit test.
+ */
+ @VisibleForTesting
+ static List<Column> schemaColumns(TableIf table) {
+ return table.getBaseSchemaForDisplay();
+ }
+
/**
* Build column information map for a given column
* @param column the column to build info for
@@ -137,7 +146,10 @@ public class TableSchemaAction extends RestBaseController {
table.readLock();
try {
try {
- List<Column> columns = table.getBaseSchema();
+ // The public schema REST response is a metadata view:
report the nullability the source
+ // declares (same as DESC / SHOW COLUMNS), not the
nullability the engine reads with.
+ // Internal tables keep read == display (TableIf's default
implementation).
+ List<Column> columns = schemaColumns(table);
List<Map<String, Object>> propList = new
ArrayList<>(columns.size());
for (Column column : columns) {
Map<String, Object> baseInfo = buildColumnInfo(column);
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/httpv2/restv2/MetaInfoActionV2.java
b/fe/fe-core/src/main/java/org/apache/doris/httpv2/restv2/MetaInfoActionV2.java
index 9427f4e2f72..8e2f48970df 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/httpv2/restv2/MetaInfoActionV2.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/httpv2/restv2/MetaInfoActionV2.java
@@ -295,7 +295,9 @@ public class MetaInfoActionV2 extends RestBaseController {
} else {
TableSchema tableSchema = new TableSchema();
tableSchema.setBaseIndex(false);
- List<Schema> schema = generateSchame(tbl.getBaseSchema());
+ // The meta REST API reports table metadata, so it shows the
nullability the source declares
+ // (same view as DESC / SHOW COLUMNS), not the read nullability
execution uses.
+ List<Schema> schema =
generateSchame(tbl.getBaseSchemaForDisplay());
tableSchema.setSchema(schema);
schemaMap.put(tbl.getName(), tableSchema);
schemaInfo.setSchemaMap(schemaMap);
@@ -332,7 +334,8 @@ public class MetaInfoActionV2 extends RestBaseController {
if (tbl.getType() == Table.TableType.OLAP) {
propMap.put("keyType", ((OlapTable) tbl).getKeysType());
}
- propMap.put("schema", generateSchema(tbl.getBaseSchema()));
+ // Metadata endpoint: the display view, like the schema map above.
+ propMap.put("schema", generateSchema(tbl.getBaseSchemaForDisplay()));
}
List<Map<String, String>> generateSchema(List<Column> columns) throws
UserException {
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/mysql/MysqlSerializer.java
b/fe/fe-core/src/main/java/org/apache/doris/mysql/MysqlSerializer.java
index e67f843acef..24d3e6a21d1 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/mysql/MysqlSerializer.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/mysql/MysqlSerializer.java
@@ -33,6 +33,8 @@ import java.nio.ByteBuffer;
// used for serialize memory data to byte stream of MySQL protocol
public class MysqlSerializer {
private static final Logger LOG =
LogManager.getLogger(MysqlSerializer.class);
+ // ColumnDefinition41 flags: the column cannot be NULL (mysql_com.h
NOT_NULL_FLAG).
+ private static final int NOT_NULL_FLAG = 0x0001;
private ByteArrayOutputStream out;
private MysqlCapability capability;
@@ -198,6 +200,16 @@ public class MysqlSerializer {
}
public void writeField(String db, String table, Column column, boolean
sendDefault) {
+ writeField(db, table, column, sendDefault, false);
+ }
+
+ /**
+ * @param encodeNotNull whether ColumnDefinition41's NOT_NULL flag follows
{@code column.isAllowNull()}.
+ * COM_FIELD_LIST passes true (the column is the display schema
column this packet describes);
+ * prepared-statement metadata passes false because its packet
describes an OUTPUT slot whose
+ * nullability can differ from the original column (e.g. the
nullable side of an outer join).
+ */
+ public void writeField(String db, String table, Column column, boolean
sendDefault, boolean encodeNotNull) {
// Catalog Name: length encoded string
writeLenEncodedString("def");
// Schema: length encoded string
@@ -219,7 +231,11 @@ public class MysqlSerializer {
// Column type: one byte integer
writeInt1(getMysqlResultType(column.getType()).getCode());
// Flags: two byte integer
- writeInt2(getMysqlFlags(column.getType()));
+ int flags = getMysqlFlags(column.getType());
+ if (encodeNotNull && !column.isAllowNull()) {
+ flags |= NOT_NULL_FLAG;
+ }
+ writeInt2(flags);
// Decimals: one byte integer
writeInt1(getMysqlDecimals(column.getType()));
// filler: two byte integer
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/mysql/protocol/MysqlResultSender.java
b/fe/fe-core/src/main/java/org/apache/doris/mysql/protocol/MysqlResultSender.java
index 7ff8cfd257f..d4327d117a9 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/mysql/protocol/MysqlResultSender.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/mysql/protocol/MysqlResultSender.java
@@ -234,7 +234,9 @@ public class MysqlResultSender implements ResultSender {
MysqlChannel channel = channel();
for (Column column : columns) {
serializer.reset();
- serializer.writeField(dbName, tableName, column, true);
+ // COM_FIELD_LIST is a metadata statement: carry the display
column's declared nullability in the
+ // ColumnDefinition41 NOT_NULL flag, like SHOW COLUMNS does.
+ serializer.writeField(dbName, tableName, column, true, true);
channel.sendOnePacket(serializer.toByteBuffer());
}
}
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 bd921e533e3..8e91c0d1c89 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
@@ -193,7 +193,7 @@ public class ShowColumnsCommand extends ShowCommand {
boolean mysqlCompatible =
ctx.getSessionVariable().enableMysqlCompatibleIndexMetadata();
Map<String, String> columnKeys = mysqlCompatible
? TableKeyMeta.buildColumnKeys(table) :
Collections.emptyMap();
- List<Column> columns = table.getBaseSchema();
+ List<Column> columns = table.getBaseSchemaForDisplay();
for (Column col : columns) {
if (matcher != null && !matcher.match(col.getName())) {
continue;
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/qe/MysqlConnectProcessor.java
b/fe/fe-core/src/main/java/org/apache/doris/qe/MysqlConnectProcessor.java
index 44495a1bf0e..af8c804259c 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/qe/MysqlConnectProcessor.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/qe/MysqlConnectProcessor.java
@@ -343,7 +343,9 @@ public class MysqlConnectProcessor extends ConnectProcessor
{
table.readLock();
try {
MysqlProtocolAdapter.of(ctx).resultSender(ctx)
- .sendFieldList(db.getFullName(), table.getName(),
table.getBaseSchema());
+ // COM_FIELD_LIST is a metadata statement: it must agree
with SHOW COLUMNS, so it serves
+ // the display schema (the nullability the source
declares), not the read schema.
+ .sendFieldList(db.getFullName(), table.getName(),
table.getBaseSchemaForDisplay());
} catch (Throwable throwable) {
handleQueryException(throwable, "", null, null);
} finally {
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 ee1e5fa9f19..4825fd767ce 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
@@ -1002,7 +1002,7 @@ public class FrontendServiceImpl implements
FrontendService.Iface {
// values such as AGG and DUP that no MySQL client
knows what to do with.
Map<String, String> columnKeys =
params.isMysqlCompatibleIndexMetadata()
? TableKeyMeta.buildColumnKeys(table) :
Collections.emptyMap();
- List<Column> baseSchema = table.getBaseSchemaOrEmpty();
+ List<Column> baseSchema =
table.getBaseSchemaForDisplayOrEmpty();
for (Column column : baseSchema) {
final TColumnDesc desc = getColumnDesc(column);
final TColumnDef colDef = new TColumnDef(desc);
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/catalog/EnvShowCreatePluginTableTest.java
b/fe/fe-core/src/test/java/org/apache/doris/catalog/EnvShowCreatePluginTableTest.java
index aa96b0baea4..ae4a81093bc 100644
---
a/fe/fe-core/src/test/java/org/apache/doris/catalog/EnvShowCreatePluginTableTest.java
+++
b/fe/fe-core/src/test/java/org/apache/doris/catalog/EnvShowCreatePluginTableTest.java
@@ -53,6 +53,10 @@ public class EnvShowCreatePluginTableTest {
Mockito.doReturn("t").when(table).getName();
Mockito.doReturn("").when(table).getComment();
Mockito.doReturn(COLUMNS).when(table).getBaseSchema(false);
+ // SHOW CREATE TABLE reads the DISPLAY schema (see
TableIf.getBaseSchemaForDisplay). Stub it with the
+ // same columns: this test pins the Env render arm, and resolving the
display view for real would need
+ // a connector-backed schema cache (the real accessor is unit-tested
in PluginDrivenExternalTableTest).
+ Mockito.doReturn(COLUMNS).when(table).getBaseSchemaForDisplay(false);
// The engine name a connector declares (here: an iceberg catalog's).
Stubbed because this test is about
// the Env render arm, not about where the name comes from — that is
pinned by
// PluginDrivenExternalTableEngineTest.
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/PluginDrivenExternalTableTest.java
b/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/PluginDrivenExternalTableTest.java
index e54812e27eb..d57a6dd73df 100644
---
a/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/PluginDrivenExternalTableTest.java
+++
b/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/PluginDrivenExternalTableTest.java
@@ -1025,6 +1025,70 @@ public class PluginDrivenExternalTableTest {
.getViewDefinition(Mockito.any(), Mockito.anyString(),
Mockito.anyString());
}
+ @Test
+ public void displaySchemaUsesSourceNullability() {
+ ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class);
+ ConnectorTableHandle handle = Mockito.mock(ConnectorTableHandle.class);
+ Mockito.when(metadata.getTableHandle(Mockito.any(), Mockito.eq("db1"),
Mockito.eq("t1")))
+ .thenReturn(Optional.of(handle));
+ Mockito.when(metadata.getTableSchema(Mockito.any(),
Mockito.eq(handle)))
+ .thenReturn(new ConnectorTableSchema("t1", ImmutableList.of(
+ new ConnectorColumn("id", ConnectorType.of("INT"), "",
true, null, true)
+ .withSourceNullable(false),
+ new ConnectorColumn("name",
ConnectorType.of("STRING"), "", true, null, true),
+ // Required + WITH_TIMEZONE: the display copy must
keep the marker Column's copy
+ // constructor drops, otherwise DESC loses "Extra:
WITH_TIMEZONE" for exactly the
+ // required TZ columns this projection touches.
+ new ConnectorColumn("ts",
ConnectorType.of("TIMESTAMPTZ"), "", true, null, true)
+ .withTimeZone().withSourceNullable(false)),
+ "ICEBERG", Collections.emptyMap()));
+ Mockito.when(metadata.fromRemoteColumnName(Mockito.any(),
Mockito.any(), Mockito.any(),
+ Mockito.anyString())).thenAnswer(inv -> inv.getArgument(3));
+ ConnectorSession session = Mockito.mock(ConnectorSession.class);
+
Mockito.when(session.getStatementScope()).thenReturn(ConnectorStatementScope.NONE);
+ Connector connector = Mockito.mock(Connector.class);
+
Mockito.when(connector.getMetadata(Mockito.any())).thenReturn(metadata);
+ ExternalDatabase db = Mockito.mock(ExternalDatabase.class);
+ Mockito.when(db.getRemoteName()).thenReturn("db1");
+ PluginDrivenExternalCatalog catalog =
Mockito.mock(PluginDrivenExternalCatalog.class);
+ Mockito.when(catalog.getConnector()).thenReturn(connector);
+ Mockito.when(catalog.buildConnectorSession()).thenReturn(session);
+ Mockito.when(catalog.buildCrossStatementSession()).thenReturn(session);
+ PluginDrivenExternalTable table =
+ Mockito.mock(PluginDrivenExternalTable.class,
Mockito.CALLS_REAL_METHODS);
+ Deencapsulation.setField(table, "catalog", catalog);
+ Deencapsulation.setField(table, "db", db);
+ Deencapsulation.setField(table, "remoteName", "t1");
+ Mockito.doReturn(false).when(table).isView();
+ SchemaCacheValue cached = table.initSchema().get();
+
Mockito.doReturn(Optional.of(cached)).when(table).getSchemaCacheValue();
+ Mockito.doReturn(cached.getSchema()).when(table).getFullSchema();
+ // Count the schema-cache reads of the display call alone, not of the
setup above.
+ Mockito.clearInvocations(table);
+
+ List<Column> display = table.getBaseSchemaForDisplay(false);
+
+ // Index-aligned with the read schema: same size, same names, same
order.
+ List<Column> read = table.getBaseSchema(false);
+ Assertions.assertEquals(read.size(), display.size());
+ for (int i = 0; i < read.size(); i++) {
+ Assertions.assertEquals(read.get(i).getName(),
display.get(i).getName());
+ }
+ // The source-required column is shown NOT NULL; the optional one
keeps its nullable view.
+ Assertions.assertFalse(display.get(0).isAllowNull());
+ Assertions.assertTrue(display.get(1).isAllowNull());
+ Assertions.assertFalse(display.get(2).isAllowNull());
+
Assertions.assertTrue(read.get(2).getExtraInfo().contains("WITH_TIMEZONE"));
+
Assertions.assertTrue(display.get(2).getExtraInfo().contains("WITH_TIMEZONE"));
+ // The cached columns keep the nullable read semantics used by scans.
+ Assertions.assertTrue(read.stream().allMatch(Column::isAllowNull));
+ // A column the source does not diverge on is shared, not copied: no
per-call projection is left.
+ Assertions.assertSame(read.get(1), display.get(1));
+ // One schema-cache read per display call (it must not re-read for the
declared nullability, which
+ // would hit the connector twice under a bypassed cache such as a
delegated credential).
+ Mockito.verify(table, Mockito.times(1)).getSchemaCacheValue();
+ }
+
@Test
public void systemTableOverridesResolveIsViewToFalse() {
// A system/metadata table ($snapshots etc.) overrides resolveIsView
to a constant false so the base
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/httpv2/rest/TableSchemaActionColumnInfoTest.java
b/fe/fe-core/src/test/java/org/apache/doris/httpv2/rest/TableSchemaActionColumnInfoTest.java
index 20e2c5e3be7..377c5f91f97 100644
---
a/fe/fe-core/src/test/java/org/apache/doris/httpv2/rest/TableSchemaActionColumnInfoTest.java
+++
b/fe/fe-core/src/test/java/org/apache/doris/httpv2/rest/TableSchemaActionColumnInfoTest.java
@@ -24,6 +24,7 @@ import org.apache.doris.catalog.MapType;
import org.apache.doris.catalog.ScalarType;
import org.apache.doris.catalog.StructField;
import org.apache.doris.catalog.StructType;
+import org.apache.doris.catalog.TableIf;
import org.apache.doris.catalog.Type;
import org.apache.doris.catalog.VariantField;
import org.apache.doris.catalog.VariantType;
@@ -34,11 +35,32 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.collect.Lists;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+import java.util.List;
import java.util.Map;
public class TableSchemaActionColumnInfoTest {
+ @Test
+ public void schemaRouteReportsDeclaredNullability() {
+ Column declaredNotNull = new Column("id", ScalarType.INT);
+ declaredNotNull.setIsAllowNull(false);
+ Column readNullable = new Column("id", ScalarType.INT);
+ readNullable.setIsAllowNull(true);
+
+ TableIf table = Mockito.mock(TableIf.class);
+
Mockito.when(table.getBaseSchemaForDisplay()).thenReturn(Lists.newArrayList(declaredNotNull));
+ // The read schema deliberately differs: switching /_schema back to it
must fail this test.
+
Mockito.when(table.getBaseSchema()).thenReturn(Lists.newArrayList(readNullable));
+
+ List<Column> columns = TableSchemaAction.schemaColumns(table);
+
+ Assertions.assertSame(declaredNotNull, columns.get(0));
+ Assertions.assertEquals("No",
TableSchemaAction.buildColumnInfo(columns.get(0)).get("is_nullable"));
+ Assertions.assertEquals("Yes",
TableSchemaAction.buildColumnInfo(readNullable).get("is_nullable"));
+ }
+
@Test
public void testArrayDecimalColumnInfo() {
Column column = new Column("array_decimal",
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/mysql/protocol/MysqlResultSenderTest.java
b/fe/fe-core/src/test/java/org/apache/doris/mysql/protocol/MysqlResultSenderTest.java
index 8fdf3d0709a..2a91ae1feb8 100644
---
a/fe/fe-core/src/test/java/org/apache/doris/mysql/protocol/MysqlResultSenderTest.java
+++
b/fe/fe-core/src/test/java/org/apache/doris/mysql/protocol/MysqlResultSenderTest.java
@@ -20,10 +20,13 @@ package org.apache.doris.mysql.protocol;
import org.apache.doris.catalog.Column;
import org.apache.doris.catalog.PrimitiveType;
import org.apache.doris.catalog.ScalarType;
+import org.apache.doris.catalog.TableIf;
import org.apache.doris.catalog.Type;
import org.apache.doris.common.FeConstants;
import org.apache.doris.mysql.MysqlChannel;
import org.apache.doris.nereids.StatementContext;
+import org.apache.doris.nereids.trees.expressions.ExprId;
+import org.apache.doris.nereids.trees.expressions.SlotReference;
import org.apache.doris.qe.CommonResultSet;
import org.apache.doris.qe.CommonResultSet.CommonResultSetMetaData;
import org.apache.doris.qe.ConnectContext;
@@ -218,6 +221,58 @@ public class MysqlResultSenderTest {
Assertions.assertEquals(2, channel.getOutbound().size());
}
+ @Test
+ public void testFieldListReportsDeclaredNullability() throws IOException {
+ RecordingMysqlChannel channel = newChannel(true);
+ ConnectContext ctx = newContext(channel);
+ Column required = new Column("id", PrimitiveType.INT);
+ required.setIsAllowNull(false);
+ Column optional = new Column("name", PrimitiveType.VARCHAR);
+ optional.setIsAllowNull(true);
+
+ // COM_FIELD_LIST is fed the display schema, so its ColumnDefinition41
packets must carry the
+ // NOT_NULL flag for a source-required column (mysql_list_fields /
MySQL clients read it).
+ MysqlProtocolAdapter.of(ctx).resultSender(ctx)
+ .sendFieldList("db1", "tbl1", Lists.newArrayList(required,
optional));
+
+ List<byte[]> packets = payloads(channel);
+ Assertions.assertEquals(2, packets.size());
+ Assertions.assertEquals(1, fieldFlags(packets.get(0)) & 1);
+ Assertions.assertEquals(0, fieldFlags(packets.get(1)) & 1);
+ }
+
+ @Test
+ public void testPrepareMetadataKeepsOuterJoinOutputNullable() throws
IOException {
+ RecordingMysqlChannel channel = newChannel(true);
+ ConnectContext ctx = newContext(channel);
+ Column required = new Column("id", PrimitiveType.INT);
+ required.setIsAllowNull(false);
+ TableIf table = Mockito.mock(TableIf.class);
+ Mockito.when(table.getName()).thenReturn("r");
+ // The nullable side of an outer join: the output slot is nullable
while the original column (which
+ // the prepare metadata packet describes) is NOT NULL.
+ SlotReference output = SlotReference.fromColumn(new ExprId(1), table,
required, Lists.newArrayList("r"))
+ .withNullable(true);
+
+ MysqlProtocolAdapter.of(ctx).resultSender(ctx)
+ .sendStmtPrepareOK(1, Lists.newArrayList(),
Lists.newArrayList(output));
+
+ List<byte[]> packets = payloads(channel);
+ Assertions.assertEquals(2, packets.size()); // prepare OK + one column
definition
+ Assertions.assertEquals(0, fieldFlags(packets.get(1)) & 1);
+ }
+
+ /** The 2-byte flags of a ColumnDefinition41 packet; all fixtures here use
one-byte length-encoded names. */
+ private static int fieldFlags(byte[] payload) {
+ int off = 0;
+ for (int i = 0; i < 6; i++) { // catalog, schema, table, org_table,
name, org_name
+ off += 1 + (payload[off] & 0xFF);
+ }
+ Assertions.assertEquals(0x0c, payload[off++] & 0xFF);
+ off += 2 + 4 + 1; // charset, column length, column type
+ return (payload[off] & 0xFF) | ((payload[off + 1] & 0xFF) << 8);
+ }
+
@Test
public void testResetClearsTheChannel() {
MysqlChannel channel = Mockito.mock(MysqlChannel.class);
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/data/external_table_p0/iceberg/test_iceberg_initial_defaults.out
b/regression-test/data/external_table_p0/iceberg/test_iceberg_initial_defaults.out
index a465b704587..bae87ce9b5f 100644
---
a/regression-test/data/external_table_p0/iceberg/test_iceberg_initial_defaults.out
+++
b/regression-test/data/external_table_p0/iceberg/test_iceberg_initial_defaults.out
@@ -82,7 +82,7 @@ default_decimal decimal(20,4) Yes true \N
default_date date Yes true \N
default_timestamp datetime(6) Yes true \N
default_timestamptz timestamptz(6) Yes true \N WITH_TIMEZONE
-default_string text Yes true \N
+default_string text No true \N
default_uuid uuid Yes true \N
default_fixed varbinary(4) Yes true \N
default_binary varbinary(2147483647) Yes true \N
@@ -179,7 +179,7 @@ default_decimal decimal(20,4) Yes true \N
default_date date Yes true \N
default_timestamp datetime(6) Yes true \N
default_timestamptz timestamptz(6) Yes true \N WITH_TIMEZONE
-default_string text Yes true \N
+default_string text No true \N
default_uuid uuid Yes true \N
default_fixed varbinary(4) Yes true \N
default_binary varbinary(2147483647) Yes true \N
@@ -276,7 +276,7 @@ default_decimal decimal(20,4) Yes true \N
default_date date Yes true \N
default_timestamp datetime(6) Yes true \N
default_timestamptz timestamptz(6) Yes true \N WITH_TIMEZONE
-default_string text Yes true \N
+default_string text No true \N
default_uuid uuid Yes true \N
default_fixed varbinary(4) Yes true \N
default_binary varbinary(2147483647) Yes true \N
@@ -373,7 +373,7 @@ default_decimal decimal(20,4) Yes true \N
default_date date Yes true \N
default_timestamp datetime(6) Yes true \N
default_timestamptz timestamptz(6) Yes true \N WITH_TIMEZONE
-default_string text Yes true \N
+default_string text No true \N
default_uuid uuid Yes true \N
default_fixed varbinary(4) Yes true \N
default_binary varbinary(2147483647) Yes true \N
diff --git
a/regression-test/data/external_table_p0/iceberg/test_iceberg_show_nullable.out
b/regression-test/data/external_table_p0/iceberg/test_iceberg_show_nullable.out
new file mode 100644
index 00000000000..b14cf5aacb7
--- /dev/null
+++
b/regression-test/data/external_table_p0/iceberg/test_iceberg_show_nullable.out
@@ -0,0 +1,29 @@
+-- This file is automatically generated. You should know what you did if you
want to edit this
+-- !desc --
+id bigint No true \N
+value text No true \N
+event_time datetime(6) Yes true \N
+
+-- !show_columns --
+id bigint NO YES \N
+value text NO YES \N
+event_time datetime(6) YES YES \N
+
+-- !show_columns_where --
+id bigint NO \N
+value string NO \N
+
+-- !information_schema --
+event_time YES
+id NO
+value NO
+
+-- !select --
+1 a 2026-01-01T00:00
+2 b \N
+
+-- !desc_after_alter --
+id bigint No true \N
+value text Yes true \N
+event_time datetime(6) 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..59b5cade746
--- /dev/null
+++
b/regression-test/suites/external_table_p0/iceberg/test_iceberg_show_nullable.groovy
@@ -0,0 +1,71 @@
+// 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") {
+ String enabled = context.config.otherConfigs.get("enableIcebergTest")
+ if (enabled == null || !enabled.equalsIgnoreCase("true")) {
+ logger.info("disable iceberg test.")
+ return
+ }
+
+ String rest_port = context.config.otherConfigs.get("iceberg_rest_uri_port")
+ String minio_port = context.config.otherConfigs.get("iceberg_minio_port")
+ String externalEnvIp = context.config.otherConfigs.get("externalEnvIp")
+ String catalog_name = "test_iceberg_show_nullable"
+
+ sql """drop catalog if exists ${catalog_name}"""
+ sql """
+ CREATE CATALOG ${catalog_name} PROPERTIES (
+ 'type'='iceberg',
+ 'iceberg.catalog.type'='rest',
+ 'uri' = 'http://${externalEnvIp}:${rest_port}',
+ "s3.access_key" = "admin",
+ "s3.secret_key" = "password",
+ "s3.endpoint" = "http://${externalEnvIp}:${minio_port}",
+ "s3.region" = "us-east-1"
+ );"""
+
+ sql """switch ${catalog_name}"""
+ sql """drop database if exists test_iceberg_show_nullable_db force"""
+ sql """create database test_iceberg_show_nullable_db"""
+ sql """use test_iceberg_show_nullable_db"""
+
+ sql """
+ create table iceberg_show_nullable (
+ id bigint not null,
+ value string not null,
+ event_time datetime
+ )"""
+ sql """insert into iceberg_show_nullable values (1, 'a', '2026-01-01
00:00:00'), (2, 'b', null)"""
+
+ qt_desc """desc iceberg_show_nullable"""
+ qt_show_columns """show columns from iceberg_show_nullable"""
+ // SHOW COLUMNS ... WHERE is rewritten to information_schema.columns; no
ORDER BY, so the rows are
+ // sorted by the framework (order_qt_) instead of being compared in an
unspecified scan order.
+ order_qt_show_columns_where """show columns from iceberg_show_nullable
where `Null` = 'NO'"""
+ order_qt_information_schema """select COLUMN_NAME, IS_NULLABLE from
information_schema.columns
+ where TABLE_SCHEMA = 'test_iceberg_show_nullable_db' and TABLE_NAME =
'iceberg_show_nullable'"""
+ String ddl = sql("""show create table iceberg_show_nullable""")[0][1]
+ assertTrue(ddl.contains("`id` bigint NOT NULL"), ddl)
+ assertTrue(ddl.contains("`value` text NOT NULL"), ddl)
+ assertTrue(ddl.contains("`event_time` datetimev2(6) NULL"), ddl)
+ order_qt_select """select * from iceberg_show_nullable"""
+
+ // Relaxing the constraint is reflected without a new data snapshot.
+ sql """alter table iceberg_show_nullable modify column value string null"""
+ qt_desc_after_alter """desc iceberg_show_nullable"""
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]