This is an automated email from the ASF dual-hosted git repository.
mxm pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg.git
The following commit(s) were added to refs/heads/main by this push:
new f74aea1e68 Flink: Support Variant type in DynamicIcebergSink (#17900)
f74aea1e68 is described below
commit f74aea1e68fc8161905a748f069c055f47ca64b5
Author: fmorillo7694 <[email protected]>
AuthorDate: Tue Oct 6 16:46:50 2026 +0200
Flink: Support Variant type in DynamicIcebergSink (#17900)
---
.../flink/sink/dynamic/CompareSchemasVisitor.java | 14 ++
.../iceberg/flink/sink/dynamic/DataConverter.java | 1 +
.../flink/sink/dynamic/EvolveSchemaVisitor.java | 13 ++
.../sink/dynamic/TestCompareSchemasVisitor.java | 45 ++++++
.../flink/sink/dynamic/TestDynamicIcebergSink.java | 180 +++++++++++++++++++++
.../sink/dynamic/TestEvolveSchemaVisitor.java | 41 +++++
.../flink/sink/dynamic/TestRowDataConverter.java | 27 ++++
7 files changed, 321 insertions(+)
diff --git
a/flink/v2.3/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/CompareSchemasVisitor.java
b/flink/v2.3/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/CompareSchemasVisitor.java
index 6c1b2e5673..a975b9d371 100644
---
a/flink/v2.3/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/CompareSchemasVisitor.java
+++
b/flink/v2.3/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/CompareSchemasVisitor.java
@@ -181,6 +181,20 @@ public class CompareSchemasVisitor
}
}
+ @Override
+ public Result variant(Types.VariantType variant, Integer tableSchemaId) {
+ if (tableSchemaId == null) {
+ return Result.SCHEMA_UPDATE_NEEDED;
+ }
+
+ Type tableSchemaType = tableSchema.findField(tableSchemaId).type();
+ if (tableSchemaType.isVariantType()) {
+ return Result.SAME;
+ } else {
+ return Result.SCHEMA_UPDATE_NEEDED;
+ }
+ }
+
/**
* Whether {@link DataConverter} can convert input data of type {@code
dataType} into the table's
* {@code tableType}. Must stay in sync with the conversions {@link
DataConverter#get} performs.
diff --git
a/flink/v2.3/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DataConverter.java
b/flink/v2.3/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DataConverter.java
index b3fe35476d..c3aa5031c5 100644
---
a/flink/v2.3/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DataConverter.java
+++
b/flink/v2.3/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DataConverter.java
@@ -80,6 +80,7 @@ interface DataConverter {
case TIMESTAMP_WITH_LOCAL_TIME_ZONE:
case BINARY:
case VARBINARY:
+ case VARIANT:
return object -> object;
case DOUBLE:
return object -> {
diff --git
a/flink/v2.3/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/EvolveSchemaVisitor.java
b/flink/v2.3/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/EvolveSchemaVisitor.java
index 3bead03d51..15dd11b7ff 100644
---
a/flink/v2.3/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/EvolveSchemaVisitor.java
+++
b/flink/v2.3/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/EvolveSchemaVisitor.java
@@ -194,6 +194,11 @@ public class EvolveSchemaVisitor extends
SchemaWithPartnerVisitor<Integer, Boole
return partnerId == null;
}
+ @Override
+ public Boolean variant(Types.VariantType variant, Integer partnerId) {
+ return partnerId == null;
+ }
+
private Type findFieldType(int fieldId) {
if (fieldId == -1) {
return existingSchema.asStruct();
@@ -210,6 +215,14 @@ public class EvolveSchemaVisitor extends
SchemaWithPartnerVisitor<Integer, Boole
private void updateColumn(Types.NestedField existingField, Types.NestedField
targetField) {
String existingColumnName =
this.existingSchema.findColumnName(existingField.fieldId());
+ // A column cannot be promoted to variant; fail instead of silently
skipping the update
+ Preconditions.checkArgument(
+ !targetField.type().isVariantType() ||
existingField.type().isVariantType(),
+ "Cannot change column type: %s: %s -> %s",
+ existingColumnName,
+ existingField.type(),
+ targetField.type());
+
boolean needsOptionalUpdate = targetField.isOptional() &&
existingField.isRequired();
boolean handledByDataConversion =
CompareSchemasVisitor.isDataConversionPossible(targetField.type(),
existingField.type());
diff --git
a/flink/v2.3/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestCompareSchemasVisitor.java
b/flink/v2.3/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestCompareSchemasVisitor.java
index 9e4d600f93..1cdcf8fd03 100644
---
a/flink/v2.3/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestCompareSchemasVisitor.java
+++
b/flink/v2.3/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestCompareSchemasVisitor.java
@@ -29,6 +29,7 @@ import org.apache.iceberg.types.Types.LongType;
import org.apache.iceberg.types.Types.MapType;
import org.apache.iceberg.types.Types.StringType;
import org.apache.iceberg.types.Types.StructType;
+import org.apache.iceberg.types.Types.VariantType;
import org.junit.jupiter.api.Test;
class TestCompareSchemasVisitor {
@@ -56,6 +57,50 @@ class TestCompareSchemasVisitor {
.isEqualTo(CompareSchemasVisitor.Result.SAME);
}
+ @Test
+ void testSchemaWithVariantSame() {
+ assertThat(
+ CompareSchemasVisitor.visit(
+ new Schema(
+ optional(1, "id", IntegerType.get()),
+ optional(2, "data", StringType.get()),
+ optional(3, "payload", VariantType.get())),
+ new Schema(
+ optional(1, "id", IntegerType.get()),
+ optional(2, "data", StringType.get()),
+ optional(3, "payload", VariantType.get())),
+ CASE_SENSITIVE,
+ PRESERVE_COLUMNS))
+ .isEqualTo(CompareSchemasVisitor.Result.SAME);
+ }
+
+ @Test
+ void testVariantAgainstNonVariantTableField() {
+ assertThat(
+ CompareSchemasVisitor.visit(
+ new Schema(
+ optional(1, "id", IntegerType.get()),
+ optional(2, "payload", VariantType.get())),
+ new Schema(
+ optional(1, "id", IntegerType.get()), optional(2,
"payload", StringType.get())),
+ CASE_SENSITIVE,
+ PRESERVE_COLUMNS))
+ .isEqualTo(CompareSchemasVisitor.Result.SCHEMA_UPDATE_NEEDED);
+ }
+
+ @Test
+ void testVariantFieldMissingFromTable() {
+ assertThat(
+ CompareSchemasVisitor.visit(
+ new Schema(
+ optional(1, "id", IntegerType.get()),
+ optional(2, "payload", VariantType.get())),
+ new Schema(optional(1, "id", IntegerType.get())),
+ CASE_SENSITIVE,
+ PRESERVE_COLUMNS))
+ .isEqualTo(CompareSchemasVisitor.Result.SCHEMA_UPDATE_NEEDED);
+ }
+
@Test
void testSchemaDifferentId() {
assertThat(
diff --git
a/flink/v2.3/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicIcebergSink.java
b/flink/v2.3/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicIcebergSink.java
index a426529d32..2abcf9ce01 100644
---
a/flink/v2.3/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicIcebergSink.java
+++
b/flink/v2.3/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicIcebergSink.java
@@ -100,7 +100,9 @@ import
org.apache.iceberg.relocated.com.google.common.collect.Iterables;
import org.apache.iceberg.relocated.com.google.common.collect.Lists;
import org.apache.iceberg.relocated.com.google.common.collect.Maps;
import org.apache.iceberg.relocated.com.google.common.collect.Sets;
+import org.apache.iceberg.types.Type;
import org.apache.iceberg.types.Types;
+import org.apache.iceberg.variants.Variant;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
@@ -692,6 +694,184 @@ class TestDynamicIcebergSink extends
TestFlinkIcebergSinkBase {
}
}
+ @Test
+ void testWriteVariant() throws Exception {
+ Schema schema =
+ new Schema(
+ Types.NestedField.optional(1, "id", Types.IntegerType.get()),
+ Types.NestedField.optional(2, "payload", Types.VariantType.get()));
+ createV3Table("t1", schema);
+
+ executeVariantSink(
+ Lists.newArrayList(new VariantInput(schema, 1), new
VariantInput(schema, 2)), this.env);
+
+ assertVariantPayloads(ImmutableMap.of(1, 1L, 2, 2L));
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {false, true})
+ void testSchemaEvolutionAddVariantField(boolean immediateUpdate) throws
Exception {
+ Schema variantSchema =
+ new Schema(
+ Types.NestedField.optional(1, "id", Types.IntegerType.get()),
+ Types.NestedField.optional(2, "data", Types.StringType.get()),
+ Types.NestedField.optional(3, "payload", Types.VariantType.get()));
+ createV3Table("t1", SimpleDataUtil.SCHEMA);
+
+ executeVariantSink(
+ Lists.newArrayList(
+ new VariantInput(SimpleDataUtil.SCHEMA, 1), new
VariantInput(variantSchema, 2)),
+ this.env,
+ immediateUpdate);
+
+ Table table =
CATALOG_EXTENSION.catalog().loadTable(TableIdentifier.of(DATABASE, "t1"));
+
assertThat(table.schema().findField("payload").type()).isEqualTo(Types.VariantType.get());
+ Map<Integer, Long> expected = Maps.newHashMap();
+ expected.put(1, null);
+ expected.put(2, 2L);
+ assertVariantPayloads(expected);
+ }
+
+ @Test
+ void testSchemaEvolutionStringToVariantFails() throws Exception {
+ assertIncompatibleVariantEvolution(
+ Types.StringType.get(),
+ Types.VariantType.get(),
+ "Cannot change column type: payload: string -> variant");
+ }
+
+ @Test
+ void testSchemaEvolutionVariantToStringFails() throws Exception {
+ assertIncompatibleVariantEvolution(
+ Types.VariantType.get(),
+ Types.StringType.get(),
+ "Cannot change column type: payload: variant -> string");
+ }
+
+ private void assertIncompatibleVariantEvolution(
+ Type tableType, Type writeType, String expectedMessage) throws Exception
{
+ Schema tableSchema =
+ new Schema(
+ Types.NestedField.optional(1, "id", Types.IntegerType.get()),
+ Types.NestedField.optional(2, "payload", tableType));
+ Schema writeSchema =
+ new Schema(
+ Types.NestedField.optional(1, "id", Types.IntegerType.get()),
+ Types.NestedField.optional(2, "payload", writeType));
+ createV3Table("t1", tableSchema);
+
+ try {
+ executeVariantSink(
+ Lists.newArrayList(new VariantInput(writeSchema, 1)),
+ StreamExecutionEnvironment.getExecutionEnvironment());
+ fail();
+ } catch (JobExecutionException e) {
+ assertThat(
+ ExceptionUtils.findThrowable(
+ e, t -> t.getMessage() != null &&
t.getMessage().contains(expectedMessage)))
+ .isNotEmpty();
+ }
+ }
+
+ private static void createV3Table(String name, Schema schema) {
+ CATALOG_EXTENSION
+ .catalog()
+ .createTable(
+ TableIdentifier.of(DATABASE, name),
+ schema,
+ PartitionSpec.unpartitioned(),
+ null,
+ ImmutableMap.of(TableProperties.FORMAT_VERSION, "3"));
+ }
+
+ private static void executeVariantSink(List<VariantInput> inputs,
StreamExecutionEnvironment env)
+ throws Exception {
+ executeVariantSink(inputs, env, true);
+ }
+
+ private static void executeVariantSink(
+ List<VariantInput> inputs, StreamExecutionEnvironment env, boolean
immediateUpdate)
+ throws Exception {
+ DataStream<VariantInput> dataStream =
+ env.fromData(inputs, TypeInformation.of(new TypeHint<>() {}));
+ env.setParallelism(1);
+ DynamicIcebergSink.forInput(dataStream)
+ .generator(new VariantGenerator())
+ .catalogLoader(CATALOG_EXTENSION.catalogLoader())
+ .writeParallelism(1)
+ .immediateTableUpdate(immediateUpdate)
+ .append();
+ env.execute("Test Iceberg Variant DataStream");
+ }
+
+ /** Reads table t1 and checks the "k" field of each row's variant payload,
keyed by row id. */
+ private static void assertVariantPayloads(Map<Integer, Long> expectedById)
throws IOException {
+ Map<Integer, Long> actualById = Maps.newHashMap();
+ try (CloseableIterable<Record> records =
+ IcebergGenerics.read(
+
CATALOG_EXTENSION.catalog().loadTable(TableIdentifier.of(DATABASE, "t1")))
+ .build()) {
+ for (Record record : records) {
+ Variant payload = (Variant) record.getField("payload");
+ actualById.put(
+ (Integer) record.getField("id"),
+ payload == null
+ ? null
+ : (Long)
payload.value().asObject().get("k").asPrimitive().get());
+ }
+ }
+
+ assertThat(actualById).isEqualTo(expectedById);
+ }
+
+ /** Input for the variant tests; the row is built in {@link
VariantGenerator}. */
+ private static class VariantInput implements Serializable {
+ private final Schema schema;
+ private final int id;
+
+ VariantInput(Schema schema, int id) {
+ this.schema = schema;
+ this.id = id;
+ }
+ }
+
+ /**
+ * Builds RowData directly, because the legacy DataFormatConverters used by
{@link Generator} do
+ * not support VARIANT. Variant payloads are {"k": id}.
+ */
+ private static class VariantGenerator implements
DynamicRecordGenerator<VariantInput> {
+ @Override
+ public void generate(VariantInput input, Collector<DynamicRecord> out) {
+ List<Types.NestedField> columns = input.schema.columns();
+ GenericRowData row = new GenericRowData(columns.size());
+ for (int i = 0; i < columns.size(); i++) {
+ Types.NestedField column = columns.get(i);
+ if (column.type().isVariantType()) {
+ row.setField(
+ i,
+ org.apache.flink.types.variant.Variant.newBuilder()
+ .object()
+ .add("k",
org.apache.flink.types.variant.Variant.newBuilder().of((long) input.id))
+ .build());
+ } else if (column.type().typeId() == Type.TypeID.INTEGER) {
+ row.setField(i, input.id);
+ } else {
+ row.setField(i, StringData.fromString(column.name() + "-" +
input.id));
+ }
+ }
+
+ out.collect(
+ new DynamicRecord(
+ TableIdentifier.of(DATABASE, "t1"),
+ SnapshotRef.MAIN_BRANCH,
+ input.schema,
+ row,
+ PartitionSpec.unpartitioned(),
+ DistributionMode.NONE,
+ 1));
+ }
+ }
+
@Test
void testPartitionSpecEvolution() throws Exception {
PartitionSpec spec1 =
PartitionSpec.builderFor(SimpleDataUtil.SCHEMA).bucket("id", 10).build();
diff --git
a/flink/v2.3/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestEvolveSchemaVisitor.java
b/flink/v2.3/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestEvolveSchemaVisitor.java
index 55e52751fe..f08f745768 100644
---
a/flink/v2.3/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestEvolveSchemaVisitor.java
+++
b/flink/v2.3/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestEvolveSchemaVisitor.java
@@ -103,6 +103,47 @@ public class TestEvolveSchemaVisitor {
assertThat(targetSchema.asStruct()).isEqualTo(updateApi.apply().asStruct());
}
+ @Test
+ public void testAddTopLevelVariant() {
+ Schema targetSchema =
+ new Schema(
+ optional(1, "id", Types.IntegerType.get()),
+ optional(2, "payload", Types.VariantType.get()));
+ UpdateSchema updateApi = loadUpdateApi(new Schema());
+ EvolveSchemaVisitor.visit(
+ TABLE, updateApi, new Schema(), targetSchema, CASE_SENSITIVE,
PRESERVE_COLUMNS);
+
assertThat(targetSchema.asStruct()).isEqualTo(updateApi.apply().asStruct());
+ }
+
+ @Test
+ public void testExistingVariantFieldIsUnchanged() {
+ Schema existingSchema =
+ new Schema(
+ optional(1, "id", Types.IntegerType.get()),
+ optional(2, "payload", Types.VariantType.get()));
+ UpdateSchema updateApi = loadUpdateApi(existingSchema);
+ EvolveSchemaVisitor.visit(
+ TABLE, updateApi, existingSchema, existingSchema, CASE_SENSITIVE,
PRESERVE_COLUMNS);
+
assertThat(existingSchema.asStruct()).isEqualTo(updateApi.apply().asStruct());
+ }
+
+ @Test
+ public void testPrimitiveToVariantFails() {
+ Schema currentSchema = new Schema(optional(1, "payload",
StringType.get()));
+ Schema targetSchema = new Schema(optional(1, "payload",
Types.VariantType.get()));
+ assertThatThrownBy(
+ () ->
+ EvolveSchemaVisitor.visit(
+ TABLE,
+ loadUpdateApi(currentSchema),
+ currentSchema,
+ targetSchema,
+ CASE_SENSITIVE,
+ PRESERVE_COLUMNS))
+ .hasMessage("Cannot change column type: payload: string -> variant")
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+
@Test
public void testMakeTopLevelPrimitivesOptional() {
Schema existingSchema = new Schema(primitiveFields(0, primitiveTypes(),
false));
diff --git
a/flink/v2.3/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestRowDataConverter.java
b/flink/v2.3/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestRowDataConverter.java
index 19e08652fb..e7efa13b44 100644
---
a/flink/v2.3/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestRowDataConverter.java
+++
b/flink/v2.3/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestRowDataConverter.java
@@ -34,6 +34,7 @@ import org.apache.flink.table.data.RowData;
import org.apache.flink.table.data.StringData;
import org.apache.flink.table.data.TimestampData;
import org.apache.flink.types.RowKind;
+import org.apache.flink.types.variant.Variant;
import org.apache.iceberg.Schema;
import org.apache.iceberg.flink.DataGenerator;
import org.apache.iceberg.flink.DataGenerators;
@@ -85,6 +86,32 @@ class TestRowDataConverter {
assertThat(convert(deleteRow, SCHEMA,
SCHEMA2).getRowKind()).isEqualTo(RowKind.DELETE);
}
+ @Test
+ void testVariantWithDataConversion() {
+ Schema sourceSchema =
+ new Schema(
+ Types.NestedField.optional(1, "id", Types.IntegerType.get()),
+ Types.NestedField.optional(2, "payload", Types.VariantType.get()));
+ Schema targetSchema =
+ new Schema(
+ Types.NestedField.optional(1, "id", Types.LongType.get()),
+ Types.NestedField.optional(2, "payload", Types.VariantType.get()));
+ assertThat(
+ CompareSchemasVisitor.visit(
+ sourceSchema,
+ targetSchema,
+ true /* caseSensitive */,
+ false /* dropUnusedColumns */))
+ .isEqualTo(CompareSchemasVisitor.Result.DATA_CONVERSION_NEEDED);
+
+ Variant variant = Variant.newBuilder().object().add("k",
Variant.newBuilder().of(1L)).build();
+ RowData row = GenericRowData.of(42, variant);
+
+ RowData converted = convert(row, sourceSchema, targetSchema);
+ assertThat(converted.getLong(0)).isEqualTo(42L);
+ assertThat(converted.getVariant(1)).isSameAs(variant);
+ }
+
@Test
void testAddRequiredColumn() {
Schema currentSchema = new Schema(Types.NestedField.optional(1, "id",
Types.IntegerType.get()));