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

JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git


The following commit(s) were added to refs/heads/master by this push:
     new 43b98b935f [core] Support plain Variant values in ROW tables (#10650)
43b98b935f is described below

commit 43b98b935f8d9ee09da2ff6babc45b9d00abafbd
Author: Jingsong Lee <[email protected]>
AuthorDate: Mon Oct 19 18:48:44 2026 +0800

    [core] Support plain Variant values in ROW tables (#10650)
---
 docs/docs/multimodal-table/variant.mdx             |  10 +-
 docs/docs/pypaimon/data-types.md                   |   7 +-
 .../apache/paimon/utils/NestedProjectedRow.java    |  58 ++++++-
 .../paimon/utils/NestedProjectedRowTest.java       | 144 +++++++++++++++++
 .../org/apache/paimon/AppendOnlyFileStore.java     |   9 ++
 .../java/org/apache/paimon/KeyValueFileStore.java  |   4 +
 .../apache/paimon/operation/RawFileSplitRead.java  |  15 +-
 .../org/apache/paimon/schema/SchemaValidation.java | 172 ++++++---------------
 .../paimon/operation/RawFileSplitReadTest.java     | 102 ++++++++++++
 .../apache/paimon/schema/SchemaValidationTest.java |  95 +++---------
 .../paimon/table/AppendOnlySimpleTableTest.java    | 149 ++++++++++++++++++
 .../flink/source/TestChangelogDataReadWrite.java   |   4 +
 paimon-python/pypaimon/read/split_read.py          |   3 +-
 .../pypaimon/tests/test_format_row_table.py        |  98 ++++++++++++
 14 files changed, 660 insertions(+), 210 deletions(-)

diff --git a/docs/docs/multimodal-table/variant.mdx 
b/docs/docs/multimodal-table/variant.mdx
index 67491d50b4..bcce4b644a 100644
--- a/docs/docs/multimodal-table/variant.mdx
+++ b/docs/docs/multimodal-table/variant.mdx
@@ -37,7 +37,7 @@ instead of storing its JSON text. The data is parsed when it 
is converted to `VA
 can extract a path as a requested SQL type.
 
 `VARIANT` does not require Data Evolution mode. SQL support requires Spark 4.0 
or later, or Flink
-2.1 or later. Variant data files must use Parquet; ORC and Avro do not support 
this type.
+2.1 or later. Variant data files can use Parquet or ROW; ORC and Avro do not 
support this type.
 
 ## Variant vs. JSON String
 
@@ -121,6 +121,11 @@ The binary representation avoids storing and reparsing 
JSON syntax. It also reta
 each scalar value. Extracting a sub-field from a plain Variant still requires 
reading the binary
 `value` field for that Variant column.
 
+The [ROW format](../concepts/spec/rowformat.md) also supports plain Variant 
values. Set
+`file.format` to `row` to store the same `value` and `metadata` bytes inside 
compressed row blocks.
+ROW reads and decompresses the selected blocks in full; it does not support 
Variant shredding or
+sub-field I/O pruning.
+
 ### Shredded Variant
 
 Shredding materializes selected Variant paths as typed Parquet sub-columns 
while keeping the
@@ -263,7 +268,8 @@ full value.
 
 ## Limitations
 
-- Variant data files must use Parquet. ORC and Avro are not supported.
+- Plain Variant data files can use Parquet or ROW. Variant shredding requires 
Parquet. ORC and
+  Avro are not supported.
 - `VARIANT` cannot be used as a primary key or partition key.
 - Variant extraction does not by itself create an index or guarantee predicate 
pushdown. Use
   shredding to reduce sub-column I/O; use an appropriate index or modeled 
table column when a path
diff --git a/docs/docs/pypaimon/data-types.md b/docs/docs/pypaimon/data-types.md
index 63893d72fe..63b8572063 100644
--- a/docs/docs/pypaimon/data-types.md
+++ b/docs/docs/pypaimon/data-types.md
@@ -72,6 +72,10 @@ Paimon supports two Parquet storage layouts for VARIANT:
 - **Shredded VARIANT** — typed sub-columns are stored alongside overflow 
bytes, enabling column-skipping
   inside the Parquet file. Controlled by the `variant.shreddingSchema` table 
option.
 
+Plain VARIANT also supports `file.format=row`. The ROW format stores the same 
`value` and
+`metadata` bytes inside compressed row blocks and exposes the same Arrow 
struct when read.
+ROW does not support Variant shredding or sub-field I/O pruning.
+
 <Tabs groupId="variant-read-write">
 
 <TabItem value="plain-variant" label="Plain VARIANT">
@@ -301,5 +305,6 @@ creates intermediate objects or extends arrays.
 
 **Limitations:**
 
-- `VARIANT` is only supported with Parquet file format. ORC and Avro are not 
supported.
+- Plain `VARIANT` supports Parquet and ROW file formats. Shredded `VARIANT` 
requires Parquet.
+  ORC and Avro are not supported.
 - `VARIANT` cannot be used as a primary key or partition key.
diff --git 
a/paimon-common/src/main/java/org/apache/paimon/utils/NestedProjectedRow.java 
b/paimon-common/src/main/java/org/apache/paimon/utils/NestedProjectedRow.java
index add679e76a..4961d9b924 100644
--- 
a/paimon-common/src/main/java/org/apache/paimon/utils/NestedProjectedRow.java
+++ 
b/paimon-common/src/main/java/org/apache/paimon/utils/NestedProjectedRow.java
@@ -21,16 +21,23 @@ package org.apache.paimon.utils;
 import org.apache.paimon.data.BinaryString;
 import org.apache.paimon.data.Blob;
 import org.apache.paimon.data.Decimal;
+import org.apache.paimon.data.GenericRow;
 import org.apache.paimon.data.InternalArray;
 import org.apache.paimon.data.InternalMap;
 import org.apache.paimon.data.InternalRow;
 import org.apache.paimon.data.InternalVector;
 import org.apache.paimon.data.Timestamp;
+import org.apache.paimon.data.variant.GenericVariant;
+import org.apache.paimon.data.variant.PaimonShreddingUtils;
+import org.apache.paimon.data.variant.PaimonShreddingUtils.FieldToExtract;
 import org.apache.paimon.data.variant.Variant;
+import org.apache.paimon.data.variant.VariantMetadataUtils;
+import org.apache.paimon.data.variant.VariantSchema;
 import org.apache.paimon.types.ArrayType;
 import org.apache.paimon.types.DataField;
 import org.apache.paimon.types.DataType;
 import org.apache.paimon.types.DataTypeRoot;
+import org.apache.paimon.types.DataTypes;
 import org.apache.paimon.types.MapType;
 import org.apache.paimon.types.MultisetType;
 import org.apache.paimon.types.RowKind;
@@ -54,17 +61,20 @@ public class NestedProjectedRow implements InternalRow {
     @Nullable private final NestedProjectedRow[] nestedProjections;
     @Nullable private final int[] nestedArity;
     @Nullable private final ElementProjection[] elementProjections;
+    @Nullable private final VariantProjection[] variantProjections;
     private InternalRow row;
 
     private NestedProjectedRow(
             int[] indexMapping,
             @Nullable NestedProjectedRow[] nestedProjections,
             @Nullable int[] nestedArity,
-            @Nullable ElementProjection[] elementProjections) {
+            @Nullable ElementProjection[] elementProjections,
+            @Nullable VariantProjection[] variantProjections) {
         this.indexMapping = indexMapping;
         this.nestedProjections = nestedProjections;
         this.nestedArity = nestedArity;
         this.elementProjections = elementProjections;
+        this.variantProjections = variantProjections;
     }
 
     public NestedProjectedRow replaceRow(InternalRow row) {
@@ -93,6 +103,7 @@ public class NestedProjectedRow implements InternalRow {
         NestedProjectedRow[] nestedProjections = null;
         int[] nestedArity = null;
         ElementProjection[] elementProjections = null;
+        VariantProjection[] variantProjections = null;
 
         for (int i = 0; i < projectedSize; i++) {
             DataField projected = projectedFields.get(i);
@@ -106,6 +117,15 @@ public class NestedProjectedRow implements InternalRow {
                     projected.name());
             indexMapping[i] = dataIdx;
 
+            if (dataField.type().is(DataTypeRoot.VARIANT)
+                    && 
VariantMetadataUtils.isVariantRowType(projected.type())) {
+                if (variantProjections == null) {
+                    variantProjections = new VariantProjection[projectedSize];
+                }
+                variantProjections[i] = new VariantProjection((RowType) 
projected.type());
+                continue;
+            }
+
             DataTypeRoot typeRoot = projected.type().getTypeRoot();
             if (typeRoot == DataTypeRoot.ROW) {
                 RowType dataNestedType = (RowType) dataField.type();
@@ -131,7 +151,11 @@ public class NestedProjectedRow implements InternalRow {
         }
 
         return new NestedProjectedRow(
-                indexMapping, nestedProjections, nestedArity, 
elementProjections);
+                indexMapping,
+                nestedProjections,
+                nestedArity,
+                elementProjections,
+                variantProjections);
     }
 
     @Nullable
@@ -318,6 +342,11 @@ public class NestedProjectedRow implements InternalRow {
 
     @Override
     public InternalRow getRow(int pos, int numFields) {
+        if (variantProjections != null && variantProjections[pos] != null) {
+            return row.isNullAt(indexMapping[pos])
+                    ? null
+                    : 
variantProjections[pos].project(row.getVariant(indexMapping[pos]));
+        }
         if (nestedProjections != null && nestedProjections[pos] != null) {
             InternalRow inner = row.getRow(indexMapping[pos], 
nestedArity[pos]);
             return nestedProjections[pos].replaceRow(inner);
@@ -325,6 +354,31 @@ public class NestedProjectedRow implements InternalRow {
         return row.getRow(indexMapping[pos], numFields);
     }
 
+    /** Extracts requested fields from an ordinary Variant using the shared 
cast semantics. */
+    private static class VariantProjection {
+
+        private final VariantSchema schema;
+        private final FieldToExtract[] fields;
+
+        private VariantProjection(RowType projectedType) {
+            this.schema =
+                    PaimonShreddingUtils.buildVariantSchema(
+                            
PaimonShreddingUtils.variantShreddingSchema(DataTypes.VARIANT()));
+            this.fields = 
PaimonShreddingUtils.getFieldsToExtract(projectedType, schema);
+        }
+
+        private InternalRow project(Variant variant) {
+            GenericVariant value =
+                    variant instanceof GenericVariant
+                            ? (GenericVariant) variant
+                            : new GenericVariant(variant.valueBuffer(), 
variant.metadataBuffer());
+            GenericRow physicalRow = new GenericRow(schema.numFields);
+            physicalRow.setField(schema.variantIdx, value.value());
+            physicalRow.setField(schema.topLevelMetadataIdx, value.metadata());
+            return PaimonShreddingUtils.assembleVariantStruct(physicalRow, 
schema, fields);
+        }
+    }
+
     // ======================== ElementProjection ========================
 
     /**
diff --git 
a/paimon-common/src/test/java/org/apache/paimon/utils/NestedProjectedRowTest.java
 
b/paimon-common/src/test/java/org/apache/paimon/utils/NestedProjectedRowTest.java
index 4a12756bb9..0c242a7b01 100644
--- 
a/paimon-common/src/test/java/org/apache/paimon/utils/NestedProjectedRowTest.java
+++ 
b/paimon-common/src/test/java/org/apache/paimon/utils/NestedProjectedRowTest.java
@@ -25,10 +25,15 @@ import org.apache.paimon.data.GenericRow;
 import org.apache.paimon.data.InternalArray;
 import org.apache.paimon.data.InternalMap;
 import org.apache.paimon.data.InternalRow;
+import org.apache.paimon.data.variant.BufferOnlyVariant;
+import org.apache.paimon.data.variant.GenericVariant;
+import org.apache.paimon.data.variant.GenericVariantBuilder;
+import org.apache.paimon.data.variant.VariantMetadataUtils;
 import org.apache.paimon.types.ArrayType;
 import org.apache.paimon.types.BigIntType;
 import org.apache.paimon.types.BooleanType;
 import org.apache.paimon.types.DataField;
+import org.apache.paimon.types.DataTypes;
 import org.apache.paimon.types.DoubleType;
 import org.apache.paimon.types.FloatType;
 import org.apache.paimon.types.IntType;
@@ -52,6 +57,145 @@ import static 
org.assertj.core.api.Assertions.assertThatThrownBy;
 /** Test for {@link NestedProjectedRow}. */
 public class NestedProjectedRowTest {
 
+    @Test
+    void testVariantExtractionProjection() {
+        RowType dataSchema =
+                DataTypes.ROW(
+                        DataTypes.FIELD(7, "id", DataTypes.INT()),
+                        DataTypes.FIELD(41, "payload", DataTypes.VARIANT()));
+        RowType extractedType =
+                VariantMetadataUtils.VariantRowTypeBuilder.builder()
+                        .field(DataTypes.BIGINT(), "$.a.b")
+                        .field(DataTypes.INT(), "$.items[1]")
+                        .field(DataTypes.STRING(), "$.missing.deep")
+                        .field(DataTypes.INT(), "$.items[10]")
+                        .field(DataTypes.BIGINT(), "$.wrong", false, "UTC")
+                        .field(DataTypes.STRING(), "$.number")
+                        .field(DataTypes.VARIANT(), "$.a")
+                        .build();
+        RowType projectionSchema =
+                DataTypes.ROW(
+                        DataTypes.FIELD(41, "payload", extractedType),
+                        DataTypes.FIELD(7, "id", DataTypes.INT()));
+        NestedProjectedRow projection = NestedProjectedRow.create(dataSchema, 
projectionSchema);
+        GenericVariant variant =
+                GenericVariant.fromJson(
+                        
"{\"a\":{\"b\":42},\"items\":[1,7],\"wrong\":\"invalid\",\"number\":123}");
+        InternalRow projected =
+                projection.replaceRow(GenericRow.of(5, new 
BufferOnlyVariant(variant)));
+        assertThat(projected.getInt(1)).isEqualTo(5);
+        InternalRow extracted = projected.getRow(0, 
extractedType.getFieldCount());
+        assertThat(extracted.getLong(0)).isEqualTo(42L);
+        assertThat(extracted.getInt(1)).isEqualTo(7);
+        assertThat(extracted.isNullAt(2)).isTrue();
+        assertThat(extracted.isNullAt(3)).isTrue();
+        assertThat(extracted.isNullAt(4)).isTrue();
+        assertThat(extracted.getString(5).toString()).isEqualTo("123");
+        assertThat(extracted.getVariant(6).toJson()).isEqualTo("{\"b\":42}");
+    }
+
+    @Test
+    void testVariantProjectionDistinguishesSqlNullAndVariantNull() {
+        RowType dataSchema = DataTypes.ROW(DataTypes.FIELD(0, "v", 
DataTypes.VARIANT()));
+        RowType extractedType =
+                VariantMetadataUtils.VariantRowTypeBuilder.builder()
+                        .field(DataTypes.STRING(), "$.field")
+                        .field(DataTypes.VARIANT(), "$")
+                        .build();
+        NestedProjectedRow projection =
+                NestedProjectedRow.create(
+                        dataSchema, DataTypes.ROW(DataTypes.FIELD(0, "v", 
extractedType)));
+        InternalRow sqlNull = projection.replaceRow(GenericRow.of((Object) 
null));
+        assertThat(sqlNull.isNullAt(0)).isTrue();
+
+        InternalRow variantNull =
+                
projection.replaceRow(GenericRow.of(GenericVariant.fromJson("null")));
+        assertThat(variantNull.isNullAt(0)).isFalse();
+        InternalRow extracted = variantNull.getRow(0, 2);
+        assertThat(extracted.isNullAt(0)).isTrue();
+        assertThat(extracted.isNullAt(1)).isFalse();
+        assertThat(extracted.getVariant(1).toJson()).isEqualTo("null");
+    }
+
+    @Test
+    void testNestedVariantProjectionCanBeReused() {
+        RowType nestedType =
+                DataTypes.ROW(
+                        DataTypes.FIELD(8, "skipped", DataTypes.INT()),
+                        DataTypes.FIELD(21, "v", DataTypes.VARIANT()));
+        RowType dataSchema =
+                DataTypes.ROW(
+                        DataTypes.FIELD(1, "nested", nestedType),
+                        DataTypes.FIELD(2, "unmodified", DataTypes.VARIANT()));
+        RowType extractedType =
+                VariantMetadataUtils.VariantRowTypeBuilder.builder()
+                        .field(DataTypes.INT(), "$.x")
+                        .build();
+        RowType projectedNested = DataTypes.ROW(DataTypes.FIELD(21, "v", 
extractedType));
+        RowType projectedSchema =
+                DataTypes.ROW(
+                        DataTypes.FIELD(2, "unmodified", DataTypes.VARIANT()),
+                        DataTypes.FIELD(1, "nested", projectedNested));
+        NestedProjectedRow projection = NestedProjectedRow.create(dataSchema, 
projectedSchema);
+        GenericVariant unchanged = GenericVariant.fromJson("[1,2]");
+        InternalRow first =
+                projection.replaceRow(
+                        GenericRow.of(
+                                GenericRow.of(100, 
GenericVariant.fromJson("{\"x\":41}")),
+                                unchanged));
+        assertThat(first.getVariant(0)).isSameAs(unchanged);
+        InternalRow firstExtracted = first.getRow(1, 1).getRow(0, 1);
+        assertThat(firstExtracted.getInt(0)).isEqualTo(41);
+        InternalRow second =
+                projection.replaceRow(
+                        GenericRow.of(
+                                GenericRow.of(200, 
GenericVariant.fromJson("{\"x\":3}")),
+                                unchanged));
+        assertThat(second.getRow(1, 1).getRow(0, 1).getInt(0)).isEqualTo(3);
+        assertThat(firstExtracted.getInt(0)).isEqualTo(41);
+        assertThat(projection.replaceRow(GenericRow.of(null, 
unchanged)).isNullAt(1)).isTrue();
+        InternalRow nullVariant =
+                projection.replaceRow(GenericRow.of(GenericRow.of(0, null), 
unchanged));
+        assertThat(nullVariant.getRow(1, 1).isNullAt(0)).isTrue();
+    }
+
+    @Test
+    void testVariantProjectionPreservesStrictCastPolicy() {
+        RowType dataSchema = DataTypes.ROW(DataTypes.FIELD(0, "v", 
DataTypes.VARIANT()));
+        RowType extractedType =
+                VariantMetadataUtils.VariantRowTypeBuilder.builder()
+                        .field(DataTypes.INT(), "$.x")
+                        .build();
+        NestedProjectedRow projection =
+                NestedProjectedRow.create(
+                        dataSchema, DataTypes.ROW(DataTypes.FIELD(0, "v", 
extractedType)));
+        InternalRow projected =
+                projection.replaceRow(
+                        
GenericRow.of(GenericVariant.fromJson("{\"x\":\"invalid\"}")));
+        assertThatThrownBy(() -> projected.getRow(0, 1))
+                .isInstanceOf(RuntimeException.class)
+                .hasMessageContaining("Invalid cast")
+                .hasMessageContaining("INT");
+    }
+
+    @Test
+    void testVariantProjectionUsesRequestedTimeZone() {
+        RowType dataSchema = DataTypes.ROW(DataTypes.FIELD(0, "v", 
DataTypes.VARIANT()));
+        RowType extractedType =
+                VariantMetadataUtils.VariantRowTypeBuilder.builder()
+                        .field(DataTypes.STRING(), "$", true, "UTC")
+                        .field(DataTypes.STRING(), "$", true, "Asia/Shanghai")
+                        .build();
+        NestedProjectedRow projection =
+                NestedProjectedRow.create(
+                        dataSchema, DataTypes.ROW(DataTypes.FIELD(0, "v", 
extractedType)));
+        GenericVariantBuilder builder = new GenericVariantBuilder(false);
+        builder.appendTimestamp(0);
+        InternalRow extracted = 
projection.replaceRow(GenericRow.of(builder.result())).getRow(0, 2);
+        assertThat(extracted.getString(0).toString()).isEqualTo("1970-01-01 
00:00:00");
+        assertThat(extracted.getString(1).toString()).isEqualTo("1970-01-01 
08:00:00");
+    }
+
     @Test
     void testReturnNullWhenSchemasAreEqual() {
         RowType schema =
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/AppendOnlyFileStore.java 
b/paimon-core/src/main/java/org/apache/paimon/AppendOnlyFileStore.java
index f0f5e1c6ff..b60e91906a 100644
--- a/paimon-core/src/main/java/org/apache/paimon/AppendOnlyFileStore.java
+++ b/paimon-core/src/main/java/org/apache/paimon/AppendOnlyFileStore.java
@@ -74,6 +74,15 @@ public class AppendOnlyFileStore extends 
AbstractFileStore<InternalRow> {
                 schemaManager,
                 schema,
                 rowType,
+                dataSchema -> {
+                    if (options.rowTrackingEnabled()) {
+                        // The row id and sequence number may be in the 
manifest entry.
+                        return SpecialFields.rowTypeWithRowTracking(
+                                        dataSchema.logicalRowType(), true, 
true)
+                                .getFields();
+                    }
+                    return dataSchema.fields();
+                },
                 FileFormatDiscover.of(options),
                 pathFactory(),
                 options);
diff --git a/paimon-core/src/main/java/org/apache/paimon/KeyValueFileStore.java 
b/paimon-core/src/main/java/org/apache/paimon/KeyValueFileStore.java
index 8f84f5e504..2f99d2367e 100644
--- a/paimon-core/src/main/java/org/apache/paimon/KeyValueFileStore.java
+++ b/paimon-core/src/main/java/org/apache/paimon/KeyValueFileStore.java
@@ -120,6 +120,10 @@ public class KeyValueFileStore extends 
AbstractFileStore<KeyValue> {
                 schemaManager,
                 schema,
                 valueType,
+                dataSchema ->
+                        KeyValue.createKeyValueFields(
+                                keyValueFieldsExtractor.keyFields(dataSchema),
+                                
keyValueFieldsExtractor.valueFields(dataSchema)),
                 FileFormatDiscover.of(options),
                 pathFactory(),
                 options);
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/operation/RawFileSplitRead.java 
b/paimon-core/src/main/java/org/apache/paimon/operation/RawFileSplitRead.java
index 84b32eaa1d..ea652001a0 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/operation/RawFileSplitRead.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/operation/RawFileSplitRead.java
@@ -51,6 +51,7 @@ import org.apache.paimon.table.source.DataSplit;
 import org.apache.paimon.table.source.DeletionFile;
 import org.apache.paimon.table.source.IncrementalSplit;
 import org.apache.paimon.table.source.Split;
+import org.apache.paimon.types.DataField;
 import org.apache.paimon.types.RowType;
 import org.apache.paimon.utils.FileStorePathFactory;
 import org.apache.paimon.utils.FormatReaderMapping;
@@ -68,9 +69,9 @@ import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.function.Function;
 
 import static org.apache.paimon.predicate.PredicateBuilder.splitAnd;
-import static org.apache.paimon.table.SpecialFields.rowTypeWithRowTracking;
 
 /** A {@link SplitRead} to read raw file directly from {@link DataSplit}. */
 public class RawFileSplitRead implements SplitRead<InternalRow> {
@@ -80,6 +81,7 @@ public class RawFileSplitRead implements 
SplitRead<InternalRow> {
     private final FileIO fileIO;
     private final SchemaManager schemaManager;
     private final TableSchema schema;
+    private final Function<TableSchema, List<DataField>> fieldsExtractor;
     private final FileFormatDiscover formatDiscover;
     private final FileStorePathFactory pathFactory;
     private final Map<FormatKey, FormatReaderMapping> formatReaderMappings;
@@ -100,12 +102,14 @@ public class RawFileSplitRead implements 
SplitRead<InternalRow> {
             SchemaManager schemaManager,
             TableSchema schema,
             RowType rowType,
+            Function<TableSchema, List<DataField>> fieldsExtractor,
             FileFormatDiscover formatDiscover,
             FileStorePathFactory pathFactory,
             CoreOptions coreOptions) {
         this.fileIO = fileIO;
         this.schemaManager = schemaManager;
         this.schema = schema;
+        this.fieldsExtractor = fieldsExtractor;
         this.formatDiscover = formatDiscover;
         this.pathFactory = pathFactory;
         this.formatReaderMappings = new HashMap<>();
@@ -263,14 +267,7 @@ public class RawFileSplitRead implements 
SplitRead<InternalRow> {
         return new Builder(
                 formatDiscover,
                 outputRowType.getFields(),
-                schema -> {
-                    if (rowTrackingEnabled) {
-                        // maybe file has no row id and sequence number, but 
in manifest entry
-                        return rowTypeWithRowTracking(schema.logicalRowType(), 
true, true)
-                                .getFields();
-                    }
-                    return schema.fields();
-                },
+                fieldsExtractor,
                 filters,
                 pushDownTopN,
                 pushDownLimit,
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/schema/SchemaValidation.java 
b/paimon-core/src/main/java/org/apache/paimon/schema/SchemaValidation.java
index ab73ca53f2..c98600f7ee 100644
--- a/paimon-core/src/main/java/org/apache/paimon/schema/SchemaValidation.java
+++ b/paimon-core/src/main/java/org/apache/paimon/schema/SchemaValidation.java
@@ -256,8 +256,7 @@ public class SchemaValidation {
         FileFormat fileFormat =
                 FileFormat.fromIdentifier(options.formatType(), new 
Options(schema.options()));
         RowType tableRowType = new RowType(schema.fields());
-        validateGeospatialTypes(schema, options, tableRowType);
-        validateVariantTypes(schema, options);
+        validateIcebergGeospatialTypes(tableRowType, options);
         validateIcebergTimestampPrecisions(tableRowType, options);
         validateIcebergTimePrecisions(tableRowType, options);
         validateIcebergPublishableTypes(tableRowType, options);
@@ -417,6 +416,24 @@ public class SchemaValidation {
 
         validateRowTracking(schema, options);
 
+        List<String> geospatialClusteringColumns =
+                schema.fields().stream()
+                        .filter(field -> 
options.clusteringColumns().contains(field.name()))
+                        .filter(
+                                field ->
+                                        containsType(
+                                                field.type(),
+                                                type ->
+                                                        type.isAnyOf(
+                                                                
DataTypeRoot.GEOMETRY,
+                                                                
DataTypeRoot.GEOGRAPHY)))
+                        .map(DataField::name)
+                        .collect(Collectors.toList());
+        checkArgument(
+                geospatialClusteringColumns.isEmpty(),
+                "Geometry and geography columns cannot be clustering columns: 
%s.",
+                geospatialClusteringColumns);
+
         validateIncrementalClustering(schema, options);
 
         validateChainTable(schema, options);
@@ -500,125 +517,6 @@ public class SchemaValidation {
         }
     }
 
-    /**
-     * Only the Parquet format can store {@code VARIANT}, so every format the 
table may write data
-     * files with has to be parquet: the main format, each per-level override 
and the changelog
-     * format. Checking here reports the column and the option at DDL time, 
rather than the bare
-     * "unsupported type" a non-parquet writer raises on the first row.
-     */
-    private static void validateVariantTypes(TableSchema schema, CoreOptions 
options) {
-        List<String> variantColumns =
-                schema.fields().stream()
-                        .filter(
-                                field ->
-                                        containsType(
-                                                field.type(),
-                                                type -> 
type.is(DataTypeRoot.VARIANT)))
-                        .map(DataField::name)
-                        .collect(Collectors.toList());
-        if (variantColumns.isEmpty()) {
-            return;
-        }
-
-        checkArgument(
-                CoreOptions.FILE_FORMAT_PARQUET.equals(options.formatType()),
-                "Variant columns %s require '%s'='parquet', but was '%s'.",
-                variantColumns,
-                CoreOptions.FILE_FORMAT.key(),
-                options.formatType());
-        options.fileFormatPerLevel()
-                .forEach(
-                        (level, format) ->
-                                checkArgument(
-                                        
CoreOptions.FILE_FORMAT_PARQUET.equals(format),
-                                        "Variant columns %s require parquet at 
every level, but '%s' contains '%s:%s'.",
-                                        variantColumns,
-                                        
CoreOptions.FILE_FORMAT_PER_LEVEL.key(),
-                                        level,
-                                        format));
-        checkArgument(
-                options.changelogFileFormat() == null
-                        || 
CoreOptions.FILE_FORMAT_PARQUET.equals(options.changelogFileFormat()),
-                "Variant columns %s require '%s' to be parquet, but was '%s'.",
-                variantColumns,
-                CoreOptions.CHANGELOG_FILE_FORMAT.key(),
-                options.changelogFileFormat());
-    }
-
-    private static void validateGeospatialTypes(
-            TableSchema schema, CoreOptions options, RowType rowType) {
-        boolean hasGeospatial =
-                containsType(
-                        rowType,
-                        type -> type.isAnyOf(DataTypeRoot.GEOMETRY, 
DataTypeRoot.GEOGRAPHY));
-        if (!hasGeospatial) {
-            return;
-        }
-
-        checkArgument(
-                CoreOptions.FILE_FORMAT_PARQUET.equals(options.formatType()),
-                "Geometry and geography columns require '%s'='parquet', but 
was '%s'.",
-                CoreOptions.FILE_FORMAT.key(),
-                options.formatType());
-        options.fileFormatPerLevel()
-                .forEach(
-                        (level, format) ->
-                                checkArgument(
-                                        
CoreOptions.FILE_FORMAT_PARQUET.equals(format),
-                                        "Geometry and geography columns 
require parquet at every level, but '%s' contains '%s:%s'.",
-                                        
CoreOptions.FILE_FORMAT_PER_LEVEL.key(),
-                                        level,
-                                        format));
-        checkArgument(
-                options.changelogFileFormat() == null
-                        || 
CoreOptions.FILE_FORMAT_PARQUET.equals(options.changelogFileFormat()),
-                "Geometry and geography columns require '%s' to be parquet, 
but was '%s'.",
-                CoreOptions.CHANGELOG_FILE_FORMAT.key(),
-                options.changelogFileFormat());
-        validateIcebergGeospatialTypes(rowType, options);
-
-        List<String> geospatialClusteringColumns =
-                schema.fields().stream()
-                        .filter(field -> 
options.clusteringColumns().contains(field.name()))
-                        .filter(
-                                field ->
-                                        containsType(
-                                                field.type(),
-                                                type ->
-                                                        type.isAnyOf(
-                                                                
DataTypeRoot.GEOMETRY,
-                                                                
DataTypeRoot.GEOGRAPHY)))
-                        .map(DataField::name)
-                        .collect(Collectors.toList());
-        checkArgument(
-                geospatialClusteringColumns.isEmpty(),
-                "Geometry and geography columns cannot be clustering columns: 
%s.",
-                geospatialClusteringColumns);
-
-        Set<String> geospatialFields =
-                schema.fields().stream()
-                        .filter(
-                                field ->
-                                        field.type()
-                                                .isAnyOf(
-                                                        DataTypeRoot.GEOMETRY,
-                                                        
DataTypeRoot.GEOGRAPHY))
-                        .map(DataField::name)
-                        .collect(Collectors.toSet());
-        Set<String> geospatialBucketKeys = new HashSet<>(schema.bucketKeys());
-        geospatialBucketKeys.retainAll(geospatialFields);
-        checkArgument(
-                geospatialBucketKeys.isEmpty(),
-                "Geometry and geography columns cannot be bucket keys: %s.",
-                geospatialBucketKeys);
-        Set<String> geospatialSequenceFields = new 
HashSet<>(options.sequenceField());
-        geospatialSequenceFields.retainAll(geospatialFields);
-        checkArgument(
-                geospatialSequenceFields.isEmpty(),
-                "Geometry and geography columns cannot be sequence fields: 
%s.",
-                geospatialSequenceFields);
-    }
-
     /**
      * Refuses the timestamp precisions the Iceberg mirror cannot publish. A 
higher precision is
      * written as Parquet INT96, which Iceberg reads as a microsecond zoned 
timestamp rather than
@@ -1603,6 +1501,22 @@ public class SchemaValidation {
                                 schema.primaryKeys(), schema.partitionKeys()));
             }
         }
+
+        Set<String> geospatialSequenceFields =
+                schema.fields().stream()
+                        .filter(field -> sequenceField.contains(field.name()))
+                        .filter(
+                                field ->
+                                        field.type()
+                                                .isAnyOf(
+                                                        DataTypeRoot.GEOMETRY,
+                                                        
DataTypeRoot.GEOGRAPHY))
+                        .map(DataField::name)
+                        .collect(Collectors.toSet());
+        checkArgument(
+                geospatialSequenceFields.isEmpty(),
+                "Geometry and geography columns cannot be sequence fields: 
%s.",
+                geospatialSequenceFields);
     }
 
     private static void validateBucket(TableSchema schema, CoreOptions 
options) {
@@ -1650,6 +1564,22 @@ public class SchemaValidation {
                 }
             }
         }
+
+        Set<String> geospatialBucketKeys =
+                schema.fields().stream()
+                        .filter(field -> 
schema.bucketKeys().contains(field.name()))
+                        .filter(
+                                field ->
+                                        field.type()
+                                                .isAnyOf(
+                                                        DataTypeRoot.GEOMETRY,
+                                                        
DataTypeRoot.GEOGRAPHY))
+                        .map(DataField::name)
+                        .collect(Collectors.toSet());
+        checkArgument(
+                geospatialBucketKeys.isEmpty(),
+                "Geometry and geography columns cannot be bucket keys: %s.",
+                geospatialBucketKeys);
     }
 
     private static boolean isPostponeBucketTable(TableSchema schema, int 
bucket) {
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/operation/RawFileSplitReadTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/operation/RawFileSplitReadTest.java
index 048fb5fb0a..1e8e462db4 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/operation/RawFileSplitReadTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/operation/RawFileSplitReadTest.java
@@ -32,10 +32,12 @@ import org.apache.paimon.fs.Path;
 import org.apache.paimon.fs.local.LocalFileIO;
 import org.apache.paimon.options.Options;
 import org.apache.paimon.predicate.Predicate;
+import org.apache.paimon.predicate.PredicateBuilder;
 import org.apache.paimon.reader.ReadBatchSizer;
 import org.apache.paimon.reader.RecordReader;
 import org.apache.paimon.schema.FileSystemSchemaManager;
 import org.apache.paimon.schema.Schema;
+import org.apache.paimon.schema.SchemaChange;
 import org.apache.paimon.schema.SchemaUtils;
 import org.apache.paimon.schema.TableSchema;
 import org.apache.paimon.table.FileStoreTable;
@@ -45,19 +47,26 @@ import org.apache.paimon.table.sink.BatchTableWrite;
 import org.apache.paimon.table.sink.BatchWriteBuilder;
 import org.apache.paimon.table.source.DataSplit;
 import org.apache.paimon.table.source.InnerTableRead;
+import org.apache.paimon.table.source.Split;
+import org.apache.paimon.table.source.TableRead;
 import org.apache.paimon.types.DataTypes;
+import org.apache.paimon.types.RowKind;
 import org.apache.paimon.types.RowType;
 import org.apache.paimon.utils.IOExceptionSupplier;
 
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
 
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
 import java.util.Map;
 import java.util.concurrent.atomic.AtomicInteger;
 
+import static org.apache.paimon.predicate.PredicateBuilder.and;
 import static org.assertj.core.api.Assertions.assertThat;
 
 /** Tests for {@link RawFileSplitRead}. */
@@ -65,6 +74,98 @@ class RawFileSplitReadTest {
 
     @TempDir java.nio.file.Path tempDir;
 
+    @ParameterizedTest
+    @CsvSource({
+        "row, false",
+        "avro, false",
+        "avro, true",
+        "parquet, false",
+        "parquet, true",
+        "orc, false",
+        "orc, true"
+    })
+    void testRawConvertibleReadUsesKeyValueSchema(String format, boolean 
thinMode)
+            throws Exception {
+        Path tablePath = new Path(tempDir.resolve("primary-key-raw").toUri());
+        Options options = new Options();
+        options.set(CoreOptions.PATH, tablePath.toString());
+        options.set(CoreOptions.BUCKET, 1);
+        options.set(CoreOptions.WRITE_ONLY, true);
+        options.set(CoreOptions.FILE_FORMAT, format);
+        options.set(CoreOptions.DATA_FILE_THIN_MODE, thinMode);
+        Schema schema =
+                Schema.newBuilder()
+                        .column("pt", DataTypes.INT())
+                        .column("a", DataTypes.INT())
+                        .column("b", DataTypes.BIGINT())
+                        .partitionKeys("pt")
+                        .primaryKey("pt", "a")
+                        .options(options.toMap())
+                        .build();
+        TableSchema tableSchema =
+                SchemaUtils.forceCommit(
+                        new FileSystemSchemaManager(LocalFileIO.create(), 
tablePath), schema);
+        FileStoreTable table =
+                FileStoreTableFactory.create(LocalFileIO.create(), tablePath, 
tableSchema);
+        BatchWriteBuilder writeBuilder = table.newBatchWriteBuilder();
+        try (BatchTableWrite write = writeBuilder.newWrite();
+                BatchTableCommit commit = writeBuilder.newCommit()) {
+            write.write(GenericRow.of(1, 10, 100L));
+            write.write(GenericRow.of(1, 20, 200L));
+            write.write(GenericRow.of(1, 30, 300L));
+            write.write(GenericRow.of(1, 40, 400L));
+            write.write(GenericRow.of(2, 20, 220L));
+            commit.commit(write.prepareCommit());
+        }
+
+        List<DataSplit> dataSplits = 
table.newSnapshotReader().read().dataSplits();
+        assertThat(dataSplits).hasSize(2);
+        for (DataSplit split : dataSplits) {
+            assertThat(split.rawConvertible()).isTrue();
+            assertThat(split.dataFiles()).hasSize(1);
+        }
+        List<Split> splits = new ArrayList<>(dataSplits);
+        for (int schemaVersion = 0; schemaVersion < 2; schemaVersion++) {
+            RowType readType =
+                    new RowType(
+                            Arrays.asList(
+                                    table.rowType().getField(2),
+                                    table.rowType().getField(1),
+                                    table.rowType().getField(0)));
+            PredicateBuilder builder = new PredicateBuilder(table.rowType());
+            TableRead read =
+                    table.newRead()
+                            .withReadType(readType)
+                            .withFilter(
+                                    and(
+                                            builder.equal(0, 1),
+                                            builder.greaterOrEqual(1, 20),
+                                            builder.lessOrEqual(2, 300L)))
+                            .executeFilter();
+            List<String> actual = new ArrayList<>();
+            try (RecordReader<InternalRow> reader = read.createReader(splits)) 
{
+                reader.forEachRemaining(
+                        row -> {
+                            assertThat(row.getFieldCount()).isEqualTo(3);
+                            
assertThat(row.getRowKind()).isEqualTo(RowKind.INSERT);
+                            actual.add(row.getLong(0) + "|" + row.getInt(1) + 
"|" + row.getInt(2));
+                        });
+            }
+            assertThat(actual).containsExactlyInAnyOrder("200|20|1", 
"300|30|1");
+
+            if (schemaVersion == 0) {
+                table.schemaManager()
+                        .commitChanges(
+                                Arrays.asList(
+                                        SchemaChange.renameColumn("a", 
"renamed_a"),
+                                        SchemaChange.renameColumn("b", 
"renamed_b")));
+                table = table.copyWithLatestSchema();
+                assertThat(dataSplits.get(0).dataFiles().get(0).schemaId())
+                        .isLessThan(table.schema().id());
+            }
+        }
+    }
+
     @Test
     void testReaderMappingIsNotSharedBetweenReadTypes() throws Exception {
         FileStoreTable table = createTable("mapping-cache");
@@ -117,6 +218,7 @@ class RawFileSplitReadTest {
                         table.schemaManager(),
                         table.schema(),
                         table.rowType(),
+                        TableSchema::fields,
                         ignored -> countingFormat,
                         table.store().pathFactory(),
                         table.coreOptions());
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/schema/SchemaValidationTest.java 
b/paimon-core/src/test/java/org/apache/paimon/schema/SchemaValidationTest.java
index 50bf9e8006..54a7088950 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/schema/SchemaValidationTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/schema/SchemaValidationTest.java
@@ -1843,35 +1843,18 @@ class SchemaValidationTest {
                                                 emptyList(),
                                                 new HashMap<>())));
 
-        Map<String, String> avroOptions = new HashMap<>();
-        avroOptions.put(CoreOptions.FILE_FORMAT.key(), "avro");
-        assertThatThrownBy(
-                        () ->
-                                validateTableSchema(
-                                        geospatialSchema(
-                                                fields, emptyList(), 
emptyList(), avroOptions)))
-                .hasMessageContaining("require 'file.format'='parquet'");
-
-        Map<String, String> perLevelOptions = new HashMap<>();
-        perLevelOptions.put(CoreOptions.FILE_FORMAT_PER_LEVEL.key(), "0:orc");
-        assertThatThrownBy(
-                        () ->
-                                validateTableSchema(
-                                        geospatialSchema(
-                                                fields, emptyList(), 
emptyList(), perLevelOptions)))
-                .hasMessageContaining("require parquet at every level");
-
-        Map<String, String> changelogOptions = new HashMap<>();
-        changelogOptions.put(CoreOptions.CHANGELOG_FILE_FORMAT.key(), "orc");
-        assertThatThrownBy(
-                        () ->
-                                validateTableSchema(
-                                        geospatialSchema(
-                                                fields,
-                                                emptyList(),
-                                                emptyList(),
-                                                changelogOptions)))
-                .hasMessageContaining("require 'changelog-file.format' to be 
parquet");
+        for (String format : Arrays.asList("parquet", "row")) {
+            Map<String, String> options = new HashMap<>();
+            options.put(CoreOptions.FILE_FORMAT.key(), format);
+            options.put(CoreOptions.FILE_FORMAT_PER_LEVEL.key(), 
"0:orc,1:avro");
+            options.put(CoreOptions.CHANGELOG_FILE_FORMAT.key(), "orc");
+            assertThatNoException()
+                    .isThrownBy(
+                            () ->
+                                    validateTableSchema(
+                                            geospatialSchema(
+                                                    fields, emptyList(), 
emptyList(), options)));
+        }
 
         Map<String, String> icebergV2Options = new HashMap<>();
         icebergV2Options.put(IcebergOptions.METADATA_ICEBERG_STORAGE.key(), 
"table-location");
@@ -2013,9 +1996,9 @@ class SchemaValidationTest {
                         "Geometry and geography columns cannot be clustering 
columns: [nested].");
     }
 
-    @Test
-    public void testVariantTypeValidation() {
-        // a top-level variant and one nested inside a struct
+    @ParameterizedTest
+    @ValueSource(strings = {"parquet", "row"})
+    public void testVariantTypeValidation(String format) {
         List<DataField> fields =
                 Arrays.asList(
                         new DataField(0, "id", DataTypes.INT()),
@@ -2024,56 +2007,20 @@ class SchemaValidationTest {
                                 2,
                                 "nested",
                                 DataTypes.ROW(DataTypes.FIELD(3, "inner", 
DataTypes.VARIANT()))));
-
+        Map<String, String> options = new HashMap<>();
+        options.put(CoreOptions.FILE_FORMAT.key(), format);
+        options.put(CoreOptions.FILE_FORMAT_PER_LEVEL.key(), "0:orc,1:avro");
+        options.put(CoreOptions.CHANGELOG_PRODUCER.key(), "input");
+        options.put(CoreOptions.CHANGELOG_FILE_FORMAT.key(), "avro");
         assertThatNoException()
                 .isThrownBy(
-                        () ->
-                                validateTableSchema(
-                                        geospatialSchema(
-                                                fields,
-                                                emptyList(),
-                                                emptyList(),
-                                                new HashMap<>())));
-
-        // the main format used to be caught by the format's own type 
converter, with a bare
-        // "Unsupported type: VARIANT" that named neither the column nor the 
option
-        Map<String, String> orcOptions = new HashMap<>();
-        orcOptions.put(CoreOptions.FILE_FORMAT.key(), "orc");
-        assertThatThrownBy(
-                        () ->
-                                validateTableSchema(
-                                        geospatialSchema(
-                                                fields, emptyList(), 
emptyList(), orcOptions)))
-                .hasMessageContaining(
-                        "Variant columns [v, nested] require 
'file.format'='parquet', but was 'orc'");
-
-        // these two used to pass DDL and fail on the first row written with 
that format
-        Map<String, String> perLevelOptions = new HashMap<>();
-        perLevelOptions.put(CoreOptions.FILE_FORMAT_PER_LEVEL.key(), "0:orc");
-        assertThatThrownBy(
                         () ->
                                 validateTableSchema(
                                         geospatialSchema(
                                                 fields,
                                                 emptyList(),
                                                 singletonList("id"),
-                                                perLevelOptions)))
-                .hasMessageContaining(
-                        "Variant columns [v, nested] require parquet at every 
level, but 'file.format.per.level' contains '0:orc'");
-
-        Map<String, String> changelogOptions = new HashMap<>();
-        changelogOptions.put(CoreOptions.CHANGELOG_PRODUCER.key(), "input");
-        changelogOptions.put(CoreOptions.CHANGELOG_FILE_FORMAT.key(), "avro");
-        assertThatThrownBy(
-                        () ->
-                                validateTableSchema(
-                                        geospatialSchema(
-                                                fields,
-                                                emptyList(),
-                                                singletonList("id"),
-                                                changelogOptions)))
-                .hasMessageContaining(
-                        "Variant columns [v, nested] require 
'changelog-file.format' to be parquet, but was 'avro'");
+                                                options)));
     }
 
     private TableSchema geospatialSchema(
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/table/AppendOnlySimpleTableTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/table/AppendOnlySimpleTableTest.java
index ce4bd35f3e..df9323bf46 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/table/AppendOnlySimpleTableTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/table/AppendOnlySimpleTableTest.java
@@ -28,6 +28,8 @@ import org.apache.paimon.data.GenericMap;
 import org.apache.paimon.data.GenericRow;
 import org.apache.paimon.data.InternalRow;
 import org.apache.paimon.data.serializer.InternalRowSerializer;
+import org.apache.paimon.data.variant.GenericVariant;
+import org.apache.paimon.data.variant.VariantMetadataUtils;
 import org.apache.paimon.fileindex.FileIndexOptions;
 import org.apache.paimon.fileindex.bitmap.BitmapFileIndexFactory;
 import org.apache.paimon.fileindex.bloomfilter.BloomFilterFileIndexFactory;
@@ -36,6 +38,7 @@ import 
org.apache.paimon.fileindex.rangebitmap.RangeBitmapFileIndexFactory;
 import org.apache.paimon.fs.FileIOFinder;
 import org.apache.paimon.fs.Path;
 import org.apache.paimon.fs.local.LocalFileIO;
+import org.apache.paimon.globalindex.IndexedSplit;
 import org.apache.paimon.io.BundleRecords;
 import org.apache.paimon.io.DataFileMeta;
 import org.apache.paimon.manifest.FileEntry;
@@ -80,6 +83,7 @@ import org.apache.paimon.types.DataTypes;
 import org.apache.paimon.types.RowType;
 import org.apache.paimon.utils.BranchMergeHandler;
 import org.apache.paimon.utils.CloseableIterator;
+import org.apache.paimon.utils.Range;
 import org.apache.paimon.utils.RoaringBitmap32;
 
 import org.apache.commons.math3.random.RandomDataGenerator;
@@ -135,6 +139,151 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
 /** Tests for {@link AppendOnlyFileStoreTable}. */
 public class AppendOnlySimpleTableTest extends SimpleTableTestBase {
 
+    @ParameterizedTest
+    @ValueSource(strings = {"append", "primary-key-raw", "primary-key-merge"})
+    public void testRowFormatVariantReadWrite(String tableKind) throws 
Exception {
+        boolean primaryKey = !"append".equals(tableKind);
+        boolean merge = "primary-key-merge".equals(tableKind);
+        RowType rowType =
+                DataTypes.ROW(
+                        DataTypes.FIELD(0, "id", DataTypes.INT()),
+                        DataTypes.FIELD(1, "payload", DataTypes.VARIANT()),
+                        DataTypes.FIELD(
+                                2,
+                                "nested",
+                                DataTypes.ROW(
+                                        DataTypes.FIELD(3, "label", 
DataTypes.STRING()),
+                                        DataTypes.FIELD(4, "detail", 
DataTypes.VARIANT()))));
+        Options options = new Options();
+        options.set(BUCKET, primaryKey ? 1 : -1);
+        options.set(FILE_FORMAT, "row");
+        options.set(WRITE_ONLY, true);
+        new FileSystemSchemaManager(FileIOFinder.find(tablePath), tablePath)
+                .createTable(
+                        new Schema(
+                                rowType.getFields(),
+                                Collections.emptyList(),
+                                primaryKey
+                                        ? Collections.singletonList("id")
+                                        : Collections.emptyList(),
+                                options.toMap(),
+                                ""));
+        FileStoreTable table =
+                FileStoreTableFactory.create(FileIOFinder.find(tablePath), 
tablePath);
+        List<InternalRow> expected =
+                Arrays.asList(
+                        GenericRow.of(
+                                1,
+                                GenericVariant.fromJson(
+                                        
"{\"a\":[1,\"two\",null],\"b\":{\"c\":true}}"),
+                                GenericRow.of(
+                                        BinaryString.fromString("one"),
+                                        
GenericVariant.fromJson("[1,{\"x\":2}]"))),
+                        GenericRow.of(
+                                2, null, GenericRow.of(null, 
GenericVariant.fromJson("null"))),
+                        GenericRow.of(3, GenericVariant.fromJson("null"), 
null),
+                        GenericRow.of(
+                                4,
+                                GenericVariant.fromJson("\"text\""),
+                                GenericRow.of(BinaryString.fromString("two"), 
null)));
+        BatchWriteBuilder writeBuilder = table.newBatchWriteBuilder();
+        for (int commitIndex = 0; commitIndex < (merge ? 2 : 1); 
commitIndex++) {
+            try (BatchTableWrite write = writeBuilder.newWrite();
+                    BatchTableCommit commit = writeBuilder.newCommit()) {
+                for (InternalRow row : expected) {
+                    write.write(
+                            merge && commitIndex == 0
+                                    ? GenericRow.of(
+                                            row.getInt(0),
+                                            
GenericVariant.fromJson("{\"a\":[-1]}"),
+                                            null)
+                                    : row);
+                }
+                commit.commit(write.prepareCommit());
+            }
+        }
+
+        ReadBuilder readBuilder = table.newReadBuilder();
+        List<Split> splits = readBuilder.newScan().plan().splits();
+        if (primaryKey) {
+            assertThat(splits).hasSize(1);
+            DataSplit split = (DataSplit) splits.get(0);
+            assertThat(split.rawConvertible()).isEqualTo(!merge);
+            assertThat(split.dataFiles()).hasSize(merge ? 2 : 1);
+        }
+        InternalRowSerializer serializer = new InternalRowSerializer(rowType);
+        List<InternalRow> actual = new ArrayList<>();
+        for (Split split : splits) {
+            try (RecordReader<InternalRow> reader = 
readBuilder.newRead().createReader(split)) {
+                reader.forEachRemaining(row -> 
actual.add(serializer.copy(row)));
+            }
+        }
+        assertThat(actual).containsExactlyInAnyOrderElementsOf(expected);
+
+        RowType payloadProjection =
+                VariantMetadataUtils.VariantRowTypeBuilder.builder()
+                        .field(DataTypes.INT(), "$.a[0]", false, "UTC")
+                        .field(DataTypes.BOOLEAN(), "$.b.c", false, "UTC")
+                        .build();
+        RowType detailProjection =
+                VariantMetadataUtils.VariantRowTypeBuilder.builder()
+                        .field(DataTypes.INT(), "$[1].x", false, "UTC")
+                        .build();
+        RowType readType =
+                DataTypes.ROW(
+                        DataTypes.FIELD(
+                                2,
+                                "nested",
+                                DataTypes.ROW(DataTypes.FIELD(4, "detail", 
detailProjection))),
+                        DataTypes.FIELD(1, "payload", payloadProjection),
+                        DataTypes.FIELD(0, "id", DataTypes.INT()));
+        ReadBuilder projectedRead = 
table.newReadBuilder().withReadType(readType);
+        InternalRowSerializer projectedSerializer = new 
InternalRowSerializer(readType);
+        List<InternalRow> projected = new ArrayList<>();
+        for (Split split : projectedRead.newScan().plan().splits()) {
+            try (RecordReader<InternalRow> reader = 
projectedRead.newRead().createReader(split)) {
+                reader.forEachRemaining(row -> 
projected.add(projectedSerializer.copy(row)));
+            }
+        }
+        assertThat(projected)
+                .containsExactlyInAnyOrder(
+                        GenericRow.of(GenericRow.of(GenericRow.of(2)), 
GenericRow.of(1, true), 1),
+                        GenericRow.of(GenericRow.of(GenericRow.of((Object) 
null)), null, 2),
+                        GenericRow.of(null, GenericRow.of(null, null), 3),
+                        GenericRow.of(GenericRow.of((Object) null), 
GenericRow.of(null, null), 4));
+
+        if ("primary-key-raw".equals(tableKind)) {
+            new FileSystemSchemaManager(FileIOFinder.find(tablePath), 
tablePath)
+                    .commitChanges(
+                            Arrays.asList(
+                                    SchemaChange.renameColumn("id", 
"renamed_id"),
+                                    SchemaChange.renameColumn("payload", 
"renamed_payload")));
+            FileStoreTable evolvedTable =
+                    FileStoreTableFactory.create(FileIOFinder.find(tablePath), 
tablePath);
+            RowType evolvedReadType =
+                    DataTypes.ROW(
+                            readType.getField(2),
+                            readType.getField(1).newName("renamed_payload"),
+                            readType.getField(0).newName("renamed_id"));
+            ReadBuilder evolvedRead = 
evolvedTable.newReadBuilder().withReadType(evolvedReadType);
+            IndexedSplit selected =
+                    new IndexedSplit(
+                            (DataSplit) splits.get(0),
+                            Arrays.asList(new Range(0, 0), new Range(2, 2)),
+                            null);
+            InternalRowSerializer evolvedSerializer = new 
InternalRowSerializer(evolvedReadType);
+            List<InternalRow> historical = new ArrayList<>();
+            try (RecordReader<InternalRow> reader = 
evolvedRead.newRead().createReader(selected)) {
+                reader.forEachRemaining(row -> 
historical.add(evolvedSerializer.copy(row)));
+            }
+            assertThat(historical)
+                    .containsExactlyInAnyOrder(
+                            GenericRow.of(
+                                    GenericRow.of(GenericRow.of(2)), 
GenericRow.of(1, true), 1),
+                            GenericRow.of(null, GenericRow.of(null, null), 3));
+        }
+    }
+
     @Test
     public void testOverwriteSameFiles() throws Exception {
         FileStoreTable table = createFileStoreTable();
diff --git 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/source/TestChangelogDataReadWrite.java
 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/source/TestChangelogDataReadWrite.java
index 920f297028..5e5eb53015 100644
--- 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/source/TestChangelogDataReadWrite.java
+++ 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/source/TestChangelogDataReadWrite.java
@@ -150,6 +150,10 @@ public class TestChangelogDataReadWrite {
                         schemaManager,
                         schema,
                         VALUE_TYPE,
+                        dataSchema ->
+                                KeyValue.createKeyValueFields(
+                                        EXTRACTOR.keyFields(dataSchema),
+                                        EXTRACTOR.valueFields(dataSchema)),
                         FileFormatDiscover.of(options),
                         pathFactory,
                         options);
diff --git a/paimon-python/pypaimon/read/split_read.py 
b/paimon-python/pypaimon/read/split_read.py
index a50deffb09..bc8d30206f 100644
--- a/paimon-python/pypaimon/read/split_read.py
+++ b/paimon-python/pypaimon/read/split_read.py
@@ -95,7 +95,8 @@ from pypaimon.globalindex.indexed_split import IndexedSplit
 from pypaimon.utils.data_evolution_utils import retrieve_anchor_file
 
 KEY_PREFIX = "_KEY_"
-KEY_FIELD_ID_START = 1000000
+# Match Java's reserved key-field range without overlapping valid user IDs.
+KEY_FIELD_ID_START = SpecialFields.SYSTEM_FIELD_ID_START
 NULL_FIELD_INDEX = -1
 
 
diff --git a/paimon-python/pypaimon/tests/test_format_row_table.py 
b/paimon-python/pypaimon/tests/test_format_row_table.py
index c483144547..b2598fa78c 100644
--- a/paimon-python/pypaimon/tests/test_format_row_table.py
+++ b/paimon-python/pypaimon/tests/test_format_row_table.py
@@ -23,8 +23,10 @@ import tempfile
 import unittest
 
 import pyarrow as pa
+import pytest
 
 from pypaimon import CatalogFactory, Schema
+from pypaimon.data.generic_variant import GenericVariant
 
 
 class RowFormatAppendOnlyTest(unittest.TestCase):
@@ -499,5 +501,101 @@ class RowFormatVectorTableTest(unittest.TestCase):
         self.assertEqual(sorted(result.column('id').to_pylist()), [1, 2, 3])
 
 
[email protected]_read
[email protected]_write
[email protected]('table_kind', [
+    'append', 'primary-key', 'primary-key-high-id', 'data-evolution',
+])
+def test_row_table_preserves_variant_values(tmp_path, table_kind):
+    variants = GenericVariant.to_arrow_array([
+        GenericVariant.from_python({'a': 1, 'nested': {'b': [True, None, 
'text']}}),
+        GenericVariant.from_python([1, {'x': 'value'}, None]),
+        GenericVariant.from_python(None),
+        None,
+        GenericVariant.from_python('scalar'),
+    ])
+    payloads = variants.to_pylist()
+    nested_type = pa.struct([pa.field('payload', variants.type), 
pa.field('tag', pa.string())])
+    data = pa.table({
+        'id': pa.array(range(5), type=pa.int32()),
+        'payload': variants,
+        'nested': pa.array([
+            {'payload': payloads[0], 'tag': 'object'},
+            {'payload': payloads[1], 'tag': 'array'},
+            {'payload': payloads[2], 'tag': 'variant-null'},
+            None,
+            {'payload': None, 'tag': 'sql-null'},
+        ], type=nested_type),
+        'items': pa.array([
+            [payloads[0], None, payloads[2]], [], None, [payloads[1]], 
[payloads[4]],
+        ], type=pa.list_(variants.type)),
+        'mapping': pa.array([
+            [('first', payloads[0]), ('missing', None)], [], None,
+            [('array', payloads[1])], [('null', payloads[2])],
+        ], type=pa.map_(pa.string(), variants.type)),
+    })
+    options = {
+        'file.format': 'row', 'write.native.enabled': 'false', 
'read.native.enabled': 'false',
+    }
+    primary_keys = []
+    if table_kind.startswith('primary-key'):
+        primary_keys = ['id']
+        options['bucket'] = '1'
+    elif table_kind == 'data-evolution':
+        options.update({'row-tracking.enabled': 'true', 
'data-evolution.enabled': 'true'})
+
+    catalog = CatalogFactory.create({'warehouse': str(tmp_path / 'warehouse')})
+    catalog.create_database('default', True)
+    schema = Schema.from_pyarrow_schema(
+        data.schema, primary_keys=primary_keys, options=options)
+    if table_kind == 'primary-key-high-id':
+        schema.fields[1].id = 1_000_000
+    catalog.create_table('default.variants', schema, False)
+    table = catalog.get_table('default.variants')
+    batches = [data]
+    if table_kind == 'primary-key-high-id':
+        stale = GenericVariant.to_arrow_array([
+            GenericVariant.from_python({'stale': True}) for _ in range(5)
+        ])
+        batches.insert(0, data.set_column(1, 'payload', stale))
+    builder = table.new_batch_write_builder()
+    for batch in batches:
+        writer, committer = builder.new_write(), builder.new_commit()
+        try:
+            writer.write_arrow(batch)
+            messages = writer.prepare_commit()
+            files = [file for message in messages for file in 
message.new_files]
+            assert files
+            for file in files:
+                assert file.file_name.endswith('.row')
+                with table.file_io.new_input_stream(file.file_path) as stream:
+                    stream.seek(file.file_size - 8)
+                    assert stream.read(1) == b'\x01'
+            committer.commit(messages)
+        finally:
+            writer.close()
+            committer.close()
+
+    read_builder = table.new_read_builder()
+    splits = read_builder.new_scan().plan().splits()
+    result = read_builder.new_read().to_arrow(splits).sort_by('id')
+    assert result.to_pydict() == data.to_pydict()
+    restored_payloads = result.column('payload').to_pylist()
+    assert GenericVariant.from_arrow_struct(restored_payloads[0]).to_python() 
== {
+        'a': 1, 'nested': {'b': [True, None, 'text']},
+    }
+    assert GenericVariant.from_arrow_struct(restored_payloads[1]).to_python() 
== [
+        1, {'x': 'value'}, None,
+    ]
+    assert restored_payloads[2] is not None
+    assert GenericVariant.from_arrow_struct(restored_payloads[2]).to_python() 
is None
+    assert restored_payloads[3] is None
+
+    projected = table.new_read_builder().with_projection(['payload', 'id'])
+    projected_result = projected.new_read().to_arrow(
+        projected.new_scan().plan().splits()).sort_by('id')
+    assert projected_result.to_pydict() == data.select(['payload', 
'id']).to_pydict()
+
+
 if __name__ == '__main__':
     unittest.main()

Reply via email to