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 20dab94092a branch-4.1:[feature](iceberg) Enforce NULL defaults for
VARIANT (#67323)
20dab94092a is described below
commit 20dab94092a91dde79b454db010f55ca5c031131
Author: daidai <[email protected]>
AuthorDate: Sun Sep 6 23:48:17 2026 +0800
branch-4.1:[feature](iceberg) Enforce NULL defaults for VARIANT (#67323)
### What problem does this PR solve?
Related PR: #66538
Problem Summary:
Iceberg V3 requires both `initial-default` and `write-default` of a
VARIANT field to be NULL. After adding general Iceberg V3 default-value
support, Doris still needs explicit VARIANT handling to avoid accepting
unsupported non-NULL defaults or reconstructing a compute-V2 VARIANT as
the legacy type.
This PR:
- Rejects non-NULL VARIANT `initial-default` and `write-default` values
in FE.
- Adds the same validation to the BE read path for malformed or
externally forged Iceberg metadata.
- Preserves the compute-V2 VARIANT type when reconstructing the current
schema from Thrift metadata.
- Supports typed SQL NULL defaults for optional VARIANT fields,
including VARIANT fields nested inside structs.
- Adds FE and BE unit coverage.
- Adds an external Iceberg regression covering historical Parquet/ORC
rows, INSERT, UPDATE, MERGE, explicit `DEFAULT`, SQL NULL versus JSON
null, invalid DDL, and Spark interoperability.
### Release note
None
### Check List (For Author)
- Test <!-- At least one of them must be included. -->
- [ ] Regression test
- [ ] 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 <!-- Add your reason? -->
- Behavior changed:
- [ ] No.
- [ ] Yes. <!-- Explain the behavior change -->
- Does this need documentation?
- [ ] No.
- [ ] Yes. <!-- Add document PR link here. eg:
https://github.com/apache/doris-website/pull/1214 -->
### Check List (For Reviewer who merge this PR)
- [ ] Confirm the release note
- [ ] Confirm test cases
- [ ] Confirm document
- [ ] Add branch pick label <!-- Add branch pick label that this PR
should merge into -->
---
be/src/format_v2/table/iceberg_reader.cpp | 8 ++
be/src/format_v2/table_reader.cpp | 5 +
be/test/format_v2/table/iceberg_reader_test.cpp | 103 +++++++++++++
.../doris/datasource/iceberg/IcebergUtils.java | 5 +
.../iceberg/IcebergWriteSchemaContext.java | 2 +
.../doris/datasource/iceberg/IcebergUtilsTest.java | 21 +++
.../iceberg/IcebergWriteSchemaContextTest.java | 42 ++++++
.../iceberg/test_iceberg_variant_defaults.out | 22 +++
.../iceberg/test_iceberg_variant_defaults.groovy | 160 +++++++++++++++++++++
9 files changed, 368 insertions(+)
diff --git a/be/src/format_v2/table/iceberg_reader.cpp
b/be/src/format_v2/table/iceberg_reader.cpp
index 24c2a5862ad..078b413a059 100644
--- a/be/src/format_v2/table/iceberg_reader.cpp
+++ b/be/src/format_v2/table/iceberg_reader.cpp
@@ -311,6 +311,10 @@ static Status build_v2_json_scalar_default(const
format::ColumnDefinition& field
std::deque<std::string>*
binary_storage,
const cctz::time_zone* timezone,
Field* result) {
const auto primitive_type = value_type->get_primitive_type();
+ if (primitive_type == TYPE_VARIANT) {
+ return Status::InvalidArgument(
+ "Iceberg VARIANT initial-default for field '{}' must be NULL",
field.name);
+ }
std::string serialized_value = iceberg_json_scalar_text(json_value);
const bool binary_like =
field.initial_default_value_is_base64 || primitive_type ==
TYPE_VARBINARY;
@@ -395,6 +399,10 @@ static Status build_v2_initial_default_field(const
format::ColumnDefinition& fie
const auto value_type = remove_nullable(data_type);
const auto primitive_type = value_type->get_primitive_type();
+ if (primitive_type == TYPE_VARIANT) {
+ return Status::InvalidArgument(
+ "Iceberg VARIANT initial-default for field '{}' must be NULL",
field.name);
+ }
if (is_complex_type(primitive_type)) {
rapidjson::Document document;
document.Parse<rapidjson::kParseFullPrecisionFlag>(field.initial_default_value->data(),
diff --git a/be/src/format_v2/table_reader.cpp
b/be/src/format_v2/table_reader.cpp
index 12756e7c319..78089087c76 100644
--- a/be/src/format_v2/table_reader.cpp
+++ b/be/src/format_v2/table_reader.cpp
@@ -511,6 +511,11 @@ DataTypePtr restore_current_primitive_type(const
schema::external::TField& field
}
DORIS_CHECK(fallback_type != nullptr);
const auto primitive_type = thrift_to_type(field.type.type);
+ if (primitive_type == TYPE_VARIANT) {
+ // TColumnType predates the execution-only variant_is_v2 marker.
Reconstructing from its
+ // primitive enum would silently replace an Iceberg compute-V2 carrier
with legacy VARIANT.
+ return fallback_type;
+ }
if (is_complex_type(primitive_type)) {
return fallback_type;
}
diff --git a/be/test/format_v2/table/iceberg_reader_test.cpp
b/be/test/format_v2/table/iceberg_reader_test.cpp
index cc2b2096e14..b66655832d7 100644
--- a/be/test/format_v2/table/iceberg_reader_test.cpp
+++ b/be/test/format_v2/table/iceberg_reader_test.cpp
@@ -60,6 +60,7 @@
#include "core/data_type/data_type_struct.h"
#include "core/data_type/data_type_timestamptz.h"
#include "core/data_type/data_type_varbinary.h"
+#include "core/data_type/data_type_variant_v2.h"
#include "exec/common/endian.h"
#include "exec/scan/access_path_parser.h"
#include "exprs/runtime_filter_expr.h"
@@ -1505,6 +1506,108 @@ TEST(IcebergV2ReaderTest,
AnnotateConvertsTimestamptzDefaultToSessionTimezone) {
"2025-01-18 09:02:03.654321");
}
+TEST(IcebergV2ReaderTest, AnnotateVariantInitialDefaultMustBeNull) {
+ const auto variant_type =
make_nullable(std::make_shared<DataTypeVariantV2>());
+
+ auto optional_variant =
+ external_schema_field("payload", 1, {}, std::nullopt,
+
external_primitive_type(TPrimitiveType::VARIANT), false, true);
+ TFileScanRangeParams valid_scan_params;
+
valid_scan_params.__set_iceberg_scan_semantics_version(ICEBERG_SCAN_SEMANTICS_VERSION_2);
+ valid_scan_params.__set_current_schema_id(100);
+ valid_scan_params.__set_history_schema_info(
+ {external_schema(100, {std::move(optional_variant)})});
+
+ ColumnDefinition valid_column;
+ valid_column.name = "payload";
+ valid_column.type = variant_type;
+ ProjectedColumnBuildContext valid_context {.scan_params =
&valid_scan_params};
+ doris::format::iceberg::IcebergTableReader reader;
+ ASSERT_TRUE(reader.annotate_projected_column(TFileScanSlotInfo(),
&valid_context, &valid_column)
+ .ok());
+ ASSERT_TRUE(valid_column.is_optional.has_value());
+ EXPECT_TRUE(*valid_column.is_optional);
+ EXPECT_FALSE(valid_column.initial_default_value.has_value());
+ EXPECT_EQ(valid_column.default_expr, nullptr);
+ ASSERT_TRUE(valid_context.schema_column.has_value());
+ EXPECT_TRUE(valid_context.schema_column->type->equals(*variant_type));
+
+ auto malformed_variant =
+ external_schema_field("payload", 1, {},
"{\"source\":\"malformed\"}",
+
external_primitive_type(TPrimitiveType::VARIANT), false, true);
+ TFileScanRangeParams malformed_scan_params;
+
malformed_scan_params.__set_iceberg_scan_semantics_version(ICEBERG_SCAN_SEMANTICS_VERSION_2);
+ malformed_scan_params.__set_current_schema_id(101);
+ malformed_scan_params.__set_history_schema_info(
+ {external_schema(101, {std::move(malformed_variant)})});
+
+ ColumnDefinition malformed_column;
+ malformed_column.name = "payload";
+ malformed_column.type = variant_type;
+ ProjectedColumnBuildContext malformed_context {.scan_params =
&malformed_scan_params};
+ const auto status = reader.annotate_projected_column(TFileScanSlotInfo(),
&malformed_context,
+ &malformed_column);
+ EXPECT_TRUE(status.is<ErrorCode::INVALID_ARGUMENT>()) << status;
+ EXPECT_NE(status.to_string().find("VARIANT initial-default for field
'payload' must be NULL"),
+ std::string::npos)
+ << status;
+}
+
+TEST(IcebergV2ReaderTest, StructInitialDefaultKeepsVariantChildNull) {
+ const auto variant_type =
make_nullable(std::make_shared<DataTypeVariantV2>());
+ const auto struct_type = make_nullable(
+ std::make_shared<DataTypeStruct>(DataTypes {variant_type}, Strings
{"payload"}));
+
+ auto optional_variant =
+ external_schema_field("payload", 2, {}, std::nullopt,
+
external_primitive_type(TPrimitiveType::VARIANT), false, true);
+ auto valid_struct =
+ external_struct_schema_field("event", 1,
{std::move(optional_variant)}, true, "{}");
+ TFileScanRangeParams valid_scan_params;
+
valid_scan_params.__set_iceberg_scan_semantics_version(ICEBERG_SCAN_SEMANTICS_VERSION_2);
+ valid_scan_params.__set_current_schema_id(100);
+ valid_scan_params.__set_history_schema_info({external_schema(100,
{std::move(valid_struct)})});
+
+ ColumnDefinition valid_column;
+ valid_column.name = "event";
+ valid_column.type = struct_type;
+ ProjectedColumnBuildContext valid_context {.scan_params =
&valid_scan_params};
+ doris::format::iceberg::IcebergTableReader reader;
+ ASSERT_TRUE(reader.annotate_projected_column(TFileScanSlotInfo(),
&valid_context, &valid_column)
+ .ok());
+ ASSERT_NE(valid_column.default_expr, nullptr);
+ const auto* literal = dynamic_cast<const
VLiteral*>(valid_column.default_expr->root().get());
+ ASSERT_NE(literal, nullptr);
+ EXPECT_TRUE(literal->get_data_type()->equals(*struct_type));
+ Field value;
+ literal->get_column_ptr()->get(0, value);
+ const auto& struct_fields = value.get<TYPE_STRUCT>();
+ ASSERT_EQ(struct_fields.size(), 1);
+ EXPECT_TRUE(struct_fields[0].is_null());
+
+ auto malformed_variant =
+ external_schema_field("payload", 2, {}, std::nullopt,
+
external_primitive_type(TPrimitiveType::VARIANT), false, true);
+ auto malformed_struct = external_struct_schema_field(
+ "event", 1, {std::move(malformed_variant)}, true,
"{\"2\":{\"source\":\"malformed\"}}");
+ TFileScanRangeParams malformed_scan_params;
+
malformed_scan_params.__set_iceberg_scan_semantics_version(ICEBERG_SCAN_SEMANTICS_VERSION_2);
+ malformed_scan_params.__set_current_schema_id(101);
+ malformed_scan_params.__set_history_schema_info(
+ {external_schema(101, {std::move(malformed_struct)})});
+
+ ColumnDefinition malformed_column;
+ malformed_column.name = "event";
+ malformed_column.type = struct_type;
+ ProjectedColumnBuildContext malformed_context {.scan_params =
&malformed_scan_params};
+ const auto status = reader.annotate_projected_column(TFileScanSlotInfo(),
&malformed_context,
+ &malformed_column);
+ EXPECT_TRUE(status.is<ErrorCode::INVALID_ARGUMENT>()) << status;
+ EXPECT_NE(status.to_string().find("VARIANT initial-default for field
'payload' must be NULL"),
+ std::string::npos)
+ << status;
+}
+
TEST(IcebergV2ReaderTest, AnnotateBuildsComplexInitialDefaults) {
const auto required_int_type = std::make_shared<DataTypeInt32>();
const auto optional_string_type =
make_nullable(std::make_shared<DataTypeString>());
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 7c56314c569..c60f64c6f9c 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
@@ -1224,6 +1224,8 @@ public class IcebergUtils {
} catch (NumberFormatException e) {
throw new IllegalArgumentException("Invalid Decimal
string: " + value, e);
}
+ case VARIANT:
+ throw new IllegalArgumentException("Iceberg VARIANT default
values must be NULL");
default:
throw new IllegalArgumentException("Cannot parse unknown type:
" + type);
}
@@ -1427,6 +1429,9 @@ public class IcebergUtils {
private static String
serializeInitialDefault(org.apache.iceberg.types.Type type, Object value,
boolean enableMappingTimestampTz) {
+ if (type.typeId() == TypeID.VARIANT) {
+ throw new IllegalArgumentException("Iceberg VARIANT
initial-default must be NULL");
+ }
if (type.isNestedType()) {
// Keep Iceberg's type-directed JSON representation for
struct/list/map values. In
// particular, struct members are keyed by field id and an empty
object is the V3
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergWriteSchemaContext.java
b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergWriteSchemaContext.java
index 4de141ad1e5..9b2e8cfc859 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergWriteSchemaContext.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergWriteSchemaContext.java
@@ -641,6 +641,8 @@ public final class IcebergWriteSchemaContext {
case STRUCT:
return structExpression((Types.StructType) icebergType, value,
targetType,
enableMappingVarbinary, enableMappingTimestampTz);
+ case VARIANT:
+ throw new AnalysisException("Iceberg VARIANT write-default
must be NULL");
default:
throw new AnalysisException("Unsupported Iceberg write-default
type: " + icebergType);
}
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergUtilsTest.java
b/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergUtilsTest.java
index 4c88db354b0..5ba783d1576 100644
---
a/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergUtilsTest.java
+++
b/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergUtilsTest.java
@@ -394,6 +394,27 @@ public class IcebergUtilsTest {
Assert.assertEquals(Types.VariantType.get(),
IcebergUtils.dorisTypeToIcebergType(type));
}
+ @Test
+ public void testIcebergVariantDefaultsMustBeNull() {
+ Types.VariantType variantType = Types.VariantType.get();
+ Schema schema = new Schema(Types.NestedField.optional(1, "payload",
variantType));
+
+ Assert.assertNull(IcebergUtils.parseIcebergLiteral(null, variantType));
+ Assert.assertTrue(IcebergUtils.getSerializedInitialDefaults(schema,
false).isEmpty());
+
+ IllegalArgumentException ddlException =
Assert.assertThrows(IllegalArgumentException.class,
+ () -> IcebergUtils.parseIcebergLiteral("{\"source\":\"ddl\"}",
variantType));
+ Assert.assertTrue(ddlException.getMessage().contains("VARIANT default
values must be NULL"));
+
+ Types.NestedField malformedField =
Mockito.mock(Types.NestedField.class);
+ Mockito.when(malformedField.fieldId()).thenReturn(1);
+ Mockito.when(malformedField.type()).thenReturn(variantType);
+
Mockito.when(malformedField.initialDefault()).thenReturn("non-null-variant");
+ IllegalArgumentException readException =
Assert.assertThrows(IllegalArgumentException.class,
+ () -> IcebergUtils.getSerializedInitialDefault(malformedField,
false));
+ Assert.assertTrue(readException.getMessage().contains("VARIANT
initial-default must be NULL"));
+ }
+
@Test
public void testIcebergVariantWriteCapabilityMatrix() {
Type variant =
IcebergUtils.icebergTypeToDorisType(Types.VariantType.get(), false, false);
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergWriteSchemaContextTest.java
b/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergWriteSchemaContextTest.java
index a93010db281..65fd64d2115 100644
---
a/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergWriteSchemaContextTest.java
+++
b/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergWriteSchemaContextTest.java
@@ -217,6 +217,48 @@ public class IcebergWriteSchemaContextTest {
assertUnhexBytes(uuid, "123E4567E89B12D3A456426614174000");
}
+ @Test
+ public void testVariantWriteDefaultMustBeNull() {
+ Types.VariantType variantType = Types.VariantType.get();
+ Schema schema = new Schema(18, Arrays.asList(
+ Types.NestedField.optional(1, "optional_payload", variantType),
+ Types.NestedField.required(2, "required_payload",
variantType)));
+ IcebergWriteSchemaContext context =
IcebergWriteSchemaContext.forSchema(
+ schema, 3, true, true);
+ Map<String, Column> columns = context.getColumns().stream()
+ .collect(Collectors.toMap(Column::getName, column -> column));
+
+ Expression optionalDefault =
context.resolveWriteDefault(columns.get("optional_payload"));
+ Assertions.assertTrue(optionalDefault instanceof NullLiteral);
+
Assertions.assertEquals(DataType.fromCatalogType(columns.get("optional_payload").getType()),
+ optionalDefault.getDataType());
+ Assertions.assertThrows(AnalysisException.class,
+ () ->
context.resolveWriteDefault(columns.get("required_payload")));
+
+ AnalysisException nonNullException =
Assertions.assertThrows(AnalysisException.class,
+ () -> IcebergWriteSchemaContext.toDorisExpression(
+ variantType, new Object(),
optionalDefault.getDataType(), true, true));
+ Assertions.assertTrue(nonNullException.getMessage().contains(
+ "VARIANT write-default must be NULL"));
+ }
+
+ @Test
+ public void testStructWriteDefaultKeepsVariantChildNull() {
+ Types.StructType structType = Types.StructType.of(
+ Types.NestedField.optional(101, "payload",
Types.VariantType.get()));
+ DataType targetType =
DataType.fromCatalogType(IcebergUtils.icebergTypeToDorisType(
+ structType, true, true));
+
+ Expression expression = IcebergWriteSchemaContext.toDorisExpression(
+ structType, new ArrayStructLike((Object) null), targetType,
true, true);
+
+ Assertions.assertTrue(expression instanceof StructLiteral);
+ List<?> fields = ((StructLiteral) expression).getValue();
+ Assertions.assertEquals(1, fields.size());
+ Assertions.assertTrue(fields.get(0) instanceof NullLiteral);
+ Assertions.assertEquals(targetType, expression.getDataType());
+ }
+
@Test
public void testWriteDefaultResolutionDoesNotFallBackToReusedName() {
Schema pinnedSchema = new Schema(18, ImmutableList.of(defaultField(
diff --git
a/regression-test/data/external_table_p0/iceberg/test_iceberg_variant_defaults.out
b/regression-test/data/external_table_p0/iceberg/test_iceberg_variant_defaults.out
new file mode 100644
index 00000000000..37954279b42
--- /dev/null
+++
b/regression-test/data/external_table_p0/iceberg/test_iceberg_variant_defaults.out
@@ -0,0 +1,22 @@
+-- This file is automatically generated. You should know what you did if you
want to edit this
+-- !variant_default_historical_row --
+1 true \N
+
+-- !variant_default_rows --
+1 true \N
+2 true \N
+3 true \N
+4 true \N
+5 true \N
+6 true \N
+7 false null
+8 true \N
+
+-- !variant_default_counts --
+8 1 7
+
+-- !variant_explicit_default_null_column --
+8 0 8
+
+-- !variant_orc_historical_null --
+10 true \N
diff --git
a/regression-test/suites/external_table_p0/iceberg/test_iceberg_variant_defaults.groovy
b/regression-test/suites/external_table_p0/iceberg/test_iceberg_variant_defaults.groovy
new file mode 100644
index 00000000000..9d23975c1b9
--- /dev/null
+++
b/regression-test/suites/external_table_p0/iceberg/test_iceberg_variant_defaults.groovy
@@ -0,0 +1,160 @@
+// 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_variant_defaults",
+
"p0,external,iceberg,external_docker,external_docker_iceberg,nonConcurrent") {
+ String enabled = context.config.otherConfigs.get("enableIcebergTest")
+ if (enabled == null || !enabled.equalsIgnoreCase("true")) {
+ logger.info("disable iceberg test")
+ return
+ }
+
+ String restPort = context.config.otherConfigs.get("iceberg_rest_uri_port")
+ String minioPort = context.config.otherConfigs.get("iceberg_minio_port")
+ String externalEnvIp = context.config.otherConfigs.get("externalEnvIp")
+ String restUri = context.config.otherConfigs.get("iceberg_rest_uri")
+ if (restUri == null) {
+ restUri = "http://${externalEnvIp}:${restPort}"
+ }
+ String catalogName = "test_iceberg_variant_defaults"
+ String dbName = "iceberg_variant_defaults_db"
+ String tableName = "variant_defaults"
+ String orcTableName = "variant_defaults_orc_history"
+
+ spark_iceberg_multi """
+ CREATE NAMESPACE IF NOT EXISTS demo.${dbName};
+ DROP TABLE IF EXISTS demo.${dbName}.${tableName};
+ CREATE TABLE demo.${dbName}.${tableName} (id INT) USING iceberg
+ TBLPROPERTIES (
+ 'format-version'='3',
+ 'write.format.default'='parquet',
+ 'write.parquet.shred-variants'='false',
+ 'write.delete.mode'='merge-on-read',
+ 'write.update.mode'='merge-on-read',
+ 'write.merge.mode'='merge-on-read'
+ );
+ INSERT INTO demo.${dbName}.${tableName} VALUES (1);
+ ALTER TABLE demo.${dbName}.${tableName} ADD COLUMN payload VARIANT;
+
+ DROP TABLE IF EXISTS demo.${dbName}.${orcTableName};
+ CREATE TABLE demo.${dbName}.${orcTableName} (id INT) USING iceberg
+ TBLPROPERTIES ('format-version'='3', 'write.format.default'='orc');
+ INSERT INTO demo.${dbName}.${orcTableName} VALUES (10);
+ ALTER TABLE demo.${dbName}.${orcTableName} ADD COLUMN payload VARIANT;
+ """
+
+ sql """DROP CATALOG IF EXISTS ${catalogName}"""
+ sql """
+ CREATE CATALOG ${catalogName} PROPERTIES (
+ 'type'='iceberg',
+ 'iceberg.catalog.type'='rest',
+ 'uri'='${restUri}',
+ 's3.access_key'='admin',
+ 's3.secret_key'='password',
+ 's3.endpoint'='http://${externalEnvIp}:${minioPort}',
+ 's3.region'='us-east-1',
+ 'meta.cache.iceberg.table.ttl-second'='0',
+ 'meta.cache.iceberg.schema.ttl-second'='0'
+ )
+ """
+ sql """SWITCH ${catalogName}"""
+ sql """USE ${dbName}"""
+ sql """SET enable_file_scanner_v2=true"""
+
+ setFeConfigTemporary([enable_variant_v2: true]) {
+ order_qt_variant_default_historical_row """
+ SELECT id, payload IS NULL, CAST(payload AS STRING)
+ FROM ${tableName}
+ ORDER BY id
+ """
+
+ sql """INSERT INTO ${tableName} (id) VALUES (2)"""
+ sql """INSERT INTO ${tableName} (id, payload) VALUES (3, DEFAULT)"""
+ sql """
+ INSERT INTO ${tableName} VALUES
+ (4, PARSE_TO_VARIANT('{"source":"update"}')),
+ (5, PARSE_TO_VARIANT('{"source":"merge"}'))
+ """
+ sql """UPDATE ${tableName} SET payload = DEFAULT(payload) WHERE id =
4"""
+ sql """
+ MERGE INTO ${tableName} t
+ USING (SELECT 5 AS id UNION ALL SELECT 6 AS id) s
+ ON t.id = s.id
+ WHEN MATCHED THEN UPDATE SET payload = DEFAULT(payload)
+ WHEN NOT MATCHED THEN INSERT (id, payload) VALUES (s.id,
DEFAULT(payload))
+ """
+ sql """INSERT INTO ${tableName} VALUES (7, PARSE_TO_VARIANT('null'))"""
+ sql """
+ INSERT INTO ${tableName} (id, payload)
+ SELECT 8, DEFAULT(payload) FROM ${tableName} WHERE id = 7
+ """
+
+ order_qt_variant_default_rows """
+ SELECT id, payload IS NULL, CAST(payload AS STRING)
+ FROM ${tableName}
+ ORDER BY id
+ """
+ qt_variant_default_counts """
+ SELECT COUNT(*), COUNT(payload), SUM(payload IS NULL)
+ FROM ${tableName}
+ """
+
+ sql """ALTER TABLE ${tableName} ADD COLUMN auxiliary VARIANT DEFAULT
NULL"""
+ qt_variant_explicit_default_null_column """
+ SELECT COUNT(*), COUNT(auxiliary), SUM(auxiliary IS NULL)
+ FROM ${tableName}
+ """
+
+ List<List<Object>> schemaBeforeInvalidDefault = sql """DESC
${tableName}"""
+ test {
+ sql """
+ ALTER TABLE ${tableName}
+ ADD COLUMN invalid_payload VARIANT DEFAULT '{"invalid":true}'
+ """
+ exception "support null"
+ }
+ assertEquals(schemaBeforeInvalidDefault, sql("DESC ${tableName}"),
+ "Rejected non-null VARIANT default changed the Iceberg schema")
+
+ test {
+ sql """
+ ALTER TABLE ${tableName}
+ ADD COLUMN required_payload VARIANT NOT NULL DEFAULT NULL
+ """
+ exception "null default value"
+ }
+ assertEquals(schemaBeforeInvalidDefault, sql("DESC ${tableName}"),
+ "Rejected required VARIANT default changed the Iceberg schema")
+
+ // Spark caches Iceberg snapshots, so refresh after Doris commits
before cross-engine reads.
+ spark_iceberg """REFRESH TABLE demo.${dbName}.${tableName}"""
+ List<List<Object>> sparkCounts = spark_iceberg """
+ SELECT COUNT(*), COUNT(payload), COUNT(auxiliary)
+ FROM demo.${dbName}.${tableName}
+ """
+ assertEquals(1, sparkCounts.size())
+ assertEquals("8", sparkCounts[0][0].toString())
+ assertEquals("1", sparkCounts[0][1].toString())
+ assertEquals("0", sparkCounts[0][2].toString())
+
+ order_qt_variant_orc_historical_null """
+ SELECT id, payload IS NULL, CAST(payload AS STRING)
+ FROM ${orcTableName}
+ ORDER BY id
+ """
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]