This is an automated email from the ASF dual-hosted git repository.
morningman 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 b549b6cfa57 [test](iceberg) Cover required columns on empty tables,
writes and SHOW FULL COLUMNS (#69351)
b549b6cfa57 is described below
commit b549b6cfa57e244df399834f714ea170f6ca10ab
Author: Mingyu Chen (Rayner) <[email protected]>
AuthorDate: Wed Oct 21 09:44:49 2026 +0100
[test](iceberg) Cover required columns on empty tables, writes and SHOW
FULL COLUMNS (#69351)
### What problem does this PR solve?
Issue Number: None
Related PR: #67856, #68640
Problem Summary:
**In short.** Tests only. #68640 shows required Iceberg columns as NOT
NULL on master, as #67856 does on branch-4.1 and branch-4.2. The two
implementations behave the same (master also covers the HTTP schema APIs
and the MySQL protocol column metadata), but master's tests miss several
cases that the 4.x tests check. This PR adds them; it changes no
production code.
Original author: @hubgeter (the 4.x tests these cases come from)
**Background.** An Iceberg column declared `NOT NULL` (required) is read
by Doris as nullable, so scans and expressions keep their nullable
semantics, while DESC, SHOW CREATE TABLE, SHOW [FULL] COLUMNS and
information_schema show the declared constraint. On master,
`ConnectorColumn.sourceNullable` carries the declared nullability, and
`PluginDrivenExternalTable` projects a display view next to the read
schema in the same schema-cache entry.
**The problem, and what it cost.** These behaviors had no test on
master, so a regression in any of them would go unnoticed:
| Case | 4.x test | master before this PR |
|---|---|---|
| A table without any snapshot shows its required columns | yes | no
(the table was written before the first DESC) |
| Writing NULL into a required column is still rejected | yes | no |
| Relaxing a column creates no snapshot; information_schema, SHOW CREATE
TABLE and later writes follow it | yes | DESC only |
| SHOW FULL COLUMNS with the column comment, SHOW COLUMNS LIKE, `WHERE
Null = 'YES'`, information_schema filtered by IS_NULLABLE | yes | no |
| A hidden column keeps its declared nullability and is listed only in
the full view | yes | no (only the visible view) |
**How this PR fixes it.**
- `test_iceberg_show_nullable` (REST catalog): checks the empty table (0
snapshots) before the first write; adds SHOW FULL COLUMNS, SHOW COLUMNS
LIKE, `SHOW FULL COLUMNS ... WHERE Null = 'YES'` and information_schema
filtered by IS_NULLABLE; checks the column comment in SHOW CREATE TABLE;
writes NULL into `id` and `value` and expects "Column 'id' is declared
non-nullable but contains nulls" (the message the 4.x test expects too);
relaxes `value`, checks that the snapshot count stays 1 and that
information_schema and SHOW CREATE TABLE follow, then writes NULL into
`value` and still expects the rejection for `id`.
- `PluginDrivenExternalTableTest`: a new test with a hidden required
column checks that `getBaseSchemaForDisplay(full)` lists exactly the
columns of `getBaseSchema(full)`, in the same order, for both values of
`full`, and that the hidden column is NOT NULL only in the display view.
The setup of the existing display test moves into a helper both tests
use.
**Results.** Both pass locally. The blocks already in
`test_iceberg_show_nullable.out` are unchanged; 11 blocks are added. The
new unit test fails if the display view ignores `full`, or if a copied
display column loses its visibility.
Co-authored-by: daidai <[email protected]>
---
.../plugin/PluginDrivenExternalTableTest.java | 99 ++++++++++++++--------
.../iceberg/test_iceberg_show_nullable.out | 44 ++++++++++
.../iceberg/test_iceberg_show_nullable.groovy | 41 ++++++++-
3 files changed, 147 insertions(+), 37 deletions(-)
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 d57a6dd73df..f21ed9f8006 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
@@ -61,6 +61,7 @@ import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.atomic.AtomicReference;
+import java.util.stream.Collectors;
/**
* Pins {@link PluginDrivenExternalTable#getFullSchema()} request-scoped
synthetic-write-column injection
@@ -1027,21 +1028,74 @@ public class PluginDrivenExternalTableTest {
@Test
public void displaySchemaUsesSourceNullability() {
+ PluginDrivenExternalTable table =
tableWithCachedSchema(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)));
+
+ 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 displaySchemaKeepsHiddenColumnsOnlyInTheFullView() {
+ // The display view mirrors getBaseSchema(full): a hidden column is
listed, with the nullability its
+ // source declares, only when hidden columns are requested, so DESC
and SHOW COLUMNS under
+ // show_hidden_columns stay index-aligned with the read schema.
MUTATION: ignoring full, or dropping
+ // the visibility of a column the projection copies -> red.
+ PluginDrivenExternalTable table =
tableWithCachedSchema(ImmutableList.of(
+ new ConnectorColumn("id", ConnectorType.of("INT"), "", true,
null, true)
+ .withSourceNullable(false),
+ new ConnectorColumn("row_lineage", ConnectorType.of("BIGINT"),
"", true, null, false)
+ .invisible().withSourceNullable(false),
+ new ConnectorColumn("name", ConnectorType.of("STRING"), "",
true, null, true)));
+
+ for (boolean full : new boolean[] {false, true}) {
+ Assertions.assertEquals(
+
table.getBaseSchema(full).stream().map(Column::getName).collect(Collectors.toList()),
+
table.getBaseSchemaForDisplay(full).stream().map(Column::getName).collect(Collectors.toList()));
+ }
+ Assertions.assertEquals(ImmutableList.of("id", "name"),
table.getBaseSchemaForDisplay(false).stream()
+ .map(Column::getName).collect(Collectors.toList()));
+ Column hidden = table.getBaseSchemaForDisplay(true).get(1);
+ Assertions.assertEquals("row_lineage", hidden.getName());
+ Assertions.assertFalse(hidden.isVisible());
+ Assertions.assertFalse(hidden.isAllowNull());
+ Assertions.assertTrue(table.getBaseSchema(true).get(1).isAllowNull());
+ }
+
+ /** A table whose schema cache holds {@code columns}, with the
schema-cache reads of this setup cleared. */
+ private static PluginDrivenExternalTable
tableWithCachedSchema(List<ConnectorColumn> columns) {
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()));
+ .thenReturn(new ConnectorTableSchema("t1", columns, "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);
@@ -1063,30 +1117,9 @@ public class PluginDrivenExternalTableTest {
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.
+ // Count the schema-cache reads of the calls under test 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();
+ return table;
}
@Test
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
index b14cf5aacb7..7370e41078d 100644
---
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
@@ -1,4 +1,17 @@
-- This file is automatically generated. You should know what you did if you
want to edit this
+-- !snapshots_empty --
+0
+
+-- !desc_empty --
+id bigint No true \N
+value text No true \N
+event_time datetime(6) Yes true \N
+
+-- !information_schema_empty --
+event_time YES
+id NO
+value NO
+
-- !desc --
id bigint No true \N
value text No true \N
@@ -9,21 +22,52 @@ id bigint NO YES \N
value text NO YES \N
event_time datetime(6) YES YES \N
+-- !show_full_columns --
+id bigint NO YES \N
+value text NO YES \N required value
+event_time datetime(6) YES YES \N
+
+-- !show_columns_like --
+id bigint NO YES \N
+
-- !show_columns_where --
id bigint NO \N
value string NO \N
+-- !show_full_columns_where --
+event_time datetime \N YES \N
+
-- !information_schema --
event_time YES
id NO
value NO
+-- !information_schema_required --
+id NO
+value NO
+
-- !select --
1 a 2026-01-01T00:00
2 b \N
+-- !snapshots_before_alter --
+1
+
+-- !snapshots_after_alter --
+1
+
-- !desc_after_alter --
id bigint No true \N
value text Yes true \N
event_time datetime(6) Yes true \N
+-- !information_schema_after_alter --
+event_time YES
+id NO
+value YES
+
+-- !select_after_alter --
+1 a 2026-01-01T00:00
+2 b \N
+3 \N \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
index 59b5cade746..5f1b9eb94e0 100644
---
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
@@ -47,25 +47,58 @@ suite("test_iceberg_show_nullable", "p0,external") {
sql """
create table iceberg_show_nullable (
id bigint not null,
- value string not null,
+ value string not null comment 'required value',
event_time datetime
)"""
+ String columnsQuery = """select COLUMN_NAME, IS_NULLABLE from
information_schema.columns
+ where TABLE_SCHEMA = 'test_iceberg_show_nullable_db' and TABLE_NAME =
'iceberg_show_nullable'"""
+
+ // A new table has no snapshot yet, but its schema already declares the
required columns.
+ qt_snapshots_empty """select count(*) from
iceberg_show_nullable\$snapshots"""
+ qt_desc_empty """desc iceberg_show_nullable"""
+ order_qt_information_schema_empty(columnsQuery)
+
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"""
+ qt_show_full_columns """show full columns from iceberg_show_nullable"""
+ qt_show_columns_like """show columns from iceberg_show_nullable like
'id'"""
// 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'"""
+ order_qt_show_full_columns_where """show full columns from
iceberg_show_nullable where `Null` = 'YES'"""
+ order_qt_information_schema(columnsQuery)
+ order_qt_information_schema_required(columnsQuery + " and IS_NULLABLE =
'NO'")
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("`value` text NOT NULL COMMENT \"required
value\""), ddl)
assertTrue(ddl.contains("`event_time` datetimev2(6) NULL"), ddl)
order_qt_select """select * from iceberg_show_nullable"""
+ // Only the metadata view is NOT NULL; the columns are read as nullable,
and a write still enforces
+ // the declared constraint.
+ test {
+ sql """insert into iceberg_show_nullable values (null, 'bad',
'2026-01-02 00:00:00')"""
+ exception "Column 'id' is declared non-nullable but contains nulls"
+ }
+ test {
+ sql """insert into iceberg_show_nullable values (3, null, '2026-01-02
00:00:00')"""
+ exception "Column 'value' is declared non-nullable but contains nulls"
+ }
+
// Relaxing the constraint is reflected without a new data snapshot.
+ qt_snapshots_before_alter """select count(*) from
iceberg_show_nullable\$snapshots"""
sql """alter table iceberg_show_nullable modify column value string null"""
+ qt_snapshots_after_alter """select count(*) from
iceberg_show_nullable\$snapshots"""
qt_desc_after_alter """desc iceberg_show_nullable"""
+ order_qt_information_schema_after_alter(columnsQuery)
+ ddl = sql("""show create table iceberg_show_nullable""")[0][1]
+ assertTrue(ddl.contains("`value` text NULL COMMENT \"required value\""),
ddl)
+ sql """insert into iceberg_show_nullable values (3, null, null)"""
+ order_qt_select_after_alter """select * from iceberg_show_nullable"""
+ test {
+ sql """insert into iceberg_show_nullable values (null, 'still
required', null)"""
+ exception "Column 'id' is declared non-nullable but contains nulls"
+ }
}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]