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

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


The following commit(s) were added to refs/heads/main by this push:
     new 7e283579 [datafusion] Support Utf8View schemas and residual filtering 
(#543)
7e283579 is described below

commit 7e283579c74f612448003645f681c679847828f8
Author: Jingsong Lee <[email protected]>
AuthorDate: Sun Jul 19 21:16:08 2026 +0800

    [datafusion] Support Utf8View schemas and residual filtering (#543)
---
 benchmarks/tpcds/tests/smoke.rs                    |  38 +++++
 bindings/python/src/context.rs                     |  69 +++++++-
 bindings/python/tests/test_datafusion.py           |  21 +++
 bindings/python/tests/test_write.py                |   6 +-
 crates/integrations/datafusion/src/catalog.rs      | 110 ++++++++----
 crates/integrations/datafusion/src/merge_into.rs   |   4 +-
 .../datafusion/src/physical_plan/scan.rs           |  36 +++-
 .../datafusion/src/physical_plan/sink.rs           |  51 +++++-
 crates/integrations/datafusion/src/sql_context.rs  |   5 +-
 crates/integrations/datafusion/src/table/mod.rs    | 121 ++++++++++++-
 crates/integrations/datafusion/src/update.rs       |   4 +-
 .../datafusion/tests/append_merge_into.rs          |  12 +-
 crates/integrations/datafusion/tests/blob_tests.rs |  24 ++-
 crates/integrations/datafusion/tests/common/mod.rs |  54 +++---
 .../datafusion/tests/dynamic_bucket_tables.rs      |  56 +++---
 .../datafusion/tests/merge_into_tests.rs           |  33 ++--
 .../integrations/datafusion/tests/mosaic_tables.rs |  24 +--
 crates/integrations/datafusion/tests/pk_tables.rs  | 189 +++++++--------------
 .../integrations/datafusion/tests/read_tables.rs   |  52 +++---
 crates/paimon/src/arrow/format/parquet.rs          |  86 ++++++++++
 crates/paimon/src/arrow/residual.rs                |  43 +++--
 21 files changed, 689 insertions(+), 349 deletions(-)

diff --git a/benchmarks/tpcds/tests/smoke.rs b/benchmarks/tpcds/tests/smoke.rs
index ff175b0f..c86d73ef 100644
--- a/benchmarks/tpcds/tests/smoke.rs
+++ b/benchmarks/tpcds/tests/smoke.rs
@@ -94,6 +94,44 @@ async fn parquet_fixture_loads_into_paimon() {
     assert_eq!(counts.value(0), 3);
 }
 
+#[tokio::test]
+async fn loaded_paimon_varchar_is_exposed_as_utf8_view() {
+    let data = TempDir::new().unwrap();
+    let warehouse = TempDir::new().unwrap();
+    write_fixture(&data, "store_sales");
+    let session = open_catalog_session(
+        &BenchmarkRuntimeConfig::default(),
+        warehouse.path(),
+        "tpcds",
+    )
+    .await
+    .unwrap();
+    load_parquet_table(
+        &session,
+        data.path(),
+        "store_sales",
+        ExistingTablePolicy::Error,
+    )
+    .await
+    .unwrap();
+
+    let batches = session
+        .sql
+        .sql("SELECT arrow_typeof(name) FROM paimon.tpcds.store_sales LIMIT 1")
+        .await
+        .unwrap()
+        .collect()
+        .await
+        .unwrap();
+    let types = batches[0]
+        .column(0)
+        .as_any()
+        .downcast_ref::<StringArray>()
+        .unwrap();
+
+    assert_eq!(types.value(0), "Utf8View");
+}
+
 #[tokio::test]
 async fn loaded_paimon_table_runs_warmups_and_measured_iterations() {
     let data = TempDir::new().unwrap();
diff --git a/bindings/python/src/context.rs b/bindings/python/src/context.rs
index b2f00f68..2536c5d5 100644
--- a/bindings/python/src/context.rs
+++ b/bindings/python/src/context.rs
@@ -18,8 +18,10 @@
 use std::collections::HashMap;
 use std::sync::Arc;
 
+use arrow::compute::cast;
 use arrow::datatypes::{DataType as ArrowDataType, Field as ArrowField};
 use arrow::pyarrow::{FromPyArrow, ToPyArrow};
+use arrow::record_batch::{RecordBatch, RecordBatchOptions};
 use datafusion::catalog::CatalogProvider;
 use datafusion::logical_expr::{Signature, TypeSignature, Volatility};
 use datafusion_ffi::catalog_provider::FFI_CatalogProvider;
@@ -37,6 +39,47 @@ use crate::table::PyTable;
 use crate::udf::{build_python_scalar_udf, udf, PyPythonScalarUDFObject};
 use paimon_datafusion::runtime::runtime;
 
+fn pyarrow_compatible_batch(batch: &RecordBatch) -> 
arrow::error::Result<RecordBatch> {
+    let mut changed = false;
+    let fields = batch
+        .schema()
+        .fields()
+        .iter()
+        .map(|field| {
+            if field.data_type() == &ArrowDataType::Utf8View {
+                changed = true;
+                
Arc::new(field.as_ref().clone().with_data_type(ArrowDataType::Utf8))
+            } else {
+                Arc::clone(field)
+            }
+        })
+        .collect::<Vec<_>>();
+
+    if !changed {
+        return Ok(batch.clone());
+    }
+
+    let schema = Arc::new(arrow::datatypes::Schema::new_with_metadata(
+        fields,
+        batch.schema().metadata().clone(),
+    ));
+    let columns = batch
+        .columns()
+        .iter()
+        .zip(schema.fields())
+        .map(|(column, field)| {
+            if column.data_type() == field.data_type() {
+                Ok(Arc::clone(column))
+            } else {
+                cast(column.as_ref(), field.data_type())
+            }
+        })
+        .collect::<arrow::error::Result<Vec<_>>>()?;
+    let options = 
RecordBatchOptions::new().with_row_count(Some(batch.num_rows()));
+
+    RecordBatch::try_new_with_options(schema, columns, &options)
+}
+
 fn build_paimon_catalog(catalog_options: HashMap<String, String>) -> 
PyResult<Arc<dyn Catalog>> {
     let rt = runtime();
     rt.block_on(async {
@@ -75,13 +118,16 @@ impl PaimonCatalog {
     #[new]
     fn new(catalog_options: HashMap<String, String>) -> PyResult<Self> {
         let catalog = build_paimon_catalog(catalog_options)?;
-        let provider = Arc::new(PaimonCatalogProvider::new(
-            None,
-            Arc::clone(&catalog),
-            Default::default(),
-            Default::default(),
-            None,
-        ));
+        let provider = Arc::new(
+            PaimonCatalogProvider::new(
+                None,
+                Arc::clone(&catalog),
+                Default::default(),
+                Default::default(),
+                None,
+            )
+            .with_schema_force_view_types(false),
+        );
         Ok(Self { catalog, provider })
     }
 
@@ -365,7 +411,14 @@ impl PySQLContext {
         })?;
         batches
             .iter()
-            .map(|batch| Ok(batch.to_pyarrow(py)?.unbind()))
+            .map(|batch| {
+                let batch = pyarrow_compatible_batch(batch).map_err(|err| {
+                    PyValueError::new_err(format!(
+                        "Failed to convert query result for PyArrow 
compatibility: {err}"
+                    ))
+                })?;
+                Ok(batch.to_pyarrow(py)?.unbind())
+            })
             .collect()
     }
 }
diff --git a/bindings/python/tests/test_datafusion.py 
b/bindings/python/tests/test_datafusion.py
index ca4b64ac..916e1cc3 100644
--- a/bindings/python/tests/test_datafusion.py
+++ b/bindings/python/tests/test_datafusion.py
@@ -478,6 +478,27 @@ def test_query_simple_table_via_catalog_provider():
     ]
 
 
+def test_catalog_provider_returns_pyarrow_compatible_strings():
+    with tempfile.TemporaryDirectory() as warehouse:
+        writer = SQLContext()
+        writer.register_catalog("paimon", {"warehouse": warehouse})
+        writer.sql("CREATE TABLE paimon.default.users (id INT, name STRING)")
+        writer.sql(
+            "INSERT INTO paimon.default.users VALUES (2, 'bob'), (1, 'alice')"
+        )
+
+        ctx = SessionContext()
+        ctx.register_catalog_provider(
+            "paimon", PaimonCatalog({"warehouse": warehouse})
+        )
+
+        batches = ctx.sql("SELECT id, name FROM 
paimon.default.users").collect()
+        assert batches[0].schema.field("name").type == pa.string()
+        assert pa.Table.from_batches(batches).sort_by("id").to_pylist() == [
+            {"id": 1, "name": "alice"},
+            {"id": 2, "name": "bob"},
+        ]
+
 
 def test_sql_context_ddl_dml():
     with tempfile.TemporaryDirectory() as warehouse:
diff --git a/bindings/python/tests/test_write.py 
b/bindings/python/tests/test_write.py
index 66d7d10d..8ef53e7a 100644
--- a/bindings/python/tests/test_write.py
+++ b/bindings/python/tests/test_write.py
@@ -56,9 +56,9 @@ def test_write_commit_read_roundtrip():
         messages = write.prepare_commit()
         assert len(messages) >= 1                # cover API shape in the 
first test
         wb.new_commit().commit(messages)   # same wb → shared commit_user
-        result = pa.Table.from_batches(
-            ctx.sql("SELECT id, name FROM paimon.wdb.t")
-        ).sort_by("id").to_pydict()
+        batches = ctx.sql("SELECT id, name FROM paimon.wdb.t")
+        assert batches[0].schema.field("name").type == pa.string()
+        result = pa.Table.from_batches(batches).sort_by("id").to_pydict()
         assert result == {"id": [1, 2, 3], "name": ["a", "b", "c"]}
 
 
diff --git a/crates/integrations/datafusion/src/catalog.rs 
b/crates/integrations/datafusion/src/catalog.rs
index 8ec6f861..26436397 100644
--- a/crates/integrations/datafusion/src/catalog.rs
+++ b/crates/integrations/datafusion/src/catalog.rs
@@ -64,6 +64,7 @@ pub struct PaimonCatalogProvider {
     temp_tables: Arc<RwLock<HashMap<String, Arc<MemorySchemaProvider>>>>,
     blob_reader_registry: BlobReaderRegistry,
     session_state: Option<SessionStateProvider>,
+    schema_force_view_types: bool,
 }
 
 impl Debug for PaimonCatalogProvider {
@@ -88,8 +89,19 @@ impl PaimonCatalogProvider {
             temp_tables: Arc::new(RwLock::new(HashMap::new())),
             blob_reader_registry,
             session_state,
+            schema_force_view_types: true,
         }
     }
+
+    /// Configure whether table schemas use Arrow view types when available.
+    ///
+    /// Disable this for consumers that cannot operate on Arrow view arrays. 
This changes the
+    /// schema exposed to DataFusion, so query operators above the table scan 
will use the classic
+    /// Arrow types as well.
+    pub fn with_schema_force_view_types(mut self, schema_force_view_types: 
bool) -> Self {
+        self.schema_force_view_types = schema_force_view_types;
+        self
+    }
 }
 
 impl CatalogProvider for PaimonCatalogProvider {
@@ -112,6 +124,7 @@ impl CatalogProvider for PaimonCatalogProvider {
         let blob_reader_registry = self.blob_reader_registry.clone();
         let catalog_name = self.catalog_name.clone();
         let session_state = self.session_state.clone();
+        let schema_force_view_types = self.schema_force_view_types;
         let name = name.to_string();
 
         let temp_provider = {
@@ -122,26 +135,32 @@ impl CatalogProvider for PaimonCatalogProvider {
         block_on_with_runtime(
             async move {
                 match catalog.get_database(&name).await {
-                    Ok(_) => Some(Arc::new(PaimonSchemaProvider::new(
-                        catalog_name,
-                        Arc::clone(&catalog),
-                        name,
-                        dynamic_options,
-                        temp_provider,
-                        blob_reader_registry,
-                        session_state,
-                    )) as Arc<dyn SchemaProvider>),
+                    Ok(_) => Some(Arc::new(
+                        PaimonSchemaProvider::new(
+                            catalog_name,
+                            Arc::clone(&catalog),
+                            name,
+                            dynamic_options,
+                            temp_provider,
+                            blob_reader_registry,
+                            session_state,
+                        )
+                        .with_schema_force_view_types(schema_force_view_types),
+                    ) as Arc<dyn SchemaProvider>),
                     Err(paimon::Error::DatabaseNotExist { .. }) => {
                         if temp_provider.is_some() {
-                            Some(Arc::new(PaimonSchemaProvider::new(
-                                catalog_name,
-                                Arc::clone(&catalog),
-                                name,
-                                dynamic_options,
-                                temp_provider,
-                                blob_reader_registry,
-                                session_state,
-                            )) as Arc<dyn SchemaProvider>)
+                            Some(Arc::new(
+                                PaimonSchemaProvider::new(
+                                    catalog_name,
+                                    Arc::clone(&catalog),
+                                    name,
+                                    dynamic_options,
+                                    temp_provider,
+                                    blob_reader_registry,
+                                    session_state,
+                                )
+                                
.with_schema_force_view_types(schema_force_view_types),
+                            ) as Arc<dyn SchemaProvider>)
                         } else {
                             None
                         }
@@ -166,6 +185,7 @@ impl CatalogProvider for PaimonCatalogProvider {
         let blob_reader_registry = self.blob_reader_registry.clone();
         let catalog_name = self.catalog_name.clone();
         let session_state = self.session_state.clone();
+        let schema_force_view_types = self.schema_force_view_types;
         let name = name.to_string();
         block_on_with_runtime(
             async move {
@@ -173,15 +193,18 @@ impl CatalogProvider for PaimonCatalogProvider {
                     .create_database(&name, false, HashMap::new())
                     .await
                     .map_err(to_datafusion_error)?;
-                Ok(Some(Arc::new(PaimonSchemaProvider::new(
-                    catalog_name,
-                    Arc::clone(&catalog),
-                    name,
-                    dynamic_options,
-                    None,
-                    blob_reader_registry,
-                    session_state,
-                )) as Arc<dyn SchemaProvider>))
+                Ok(Some(Arc::new(
+                    PaimonSchemaProvider::new(
+                        catalog_name,
+                        Arc::clone(&catalog),
+                        name,
+                        dynamic_options,
+                        None,
+                        blob_reader_registry,
+                        session_state,
+                    )
+                    .with_schema_force_view_types(schema_force_view_types),
+                ) as Arc<dyn SchemaProvider>))
             },
             "paimon catalog access thread panicked",
         )
@@ -197,6 +220,7 @@ impl CatalogProvider for PaimonCatalogProvider {
         let blob_reader_registry = self.blob_reader_registry.clone();
         let catalog_name = self.catalog_name.clone();
         let session_state = self.session_state.clone();
+        let schema_force_view_types = self.schema_force_view_types;
         let name = name.to_string();
         block_on_with_runtime(
             async move {
@@ -204,15 +228,18 @@ impl CatalogProvider for PaimonCatalogProvider {
                     .drop_database(&name, false, cascade)
                     .await
                     .map_err(to_datafusion_error)?;
-                Ok(Some(Arc::new(PaimonSchemaProvider::new(
-                    catalog_name,
-                    Arc::clone(&catalog),
-                    name,
-                    dynamic_options,
-                    None,
-                    blob_reader_registry,
-                    session_state,
-                )) as Arc<dyn SchemaProvider>))
+                Ok(Some(Arc::new(
+                    PaimonSchemaProvider::new(
+                        catalog_name,
+                        Arc::clone(&catalog),
+                        name,
+                        dynamic_options,
+                        None,
+                        blob_reader_registry,
+                        session_state,
+                    )
+                    .with_schema_force_view_types(schema_force_view_types),
+                ) as Arc<dyn SchemaProvider>))
             },
             "paimon catalog access thread panicked",
         )
@@ -313,6 +340,7 @@ pub struct PaimonSchemaProvider {
     temp_provider: Option<Arc<MemorySchemaProvider>>,
     blob_reader_registry: BlobReaderRegistry,
     session_state: Option<SessionStateProvider>,
+    schema_force_view_types: bool,
 }
 
 impl Debug for PaimonSchemaProvider {
@@ -343,8 +371,14 @@ impl PaimonSchemaProvider {
             temp_provider,
             blob_reader_registry,
             session_state,
+            schema_force_view_types: true,
         }
     }
+
+    fn with_schema_force_view_types(mut self, schema_force_view_types: bool) 
-> Self {
+        self.schema_force_view_types = schema_force_view_types;
+        self
+    }
 }
 
 #[async_trait]
@@ -409,6 +443,7 @@ impl SchemaProvider for PaimonSchemaProvider {
         let blob_reader_registry = self.blob_reader_registry.clone();
         let catalog_name = self.catalog_name.clone();
         let session_state = self.session_state.clone();
+        let schema_force_view_types = self.schema_force_view_types;
         let identifier = Identifier::new(self.database.clone(), 
object.table().to_string());
         let branch = object.branch().map(str::to_string);
         await_with_runtime(async move {
@@ -440,7 +475,8 @@ impl SchemaProvider for PaimonSchemaProvider {
                             blob_reader_registry,
                             table_definition,
                         )?
-                    };
+                    }
+                    .with_schema_force_view_types(schema_force_view_types)?;
                     Ok(Some(Arc::new(provider) as Arc<dyn TableProvider>))
                 }
                 Err(paimon::Error::TableNotExist { .. }) => {
diff --git a/crates/integrations/datafusion/src/merge_into.rs 
b/crates/integrations/datafusion/src/merge_into.rs
index 2620d9d4..97c36079 100644
--- a/crates/integrations/datafusion/src/merge_into.rs
+++ b/crates/integrations/datafusion/src/merge_into.rs
@@ -1782,7 +1782,7 @@ mod tests {
             let names = batch
                 .column(1)
                 .as_any()
-                .downcast_ref::<datafusion::arrow::array::StringArray>()
+                .downcast_ref::<datafusion::arrow::array::StringViewArray>()
                 .unwrap();
             let values = batch
                 .column(2)
@@ -1925,7 +1925,7 @@ mod tests {
             let names = batch
                 .column(1)
                 .as_any()
-                .downcast_ref::<datafusion::arrow::array::StringArray>()
+                .downcast_ref::<datafusion::arrow::array::StringViewArray>()
                 .unwrap();
             let values = batch
                 .column(2)
diff --git a/crates/integrations/datafusion/src/physical_plan/scan.rs 
b/crates/integrations/datafusion/src/physical_plan/scan.rs
index 55d28f3b..d31891ed 100644
--- a/crates/integrations/datafusion/src/physical_plan/scan.rs
+++ b/crates/integrations/datafusion/src/physical_plan/scan.rs
@@ -17,7 +17,9 @@
 
 use std::sync::Arc;
 
+use datafusion::arrow::compute::cast;
 use datafusion::arrow::datatypes::SchemaRef as ArrowSchemaRef;
+use datafusion::arrow::record_batch::{RecordBatch, RecordBatchOptions};
 use datafusion::common::stats::Precision;
 use datafusion::common::Statistics;
 use datafusion::error::Result as DFResult;
@@ -33,6 +35,33 @@ use paimon::DataSplit;
 
 use crate::error::to_datafusion_error;
 
+fn to_datafusion_batch(batch: RecordBatch, schema: &ArrowSchemaRef) -> 
DFResult<RecordBatch> {
+    if batch.num_columns() != schema.fields().len() {
+        return Err(datafusion::error::DataFusionError::Execution(format!(
+            "Paimon reader returned {} columns for DataFusion schema with {} 
fields",
+            batch.num_columns(),
+            schema.fields().len()
+        )));
+    }
+
+    let row_count = batch.num_rows();
+    let columns = batch
+        .columns()
+        .iter()
+        .zip(schema.fields())
+        .map(|(column, field)| {
+            if column.data_type() == field.data_type() {
+                Ok(Arc::clone(column))
+            } else {
+                cast(column.as_ref(), field.data_type()).map_err(Into::into)
+            }
+        })
+        .collect::<DFResult<Vec<_>>>()?;
+    let options = RecordBatchOptions::new().with_row_count(Some(row_count));
+
+    RecordBatch::try_new_with_options(Arc::clone(schema), columns, 
&options).map_err(Into::into)
+}
+
 /// Execution plan that scans a Paimon table with optional column projection.
 ///
 /// Planning is performed eagerly in 
[`super::super::table::PaimonTableProvider::scan`],
@@ -172,7 +201,12 @@ impl ExecutionPlan for PaimonTableScan {
 
             let read = read_builder.new_read().map_err(to_datafusion_error)?;
             let stream = read.to_arrow(&splits).map_err(to_datafusion_error)?;
-            let stream = stream.map(|r| r.map_err(to_datafusion_error));
+            let batch_schema = Arc::clone(&schema);
+            let stream = stream.map(move |result| {
+                result
+                    .map_err(to_datafusion_error)
+                    .and_then(|batch| to_datafusion_batch(batch, 
&batch_schema))
+            });
 
             Ok::<_, 
datafusion::error::DataFusionError>(RecordBatchStreamAdapter::new(
                 schema,
diff --git a/crates/integrations/datafusion/src/physical_plan/sink.rs 
b/crates/integrations/datafusion/src/physical_plan/sink.rs
index 3d40e2ad..64575c02 100644
--- a/crates/integrations/datafusion/src/physical_plan/sink.rs
+++ b/crates/integrations/datafusion/src/physical_plan/sink.rs
@@ -21,7 +21,11 @@ use std::fmt;
 use std::sync::Arc;
 
 use async_trait::async_trait;
-use datafusion::arrow::datatypes::SchemaRef as ArrowSchemaRef;
+use datafusion::arrow::compute::cast;
+use datafusion::arrow::datatypes::{
+    DataType as ArrowDataType, Schema, SchemaRef as ArrowSchemaRef,
+};
+use datafusion::arrow::record_batch::{RecordBatch, RecordBatchOptions};
 use datafusion::datasource::sink::DataSink;
 use datafusion::error::Result as DFResult;
 use datafusion::execution::SendableRecordBatchStream;
@@ -32,6 +36,49 @@ use paimon::table::Table;
 
 use crate::error::to_datafusion_error;
 
+fn to_paimon_batch(batch: RecordBatch) -> DFResult<RecordBatch> {
+    if !batch
+        .schema()
+        .fields()
+        .iter()
+        .any(|field| field.data_type() == &ArrowDataType::Utf8View)
+    {
+        return Ok(batch);
+    }
+
+    let fields = batch
+        .schema()
+        .fields()
+        .iter()
+        .map(|field| {
+            if field.data_type() == &ArrowDataType::Utf8View {
+                
Arc::new(field.as_ref().clone().with_data_type(ArrowDataType::Utf8))
+            } else {
+                Arc::clone(field)
+            }
+        })
+        .collect::<Vec<_>>();
+    let schema = Arc::new(Schema::new_with_metadata(
+        fields,
+        batch.schema().metadata().clone(),
+    ));
+    let columns = batch
+        .columns()
+        .iter()
+        .zip(schema.fields())
+        .map(|(column, field)| {
+            if column.data_type() == field.data_type() {
+                Ok(Arc::clone(column))
+            } else {
+                cast(column.as_ref(), field.data_type()).map_err(Into::into)
+            }
+        })
+        .collect::<DFResult<Vec<_>>>()?;
+    let options = 
RecordBatchOptions::new().with_row_count(Some(batch.num_rows()));
+
+    RecordBatch::try_new_with_options(schema, columns, 
&options).map_err(Into::into)
+}
+
 /// DataSink that writes RecordBatches to a Paimon table.
 ///
 /// Uses the Paimon write pipeline: `WriteBuilder` → `TableWrite` → 
`TableCommit`.
@@ -84,7 +131,7 @@ impl DataSink for PaimonDataSink {
         let mut row_count = 0u64;
 
         while let Some(batch) = data.next().await {
-            let batch = batch?;
+            let batch = to_paimon_batch(batch?)?;
             row_count += batch.num_rows() as u64;
             tw.write_arrow_batch(&batch)
                 .await
diff --git a/crates/integrations/datafusion/src/sql_context.rs 
b/crates/integrations/datafusion/src/sql_context.rs
index 4ad6897d..dedd7ebc 100644
--- a/crates/integrations/datafusion/src/sql_context.rs
+++ b/crates/integrations/datafusion/src/sql_context.rs
@@ -3272,6 +3272,7 @@ mod tests {
     use std::sync::Mutex;
 
     use async_trait::async_trait;
+    use datafusion::arrow::array::StringViewArray;
     use paimon::catalog::Database;
     use paimon::spec::{
         DataField as PaimonDataField, DataType as PaimonDataType, IntType, 
Schema as PaimonSchema,
@@ -6691,7 +6692,7 @@ mod tests {
             let pts = batch
                 .column(0)
                 .as_any()
-                .downcast_ref::<StringArray>()
+                .downcast_ref::<StringViewArray>()
                 .unwrap();
             let ids = batch
                 .column(1)
@@ -6739,7 +6740,7 @@ mod tests {
             let pts = batch
                 .column(0)
                 .as_any()
-                .downcast_ref::<StringArray>()
+                .downcast_ref::<StringViewArray>()
                 .unwrap();
             let ids = batch
                 .column(1)
diff --git a/crates/integrations/datafusion/src/table/mod.rs 
b/crates/integrations/datafusion/src/table/mod.rs
index c84a48e0..1bfc47fc 100644
--- a/crates/integrations/datafusion/src/table/mod.rs
+++ b/crates/integrations/datafusion/src/table/mod.rs
@@ -21,7 +21,9 @@ use std::fmt::Write as _;
 use std::sync::Arc;
 
 use async_trait::async_trait;
-use datafusion::arrow::datatypes::{Field, Schema, SchemaRef as ArrowSchemaRef};
+use datafusion::arrow::datatypes::{
+    DataType as ArrowDataType, Field, Schema, SchemaRef as ArrowSchemaRef,
+};
 use datafusion::catalog::Session;
 use datafusion::datasource::sink::DataSinkExec;
 use datafusion::datasource::{TableProvider, TableType};
@@ -44,6 +46,8 @@ use crate::filter_pushdown::{analyze_filters, 
classify_filter_pushdown};
 use crate::physical_plan::PaimonTableScan;
 use crate::runtime::await_with_runtime;
 
+const PARQUET_FIELD_ID_META_KEY: &str = "PARQUET:field_id";
+
 pub(crate) fn datafusion_read_fields(table: &Table) -> Vec<DataField> {
     let mut fields = table.schema().fields().to_vec();
     if CoreOptions::new(table.schema().options()).data_evolution_enabled() {
@@ -56,6 +60,38 @@ pub(crate) fn datafusion_read_fields(table: &Table) -> 
Vec<DataField> {
     fields
 }
 
+fn datafusion_arrow_schema(
+    fields: &[DataField],
+    schema_force_view_types: bool,
+) -> DFResult<ArrowSchemaRef> {
+    let paimon_schema =
+        
paimon::arrow::build_target_arrow_schema(fields).map_err(to_datafusion_error)?;
+    let fields = paimon_schema
+        .fields()
+        .iter()
+        .map(|field| {
+            let mut metadata = field.metadata().clone();
+            metadata.remove(PARQUET_FIELD_ID_META_KEY);
+            let data_type = match field.data_type() {
+                ArrowDataType::Utf8 if schema_force_view_types => 
ArrowDataType::Utf8View,
+                data_type => data_type.clone(),
+            };
+            Arc::new(
+                field
+                    .as_ref()
+                    .clone()
+                    .with_data_type(data_type)
+                    .with_metadata(metadata),
+            )
+        })
+        .collect::<Vec<_>>();
+
+    Ok(Arc::new(Schema::new_with_metadata(
+        fields,
+        paimon_schema.metadata().clone(),
+    )))
+}
+
 /// Read-only table provider for a Paimon table.
 ///
 /// Supports full table scan, column projection, and predicate pushdown for
@@ -86,8 +122,7 @@ impl PaimonTableProvider {
         table_definition: Option<String>,
     ) -> DFResult<Self> {
         let fields = datafusion_read_fields(&table);
-        let schema =
-            
paimon::arrow::build_target_arrow_schema(&fields).map_err(to_datafusion_error)?;
+        let schema = datafusion_arrow_schema(&fields, true)?;
         Ok(Self {
             table,
             schema,
@@ -114,6 +149,18 @@ impl PaimonTableProvider {
         Self::try_new_with_table_definition(table, table_definition)
     }
 
+    pub(crate) fn with_schema_force_view_types(
+        mut self,
+        schema_force_view_types: bool,
+    ) -> DFResult<Self> {
+        if schema_force_view_types {
+            return Ok(self);
+        }
+        let fields = datafusion_read_fields(&self.table);
+        self.schema = datafusion_arrow_schema(&fields, 
schema_force_view_types)?;
+        Ok(self)
+    }
+
     pub fn table(&self) -> &Table {
         &self.table
     }
@@ -479,6 +526,7 @@ mod tests {
     use datafusion::logical_expr::{col, lit, Expr};
     use datafusion::prelude::{SessionConfig, SessionContext};
     use paimon::catalog::Identifier;
+    use paimon::spec::{ArrayType, MapType, RowType, VarCharType};
     use paimon::{Catalog, CatalogOptions, DataSplit, FileSystemCatalog, 
Options};
 
     use crate::physical_plan::PaimonTableScan;
@@ -749,6 +797,71 @@ mod tests {
         PaimonTableProvider::try_new(table).expect("provider should be 
created")
     }
 
+    #[tokio::test]
+    async fn test_datafusion_schema_hides_paimon_field_ids() {
+        let provider = data_evolution_projection_pruning_provider().await;
+
+        for field in provider.schema().fields() {
+            assert!(
+                !field.metadata().contains_key("PARQUET:field_id"),
+                "storage field id leaked through DataFusion schema for {}",
+                field.name()
+            );
+        }
+    }
+
+    #[test]
+    fn test_datafusion_schema_uses_views_only_for_top_level_strings() {
+        let string_type = || DataType::VarChar(VarCharType::string_type());
+        let schema = datafusion_arrow_schema(
+            &[
+                DataField::new(0, "plain".to_string(), string_type()),
+                DataField::new(
+                    1,
+                    "array".to_string(),
+                    DataType::Array(ArrayType::new(string_type())),
+                ),
+                DataField::new(
+                    2,
+                    "map".to_string(),
+                    DataType::Map(MapType::new(string_type(), string_type())),
+                ),
+                DataField::new(
+                    3,
+                    "row".to_string(),
+                    DataType::Row(RowType::new(vec![DataField::new(
+                        4,
+                        "nested".to_string(),
+                        string_type(),
+                    )])),
+                ),
+            ],
+            true,
+        )
+        .expect("DataFusion schema should be created");
+
+        assert_eq!(schema.field(0).data_type(), &ArrowDataType::Utf8View);
+
+        let ArrowDataType::List(element) = schema.field(1).data_type() else {
+            panic!("array field should map to an Arrow List");
+        };
+        assert_eq!(element.data_type(), &ArrowDataType::Utf8);
+
+        let ArrowDataType::Map(entries, _) = schema.field(2).data_type() else {
+            panic!("map field should map to an Arrow Map");
+        };
+        let ArrowDataType::Struct(map_fields) = entries.data_type() else {
+            panic!("map entries should map to an Arrow Struct");
+        };
+        assert_eq!(map_fields[0].data_type(), &ArrowDataType::Utf8);
+        assert_eq!(map_fields[1].data_type(), &ArrowDataType::Utf8);
+
+        let ArrowDataType::Struct(row_fields) = schema.field(3).data_type() 
else {
+            panic!("row field should map to an Arrow Struct");
+        };
+        assert_eq!(row_fields[0].data_type(), &ArrowDataType::Utf8);
+    }
+
     fn planned_file_names(scan: &PaimonTableScan) -> Vec<String> {
         let mut names = scan
             .planned_partitions()
@@ -1107,7 +1220,7 @@ mod tests {
             let pts = batch
                 .column(0)
                 .as_any()
-                .downcast_ref::<datafusion::arrow::array::StringArray>()
+                .downcast_ref::<datafusion::arrow::array::StringViewArray>()
                 .unwrap();
             let ids = batch
                 .column(1)
diff --git a/crates/integrations/datafusion/src/update.rs 
b/crates/integrations/datafusion/src/update.rs
index e78037a5..5362b09a 100644
--- a/crates/integrations/datafusion/src/update.rs
+++ b/crates/integrations/datafusion/src/update.rs
@@ -335,7 +335,7 @@ mod tests {
     use super::*;
     use std::sync::Arc;
 
-    use datafusion::arrow::array::{Int32Array, StringArray, UInt64Array};
+    use datafusion::arrow::array::{Int32Array, StringViewArray, UInt64Array};
     use datafusion::sql::sqlparser::dialect::GenericDialect;
     use datafusion::sql::sqlparser::parser::Parser;
     use paimon::catalog::{Catalog, Identifier};
@@ -412,7 +412,7 @@ mod tests {
             let names = batch
                 .column(1)
                 .as_any()
-                .downcast_ref::<StringArray>()
+                .downcast_ref::<StringViewArray>()
                 .unwrap();
             let values = batch
                 .column(2)
diff --git a/crates/integrations/datafusion/tests/append_merge_into.rs 
b/crates/integrations/datafusion/tests/append_merge_into.rs
index c9d66bb0..3cd3c97d 100644
--- a/crates/integrations/datafusion/tests/append_merge_into.rs
+++ b/crates/integrations/datafusion/tests/append_merge_into.rs
@@ -22,12 +22,12 @@
 
 mod common;
 
-use arrow_array::{Array, Int32Array, StringArray};
+use arrow_array::{Array, Int32Array};
 use paimon_datafusion::SQLContext;
 
 use common::{
     collect_int_int_str, collect_int_str, collect_three_ints, 
create_sql_context, create_test_env,
-    exec,
+    exec, string_value,
 };
 
 // ======================= Helpers =======================
@@ -278,11 +278,7 @@ async fn test_partial_insert_with_null() {
             .as_any()
             .downcast_ref::<Int32Array>()
             .unwrap();
-        let c = batch
-            .column(2)
-            .as_any()
-            .downcast_ref::<StringArray>()
-            .unwrap();
+        let c = batch.column(2);
         for i in 0..batch.num_rows() {
             rows.push((
                 a.value(i),
@@ -290,7 +286,7 @@ async fn test_partial_insert_with_null() {
                 if c.is_null(i) {
                     None
                 } else {
-                    Some(c.value(i).to_string())
+                    Some(string_value(c.as_ref(), i).to_string())
                 },
             ));
         }
diff --git a/crates/integrations/datafusion/tests/blob_tests.rs 
b/crates/integrations/datafusion/tests/blob_tests.rs
index b844f65e..59058d9b 100644
--- a/crates/integrations/datafusion/tests/blob_tests.rs
+++ b/crates/integrations/datafusion/tests/blob_tests.rs
@@ -21,8 +21,8 @@
 
 mod common;
 
-use arrow_array::{Array, BinaryArray, Int32Array, RecordBatch, StringArray};
-use common::{assert_sql_error, create_sql_context, create_test_env, exec};
+use arrow_array::{Array, BinaryArray, Int32Array, RecordBatch};
+use common::{assert_sql_error, create_sql_context, create_test_env, exec, 
string_value};
 use paimon::catalog::Identifier;
 use paimon::spec::{BlobDescriptor, BlobViewStruct};
 use paimon::table::BranchManager;
@@ -54,11 +54,7 @@ fn collect_id_name_picture(batches: &[RecordBatch]) -> 
Vec<(i32, String, Option<
             .as_any()
             .downcast_ref::<Int32Array>()
             .unwrap();
-        let names = batch
-            .column(1)
-            .as_any()
-            .downcast_ref::<StringArray>()
-            .unwrap();
+        let names = batch.column(1);
         let pics = batch
             .column(2)
             .as_any()
@@ -70,7 +66,11 @@ fn collect_id_name_picture(batches: &[RecordBatch]) -> 
Vec<(i32, String, Option<
             } else {
                 Some(pics.value(i).to_vec())
             };
-            rows.push((ids.value(i), names.value(i).to_string(), pic));
+            rows.push((
+                ids.value(i),
+                string_value(names.as_ref(), i).to_string(),
+                pic,
+            ));
         }
     }
     rows.sort_by_key(|(id, _, _)| *id);
@@ -93,13 +93,9 @@ fn collect_id_name(batches: &[RecordBatch]) -> Vec<(i32, 
String)> {
             .as_any()
             .downcast_ref::<Int32Array>()
             .unwrap();
-        let names = batch
-            .column(1)
-            .as_any()
-            .downcast_ref::<StringArray>()
-            .unwrap();
+        let names = batch.column(1);
         for i in 0..batch.num_rows() {
-            rows.push((ids.value(i), names.value(i).to_string()));
+            rows.push((ids.value(i), string_value(names.as_ref(), 
i).to_string()));
         }
     }
     rows.sort_by_key(|(id, _)| *id);
diff --git a/crates/integrations/datafusion/tests/common/mod.rs 
b/crates/integrations/datafusion/tests/common/mod.rs
index c01201d1..23d9fb9c 100644
--- a/crates/integrations/datafusion/tests/common/mod.rs
+++ b/crates/integrations/datafusion/tests/common/mod.rs
@@ -19,13 +19,26 @@
 
 use std::sync::Arc;
 
-use datafusion::arrow::array::{Int32Array, StringArray};
+use datafusion::arrow::array::{Int32Array, LargeStringArray, StringArray, 
StringViewArray};
 use paimon::{CatalogOptions, FileSystemCatalog, Options};
 use paimon_datafusion::SQLContext;
 use tempfile::TempDir;
 
 use arrow_array::{Array, RecordBatch, UInt64Array};
 
+#[allow(dead_code)]
+pub fn string_value(array: &dyn Array, row: usize) -> &str {
+    if let Some(array) = array.as_any().downcast_ref::<StringArray>() {
+        array.value(row)
+    } else if let Some(array) = 
array.as_any().downcast_ref::<LargeStringArray>() {
+        array.value(row)
+    } else if let Some(array) = 
array.as_any().downcast_ref::<StringViewArray>() {
+        array.value(row)
+    } else {
+        panic!("expected a string array, got {}", array.data_type())
+    }
+}
+
 pub fn create_test_env() -> (TempDir, Arc<FileSystemCatalog>) {
     let temp_dir = TempDir::new().expect("Failed to create temp dir");
     let warehouse = format!("file://{}", temp_dir.path().display());
@@ -76,12 +89,9 @@ pub fn collect_id_name_from_batches_in_order(batches: 
&[RecordBatch]) -> Vec<(i3
             .column_by_name("id")
             .and_then(|c| c.as_any().downcast_ref::<Int32Array>())
             .expect("id column");
-        let names = batch
-            .column_by_name("name")
-            .and_then(|c| c.as_any().downcast_ref::<StringArray>())
-            .expect("name column");
+        let names = batch.column_by_name("name").expect("name column");
         for i in 0..batch.num_rows() {
-            rows.push((ids.value(i), names.value(i).to_string()));
+            rows.push((ids.value(i), string_value(names.as_ref(), 
i).to_string()));
         }
     }
     rows
@@ -168,13 +178,13 @@ pub fn collect_int_int_str(batches: &[RecordBatch]) -> 
Vec<(i32, i32, String)> {
             .as_any()
             .downcast_ref::<Int32Array>()
             .unwrap();
-        let col2 = batch
-            .column(2)
-            .as_any()
-            .downcast_ref::<StringArray>()
-            .unwrap();
+        let col2 = batch.column(2);
         for i in 0..batch.num_rows() {
-            rows.push((col0.value(i), col1.value(i), 
col2.value(i).to_string()));
+            rows.push((
+                col0.value(i),
+                col1.value(i),
+                string_value(col2.as_ref(), i).to_string(),
+            ));
         }
     }
     rows.sort_by_key(|r| (r.0, r.1));
@@ -191,13 +201,9 @@ pub fn collect_int_str(batches: &[RecordBatch]) -> 
Vec<(i32, String)> {
             .as_any()
             .downcast_ref::<Int32Array>()
             .unwrap();
-        let col1 = batch
-            .column(1)
-            .as_any()
-            .downcast_ref::<StringArray>()
-            .unwrap();
+        let col1 = batch.column(1);
         for i in 0..batch.num_rows() {
-            rows.push((col0.value(i), col1.value(i).to_string()));
+            rows.push((col0.value(i), string_value(col1.as_ref(), 
i).to_string()));
         }
     }
     rows.sort_by_key(|r| r.0);
@@ -242,18 +248,18 @@ pub fn collect_int_str_int(batches: &[RecordBatch]) -> 
Vec<(i32, String, i32)> {
             .as_any()
             .downcast_ref::<Int32Array>()
             .unwrap();
-        let col1 = batch
-            .column(1)
-            .as_any()
-            .downcast_ref::<StringArray>()
-            .unwrap();
+        let col1 = batch.column(1);
         let col2 = batch
             .column(2)
             .as_any()
             .downcast_ref::<Int32Array>()
             .unwrap();
         for i in 0..batch.num_rows() {
-            rows.push((col0.value(i), col1.value(i).to_string(), 
col2.value(i)));
+            rows.push((
+                col0.value(i),
+                string_value(col1.as_ref(), i).to_string(),
+                col2.value(i),
+            ));
         }
     }
     rows.sort_by_key(|r| r.0);
diff --git a/crates/integrations/datafusion/tests/dynamic_bucket_tables.rs 
b/crates/integrations/datafusion/tests/dynamic_bucket_tables.rs
index 97d7052b..34aea68f 100644
--- a/crates/integrations/datafusion/tests/dynamic_bucket_tables.rs
+++ b/crates/integrations/datafusion/tests/dynamic_bucket_tables.rs
@@ -21,8 +21,9 @@ mod common;
 
 use common::{
     collect_id_name, collect_id_value, create_sql_context, create_test_env, 
setup_sql_context,
+    string_value,
 };
-use datafusion::arrow::array::{Array, Int32Array, StringArray};
+use datafusion::arrow::array::{Array, Int32Array};
 use paimon::catalog::Identifier;
 use paimon::spec::{IndexManifest, IndexManifestEntry};
 use paimon::{Catalog, CatalogOptions, DataSplit, FileSystemCatalog, Options, 
SnapshotManager};
@@ -97,10 +98,7 @@ async fn collect_partial_update_rows(
             .column_by_name("v_int")
             .and_then(|c| c.as_any().downcast_ref::<Int32Array>())
             .unwrap();
-        let strs = batch
-            .column_by_name("v_str")
-            .and_then(|c| c.as_any().downcast_ref::<StringArray>())
-            .unwrap();
+        let strs = batch.column_by_name("v_str").unwrap();
         for i in 0..batch.num_rows() {
             rows.push((
                 ids.value(i),
@@ -112,7 +110,7 @@ async fn collect_partial_update_rows(
                 if strs.is_null(i) {
                     None
                 } else {
-                    Some(strs.value(i).to_string())
+                    Some(string_value(strs.as_ref(), i).to_string())
                 },
             ));
         }
@@ -136,14 +134,8 @@ async fn collect_aggregation_rows(
             .column_by_name("amount")
             .and_then(|c| c.as_any().downcast_ref::<Int32Array>())
             .unwrap();
-        let tags = batch
-            .column_by_name("tag")
-            .and_then(|c| c.as_any().downcast_ref::<StringArray>())
-            .unwrap();
-        let notes = batch
-            .column_by_name("note")
-            .and_then(|c| c.as_any().downcast_ref::<StringArray>())
-            .unwrap();
+        let tags = batch.column_by_name("tag").unwrap();
+        let notes = batch.column_by_name("note").unwrap();
         for i in 0..batch.num_rows() {
             rows.push((
                 ids.value(i),
@@ -155,12 +147,12 @@ async fn collect_aggregation_rows(
                 if tags.is_null(i) {
                     None
                 } else {
-                    Some(tags.value(i).to_string())
+                    Some(string_value(tags.as_ref(), i).to_string())
                 },
                 if notes.is_null(i) {
                     None
                 } else {
-                    Some(notes.value(i).to_string())
+                    Some(string_value(notes.as_ref(), i).to_string())
                 },
             ));
         }
@@ -573,10 +565,7 @@ async fn test_pk_dynamic_bucket_partitioned() {
 
     let mut rows = Vec::new();
     for batch in &batches {
-        let dts = batch
-            .column_by_name("dt")
-            .and_then(|c| c.as_any().downcast_ref::<StringArray>())
-            .unwrap();
+        let dts = batch.column_by_name("dt").unwrap();
         let ids = batch
             .column_by_name("id")
             .and_then(|c| c.as_any().downcast_ref::<Int32Array>())
@@ -586,7 +575,11 @@ async fn test_pk_dynamic_bucket_partitioned() {
             .and_then(|c| c.as_any().downcast_ref::<Int32Array>())
             .unwrap();
         for i in 0..batch.num_rows() {
-            rows.push((dts.value(i).to_string(), ids.value(i), vals.value(i)));
+            rows.push((
+                string_value(dts.as_ref(), i).to_string(),
+                ids.value(i),
+                vals.value(i),
+            ));
         }
     }
     rows.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
@@ -655,10 +648,7 @@ async fn 
test_pk_dynamic_bucket_partitioned_partial_update() {
 
     let mut rows = Vec::new();
     for batch in &batches {
-        let dts = batch
-            .column_by_name("dt")
-            .and_then(|c| c.as_any().downcast_ref::<StringArray>())
-            .unwrap();
+        let dts = batch.column_by_name("dt").unwrap();
         let ids = batch
             .column_by_name("id")
             .and_then(|c| c.as_any().downcast_ref::<Int32Array>())
@@ -667,13 +657,10 @@ async fn 
test_pk_dynamic_bucket_partitioned_partial_update() {
             .column_by_name("v_int")
             .and_then(|c| c.as_any().downcast_ref::<Int32Array>())
             .unwrap();
-        let strs = batch
-            .column_by_name("v_str")
-            .and_then(|c| c.as_any().downcast_ref::<StringArray>())
-            .unwrap();
+        let strs = batch.column_by_name("v_str").unwrap();
         for i in 0..batch.num_rows() {
             rows.push((
-                dts.value(i).to_string(),
+                string_value(dts.as_ref(), i).to_string(),
                 ids.value(i),
                 if ints.is_null(i) {
                     None
@@ -683,7 +670,7 @@ async fn 
test_pk_dynamic_bucket_partitioned_partial_update() {
                 if strs.is_null(i) {
                     None
                 } else {
-                    Some(strs.value(i).to_string())
+                    Some(string_value(strs.as_ref(), i).to_string())
                 },
             ));
         }
@@ -1066,12 +1053,9 @@ async fn 
test_read_spark_dynamic_bucket_and_compare_index() {
             .column_by_name("id")
             .and_then(|c| c.as_any().downcast_ref::<Int32Array>())
             .unwrap();
-        let names = batch
-            .column_by_name("name")
-            .and_then(|c| c.as_any().downcast_ref::<StringArray>())
-            .unwrap();
+        let names = batch.column_by_name("name").unwrap();
         for i in 0..batch.num_rows() {
-            all_rows.push((ids.value(i), names.value(i).to_string()));
+            all_rows.push((ids.value(i), string_value(names.as_ref(), 
i).to_string()));
         }
     }
     all_rows.sort_by_key(|(id, _)| *id);
diff --git a/crates/integrations/datafusion/tests/merge_into_tests.rs 
b/crates/integrations/datafusion/tests/merge_into_tests.rs
index f3e93582..87fb50be 100644
--- a/crates/integrations/datafusion/tests/merge_into_tests.rs
+++ b/crates/integrations/datafusion/tests/merge_into_tests.rs
@@ -21,9 +21,12 @@
 //! join on `_ROW_ID`, and error path validation.
 //! Reference: Java Paimon's `RowTrackingTestBase`.
 
+mod common;
+
 use std::sync::Arc;
 
-use arrow_array::{Int32Array, Int64Array, StringArray};
+use arrow_array::{Int32Array, Int64Array};
+use common::string_value;
 use paimon::catalog::Identifier;
 use paimon::table::SnapshotManager;
 use paimon::{Catalog, CatalogOptions, FileSystemCatalog, Options};
@@ -80,18 +83,18 @@ async fn collect_rows_3col(sql_context: &SQLContext, sql: 
&str) -> Vec<(i32, Str
             .as_any()
             .downcast_ref::<Int32Array>()
             .unwrap();
-        let names = batch
-            .column(1)
-            .as_any()
-            .downcast_ref::<StringArray>()
-            .unwrap();
+        let names = batch.column(1);
         let values = batch
             .column(2)
             .as_any()
             .downcast_ref::<Int32Array>()
             .unwrap();
         for i in 0..batch.num_rows() {
-            rows.push((ids.value(i), names.value(i).to_string(), 
values.value(i)));
+            rows.push((
+                ids.value(i),
+                string_value(names.as_ref(), i).to_string(),
+                values.value(i),
+            ));
         }
     }
     rows
@@ -1241,26 +1244,18 @@ async fn 
test_merge_insert_reordered_columns_on_partitioned_table() {
 
     let mut rows = Vec::new();
     for batch in &batches {
-        let dts = batch
-            .column(0)
-            .as_any()
-            .downcast_ref::<StringArray>()
-            .unwrap();
+        let dts = batch.column(0);
         let ids = batch
             .column(1)
             .as_any()
             .downcast_ref::<Int32Array>()
             .unwrap();
-        let names = batch
-            .column(2)
-            .as_any()
-            .downcast_ref::<StringArray>()
-            .unwrap();
+        let names = batch.column(2);
         for i in 0..batch.num_rows() {
             rows.push((
-                dts.value(i).to_string(),
+                string_value(dts.as_ref(), i).to_string(),
                 ids.value(i),
-                names.value(i).to_string(),
+                string_value(names.as_ref(), i).to_string(),
             ));
         }
     }
diff --git a/crates/integrations/datafusion/tests/mosaic_tables.rs 
b/crates/integrations/datafusion/tests/mosaic_tables.rs
index 426515d1..7ada5e3c 100644
--- a/crates/integrations/datafusion/tests/mosaic_tables.rs
+++ b/crates/integrations/datafusion/tests/mosaic_tables.rs
@@ -17,14 +17,18 @@
 
 //! Mosaic file format read compatibility tests.
 
+mod common;
+
 use std::path::Path;
 use std::sync::Arc;
 
-use datafusion::arrow::array::{Int32Array, Int64Array, StringArray};
+use datafusion::arrow::array::{Int32Array, Int64Array};
 use datafusion::arrow::record_batch::RecordBatch;
 use paimon::{Catalog, CatalogOptions, FileSystemCatalog, Options};
 use paimon_datafusion::SQLContext;
 
+use common::string_value;
+
 const FIXTURE_TABLE: &str = "test_mosaic_read";
 
 fn extract_test_warehouse() -> (tempfile::TempDir, String) {
@@ -74,10 +78,7 @@ fn collect_id_name_score(batches: &[RecordBatch]) -> 
Vec<(i32, String, i64)> {
             .column_by_name("id")
             .and_then(|column| column.as_any().downcast_ref::<Int32Array>())
             .expect("id column");
-        let names = batch
-            .column_by_name("name")
-            .and_then(|column| column.as_any().downcast_ref::<StringArray>())
-            .expect("name column");
+        let names = batch.column_by_name("name").expect("name column");
         let scores = batch
             .column_by_name("score")
             .and_then(|column| column.as_any().downcast_ref::<Int64Array>())
@@ -86,7 +87,7 @@ fn collect_id_name_score(batches: &[RecordBatch]) -> 
Vec<(i32, String, i64)> {
         for row in 0..batch.num_rows() {
             rows.push((
                 ids.value(row),
-                names.value(row).to_string(),
+                string_value(names.as_ref(), row).to_string(),
                 scores.value(row),
             ));
         }
@@ -97,11 +98,7 @@ fn collect_id_name_score(batches: &[RecordBatch]) -> 
Vec<(i32, String, i64)> {
 fn collect_name_id(batches: &[RecordBatch]) -> Vec<(String, i32)> {
     let mut rows = Vec::new();
     for batch in batches {
-        let names = batch
-            .column(0)
-            .as_any()
-            .downcast_ref::<StringArray>()
-            .expect("first column should be name");
+        let names = batch.column(0);
         let ids = batch
             .column(1)
             .as_any()
@@ -109,7 +106,10 @@ fn collect_name_id(batches: &[RecordBatch]) -> 
Vec<(String, i32)> {
             .expect("second column should be id");
 
         for row in 0..batch.num_rows() {
-            rows.push((names.value(row).to_string(), ids.value(row)));
+            rows.push((
+                string_value(names.as_ref(), row).to_string(),
+                ids.value(row),
+            ));
         }
     }
     rows
diff --git a/crates/integrations/datafusion/tests/pk_tables.rs 
b/crates/integrations/datafusion/tests/pk_tables.rs
index 66d5de44..4b0840c0 100644
--- a/crates/integrations/datafusion/tests/pk_tables.rs
+++ b/crates/integrations/datafusion/tests/pk_tables.rs
@@ -30,9 +30,9 @@ mod common;
 
 use common::{
     collect_id_name, collect_id_value, collect_int_int_str, 
create_sql_context, create_test_env,
-    row_count, setup_sql_context,
+    row_count, setup_sql_context, string_value,
 };
-use datafusion::arrow::array::{Array, Int32Array, Int64Array, StringArray};
+use datafusion::arrow::array::{Array, Int32Array, Int64Array};
 use paimon::catalog::Identifier;
 use paimon::Catalog;
 
@@ -146,10 +146,7 @@ async fn test_pk_partial_update_fixed_bucket_e2e() {
             .column_by_name("v_int")
             .and_then(|c| c.as_any().downcast_ref::<Int32Array>())
             .unwrap();
-        let strs = batch
-            .column_by_name("v_str")
-            .and_then(|c| c.as_any().downcast_ref::<StringArray>())
-            .unwrap();
+        let strs = batch.column_by_name("v_str").unwrap();
         for i in 0..batch.num_rows() {
             rows.push((
                 ids.value(i),
@@ -161,7 +158,7 @@ async fn test_pk_partial_update_fixed_bucket_e2e() {
                 if strs.is_null(i) {
                     None
                 } else {
-                    Some(strs.value(i).to_string())
+                    Some(string_value(strs.as_ref(), i).to_string())
                 },
             ));
         }
@@ -474,23 +471,17 @@ async fn test_pk_partitioned_dedup_across_commits() {
 
     let mut rows = Vec::new();
     for batch in &batches {
-        let dts = batch
-            .column_by_name("dt")
-            .and_then(|c| c.as_any().downcast_ref::<StringArray>())
-            .unwrap();
+        let dts = batch.column_by_name("dt").unwrap();
         let ids = batch
             .column_by_name("id")
             .and_then(|c| c.as_any().downcast_ref::<Int32Array>())
             .unwrap();
-        let names = batch
-            .column_by_name("name")
-            .and_then(|c| c.as_any().downcast_ref::<StringArray>())
-            .unwrap();
+        let names = batch.column_by_name("name").unwrap();
         for i in 0..batch.num_rows() {
             rows.push((
-                dts.value(i).to_string(),
+                string_value(dts.as_ref(), i).to_string(),
                 ids.value(i),
-                names.value(i).to_string(),
+                string_value(names.as_ref(), i).to_string(),
             ));
         }
     }
@@ -646,13 +637,9 @@ async fn test_pk_column_projection() {
 
     let mut names = Vec::new();
     for batch in &batches {
-        let arr = batch
-            .column(0)
-            .as_any()
-            .downcast_ref::<StringArray>()
-            .unwrap();
+        let arr = batch.column(0);
         for i in 0..batch.num_rows() {
-            names.push(arr.value(i).to_string());
+            names.push(string_value(arr.as_ref(), i).to_string());
         }
     }
     names.sort();
@@ -766,23 +753,17 @@ async fn test_pk_insert_overwrite_partitioned() {
 
     let mut rows = Vec::new();
     for batch in &batches {
-        let dts = batch
-            .column_by_name("dt")
-            .and_then(|c| c.as_any().downcast_ref::<StringArray>())
-            .unwrap();
+        let dts = batch.column_by_name("dt").unwrap();
         let ids = batch
             .column_by_name("id")
             .and_then(|c| c.as_any().downcast_ref::<Int32Array>())
             .unwrap();
-        let names = batch
-            .column_by_name("name")
-            .and_then(|c| c.as_any().downcast_ref::<StringArray>())
-            .unwrap();
+        let names = batch.column_by_name("name").unwrap();
         for i in 0..batch.num_rows() {
             rows.push((
-                dts.value(i).to_string(),
+                string_value(dts.as_ref(), i).to_string(),
                 ids.value(i),
-                names.value(i).to_string(),
+                string_value(names.as_ref(), i).to_string(),
             ));
         }
     }
@@ -848,23 +829,17 @@ async fn test_pk_insert_overwrite_with_partition_clause() 
{
 
     let mut rows = Vec::new();
     for batch in &batches {
-        let dts = batch
-            .column_by_name("dt")
-            .and_then(|c| c.as_any().downcast_ref::<StringArray>())
-            .unwrap();
+        let dts = batch.column_by_name("dt").unwrap();
         let ids = batch
             .column_by_name("id")
             .and_then(|c| c.as_any().downcast_ref::<Int32Array>())
             .unwrap();
-        let names = batch
-            .column_by_name("name")
-            .and_then(|c| c.as_any().downcast_ref::<StringArray>())
-            .unwrap();
+        let names = batch.column_by_name("name").unwrap();
         for i in 0..batch.num_rows() {
             rows.push((
-                dts.value(i).to_string(),
+                string_value(dts.as_ref(), i).to_string(),
                 ids.value(i),
-                names.value(i).to_string(),
+                string_value(names.as_ref(), i).to_string(),
             ));
         }
     }
@@ -934,28 +909,19 @@ async fn 
test_pk_insert_overwrite_partial_partition_clause() {
 
     let mut rows = Vec::new();
     for batch in &batches {
-        let dts = batch
-            .column_by_name("dt")
-            .and_then(|c| c.as_any().downcast_ref::<StringArray>())
-            .unwrap();
-        let regions = batch
-            .column_by_name("region")
-            .and_then(|c| c.as_any().downcast_ref::<StringArray>())
-            .unwrap();
+        let dts = batch.column_by_name("dt").unwrap();
+        let regions = batch.column_by_name("region").unwrap();
         let ids = batch
             .column_by_name("id")
             .and_then(|c| c.as_any().downcast_ref::<Int32Array>())
             .unwrap();
-        let names = batch
-            .column_by_name("name")
-            .and_then(|c| c.as_any().downcast_ref::<StringArray>())
-            .unwrap();
+        let names = batch.column_by_name("name").unwrap();
         for i in 0..batch.num_rows() {
             rows.push((
-                dts.value(i).to_string(),
-                regions.value(i).to_string(),
+                string_value(dts.as_ref(), i).to_string(),
+                string_value(regions.as_ref(), i).to_string(),
                 ids.value(i),
-                names.value(i).to_string(),
+                string_value(names.as_ref(), i).to_string(),
             ));
         }
     }
@@ -1032,23 +998,17 @@ async fn test_pk_insert_overwrite_partition_truncate() {
 
     let mut rows = Vec::new();
     for batch in &batches {
-        let dts = batch
-            .column_by_name("dt")
-            .and_then(|c| c.as_any().downcast_ref::<StringArray>())
-            .unwrap();
+        let dts = batch.column_by_name("dt").unwrap();
         let ids = batch
             .column_by_name("id")
             .and_then(|c| c.as_any().downcast_ref::<Int32Array>())
             .unwrap();
-        let names = batch
-            .column_by_name("name")
-            .and_then(|c| c.as_any().downcast_ref::<StringArray>())
-            .unwrap();
+        let names = batch.column_by_name("name").unwrap();
         for i in 0..batch.num_rows() {
             rows.push((
-                dts.value(i).to_string(),
+                string_value(dts.as_ref(), i).to_string(),
                 ids.value(i),
-                names.value(i).to_string(),
+                string_value(names.as_ref(), i).to_string(),
             ));
         }
     }
@@ -1142,23 +1102,17 @@ async fn 
test_pk_insert_overwrite_dynamic_partition_preserves_other_partitions()
 
     let mut rows = Vec::new();
     for batch in &batches {
-        let dts = batch
-            .column_by_name("dt")
-            .and_then(|c| c.as_any().downcast_ref::<StringArray>())
-            .unwrap();
+        let dts = batch.column_by_name("dt").unwrap();
         let ids = batch
             .column_by_name("id")
             .and_then(|c| c.as_any().downcast_ref::<Int32Array>())
             .unwrap();
-        let names = batch
-            .column_by_name("name")
-            .and_then(|c| c.as_any().downcast_ref::<StringArray>())
-            .unwrap();
+        let names = batch.column_by_name("name").unwrap();
         for i in 0..batch.num_rows() {
             rows.push((
-                dts.value(i).to_string(),
+                string_value(dts.as_ref(), i).to_string(),
                 ids.value(i),
-                names.value(i).to_string(),
+                string_value(names.as_ref(), i).to_string(),
             ));
         }
     }
@@ -1254,23 +1208,17 @@ async fn 
test_pk_insert_overwrite_with_after_columns_reorder() {
 
     let mut rows = Vec::new();
     for batch in &batches {
-        let dts = batch
-            .column_by_name("dt")
-            .and_then(|c| c.as_any().downcast_ref::<StringArray>())
-            .unwrap();
+        let dts = batch.column_by_name("dt").unwrap();
         let ids = batch
             .column_by_name("id")
             .and_then(|c| c.as_any().downcast_ref::<Int32Array>())
             .unwrap();
-        let names = batch
-            .column_by_name("name")
-            .and_then(|c| c.as_any().downcast_ref::<StringArray>())
-            .unwrap();
+        let names = batch.column_by_name("name").unwrap();
         for i in 0..batch.num_rows() {
             rows.push((
-                dts.value(i).to_string(),
+                string_value(dts.as_ref(), i).to_string(),
                 ids.value(i),
-                names.value(i).to_string(),
+                string_value(names.as_ref(), i).to_string(),
             ));
         }
     }
@@ -1332,10 +1280,7 @@ async fn test_pk_composite_key() {
 
     let mut rows = Vec::new();
     for batch in &batches {
-        let regions = batch
-            .column_by_name("region")
-            .and_then(|c| c.as_any().downcast_ref::<StringArray>())
-            .unwrap();
+        let regions = batch.column_by_name("region").unwrap();
         let ids = batch
             .column_by_name("id")
             .and_then(|c| c.as_any().downcast_ref::<Int32Array>())
@@ -1345,7 +1290,11 @@ async fn test_pk_composite_key() {
             .and_then(|c| c.as_any().downcast_ref::<Int32Array>())
             .unwrap();
         for i in 0..batch.num_rows() {
-            rows.push((regions.value(i).to_string(), ids.value(i), 
vals.value(i)));
+            rows.push((
+                string_value(regions.as_ref(), i).to_string(),
+                ids.value(i),
+                vals.value(i),
+            ));
         }
     }
 
@@ -1492,10 +1441,7 @@ async fn test_pk_partitioned_multi_bucket() {
 
     let mut rows = Vec::new();
     for batch in &batches {
-        let dts = batch
-            .column_by_name("dt")
-            .and_then(|c| c.as_any().downcast_ref::<StringArray>())
-            .unwrap();
+        let dts = batch.column_by_name("dt").unwrap();
         let ids = batch
             .column_by_name("id")
             .and_then(|c| c.as_any().downcast_ref::<Int32Array>())
@@ -1505,7 +1451,11 @@ async fn test_pk_partitioned_multi_bucket() {
             .and_then(|c| c.as_any().downcast_ref::<Int32Array>())
             .unwrap();
         for i in 0..batch.num_rows() {
-            rows.push((dts.value(i).to_string(), ids.value(i), vals.value(i)));
+            rows.push((
+                string_value(dts.as_ref(), i).to_string(),
+                ids.value(i),
+                vals.value(i),
+            ));
         }
     }
 
@@ -1606,16 +1556,13 @@ async fn test_pk_string_key() {
 
     let mut rows = Vec::new();
     for batch in &batches {
-        let codes = batch
-            .column_by_name("code")
-            .and_then(|c| c.as_any().downcast_ref::<StringArray>())
-            .unwrap();
-        let names = batch
-            .column_by_name("name")
-            .and_then(|c| c.as_any().downcast_ref::<StringArray>())
-            .unwrap();
+        let codes = batch.column_by_name("code").unwrap();
+        let names = batch.column_by_name("name").unwrap();
         for i in 0..batch.num_rows() {
-            rows.push((codes.value(i).to_string(), 
names.value(i).to_string()));
+            rows.push((
+                string_value(codes.as_ref(), i).to_string(),
+                string_value(names.as_ref(), i).to_string(),
+            ));
         }
     }
 
@@ -1680,10 +1627,7 @@ async fn test_pk_multiple_value_columns() {
             .column_by_name("col_a")
             .and_then(|c| c.as_any().downcast_ref::<Int32Array>())
             .unwrap();
-        let bs = batch
-            .column_by_name("col_b")
-            .and_then(|c| c.as_any().downcast_ref::<StringArray>())
-            .unwrap();
+        let bs = batch.column_by_name("col_b").unwrap();
         let cs = batch
             .column_by_name("col_c")
             .and_then(|c| c.as_any().downcast_ref::<Int32Array>())
@@ -1692,7 +1636,7 @@ async fn test_pk_multiple_value_columns() {
             rows.push((
                 ids.value(i),
                 as_.value(i),
-                bs.value(i).to_string(),
+                string_value(bs.as_ref(), i).to_string(),
                 cs.value(i),
             ));
         }
@@ -2343,10 +2287,7 @@ async fn 
test_pk_aggregation_sum_and_listagg_fixed_multi_bucket_e2e() {
             .column_by_name("amount")
             .and_then(|c| c.as_any().downcast_ref::<Int32Array>())
             .unwrap();
-        let tags = batch
-            .column_by_name("tag")
-            .and_then(|c| c.as_any().downcast_ref::<StringArray>())
-            .unwrap();
+        let tags = batch.column_by_name("tag").unwrap();
         for i in 0..batch.num_rows() {
             rows.push((
                 ids.value(i),
@@ -2358,7 +2299,7 @@ async fn 
test_pk_aggregation_sum_and_listagg_fixed_multi_bucket_e2e() {
                 if tags.is_null(i) {
                     None
                 } else {
-                    Some(tags.value(i).to_string())
+                    Some(string_value(tags.as_ref(), i).to_string())
                 },
             ));
         }
@@ -2437,13 +2378,10 @@ async fn test_pk_aggregation_default_function() {
         .column_by_name("a")
         .and_then(|c| c.as_any().downcast_ref::<Int32Array>())
         .unwrap();
-    let b = batch
-        .column_by_name("b")
-        .and_then(|c| c.as_any().downcast_ref::<StringArray>())
-        .unwrap();
+    let b = batch.column_by_name("b").unwrap();
     assert_eq!(id.value(0), 1);
     assert_eq!(a.value(0), 99); // latest non-null int across the three commits
-    assert_eq!(b.value(0), "new"); // latest non-null string
+    assert_eq!(string_value(b.as_ref(), 0), "new"); // latest non-null string
 }
 
 /// Mixed aggregators in a single table: sum / max / bool_or / 
first_non_null_value.
@@ -2504,14 +2442,11 @@ async fn test_pk_aggregation_mixed_aggregators() {
         .column_by_name("ok")
         .and_then(|c| c.as_any().downcast_ref::<BooleanArray>())
         .unwrap();
-    let first_seen = batch
-        .column_by_name("first_seen")
-        .and_then(|c| c.as_any().downcast_ref::<StringArray>())
-        .unwrap();
+    let first_seen = batch.column_by_name("first_seen").unwrap();
     assert_eq!(total.value(0), 18); // 10 + 5 + 3
     assert_eq!(peak.value(0), 8); // max(5, 8, 7)
     assert!(ok.value(0)); // bool_or = true if any is true
-    assert_eq!(first_seen.value(0), "a"); // first non-null wins
+    assert_eq!(string_value(first_seen.as_ref(), 0), "a"); // first non-null 
wins
 }
 
 /// `sequence.field` forces the named column to `last_value`, even when a
diff --git a/crates/integrations/datafusion/tests/read_tables.rs 
b/crates/integrations/datafusion/tests/read_tables.rs
index 5ee87099..66d75d5b 100644
--- a/crates/integrations/datafusion/tests/read_tables.rs
+++ b/crates/integrations/datafusion/tests/read_tables.rs
@@ -20,7 +20,8 @@ use std::sync::Arc;
 
 mod common;
 
-use datafusion::arrow::array::{Array, Int32Array, StringArray};
+use common::string_value;
+use datafusion::arrow::array::{Array, Int32Array};
 use datafusion::arrow::record_batch::RecordBatch;
 use datafusion::arrow::util::display::array_value_to_string;
 use datafusion::catalog::CatalogProvider;
@@ -164,13 +165,12 @@ fn extract_id_name_rows(
             .expect("Expected Int32Array for id column");
         let name_array = batch
             .column_by_name("name")
-            .and_then(|column| column.as_any().downcast_ref::<StringArray>())
-            .expect("Expected StringArray for name column");
+            .expect("Expected string array for name column");
 
         for row_index in 0..batch.num_rows() {
             rows.push((
                 id_array.value(row_index),
-                name_array.value(row_index).to_string(),
+                string_value(name_array.as_ref(), row_index).to_string(),
             ));
         }
     }
@@ -1081,20 +1081,22 @@ async fn test_data_evolution_drop_column_null_fill() {
             .expect("Expected Int32Array for id");
         let name_array = batch
             .column_by_name("name")
-            .and_then(|c| c.as_any().downcast_ref::<StringArray>())
-            .expect("Expected StringArray for name");
+            .expect("Expected string array for name");
         let extra_array = batch
             .column_by_name("extra")
-            .and_then(|c| c.as_any().downcast_ref::<StringArray>())
-            .expect("Expected StringArray for extra");
+            .expect("Expected string array for extra");
 
         for i in 0..batch.num_rows() {
             let extra = if extra_array.is_null(i) {
                 None
             } else {
-                Some(extra_array.value(i).to_string())
+                Some(string_value(extra_array.as_ref(), i).to_string())
             };
-            rows.push((id_array.value(i), name_array.value(i).to_string(), 
extra));
+            rows.push((
+                id_array.value(i),
+                string_value(name_array.as_ref(), i).to_string(),
+                extra,
+            ));
         }
     }
     rows.sort_by_key(|(id, _, _)| *id);
@@ -1447,12 +1449,14 @@ async fn 
test_case_insensitive_column_not_supported_via_sql() {
 mod fulltext_tests {
     use std::sync::Arc;
 
-    use datafusion::arrow::array::{Int32Array, StringArray};
+    use datafusion::arrow::array::Int32Array;
     use paimon::catalog::Identifier;
     use paimon::table::BranchManager;
     use paimon::{Catalog, CatalogOptions, FileSystemCatalog, Options};
     use paimon_datafusion::{register_full_text_search, SQLContext};
 
+    use super::common::string_value;
+
     /// Extract the bundled tar.gz into a temp dir and return (tempdir, 
warehouse_path).
     fn extract_test_warehouse() -> (tempfile::TempDir, String) {
         let archive_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
@@ -1496,10 +1500,12 @@ mod fulltext_tests {
                 .expect("Expected Int32Array for id");
             let content_array = batch
                 .column_by_name("content")
-                .and_then(|c| c.as_any().downcast_ref::<StringArray>())
-                .expect("Expected StringArray for content");
+                .expect("Expected string array for content");
             for i in 0..batch.num_rows() {
-                rows.push((id_array.value(i), 
content_array.value(i).to_string()));
+                rows.push((
+                    id_array.value(i),
+                    string_value(content_array.as_ref(), i).to_string(),
+                ));
             }
         }
         rows.sort_by_key(|(id, _)| *id);
@@ -1619,6 +1625,8 @@ mod vector_search_tests {
     use paimon::{Catalog, CatalogOptions, FileSystemCatalog, Options};
     use paimon_datafusion::{register_vector_search, SQLContext};
 
+    use super::common::string_value;
+
     fn extract_test_warehouse(archive_name: &str) -> (tempfile::TempDir, 
String) {
         let archive_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
             .join("testdata")
@@ -1791,11 +1799,7 @@ mod vector_search_tests {
         for batch in batches {
             let index_type_array = batch
                 .column_by_name("index_type")
-                .and_then(|c| {
-                    c.as_any()
-                        
.downcast_ref::<datafusion::arrow::array::StringArray>()
-                })
-                .expect("Expected StringArray for index_type");
+                .expect("Expected string array for index_type");
             let row_count_array = batch
                 .column_by_name("row_count")
                 .and_then(|c| {
@@ -1819,19 +1823,15 @@ mod vector_search_tests {
                 .expect("Expected Int64Array for row_range_end");
             let index_field_name_array = batch
                 .column_by_name("index_field_name")
-                .and_then(|c| {
-                    c.as_any()
-                        
.downcast_ref::<datafusion::arrow::array::StringArray>()
-                })
-                .expect("Expected StringArray for index_field_name");
+                .expect("Expected string array for index_field_name");
 
             for row_index in 0..batch.num_rows() {
                 rows.push((
-                    index_type_array.value(row_index).to_string(),
+                    string_value(index_type_array.as_ref(), 
row_index).to_string(),
                     row_count_array.value(row_index),
                     row_range_start_array.value(row_index),
                     row_range_end_array.value(row_index),
-                    index_field_name_array.value(row_index).to_string(),
+                    string_value(index_field_name_array.as_ref(), 
row_index).to_string(),
                 ));
             }
         }
diff --git a/crates/paimon/src/arrow/format/parquet.rs 
b/crates/paimon/src/arrow/format/parquet.rs
index 1573342c..c15ead0d 100644
--- a/crates/paimon/src/arrow/format/parquet.rs
+++ b/crates/paimon/src/arrow/format/parquet.rs
@@ -1964,6 +1964,92 @@ mod tests {
         assert_eq!(mask, expected);
     }
 
+    #[test]
+    fn test_evaluate_string_view_comparison_families() {
+        use crate::spec::VarCharType;
+        use arrow_array::{ArrayRef, BooleanArray, StringViewArray};
+
+        let column: ArrayRef = Arc::new(StringViewArray::from(vec![
+            Some("a"),
+            Some("b"),
+            Some("c"),
+            None,
+        ]));
+        let data_type = DataType::VarChar(VarCharType::default());
+        let cases = [
+            (
+                super::PredicateOperator::Eq,
+                vec![Datum::String("b".to_string())],
+                vec![false, true, false, false],
+            ),
+            (
+                super::PredicateOperator::NotEq,
+                vec![Datum::String("b".to_string())],
+                vec![true, false, true, false],
+            ),
+            (
+                super::PredicateOperator::Lt,
+                vec![Datum::String("b".to_string())],
+                vec![true, false, false, false],
+            ),
+            (
+                super::PredicateOperator::LtEq,
+                vec![Datum::String("b".to_string())],
+                vec![true, true, false, false],
+            ),
+            (
+                super::PredicateOperator::Gt,
+                vec![Datum::String("b".to_string())],
+                vec![false, false, true, false],
+            ),
+            (
+                super::PredicateOperator::GtEq,
+                vec![Datum::String("b".to_string())],
+                vec![false, true, true, false],
+            ),
+            (
+                super::PredicateOperator::In,
+                vec![
+                    Datum::String("a".to_string()),
+                    Datum::String("c".to_string()),
+                ],
+                vec![true, false, true, false],
+            ),
+            (
+                super::PredicateOperator::NotIn,
+                vec![
+                    Datum::String("a".to_string()),
+                    Datum::String("c".to_string()),
+                ],
+                vec![false, true, false, false],
+            ),
+            (
+                super::PredicateOperator::Between,
+                vec![
+                    Datum::String("a".to_string()),
+                    Datum::String("b".to_string()),
+                ],
+                vec![true, true, false, false],
+            ),
+            (
+                super::PredicateOperator::NotBetween,
+                vec![
+                    Datum::String("a".to_string()),
+                    Datum::String("b".to_string()),
+                ],
+                vec![false, false, true, false],
+            ),
+        ];
+
+        for (op, literals, expected) in cases {
+            let mask = crate::arrow::residual::evaluate_exact_leaf_predicate(
+                &column, &data_type, op, &literals,
+            )
+            .unwrap_or_else(|error| panic!("{op:?} should evaluate: {error}"));
+            assert_eq!(mask, BooleanArray::from(expected), "operator {op:?}");
+        }
+    }
+
     #[test]
     fn test_evaluate_like_pattern_with_underscore_and_percent() {
         use arrow_array::StringArray;
diff --git a/crates/paimon/src/arrow/residual.rs 
b/crates/paimon/src/arrow/residual.rs
index b767d2aa..1a6ff5a1 100644
--- a/crates/paimon/src/arrow/residual.rs
+++ b/crates/paimon/src/arrow/residual.rs
@@ -571,6 +571,8 @@ fn evaluate_column_predicate(
     scalar: &Scalar<ArrayRef>,
     op: PredicateOperator,
 ) -> Result<BooleanArray, ArrowError> {
+    let scalar = string_scalar_for_column(scalar, column.data_type())?;
+
     // Binary ordering must match Paimon's Datum::Bytes semantics (Java 
signed-byte
     // order, 0xFF < 0x00), which Arrow's unsigned byte comparison does not. 
Route
     // ordering ops on Binary/VarBinary columns through the signed comparator.
@@ -585,28 +587,25 @@ fn evaluate_column_predicate(
             | PredicateOperator::Gt
             | PredicateOperator::GtEq
     ) {
-        return evaluate_binary_ordering_predicate(column, scalar, op);
+        return evaluate_binary_ordering_predicate(column, &scalar, op);
     }
     match op {
-        PredicateOperator::Eq => arrow_eq(column, scalar),
-        PredicateOperator::NotEq => arrow_neq(column, scalar),
-        PredicateOperator::Lt => arrow_lt(column, scalar),
-        PredicateOperator::LtEq => arrow_lt_eq(column, scalar),
-        PredicateOperator::Gt => arrow_gt(column, scalar),
-        PredicateOperator::GtEq => arrow_gt_eq(column, scalar),
+        PredicateOperator::Eq => arrow_eq(column, &scalar),
+        PredicateOperator::NotEq => arrow_neq(column, &scalar),
+        PredicateOperator::Lt => arrow_lt(column, &scalar),
+        PredicateOperator::LtEq => arrow_lt_eq(column, &scalar),
+        PredicateOperator::Gt => arrow_gt(column, &scalar),
+        PredicateOperator::GtEq => arrow_gt_eq(column, &scalar),
         PredicateOperator::StartsWith
         | PredicateOperator::EndsWith
         | PredicateOperator::Contains
-        | PredicateOperator::Like => {
-            let pattern = pattern_scalar_for_string_kernel(scalar, 
column.data_type())?;
-            match op {
-                PredicateOperator::StartsWith => arrow_starts_with(column, 
&pattern),
-                PredicateOperator::EndsWith => arrow_ends_with(column, 
&pattern),
-                PredicateOperator::Contains => arrow_contains(column, 
&pattern),
-                PredicateOperator::Like => arrow_like(column, &pattern),
-                _ => unreachable!(),
-            }
-        }
+        | PredicateOperator::Like => match op {
+            PredicateOperator::StartsWith => arrow_starts_with(column, 
&scalar),
+            PredicateOperator::EndsWith => arrow_ends_with(column, &scalar),
+            PredicateOperator::Contains => arrow_contains(column, &scalar),
+            PredicateOperator::Like => arrow_like(column, &scalar),
+            _ => unreachable!(),
+        },
         PredicateOperator::IsNull
         | PredicateOperator::IsNotNull
         | PredicateOperator::In
@@ -665,11 +664,11 @@ fn evaluate_binary_ordering_predicate(
     Ok(mask)
 }
 
-/// `arrow_string::like::*` kernels reject mismatched string types — Utf8 
column
-/// against Utf8 pattern is fine, but a LargeUtf8 / Utf8View column needs a
-/// pattern of the same flavour. The shared scalar built upstream is always
-/// `StringArray` (Utf8); promote it to match the column when needed.
-fn pattern_scalar_for_string_kernel(
+/// Arrow comparison and pattern kernels reject mismatched string types. The
+/// shared scalar built from Paimon's logical Char/VarChar type is Utf8, while 
a
+/// decoded file column may be Utf8, LargeUtf8, or Utf8View. Promote the scalar
+/// to the actual column representation before invoking any string kernel.
+fn string_scalar_for_column(
     scalar: &Scalar<ArrayRef>,
     column_type: &arrow_schema::DataType,
 ) -> Result<Scalar<ArrayRef>, ArrowError> {

Reply via email to