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 e5b3be5 feat: Implement Blob View support (#486)
e5b3be5 is described below
commit e5b3be5f964f26bc5e85aa937f129ab5b8c28b7a
Author: Jingsong Lee <[email protected]>
AuthorDate: Wed Jul 8 23:20:16 2026 +0800
feat: Implement Blob View support (#486)
---
crates/integrations/datafusion/src/blob_view.rs | 267 ++++++++++
crates/integrations/datafusion/src/lib.rs | 2 +
crates/integrations/datafusion/src/sql_context.rs | 36 +-
crates/integrations/datafusion/tests/blob_tests.rs | 164 +++++-
crates/paimon/src/catalog/rest/rest_catalog.rs | 82 +--
crates/paimon/src/spec/blob_view_struct.rs | 217 ++++++++
crates/paimon/src/spec/core_options.rs | 80 ++-
crates/paimon/src/spec/mod.rs | 3 +
crates/paimon/src/table/blob_resolver.rs | 227 ++++++++
crates/paimon/src/table/data_evolution_reader.rs | 585 ++++++++++++++++++---
.../src/table/dedicated_format_file_writer.rs | 110 ++--
crates/paimon/src/table/mod.rs | 1 +
crates/paimon/src/table/rest_env.rs | 118 ++++-
crates/paimon/src/table/table_read.rs | 3 +
crates/paimon/src/table/table_write.rs | 39 +-
crates/paimon/tests/rest_catalog_test.rs | 237 ++++++++-
docs/src/sql.md | 76 ++-
17 files changed, 2015 insertions(+), 232 deletions(-)
diff --git a/crates/integrations/datafusion/src/blob_view.rs
b/crates/integrations/datafusion/src/blob_view.rs
new file mode 100644
index 0000000..345da1f
--- /dev/null
+++ b/crates/integrations/datafusion/src/blob_view.rs
@@ -0,0 +1,267 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use std::collections::HashMap;
+use std::hash::{Hash, Hasher};
+use std::sync::Arc;
+
+use datafusion::arrow::array::{
+ Array, BinaryBuilder, Int16Array, Int32Array, Int64Array, Int8Array,
LargeStringArray,
+ StringArray, StringViewArray, UInt16Array, UInt32Array, UInt64Array,
UInt8Array,
+};
+use datafusion::arrow::datatypes::{DataType as ArrowDataType, Field, FieldRef};
+use datafusion::common::{DataFusionError, Result as DFResult};
+use datafusion::logical_expr::{
+ ColumnarValue, ReturnFieldArgs, ScalarFunctionArgs, ScalarUDF,
ScalarUDFImpl, Signature,
+ Volatility,
+};
+use datafusion::prelude::SessionContext;
+use paimon::catalog::Catalog;
+use paimon::spec::BlobViewStruct;
+
+use crate::error::to_datafusion_error;
+use crate::runtime::block_on_with_runtime;
+use crate::table_function_args::parse_table_identifier;
+
+const FUNCTION_NAME: &str = "blob_view";
+
+pub fn register_blob_view(ctx: &SessionContext, catalog: Arc<dyn Catalog>,
default_database: &str) {
+ ctx.register_udf(ScalarUDF::from(BlobViewFunc::new(
+ catalog,
+ default_database,
+ )));
+}
+
+#[derive(Clone)]
+struct BlobViewFunc {
+ catalog: Arc<dyn Catalog>,
+ default_database: String,
+ signature: Signature,
+ aliases: Vec<String>,
+}
+
+impl BlobViewFunc {
+ fn new(catalog: Arc<dyn Catalog>, default_database: &str) -> Self {
+ Self {
+ catalog,
+ default_database: default_database.to_string(),
+ signature: Signature::any(3, Volatility::Immutable),
+ aliases: vec!["sys.blob_view".to_string()],
+ }
+ }
+
+ fn field_id(&self, table_name: &str, field_name: &str) -> DFResult<i32> {
+ let identifier = parse_table_identifier(FUNCTION_NAME, table_name,
&self.default_database)?;
+ let catalog = Arc::clone(&self.catalog);
+ let table = block_on_with_runtime(
+ async move { catalog.get_table(&identifier).await },
+ "blob_view: catalog access thread panicked",
+ )
+ .map_err(to_datafusion_error)?;
+
+ let field = table
+ .schema()
+ .fields()
+ .iter()
+ .find(|field| field.name() == field_name)
+ .ok_or_else(|| {
+ DataFusionError::Plan(format!(
+ "blob_view: cannot find blob field {field_name} in
upstream table {table_name}"
+ ))
+ })?;
+ if !field.data_type().is_blob_type() {
+ return Err(DataFusionError::Plan(format!(
+ "blob_view: field {field_name} in upstream table {table_name}
is not a BLOB field"
+ )));
+ }
+ Ok(field.id())
+ }
+}
+
+impl std::fmt::Debug for BlobViewFunc {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.debug_struct("BlobViewFunc")
+ .field("default_database", &self.default_database)
+ .field("aliases", &self.aliases)
+ .finish()
+ }
+}
+
+impl PartialEq for BlobViewFunc {
+ fn eq(&self, other: &Self) -> bool {
+ self.default_database == other.default_database
+ && Arc::ptr_eq(&self.catalog, &other.catalog)
+ }
+}
+
+impl Eq for BlobViewFunc {}
+
+impl Hash for BlobViewFunc {
+ fn hash<H: Hasher>(&self, state: &mut H) {
+ FUNCTION_NAME.hash(state);
+ self.default_database.hash(state);
+ let ptr = Arc::as_ptr(&self.catalog) as *const () as usize;
+ ptr.hash(state);
+ }
+}
+
+impl ScalarUDFImpl for BlobViewFunc {
+ fn name(&self) -> &str {
+ FUNCTION_NAME
+ }
+
+ fn aliases(&self) -> &[String] {
+ &self.aliases
+ }
+
+ fn signature(&self) -> &Signature {
+ &self.signature
+ }
+
+ fn return_type(&self, _arg_types: &[ArrowDataType]) ->
DFResult<ArrowDataType> {
+ Ok(ArrowDataType::Binary)
+ }
+
+ fn return_field_from_args(&self, _args: ReturnFieldArgs) ->
DFResult<FieldRef> {
+ Ok(Arc::new(Field::new(
+ FUNCTION_NAME,
+ ArrowDataType::Binary,
+ true,
+ )))
+ }
+
+ fn invoke_with_args(&self, args: ScalarFunctionArgs) ->
DFResult<ColumnarValue> {
+ if args.args.len() != 3 {
+ return Err(DataFusionError::Plan(
+ "blob_view expects 3 arguments: (table_name, field_name_or_id,
row_id)".to_string(),
+ ));
+ }
+
+ let arrays = ColumnarValue::values_to_arrays(&args.args)?;
+ let table_names = arrays[0].as_ref();
+ let fields = arrays[1].as_ref();
+ let row_ids = arrays[2].as_ref();
+ let mut field_id_cache = HashMap::new();
+ let mut builder = BinaryBuilder::new();
+
+ for row in 0..table_names.len() {
+ let Some(table_name) = string_at(table_names, row)? else {
+ builder.append_null();
+ continue;
+ };
+ let Some(row_id) = int64_at(row_ids, row, "row_id")? else {
+ builder.append_null();
+ continue;
+ };
+
+ let field_id = if is_string_array(fields) {
+ let Some(field_name) = string_at(fields, row)? else {
+ builder.append_null();
+ continue;
+ };
+ let cache_key = (table_name.clone(), field_name.clone());
+ match field_id_cache.get(&cache_key) {
+ Some(field_id) => *field_id,
+ None => {
+ let field_id = self.field_id(&table_name,
&field_name)?;
+ field_id_cache.insert(cache_key, field_id);
+ field_id
+ }
+ }
+ } else {
+ match int64_at(fields, row, "field_id")? {
+ Some(field_id) => i32::try_from(field_id).map_err(|_| {
+ DataFusionError::Plan(format!(
+ "blob_view: field_id {field_id} is outside i32
range"
+ ))
+ })?,
+ None => {
+ builder.append_null();
+ continue;
+ }
+ }
+ };
+
+ let identifier =
+ parse_table_identifier(FUNCTION_NAME, &table_name,
&self.default_database)?;
+ let value = BlobViewStruct::new(identifier, field_id, row_id)
+ .serialize()
+ .map_err(to_datafusion_error)?;
+ builder.append_value(value);
+ }
+
+ Ok(ColumnarValue::Array(Arc::new(builder.finish())))
+ }
+}
+
+fn is_string_array(array: &dyn Array) -> bool {
+ matches!(
+ array.data_type(),
+ ArrowDataType::Utf8 | ArrowDataType::LargeUtf8 |
ArrowDataType::Utf8View
+ )
+}
+
+fn string_at(array: &dyn Array, row: usize) -> DFResult<Option<String>> {
+ if array.is_null(row) {
+ return Ok(None);
+ }
+ if let Some(values) = array.as_any().downcast_ref::<StringArray>() {
+ return Ok(Some(values.value(row).to_string()));
+ }
+ if let Some(values) = array.as_any().downcast_ref::<LargeStringArray>() {
+ return Ok(Some(values.value(row).to_string()));
+ }
+ if let Some(values) = array.as_any().downcast_ref::<StringViewArray>() {
+ return Ok(Some(values.value(row).to_string()));
+ }
+ Err(DataFusionError::Plan(format!(
+ "blob_view: expected string argument, got {:?}",
+ array.data_type()
+ )))
+}
+
+fn int64_at(array: &dyn Array, row: usize, name: &str) ->
DFResult<Option<i64>> {
+ if array.is_null(row) {
+ return Ok(None);
+ }
+ macro_rules! downcast_int {
+ ($ty:ty) => {
+ if let Some(values) = array.as_any().downcast_ref::<$ty>() {
+ return Ok(Some(values.value(row) as i64));
+ }
+ };
+ }
+ downcast_int!(Int8Array);
+ downcast_int!(Int16Array);
+ downcast_int!(Int32Array);
+ downcast_int!(Int64Array);
+ downcast_int!(UInt8Array);
+ downcast_int!(UInt16Array);
+ downcast_int!(UInt32Array);
+ if let Some(values) = array.as_any().downcast_ref::<UInt64Array>() {
+ return i64::try_from(values.value(row)).map(Some).map_err(|_| {
+ DataFusionError::Plan(format!(
+ "blob_view: {name} {} is outside i64 range",
+ values.value(row)
+ ))
+ });
+ }
+ Err(DataFusionError::Plan(format!(
+ "blob_view: expected integer {name}, got {:?}",
+ array.data_type()
+ )))
+}
diff --git a/crates/integrations/datafusion/src/lib.rs
b/crates/integrations/datafusion/src/lib.rs
index c4b79b5..675fda4 100644
--- a/crates/integrations/datafusion/src/lib.rs
+++ b/crates/integrations/datafusion/src/lib.rs
@@ -37,6 +37,7 @@
//! translatable partition-only conjuncts from DataFusion filters.
mod blob_reader;
+mod blob_view;
mod catalog;
mod delete;
mod error;
@@ -69,6 +70,7 @@ use std::sync::{Arc, RwLock};
pub(crate) type DynamicOptions = Arc<RwLock<HashMap<String, String>>>;
pub use blob_reader::BlobReaderRegistry;
+pub use blob_view::register_blob_view;
pub use catalog::{PaimonCatalogProvider, PaimonSchemaProvider};
pub use error::to_datafusion_error;
#[cfg(feature = "fulltext")]
diff --git a/crates/integrations/datafusion/src/sql_context.rs
b/crates/integrations/datafusion/src/sql_context.rs
index babd650..e7b0ba2 100644
--- a/crates/integrations/datafusion/src/sql_context.rs
+++ b/crates/integrations/datafusion/src/sql_context.rs
@@ -674,11 +674,13 @@ impl SQLContext {
let identifier = self.resolve_table_name(&ct.name)?;
let mut builder = paimon::spec::Schema::builder();
+ let table_options = extract_options(&ct.table_options)?;
// Columns
for col in &ct.columns {
let paimon_type = column_def_to_paimon_type(col)?;
- builder = builder.column(col.name.value.clone(), paimon_type);
+ let comment = column_def_comment(col);
+ builder = builder.column_with_description(col.name.value.clone(),
paimon_type, comment);
}
// Primary key from constraints: PRIMARY KEY (col, ...)
@@ -707,7 +709,7 @@ impl SQLContext {
}
// Table options from WITH ('key' = 'value', ...)
- for (k, v) in extract_options(&ct.table_options)? {
+ for (k, v) in table_options {
builder = builder.option(k, v);
}
@@ -907,8 +909,7 @@ impl SQLContext {
for op in operations {
match op {
AlterTableOperation::AddColumn { column_def, .. } => {
- let change = column_def_to_add_column(column_def)?;
- changes.push(change);
+ changes.push(column_def_to_add_column(column_def)?);
}
AlterTableOperation::DropColumn {
column_names,
@@ -1706,16 +1707,27 @@ fn extract_partition_by(sql: &str) -> DFResult<(String,
Vec<String>)> {
/// Convert a sqlparser [`ColumnDef`] to a Paimon [`SchemaChange::AddColumn`].
fn column_def_to_add_column(col: &ColumnDef) -> DFResult<SchemaChange> {
let paimon_type = column_def_to_paimon_type(col)?;
- Ok(SchemaChange::add_column(
- col.name.value.clone(),
- paimon_type,
- ))
+ let comment = column_def_comment(col);
+
+ Ok(SchemaChange::AddColumn {
+ field_names: vec![col.name.value.clone()],
+ data_type: paimon_type,
+ comment,
+ column_move: None,
+ })
}
fn column_def_to_paimon_type(col: &ColumnDef) -> DFResult<PaimonDataType> {
sql_data_type_to_paimon_type(&col.data_type, column_def_nullable(col))
}
+fn column_def_comment(col: &ColumnDef) -> Option<String> {
+ col.options.iter().find_map(|opt| match &opt.option {
+ datafusion::sql::sqlparser::ast::ColumnOption::Comment(comment) =>
Some(comment.clone()),
+ _ => None,
+ })
+}
+
fn primary_key_column_name(expr: &SqlExpr) -> String {
match expr {
SqlExpr::Identifier(ident) => ident.value.clone(),
@@ -2579,6 +2591,7 @@ fn register_table_functions(
catalog: &Arc<dyn Catalog>,
default_database: &str,
) {
+ crate::blob_view::register_blob_view(ctx, Arc::clone(catalog),
default_database);
crate::vector_search::register_vector_search(ctx, Arc::clone(catalog),
default_database);
#[cfg(feature = "fulltext")]
crate::full_text_search::register_full_text_search(ctx,
Arc::clone(catalog), default_database);
@@ -2593,7 +2606,7 @@ mod tests {
use async_trait::async_trait;
use paimon::catalog::Database;
- use paimon::spec::{DataType as PaimonDataType, Schema as PaimonSchema};
+ use paimon::spec::{DataType as PaimonDataType, IntType, Schema as
PaimonSchema};
use paimon::table::Table;
// ==================== Mock Catalog ====================
@@ -2620,12 +2633,14 @@ mod tests {
struct MockCatalog {
calls: Mutex<Vec<CatalogCall>>,
+ existing_table: Mutex<Option<Table>>,
}
impl MockCatalog {
fn new() -> Self {
Self {
calls: Mutex::new(Vec::new()),
+ existing_table: Mutex::new(None),
}
}
@@ -2661,6 +2676,9 @@ mod tests {
Ok(())
}
async fn get_table(&self, _identifier: &Identifier) ->
paimon::Result<Table> {
+ if let Some(table) = self.existing_table.lock().unwrap().clone() {
+ return Ok(table);
+ }
Err(paimon::Error::TableNotExist {
full_name: _identifier.to_string(),
})
diff --git a/crates/integrations/datafusion/tests/blob_tests.rs
b/crates/integrations/datafusion/tests/blob_tests.rs
index 3c3d3c6..10d4624 100644
--- a/crates/integrations/datafusion/tests/blob_tests.rs
+++ b/crates/integrations/datafusion/tests/blob_tests.rs
@@ -23,7 +23,7 @@ mod common;
use arrow_array::{Array, BinaryArray, Int32Array, RecordBatch, StringArray};
use common::{create_sql_context, create_test_env, exec};
-use paimon::spec::BlobDescriptor;
+use paimon::spec::{BlobDescriptor, BlobViewStruct};
use paimon_datafusion::SQLContext;
// ======================= Helpers =======================
@@ -706,6 +706,39 @@ async fn
test_blob_descriptor_field_resolve_descriptor_value() {
);
}
+#[tokio::test]
+async fn
test_blob_descriptor_filter_before_resolve_skips_filtered_bad_descriptor() {
+ let (tmp, sql_context) = setup(
+ "CREATE TABLE paimon.test_db.t (\
+ id INT, \
+ name STRING, \
+ picture BLOB \
+ ) WITH (\
+ 'data-evolution.enabled' = 'true', \
+ 'row-tracking.enabled' = 'true', \
+ 'blob-descriptor-field' = 'picture'\
+ )",
+ )
+ .await;
+
+ let missing_uri = format!("file://{}",
tmp.path().join("missing_blob.bin").display());
+ let bad_desc = BlobDescriptor::new(missing_uri, 0, 1);
+ let bad_desc_hex = to_hex(&bad_desc.serialize());
+ let sql = format!(
+ "INSERT INTO paimon.test_db.t (id, name, picture) VALUES \
+ (1, 'Filtered', X'{bad_desc_hex}'), \
+ (2, 'Kept', X'4F4B')"
+ );
+ exec(&sql_context, &sql).await;
+
+ let rows = query_id_name_picture(
+ &sql_context,
+ "SELECT id, name, picture FROM paimon.test_db.t WHERE id = 2",
+ )
+ .await;
+ assert_eq!(rows, vec![(2, "Kept".into(), Some(b"OK".to_vec()))]);
+}
+
/// SET 'paimon.blob-as-descriptor' = 'true' should return serialized
BlobDescriptor
/// bytes instead of the actual blob content.
#[tokio::test]
@@ -765,3 +798,132 @@ async fn test_blob_as_descriptor_dynamic_option() {
assert_eq!(rows[0].2, Some(b"Hello".to_vec()));
assert_eq!(rows[1].2, Some(b"World".to_vec()));
}
+
+#[tokio::test]
+async fn test_blob_view_without_rest_env_preserves_reference() {
+ let (tmp, catalog) = create_test_env();
+ let sql_context = create_sql_context(catalog).await;
+ sql_context
+ .sql("CREATE SCHEMA paimon.test_db")
+ .await
+ .unwrap();
+
+ sql_context
+ .sql(
+ "CREATE TABLE paimon.test_db.src (\
+ id INT, \
+ name STRING, \
+ picture BLOB\
+ ) WITH (\
+ 'data-evolution.enabled' = 'true', \
+ 'row-tracking.enabled' = 'true'\
+ )",
+ )
+ .await
+ .unwrap();
+ exec(
+ &sql_context,
+ "INSERT INTO paimon.test_db.src (id, name, picture) VALUES \
+ (1, 'Alice', X'616C696365'), \
+ (2, 'Bob', X'626F62')",
+ )
+ .await;
+
+ sql_context
+ .sql(
+ "CREATE TABLE paimon.test_db.view_t (\
+ id INT, \
+ name STRING, \
+ picture BLOB\
+ ) WITH (\
+ 'data-evolution.enabled' = 'true', \
+ 'row-tracking.enabled' = 'true', \
+ 'blob-view-field' = 'picture'\
+ )",
+ )
+ .await
+ .unwrap();
+ exec(
+ &sql_context,
+ "INSERT INTO paimon.test_db.view_t (id, name, picture) \
+ SELECT id, name, sys.blob_view('test_db.src', 'picture', \"_ROW_ID\")
\
+ FROM paimon.test_db.src",
+ )
+ .await;
+
+ let rows = query_id_name_picture(
+ &sql_context,
+ "SELECT id, name, picture FROM paimon.test_db.view_t ORDER BY id",
+ )
+ .await;
+ assert_eq!(rows[0].1, "Alice");
+ assert_eq!(rows[1].1, "Bob");
+ let view0_bytes = rows[0].2.as_ref().expect("view bytes should be
preserved");
+ let view1_bytes = rows[1].2.as_ref().expect("view bytes should be
preserved");
+ assert!(BlobViewStruct::is_blob_view_struct(view0_bytes));
+ assert!(BlobViewStruct::is_blob_view_struct(view1_bytes));
+ let view0 = BlobViewStruct::deserialize(view0_bytes).unwrap();
+ let view1 = BlobViewStruct::deserialize(view1_bytes).unwrap();
+ assert_eq!(view0.identifier().full_name(), "test_db.src");
+ assert_eq!(view1.identifier().full_name(), "test_db.src");
+ assert_eq!(view0.row_id(), 0);
+ assert_eq!(view1.row_id(), 1);
+
+ drop(tmp);
+}
+
+#[tokio::test]
+async fn test_blob_view_resolve_disabled_preserves_reference() {
+ let (_tmp, sql_context) = setup(
+ "CREATE TABLE paimon.test_db.src (\
+ id INT, \
+ name STRING, \
+ picture BLOB\
+ ) WITH (\
+ 'data-evolution.enabled' = 'true', \
+ 'row-tracking.enabled' = 'true'\
+ )",
+ )
+ .await;
+ exec(
+ &sql_context,
+ "INSERT INTO paimon.test_db.src (id, name, picture) VALUES (1,
'Alice', X'616C696365')",
+ )
+ .await;
+ sql_context
+ .sql(
+ "CREATE TABLE paimon.test_db.view_t (\
+ id INT, \
+ name STRING, \
+ picture BLOB\
+ ) WITH (\
+ 'data-evolution.enabled' = 'true', \
+ 'row-tracking.enabled' = 'true', \
+ 'blob-view-field' = 'picture'\
+ )",
+ )
+ .await
+ .unwrap();
+ exec(
+ &sql_context,
+ "INSERT INTO paimon.test_db.view_t (id, name, picture) \
+ SELECT id, name, blob_view('test_db.src', 'picture', \"_ROW_ID\") \
+ FROM paimon.test_db.src",
+ )
+ .await;
+
+ sql_context
+ .sql("SET 'paimon.blob-view.resolve.enabled' = 'false'")
+ .await
+ .unwrap();
+ let rows = query_id_name_picture(
+ &sql_context,
+ "SELECT id, name, picture FROM paimon.test_db.view_t ORDER BY id",
+ )
+ .await;
+ let view_bytes = rows[0].2.as_ref().expect("view bytes should be
preserved");
+ assert!(BlobViewStruct::is_blob_view_struct(view_bytes));
+ let view = BlobViewStruct::deserialize(view_bytes).unwrap();
+ assert_eq!(view.identifier().full_name(), "test_db.src");
+ assert_eq!(view.row_id(), 0);
+}
diff --git a/crates/paimon/src/catalog/rest/rest_catalog.rs
b/crates/paimon/src/catalog/rest/rest_catalog.rs
index 5ff5d6b..4d87008 100644
--- a/crates/paimon/src/catalog/rest/rest_catalog.rs
+++ b/crates/paimon/src/catalog/rest/rest_catalog.rs
@@ -33,13 +33,10 @@ use crate::catalog::{
};
use crate::common::{CatalogOptions, Options};
use crate::error::Error;
-use crate::io::FileIO;
-use crate::spec::{Partition, Schema, SchemaChange, TableSchema};
+use crate::spec::{Partition, Schema, SchemaChange};
use crate::table::{RESTEnv, Table};
use crate::Result;
-use super::rest_token_file_io::RESTTokenFileIO;
-
/// REST catalog implementation.
///
/// This catalog communicates with a Paimon REST catalog server
@@ -199,76 +196,13 @@ impl Catalog for RESTCatalog {
// ======================= table methods ===============================
async fn get_table(&self, identifier: &Identifier) -> Result<Table> {
- let response = self
- .api
- .get_table(identifier)
- .await
- .map_err(|e| map_rest_error_for_table(e, identifier))?;
-
- // Extract schema from response
- let schema = response.schema.ok_or_else(|| Error::DataInvalid {
- message: format!("Table {} response missing schema",
identifier.full_name()),
- source: None,
- })?;
-
- let schema_id = response.schema_id.ok_or_else(|| Error::DataInvalid {
- message: format!(
- "Table {} response missing schema_id",
- identifier.full_name()
- ),
- source: None,
- })?;
- let table_schema = TableSchema::new(schema_id, &schema);
-
- // Extract table path from response
- let table_path = response.path.ok_or_else(|| Error::DataInvalid {
- message: format!("Table {} response missing path",
identifier.full_name()),
- source: None,
- })?;
-
- // Check if the table is external
- let is_external = response.is_external.ok_or_else(||
Error::DataInvalid {
- message: format!(
- "Table {} response missing is_external",
- identifier.full_name()
- ),
- source: None,
- })?;
-
- // Extract table uuid for RESTEnv
- let uuid = response.id.ok_or_else(|| Error::DataInvalid {
- message: format!(
- "Table {} response missing id (uuid)",
- identifier.full_name()
- ),
- source: None,
- })?;
-
- // Build FileIO based on data_token_enabled and is_external
- // TODO Support token cache and direct oss access
- let file_io = if self.data_token_enabled && !is_external {
- // Use RESTTokenFileIO to get token-based FileIO
- let token_file_io =
- RESTTokenFileIO::new(identifier.clone(), table_path.clone(),
self.options.clone());
- token_file_io.build_file_io().await?
- } else {
- // Mirrors Java RESTCatalog.fileIOFromOptions: build FileIO from
- // catalog options so OSS-backed paths can pick up the
- // user-supplied `fs.oss.*` keys.
- let mut builder = FileIO::from_path(&table_path)?;
- builder = builder.with_props(self.options.to_map());
- builder.build()?
- };
-
- let rest_env = RESTEnv::new(identifier.clone(), uuid,
self.api.clone());
-
- Ok(Table::new(
- file_io,
- identifier.clone(),
- table_path,
- table_schema,
- Some(rest_env),
- ))
+ RESTEnv::load_table(
+ identifier,
+ self.api.clone(),
+ self.options.clone(),
+ self.data_token_enabled,
+ )
+ .await
}
async fn list_tables(&self, database_name: &str) -> Result<Vec<String>> {
diff --git a/crates/paimon/src/spec/blob_view_struct.rs
b/crates/paimon/src/spec/blob_view_struct.rs
new file mode 100644
index 0000000..c76ff69
--- /dev/null
+++ b/crates/paimon/src/spec/blob_view_struct.rs
@@ -0,0 +1,217 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use crate::catalog::{Identifier, UNKNOWN_DATABASE};
+use crate::Error;
+
+const CURRENT_VERSION: u8 = 1;
+const MAGIC: u64 = 0x424C4F4256494557; // "BLOBVIEW"
+
+/// Serialized coordinates for a BLOB value stored in an upstream table.
+///
+/// Matches Java `org.apache.paimon.data.BlobViewStruct`: version byte,
+/// `"BLOBVIEW"` magic, UTF-8 `database.table`, field id, and row id, all
+/// little-endian where applicable.
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+pub struct BlobViewStruct {
+ identifier: Identifier,
+ field_id: i32,
+ row_id: i64,
+}
+
+impl BlobViewStruct {
+ pub fn new(identifier: Identifier, field_id: i32, row_id: i64) -> Self {
+ Self {
+ identifier,
+ field_id,
+ row_id,
+ }
+ }
+
+ pub fn identifier(&self) -> &Identifier {
+ &self.identifier
+ }
+
+ pub fn field_id(&self) -> i32 {
+ self.field_id
+ }
+
+ pub fn row_id(&self) -> i64 {
+ self.row_id
+ }
+
+ pub fn serialize(&self) -> crate::Result<Vec<u8>> {
+ if self.identifier.database() == UNKNOWN_DATABASE {
+ return Err(Error::DataInvalid {
+ message: format!(
+ "Blob view upstream table identifier must include database
name: {}",
+ self.identifier.full_name()
+ ),
+ source: None,
+ });
+ }
+
+ let identifier = self.identifier.full_name();
+ let identifier_bytes = identifier.as_bytes();
+ let identifier_length =
+ i32::try_from(identifier_bytes.len()).map_err(|e|
Error::DataInvalid {
+ message: format!("BlobViewStruct identifier is too long:
{identifier}"),
+ source: Some(Box::new(e)),
+ })?;
+ let total_size = 1 + 8 + 4 + identifier_bytes.len() + 4 + 8;
+ let mut buf = Vec::with_capacity(total_size);
+
+ buf.push(CURRENT_VERSION);
+ buf.extend_from_slice(&MAGIC.to_le_bytes());
+ buf.extend_from_slice(&identifier_length.to_le_bytes());
+ buf.extend_from_slice(identifier_bytes);
+ buf.extend_from_slice(&self.field_id.to_le_bytes());
+ buf.extend_from_slice(&self.row_id.to_le_bytes());
+ Ok(buf)
+ }
+
+ pub fn deserialize(bytes: &[u8]) -> crate::Result<Self> {
+ if bytes.is_empty() {
+ return Err(invalid_payload("too short"));
+ }
+
+ let version = bytes[0];
+ if version != CURRENT_VERSION {
+ return Err(Error::Unsupported {
+ message: format!(
+ "Expecting BlobViewStruct version to be {CURRENT_VERSION},
but found {version}."
+ ),
+ });
+ }
+
+ let mut pos = 1;
+ if bytes.len() < pos + 8 {
+ return Err(invalid_payload("too short"));
+ }
+ let magic = u64::from_le_bytes(bytes[pos..pos +
8].try_into().unwrap());
+ if magic != MAGIC {
+ return Err(Error::DataInvalid {
+ message: format!(
+ "Invalid BlobViewStruct: missing magic header. Expected
magic: {MAGIC}, but found: {magic}"
+ ),
+ source: None,
+ });
+ }
+ pos += 8;
+
+ if bytes.len() < pos + 4 {
+ return Err(invalid_payload("too short"));
+ }
+ let identifier_length = i32::from_le_bytes(bytes[pos..pos +
4].try_into().unwrap());
+ if identifier_length < 0 {
+ return Err(invalid_payload(&format!(
+ "negative identifier length: {identifier_length}"
+ )));
+ }
+ let identifier_length = identifier_length as usize;
+ pos += 4;
+
+ if bytes.len() < pos + identifier_length + 4 + 8 {
+ return Err(invalid_payload("identifier length exceeds data size"));
+ }
+ let identifier_text = String::from_utf8(bytes[pos..pos +
identifier_length].to_vec())
+ .map_err(|e| Error::DataInvalid {
+ message: format!("Invalid UTF-8 in BlobViewStruct identifier:
{e}"),
+ source: Some(Box::new(e)),
+ })?;
+ pos += identifier_length;
+
+ let field_id = i32::from_le_bytes(bytes[pos..pos +
4].try_into().unwrap());
+ pos += 4;
+ let row_id = i64::from_le_bytes(bytes[pos..pos +
8].try_into().unwrap());
+ pos += 8;
+
+ if pos != bytes.len() {
+ return Err(invalid_payload("trailing bytes"));
+ }
+
+ Ok(Self {
+ identifier: parse_identifier(&identifier_text)?,
+ field_id,
+ row_id,
+ })
+ }
+
+ pub fn is_blob_view_struct(bytes: &[u8]) -> bool {
+ if bytes.len() < 9 {
+ return false;
+ }
+ let version = bytes[0];
+ if version != CURRENT_VERSION {
+ return false;
+ }
+ let magic = u64::from_le_bytes(bytes[1..9].try_into().unwrap());
+ magic == MAGIC
+ }
+}
+
+fn parse_identifier(full_name: &str) -> crate::Result<Identifier> {
+ let Some((database, object)) = full_name.rsplit_once('.') else {
+ return Err(Error::DataInvalid {
+ message: format!(
+ "BlobViewStruct identifier must be 'database.table', got:
{full_name}"
+ ),
+ source: None,
+ });
+ };
+ Ok(Identifier::new(database, object))
+}
+
+fn invalid_payload(message: &str) -> Error {
+ Error::DataInvalid {
+ message: format!("Invalid BlobViewStruct data: {message}"),
+ source: None,
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_serialize_deserialize_roundtrip() {
+ let view = BlobViewStruct::new(Identifier::new("db", "source"), 3, 42);
+ let bytes = view.serialize().unwrap();
+
+ assert!(BlobViewStruct::is_blob_view_struct(&bytes));
+ assert_eq!(BlobViewStruct::deserialize(&bytes).unwrap(), view);
+ }
+
+ #[test]
+ fn test_rejects_unknown_database_on_serialize() {
+ let view = BlobViewStruct::new(Identifier::new(UNKNOWN_DATABASE,
"source"), 3, 42);
+ assert!(view.serialize().is_err());
+ }
+
+ #[test]
+ fn test_rejects_trailing_bytes() {
+ let mut bytes = BlobViewStruct::new(Identifier::new("db", "source"),
3, 42)
+ .serialize()
+ .unwrap();
+ bytes.push(1);
+
+ let err = BlobViewStruct::deserialize(&bytes).unwrap_err();
+ assert!(
+ matches!(err, Error::DataInvalid { message, .. } if
message.contains("trailing bytes"))
+ );
+ }
+}
diff --git a/crates/paimon/src/spec/core_options.rs
b/crates/paimon/src/spec/core_options.rs
index 8585e37..a28b0fa 100644
--- a/crates/paimon/src/spec/core_options.rs
+++ b/crates/paimon/src/spec/core_options.rs
@@ -94,6 +94,8 @@ const DEFAULT_GLOBAL_INDEX_ROW_COUNT_PER_SHARD: i64 = 100_000;
const DEFAULT_GLOBAL_INDEX_FALLBACK_SCAN_MAX_SIZE: i64 = 256 * 1024 * 1024;
const BLOB_AS_DESCRIPTOR_OPTION: &str = "blob-as-descriptor";
const BLOB_DESCRIPTOR_FIELD_OPTION: &str = "blob-descriptor-field";
+const BLOB_VIEW_FIELD_OPTION: &str = "blob-view-field";
+pub const BLOB_VIEW_RESOLVE_ENABLED_OPTION: &str = "blob-view.resolve.enabled";
/// Merge engine for primary-key tables.
///
@@ -834,9 +836,43 @@ impl<'a> CoreOptions<'a> {
/// Comma-separated BLOB field names stored as serialized BlobDescriptor
/// bytes inline in normal data files (no .blob files for these fields).
pub fn blob_descriptor_fields(&self) -> HashSet<String> {
+ self.parse_csv_set(BLOB_DESCRIPTOR_FIELD_OPTION)
+ }
+
+ /// Comma-separated BLOB field names stored as serialized BlobViewStruct
+ /// bytes inline in normal data files.
+ pub fn blob_view_fields(&self) -> HashSet<String> {
+ self.parse_csv_set(BLOB_VIEW_FIELD_OPTION)
+ }
+
+ /// Whether blob-view fields should resolve upstream BLOB values on reads.
+ /// Default is true, matching Java `CoreOptions.BLOB_VIEW_RESOLVE_ENABLED`.
+ pub fn blob_view_resolve_enabled(&self) -> bool {
+ self.options
+ .get(BLOB_VIEW_RESOLVE_ENABLED_OPTION)
+ .map(|v| v.eq_ignore_ascii_case("true"))
+ .unwrap_or(true)
+ }
+
+ /// BLOB fields stored inline in normal data files: descriptor fields plus
+ /// view fields. Non-inline BLOB fields are written to dedicated `.blob`
+ /// files.
+ pub fn blob_inline_fields(&self) -> HashSet<String> {
+ let mut fields = self.blob_descriptor_fields();
+ fields.extend(self.blob_view_fields());
+ fields
+ }
+
+ fn parse_csv_set(&self, option_name: &'static str) -> HashSet<String> {
self.options
- .get(BLOB_DESCRIPTOR_FIELD_OPTION)
- .map(|s| s.split(',').map(|f| f.trim().to_string()).collect())
+ .get(option_name)
+ .map(|s| {
+ s.split(',')
+ .map(str::trim)
+ .filter(|f| !f.is_empty())
+ .map(ToString::to_string)
+ .collect()
+ })
.unwrap_or_default()
}
}
@@ -1409,6 +1445,46 @@ mod tests {
assert_eq!(core.write_parquet_buffer_size(), 32 * 1024 * 1024);
}
+ #[test]
+ fn test_blob_view_options() {
+ let options = HashMap::from([
+ (
+ BLOB_DESCRIPTOR_FIELD_OPTION.to_string(),
+ "thumb, payload".to_string(),
+ ),
+ (
+ BLOB_VIEW_FIELD_OPTION.to_string(),
+ "image, video".to_string(),
+ ),
+ ]);
+ let core = CoreOptions::new(&options);
+
+ assert_eq!(
+ core.blob_descriptor_fields(),
+ HashSet::from(["thumb".to_string(), "payload".to_string()])
+ );
+ assert_eq!(
+ core.blob_view_fields(),
+ HashSet::from(["image".to_string(), "video".to_string()])
+ );
+ assert_eq!(
+ core.blob_inline_fields(),
+ HashSet::from([
+ "thumb".to_string(),
+ "payload".to_string(),
+ "image".to_string(),
+ "video".to_string()
+ ])
+ );
+ assert!(core.blob_view_resolve_enabled());
+
+ let disabled = HashMap::from([(
+ BLOB_VIEW_RESOLVE_ENABLED_OPTION.to_string(),
+ "false".to_string(),
+ )]);
+ assert!(!CoreOptions::new(&disabled).blob_view_resolve_enabled());
+ }
+
#[test]
fn test_validate_scan_options_rejects_unsupported() {
for key in [
diff --git a/crates/paimon/src/spec/mod.rs b/crates/paimon/src/spec/mod.rs
index eb4dc54..9e8b07b 100644
--- a/crates/paimon/src/spec/mod.rs
+++ b/crates/paimon/src/spec/mod.rs
@@ -25,6 +25,9 @@ pub use binary_row::*;
mod blob_descriptor;
pub use blob_descriptor::BlobDescriptor;
+mod blob_view_struct;
+pub use blob_view_struct::BlobViewStruct;
+
mod data_file;
pub use data_file::*;
diff --git a/crates/paimon/src/table/blob_resolver.rs
b/crates/paimon/src/table/blob_resolver.rs
new file mode 100644
index 0000000..bbbf24b
--- /dev/null
+++ b/crates/paimon/src/table/blob_resolver.rs
@@ -0,0 +1,227 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use crate::io::{FileIO, FileRead};
+use crate::spec::BlobDescriptor;
+use crate::Result;
+use arrow_array::builder::BinaryBuilder;
+use arrow_array::{Array, BinaryArray};
+use bytes::Bytes;
+use std::collections::HashMap;
+
+const BLOB_RANGE_MERGE_GAP: u64 = 64 * 1024;
+const BLOB_RANGE_MERGE_MAX_SPAN: u64 = 8 * 1024 * 1024;
+
+/// For each row in a blob column, if the value is a serialized
`BlobDescriptor`,
+/// resolve it by reading the actual data from the referenced
URI+offset+length.
+/// Raw data values are passed through unchanged.
+pub(crate) async fn resolve_blob_column(
+ col: &BinaryArray,
+ file_io: &FileIO,
+) -> Result<BinaryArray> {
+ let mut needs_resolve = false;
+ for i in 0..col.len() {
+ if !col.is_null(i) && BlobDescriptor::is_blob_descriptor(col.value(i))
{
+ needs_resolve = true;
+ break;
+ }
+ }
+
+ if !needs_resolve {
+ return Ok(col.clone());
+ }
+
+ let mut cells = Vec::with_capacity(col.len());
+ let mut requests_by_uri: HashMap<String, Vec<BlobReadRequest>> =
HashMap::new();
+ let mut value_capacity = 0usize;
+
+ for row in 0..col.len() {
+ if col.is_null(row) {
+ cells.push(ResolvedBlobCell::Null);
+ continue;
+ }
+
+ let value = col.value(row);
+ if BlobDescriptor::is_blob_descriptor(value) {
+ let desc = BlobDescriptor::deserialize(value)?;
+ let offset = u64::try_from(desc.offset()).map_err(|e|
crate::Error::DataInvalid {
+ message: format!(
+ "BlobDescriptor offset must be non-negative: {}",
+ desc.offset()
+ ),
+ source: Some(Box::new(e)),
+ })?;
+ let length = u64::try_from(desc.length()).map_err(|e|
crate::Error::DataInvalid {
+ message: format!(
+ "BlobDescriptor length must be non-negative: {}",
+ desc.length()
+ ),
+ source: Some(Box::new(e)),
+ })?;
+ offset
+ .checked_add(length)
+ .ok_or_else(|| crate::Error::DataInvalid {
+ message: format!(
+ "BlobDescriptor range overflows u64: offset={offset},
length={length}"
+ ),
+ source: None,
+ })?;
+ value_capacity = value_capacity.saturating_add(length as usize);
+ requests_by_uri
+ .entry(desc.uri().to_string())
+ .or_default()
+ .push(BlobReadRequest {
+ row,
+ offset,
+ length,
+ });
+ cells.push(ResolvedBlobCell::Null);
+ } else {
+ value_capacity = value_capacity.saturating_add(value.len());
+ cells.push(ResolvedBlobCell::Value(Bytes::copy_from_slice(value)));
+ }
+ }
+
+ let mut readers: HashMap<String, Box<dyn FileRead>> = HashMap::new();
+ for (uri, requests) in requests_by_uri {
+ if !readers.contains_key(&uri) {
+ let input = file_io.new_input(&uri)?;
+ let reader = input.reader().await?;
+ readers.insert(uri.clone(), Box::new(reader));
+ }
+ let reader = readers.get(&uri).unwrap();
+ for merged in merge_blob_read_requests(requests) {
+ let data = reader.read(merged.start..merged.end).await?;
+ for request in merged.requests {
+ let start = (request.offset - merged.start) as usize;
+ let end = start + request.length as usize;
+ cells[request.row] =
ResolvedBlobCell::Value(data.slice(start..end));
+ }
+ }
+ }
+
+ let mut builder = BinaryBuilder::with_capacity(col.len(), value_capacity);
+ for cell in cells {
+ match cell {
+ ResolvedBlobCell::Null => builder.append_null(),
+ ResolvedBlobCell::Value(value) =>
builder.append_value(value.as_ref()),
+ }
+ }
+ Ok(builder.finish())
+}
+
+#[derive(Debug)]
+enum ResolvedBlobCell {
+ Null,
+ Value(Bytes),
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+struct BlobReadRequest {
+ row: usize,
+ offset: u64,
+ length: u64,
+}
+
+#[derive(Debug, PartialEq, Eq)]
+struct MergedBlobRead {
+ start: u64,
+ end: u64,
+ requests: Vec<BlobReadRequest>,
+}
+
+fn merge_blob_read_requests(mut requests: Vec<BlobReadRequest>) ->
Vec<MergedBlobRead> {
+ if requests.is_empty() {
+ return Vec::new();
+ }
+
+ requests.sort_by_key(|request| (request.offset, request.length,
request.row));
+ let mut merged = Vec::new();
+ let mut current = MergedBlobRead {
+ start: requests[0].offset,
+ end: requests[0].offset + requests[0].length,
+ requests: vec![requests[0].clone()],
+ };
+
+ for request in requests.into_iter().skip(1) {
+ let request_end = request.offset + request.length;
+ let close_enough = current
+ .end
+ .checked_add(BLOB_RANGE_MERGE_GAP)
+ .is_some_and(|merge_limit| request.offset <= merge_limit);
+ let merged_end = current.end.max(request_end);
+ let merged_span = merged_end - current.start;
+ if close_enough && merged_span <= BLOB_RANGE_MERGE_MAX_SPAN {
+ current.end = merged_end;
+ current.requests.push(request);
+ } else {
+ merged.push(current);
+ current = MergedBlobRead {
+ start: request.offset,
+ end: request_end,
+ requests: vec![request],
+ };
+ }
+ }
+ merged.push(current);
+ merged
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_merge_blob_read_requests_merges_nearby_ranges() {
+ let merged = merge_blob_read_requests(vec![
+ BlobReadRequest {
+ row: 2,
+ offset: 120,
+ length: 5,
+ },
+ BlobReadRequest {
+ row: 0,
+ offset: 0,
+ length: 10,
+ },
+ BlobReadRequest {
+ row: 1,
+ offset: 10,
+ length: 8,
+ },
+ BlobReadRequest {
+ row: 3,
+ offset: BLOB_RANGE_MERGE_MAX_SPAN + 1,
+ length: 4,
+ },
+ ]);
+
+ assert_eq!(merged.len(), 2);
+ assert_eq!(merged[0].start, 0);
+ assert_eq!(merged[0].end, 125);
+ assert_eq!(
+ merged[0]
+ .requests
+ .iter()
+ .map(|request| request.row)
+ .collect::<Vec<_>>(),
+ vec![0, 1, 2]
+ );
+ assert_eq!(merged[1].start, BLOB_RANGE_MERGE_MAX_SPAN + 1);
+ assert_eq!(merged[1].end, BLOB_RANGE_MERGE_MAX_SPAN + 5);
+ }
+}
diff --git a/crates/paimon/src/table/data_evolution_reader.rs
b/crates/paimon/src/table/data_evolution_reader.rs
index ccb9cae..1c5e9a8 100644
--- a/crates/paimon/src/table/data_evolution_reader.rs
+++ b/crates/paimon/src/table/data_evolution_reader.rs
@@ -23,13 +23,15 @@ use crate::arrow::build_target_arrow_schema;
use crate::arrow::format::FilePredicates;
use crate::deletion_vector::{DeletionVector, DeletionVectorFactory};
use crate::io::FileIO;
-use crate::spec::{DataField, DataFileMeta, DataType, Predicate,
ROW_ID_FIELD_NAME};
+use crate::spec::{
+ BigIntType, BlobDescriptor, BlobViewStruct, DataField, DataFileMeta,
DataType, Predicate,
+ ROW_ID_FIELD_ID, ROW_ID_FIELD_NAME,
+};
use crate::table::dedicated_format_file_writer::is_blob_file_name;
use crate::table::schema_manager::SchemaManager;
-use crate::table::ArrowRecordBatchStream;
-use crate::table::RowRange;
+use crate::table::{ArrowRecordBatchStream, RESTEnv, RowRange};
use crate::{DataSplit, Error};
-use arrow_array::{Array, Int64Array, RecordBatch};
+use arrow_array::{Array, BinaryArray, Int64Array, RecordBatch};
use async_stream::try_stream;
use futures::StreamExt;
use roaring::RoaringBitmap;
@@ -101,6 +103,9 @@ pub(crate) struct DataEvolutionReader {
predicates: Vec<Predicate>,
blob_as_descriptor: bool,
blob_descriptor_fields: HashSet<String>,
+ blob_view_fields: HashSet<String>,
+ blob_view_resolve_enabled: bool,
+ blob_view_rest_env: Option<RESTEnv>,
}
impl DataEvolutionReader {
@@ -114,6 +119,9 @@ impl DataEvolutionReader {
predicates: Vec<Predicate>,
blob_as_descriptor: bool,
blob_descriptor_fields: HashSet<String>,
+ blob_view_fields: HashSet<String>,
+ blob_view_resolve_enabled: bool,
+ blob_view_rest_env: Option<RESTEnv>,
) -> crate::Result<Self> {
let row_id_index = read_type.iter().position(|f| f.name() ==
ROW_ID_FIELD_NAME);
let file_read_type: Vec<DataField> = read_type
@@ -125,7 +133,7 @@ impl DataEvolutionReader {
// Widen the file read set with predicate columns not already projected
// so the residual filter can evaluate every leaf. Extras land strictly
- // at the END: `filter_and_project_output` relies on that to project
the
+ // at the END: `project_output` relies on that to project the
// final batch back to `read_type` by prefix. Predicate leaf indices
// point into the table schema, so `file_fields` = `table_fields`.
let file_predicates = (!predicates.is_empty()).then(|| FilePredicates {
@@ -154,6 +162,9 @@ impl DataEvolutionReader {
predicates,
blob_as_descriptor,
blob_descriptor_fields,
+ blob_view_fields,
+ blob_view_resolve_enabled,
+ blob_view_rest_env,
})
}
@@ -162,6 +173,17 @@ impl DataEvolutionReader {
let splits: Vec<DataSplit> = data_splits.to_vec();
Ok(try_stream! {
+ let resolve_blob_views = !self.blob_view_read_fields().is_empty();
+ let descriptor_fields =
self.descriptor_fields_to_resolve(resolve_blob_views);
+ let filter_before_blob_resolution =
+ self.can_filter_before_blob_resolution(resolve_blob_views,
&descriptor_fields);
+ let blob_view_lookup = self
+ .preload_blob_view_lookup(&splits,
filter_before_blob_resolution)
+ .await?;
+ let descriptor_fields =
self.descriptor_fields_to_resolve(blob_view_lookup.is_some());
+ let filter_before_blob_resolution =
+
self.can_filter_before_blob_resolution(blob_view_lookup.is_some(),
&descriptor_fields);
+
let file_reader = DataFileReader::new(
self.file_io.clone(),
self.schema_manager.clone(),
@@ -224,32 +246,27 @@ impl DataEvolutionReader {
)?;
while let Some(batch) = stream.next().await {
let batch = batch?;
- let batch = if !self.blob_as_descriptor &&
!self.blob_descriptor_fields.is_empty() {
- resolve_descriptor_columns(batch,
&self.blob_descriptor_fields, &self.file_io).await?
- } else {
- batch
- };
let num_rows = batch.num_rows();
- if let Some(idx) = self.row_id_index {
+ let batch = if let Some(idx) = self.row_id_index {
if !has_row_id {
- yield self.filter_and_project_output(
- append_null_row_id_column(batch, idx,
&self.wide_output_schema)?,
- )?;
+ append_null_row_id_column(batch, idx,
&self.wide_output_schema)?
} else if let Some(ref ids) = selected_row_ids
{
- yield self.filter_and_project_output(
- attach_row_id(batch, idx, ids, &mut
row_id_offset, &self.wide_output_schema)?,
- )?;
+ attach_row_id(batch, idx, ids, &mut
row_id_offset, &self.wide_output_schema)?
} else {
let row_ids: Vec<i64> =
(row_id_cursor..row_id_cursor + num_rows as i64).collect();
row_id_cursor += num_rows as i64;
let array: Arc<dyn arrow_array::Array> =
Arc::new(Int64Array::from(row_ids));
- yield self.filter_and_project_output(
- insert_column_at(batch, array, idx,
&self.wide_output_schema)?,
- )?;
+ insert_column_at(batch, array, idx,
&self.wide_output_schema)?
}
} else {
- yield self.filter_and_project_output(batch)?;
- }
+ batch
+ };
+ yield self.finish_wide_batch(
+ batch,
+ blob_view_lookup.as_ref(),
+ &descriptor_fields,
+ filter_before_blob_resolution,
+ ).await?;
}
}
} else {
@@ -298,22 +315,24 @@ impl DataEvolutionReader {
while let Some(batch) = merge_stream.next().await {
let batch = batch?;
let num_rows = batch.num_rows();
- if let Some(idx) = self.row_id_index {
+ let batch = if let Some(idx) = self.row_id_index {
if let Some(ref ids) = selected_row_ids {
- yield self.filter_and_project_output(
- attach_row_id(batch, idx, ids, &mut
row_id_offset, &self.wide_output_schema)?,
- )?;
+ attach_row_id(batch, idx, ids, &mut
row_id_offset, &self.wide_output_schema)?
} else {
let row_ids: Vec<i64> =
(row_id_cursor..row_id_cursor + num_rows as i64).collect();
row_id_cursor += num_rows as i64;
let array: Arc<dyn arrow_array::Array> =
Arc::new(Int64Array::from(row_ids));
- yield self.filter_and_project_output(
- insert_column_at(batch, array, idx,
&self.wide_output_schema)?,
- )?;
+ insert_column_at(batch, array, idx,
&self.wide_output_schema)?
}
} else {
- yield self.filter_and_project_output(batch)?;
- }
+ batch
+ };
+ yield self.finish_wide_batch(
+ batch,
+ blob_view_lookup.as_ref(),
+ &descriptor_fields,
+ filter_before_blob_resolution,
+ ).await?;
}
}
}
@@ -321,40 +340,39 @@ impl DataEvolutionReader {
.boxed())
}
- /// Apply the residual predicates to a wide batch (post `_ROW_ID` attach)
- /// and project it back to the caller's read_type.
+ /// Apply the residual predicates to a wide batch (post `_ROW_ID` attach).
///
- /// Layout invariant: the first `output_schema.fields().len()` columns of
- /// `batch` are exactly the original read_type columns — extras were
- /// appended at the end by `widen_scan_fields`, and `_ROW_ID` insertion at
- /// `row_id_index` keeps them trailing. `_ROW_ID` correctness: ids are
- /// attached before this filter, so surviving rows keep their original ids.
- ///
- /// No predicates implies no widening (extras only come from predicates),
- /// so the empty-predicate fast path returns the batch untouched.
- fn filter_and_project_output(&self, batch: RecordBatch) ->
crate::Result<RecordBatch> {
- let filtered =
- if self.predicates.is_empty() {
- batch
- } else {
- let mask = crate::arrow::residual::evaluate_predicates_mask(
- &batch,
- &self.predicates,
- &self.table_fields,
- &self.wide_file_read_type,
- )?;
- match mask {
- Some(mask) =>
arrow_select::filter::filter_record_batch(&batch, &mask)
- .map_err(|e| Error::DataInvalid {
- message: format!(
- "Failed to filter data-evolution batch by
predicates: {e}"
- ),
- source: Some(Box::new(e)),
- })?,
- None => batch,
+ /// `_ROW_ID` correctness: ids are attached before this filter, so
surviving
+ /// rows keep their original ids.
+ fn filter_wide_batch(&self, batch: RecordBatch) ->
crate::Result<RecordBatch> {
+ if self.predicates.is_empty() {
+ return Ok(batch);
+ }
+
+ let mask = crate::arrow::residual::evaluate_predicates_mask(
+ &batch,
+ &self.predicates,
+ &self.table_fields,
+ &self.wide_file_read_type,
+ )?;
+ match mask {
+ Some(mask) => arrow_select::filter::filter_record_batch(&batch,
&mask).map_err(|e| {
+ Error::DataInvalid {
+ message: format!("Failed to filter data-evolution batch by
predicates: {e}"),
+ source: Some(Box::new(e)),
}
- };
+ }),
+ None => Ok(batch),
+ }
+ }
+ /// Project a wide batch back to the caller's read_type.
+ ///
+ /// Layout invariant: the first `output_schema.fields().len()` columns of
+ /// `batch` are exactly the original read_type columns. Extras were
appended
+ /// at the end by `widen_scan_fields`, and `_ROW_ID` insertion at
+ /// `row_id_index` keeps them trailing.
+ fn project_output(&self, filtered: RecordBatch) ->
crate::Result<RecordBatch> {
let final_width = self.output_schema.fields().len();
if filtered.num_columns() == final_width {
return Ok(filtered);
@@ -375,6 +393,126 @@ impl DataEvolutionReader {
})
}
+ async fn finish_wide_batch(
+ &self,
+ batch: RecordBatch,
+ blob_view_lookup: Option<&BlobViewLookup>,
+ descriptor_fields: &HashSet<String>,
+ filter_before_blob_resolution: bool,
+ ) -> crate::Result<RecordBatch> {
+ let mut batch = if filter_before_blob_resolution {
+ self.filter_wide_batch(batch)?
+ } else {
+ batch
+ };
+ if filter_before_blob_resolution && batch.num_rows() == 0 {
+ return self.project_output(batch);
+ }
+
+ batch = self.resolve_blob_view_columns(batch, blob_view_lookup)?;
+ let mut batch = if !self.blob_as_descriptor &&
!descriptor_fields.is_empty() {
+ resolve_descriptor_columns(batch, descriptor_fields,
&self.file_io).await?
+ } else {
+ batch
+ };
+
+ if !filter_before_blob_resolution {
+ batch = self.filter_wide_batch(batch)?;
+ }
+ self.project_output(batch)
+ }
+
+ fn can_filter_before_blob_resolution(
+ &self,
+ resolve_blob_views: bool,
+ descriptor_fields: &HashSet<String>,
+ ) -> bool {
+ let mut transformed_fields = HashSet::new();
+ if resolve_blob_views {
+ transformed_fields.extend(self.blob_view_fields.iter().cloned());
+ }
+ if !self.blob_as_descriptor {
+ transformed_fields.extend(descriptor_fields.iter().cloned());
+ }
+ transformed_fields.is_empty()
+ || !predicates_reference_any_field(
+ &self.predicates,
+ &transformed_fields,
+ &self.table_fields,
+ )
+ }
+
+ fn blob_view_read_fields(&self) -> Vec<DataField> {
+ if !self.blob_view_resolve_enabled ||
self.blob_view_rest_env.is_none() {
+ return Vec::new();
+ }
+ self.wide_file_read_type
+ .iter()
+ .filter(|field| self.blob_view_fields.contains(field.name()))
+ .cloned()
+ .collect()
+ }
+
+ async fn preload_blob_view_lookup(
+ &self,
+ splits: &[DataSplit],
+ filter_before_blob_resolution: bool,
+ ) -> crate::Result<Option<BlobViewLookup>> {
+ let view_fields = self.blob_view_read_fields();
+ if view_fields.is_empty() {
+ return Ok(None);
+ }
+ let Some(rest_env) = self.blob_view_rest_env.clone() else {
+ return Ok(None);
+ };
+
+ let prescan = DataEvolutionReader::new(
+ self.file_io.clone(),
+ self.schema_manager.clone(),
+ self.table_schema_id,
+ self.table_fields.clone(),
+ view_fields.clone(),
+ if filter_before_blob_resolution {
+ self.predicates.clone()
+ } else {
+ Vec::new()
+ },
+ true,
+ HashSet::new(),
+ HashSet::new(),
+ false,
+ None,
+ )?;
+ let mut stream = prescan.read(splits)?;
+ let mut view_structs = HashSet::new();
+ while let Some(batch) = stream.next().await {
+ let batch = batch?;
+ collect_blob_view_structs(&batch, &self.blob_view_fields, &mut
view_structs)?;
+ }
+
+ BlobViewLookup::load(rest_env, view_structs).await.map(Some)
+ }
+
+ fn descriptor_fields_to_resolve(&self, resolve_blob_views: bool) ->
HashSet<String> {
+ let mut fields = self.blob_descriptor_fields.clone();
+ if resolve_blob_views {
+ fields.extend(self.blob_view_fields.iter().cloned());
+ }
+ fields
+ }
+
+ fn resolve_blob_view_columns(
+ &self,
+ batch: RecordBatch,
+ lookup: Option<&BlobViewLookup>,
+ ) -> crate::Result<RecordBatch> {
+ let Some(lookup) = lookup else {
+ return Ok(batch);
+ };
+
+ replace_blob_view_columns(batch, &self.blob_view_fields, lookup)
+ }
+
/// Merge multiple logical sources column-wise for data evolution.
///
/// Normal partial-column files remain one source per file. Rolling `.blob`
@@ -398,13 +536,10 @@ impl DataEvolutionReader {
let split = split.clone();
let prepared_group = prepared_group.clone();
// The merge plan reads the WIDE field set so widened predicate columns
- // are materialized for the residual filter. Blob-descriptor note: if a
- // widened predicate column is a blob-descriptor field,
- // `resolve_descriptor_columns` resolves it by name in the wide batch
- // before the residual runs, so with `blob_as_descriptor = false` the
- // residual compares resolved bytes; with `true` it compares descriptor
- // bytes. Resolution before filtering can do IO for rows the filter
then
- // drops — accepted trade-off.
+ // are materialized for the residual filter. Blob descriptor/view
+ // resolution is intentionally deferred to `finish_wide_batch`, where
+ // the reader can filter first when predicates do not depend on fields
+ // whose values would be transformed by resolution.
let read_type = self.wide_file_read_type.clone();
let table_fields = self.table_fields.clone();
let blob_descriptor_fields = self.blob_descriptor_fields.clone();
@@ -580,11 +715,6 @@ impl DataEvolutionReader {
source: Some(Box::new(e)),
}
})?;
- let merged = if !blob_as_descriptor &&
!blob_descriptor_fields.is_empty() {
- resolve_descriptor_columns(merged,
&blob_descriptor_fields, &file_io).await?
- } else {
- merged
- };
yield merged;
}
}
@@ -608,9 +738,7 @@ async fn resolve_descriptor_columns(
.as_any()
.downcast_ref::<arrow_array::BinaryArray>()
{
- let resolved =
-
super::dedicated_format_file_writer::resolve_blob_column(bin_col, file_io)
- .await?;
+ let resolved =
super::blob_resolver::resolve_blob_column(bin_col, file_io).await?;
columns.push(Arc::new(resolved));
changed = true;
continue;
@@ -629,6 +757,309 @@ async fn resolve_descriptor_columns(
})
}
+fn predicates_reference_any_field(
+ predicates: &[Predicate],
+ field_names: &HashSet<String>,
+ table_fields: &[DataField],
+) -> bool {
+ predicates
+ .iter()
+ .any(|predicate| predicate_references_any_field(predicate,
field_names, table_fields))
+}
+
+fn predicate_references_any_field(
+ predicate: &Predicate,
+ field_names: &HashSet<String>,
+ table_fields: &[DataField],
+) -> bool {
+ match predicate {
+ Predicate::Leaf { column, index, .. } => {
+ field_names.contains(column)
+ || table_fields
+ .get(*index)
+ .is_some_and(|field| field_names.contains(field.name()))
+ }
+ Predicate::And(children) | Predicate::Or(children) => children
+ .iter()
+ .any(|child| predicate_references_any_field(child, field_names,
table_fields)),
+ Predicate::Not(inner) => predicate_references_any_field(inner,
field_names, table_fields),
+ Predicate::AlwaysTrue | Predicate::AlwaysFalse => false,
+ }
+}
+
+fn collect_blob_view_structs(
+ batch: &RecordBatch,
+ blob_view_fields: &HashSet<String>,
+ view_structs: &mut HashSet<BlobViewStruct>,
+) -> crate::Result<()> {
+ for (idx, field) in batch.schema().fields().iter().enumerate() {
+ if !blob_view_fields.contains(field.name()) {
+ continue;
+ }
+ let col = binary_column(batch, idx, field.name())?;
+ for row in 0..col.len() {
+ if col.is_null(row) {
+ continue;
+ }
+ let value = col.value(row);
+ if !BlobViewStruct::is_blob_view_struct(value) {
+ return Err(Error::DataInvalid {
+ message: format!(
+ "blob-view-field '{}' requires blob field value to be
a serialized BlobViewStruct",
+ field.name()
+ ),
+ source: None,
+ });
+ }
+ view_structs.insert(BlobViewStruct::deserialize(value)?);
+ }
+ }
+ Ok(())
+}
+
+fn replace_blob_view_columns(
+ batch: RecordBatch,
+ blob_view_fields: &HashSet<String>,
+ lookup: &BlobViewLookup,
+) -> crate::Result<RecordBatch> {
+ let schema = batch.schema();
+ let mut columns: Vec<Arc<dyn arrow_array::Array>> =
Vec::with_capacity(batch.num_columns());
+ let mut changed = false;
+
+ for (idx, field) in schema.fields().iter().enumerate() {
+ if !blob_view_fields.contains(field.name()) {
+ columns.push(batch.column(idx).clone());
+ continue;
+ }
+
+ let col = binary_column(&batch, idx, field.name())?;
+ let mut builder = arrow_array::builder::BinaryBuilder::new();
+ for row in 0..col.len() {
+ if col.is_null(row) {
+ builder.append_null();
+ continue;
+ }
+
+ let value = col.value(row);
+ if !BlobViewStruct::is_blob_view_struct(value) {
+ return Err(Error::DataInvalid {
+ message: format!(
+ "blob-view-field '{}' requires blob field value to be
a serialized BlobViewStruct",
+ field.name()
+ ),
+ source: None,
+ });
+ }
+ let view_struct = BlobViewStruct::deserialize(value)?;
+ match lookup.descriptor(&view_struct)? {
+ None => builder.append_null(),
+ Some(descriptor) =>
builder.append_value(descriptor.serialize()),
+ }
+ }
+ columns.push(Arc::new(builder.finish()));
+ changed = true;
+ }
+
+ if !changed {
+ return Ok(batch);
+ }
+
+ RecordBatch::try_new(schema, columns).map_err(|e| Error::UnexpectedError {
+ message: format!("Failed to rebuild RecordBatch after resolving blob
views: {e}"),
+ source: Some(Box::new(e)),
+ })
+}
+
+fn binary_column<'a>(
+ batch: &'a RecordBatch,
+ idx: usize,
+ field_name: &str,
+) -> crate::Result<&'a BinaryArray> {
+ batch
+ .column(idx)
+ .as_any()
+ .downcast_ref::<BinaryArray>()
+ .ok_or_else(|| Error::DataInvalid {
+ message: format!("blob-view-field '{field_name}' requires a
BinaryArray column"),
+ source: None,
+ })
+}
+
+#[derive(Debug, Default)]
+struct BlobViewLookup {
+ descriptors: HashMap<BlobViewStruct, Option<BlobDescriptor>>,
+}
+
+impl BlobViewLookup {
+ async fn load(rest_env: RESTEnv, view_structs: HashSet<BlobViewStruct>) ->
crate::Result<Self> {
+ if view_structs.is_empty() {
+ return Ok(Self::default());
+ }
+
+ let mut by_table_and_field: HashMap<
+ (crate::catalog::Identifier, i32),
+ Vec<BlobViewStruct>,
+ > = HashMap::new();
+ for view_struct in view_structs {
+ by_table_and_field
+ .entry((view_struct.identifier().clone(),
view_struct.field_id()))
+ .or_default()
+ .push(view_struct);
+ }
+
+ let mut lookup = Self::default();
+ for ((identifier, field_id), refs) in by_table_and_field {
+ let table = rest_env.get_table(&identifier).await?;
+ let field = table
+ .schema()
+ .fields()
+ .iter()
+ .find(|field| field.id() == field_id)
+ .cloned()
+ .ok_or_else(|| Error::DataInvalid {
+ message: format!(
+ "Cannot find blob field id {field_id} in upstream
table {}",
+ identifier.full_name()
+ ),
+ source: None,
+ })?;
+ if !field.data_type().is_blob_type() {
+ return Err(Error::DataInvalid {
+ message: format!(
+ "Field id {field_id} in upstream table {} is not a
BLOB field",
+ identifier.full_name()
+ ),
+ source: None,
+ });
+ }
+
+ let mut options = HashMap::new();
+ options.insert("blob-as-descriptor".to_string(),
"true".to_string());
+ let table = table.copy_with_options(options);
+ let row_ranges = row_ranges_for_blob_view_refs(&refs);
+ let mut read_builder = table.new_read_builder();
+ read_builder.with_read_type(vec![field.clone(), row_id_field()]);
+ read_builder.with_row_ranges(row_ranges);
+ let plan = read_builder.new_scan().plan().await?;
+ let read = read_builder.new_read()?;
+ let mut stream = read.to_arrow(plan.splits())?;
+
+ while let Some(batch) = stream.next().await {
+ let batch = batch?;
+ let blob_col = batch
+ .column(0)
+ .as_any()
+ .downcast_ref::<BinaryArray>()
+ .ok_or_else(|| Error::DataInvalid {
+ message: format!(
+ "Upstream blob field '{}' did not read as
BinaryArray",
+ field.name()
+ ),
+ source: None,
+ })?;
+ let row_id_col = batch
+ .column(1)
+ .as_any()
+ .downcast_ref::<Int64Array>()
+ .ok_or_else(|| Error::DataInvalid {
+ message: "Upstream _ROW_ID did not read as
Int64Array".to_string(),
+ source: None,
+ })?;
+
+ for row in 0..batch.num_rows() {
+ if row_id_col.is_null(row) {
+ continue;
+ }
+ let view_struct =
+ BlobViewStruct::new(identifier.clone(), field_id,
row_id_col.value(row));
+ if blob_col.is_null(row) {
+ lookup.descriptors.insert(view_struct, None);
+ continue;
+ }
+ let value = blob_col.value(row);
+ if !BlobDescriptor::is_blob_descriptor(value) {
+ return Err(Error::DataInvalid {
+ message: format!(
+ "BlobViewStruct {} field_id={} row_id={}
resolved to non-BlobDescriptor bytes",
+ identifier.full_name(),
+ field_id,
+ row_id_col.value(row)
+ ),
+ source: None,
+ });
+ }
+ lookup
+ .descriptors
+ .insert(view_struct,
Some(BlobDescriptor::deserialize(value)?));
+ }
+ }
+
+ for view_struct in refs {
+ if !lookup.descriptors.contains_key(&view_struct) {
+ return Err(Error::DataInvalid {
+ message: format!(
+ "BlobViewStruct not found in upstream table:
identifier={}, field_id={}, row_id={}",
+ view_struct.identifier().full_name(),
+ view_struct.field_id(),
+ view_struct.row_id()
+ ),
+ source: None,
+ });
+ }
+ }
+ }
+
+ Ok(lookup)
+ }
+
+ fn descriptor(&self, view_struct: &BlobViewStruct) ->
crate::Result<Option<&BlobDescriptor>> {
+ self.descriptors
+ .get(view_struct)
+ .map(Option::as_ref)
+ .ok_or_else(|| Error::DataInvalid {
+ message: format!(
+ "BlobViewStruct not found in preloaded cache:
identifier={}, field_id={}, row_id={}",
+ view_struct.identifier().full_name(),
+ view_struct.field_id(),
+ view_struct.row_id()
+ ),
+ source: None,
+ })
+ }
+}
+
+fn row_ranges_for_blob_view_refs(refs: &[BlobViewStruct]) -> Vec<RowRange> {
+ let mut row_ids =
refs.iter().map(BlobViewStruct::row_id).collect::<Vec<_>>();
+ row_ids.sort_unstable();
+ row_ids.dedup();
+
+ let mut ranges = Vec::new();
+ let mut iter = row_ids.into_iter();
+ let Some(mut start) = iter.next() else {
+ return ranges;
+ };
+ let mut end = start;
+ for row_id in iter {
+ if end.checked_add(1) == Some(row_id) {
+ end = row_id;
+ } else {
+ ranges.push(RowRange::new(start, end));
+ start = row_id;
+ end = row_id;
+ }
+ }
+ ranges.push(RowRange::new(start, end));
+ ranges
+}
+
+fn row_id_field() -> DataField {
+ DataField::new(
+ ROW_ID_FIELD_ID,
+ ROW_ID_FIELD_NAME.to_string(),
+ DataType::BigInt(BigIntType::with_nullable(true)),
+ )
+}
+
#[allow(clippy::too_many_arguments)]
fn open_source_stream(
split: &DataSplit,
diff --git a/crates/paimon/src/table/dedicated_format_file_writer.rs
b/crates/paimon/src/table/dedicated_format_file_writer.rs
index 347d435..15f8803 100644
--- a/crates/paimon/src/table/dedicated_format_file_writer.rs
+++ b/crates/paimon/src/table/dedicated_format_file_writer.rs
@@ -16,10 +16,9 @@
// under the License.
use crate::io::FileIO;
-use crate::spec::{BlobDescriptor, DataField, DataFileMeta, DataType};
+use crate::spec::{BlobViewStruct, DataField, DataFileMeta, DataType};
use crate::table::data_file_writer::DataFileWriter;
use crate::Result;
-use arrow_array::builder::BinaryBuilder;
use arrow_array::{Array, RecordBatch};
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
@@ -57,6 +56,7 @@ pub(crate) struct AppendDedicatedFormatFileWriter {
vector_writer: Option<VectorFieldWriter>,
normal_column_indices: Vec<usize>,
normal_schema: Arc<arrow_schema::Schema>,
+ blob_view_column_indices: Vec<(usize, String)>,
}
impl AppendDedicatedFormatFileWriter {
@@ -78,7 +78,8 @@ impl AppendDedicatedFormatFileWriter {
input_schema: &arrow_schema::Schema,
table_fields: &[DataField],
format_options: &HashMap<String, String>,
- blob_descriptor_fields: &HashSet<String>,
+ blob_inline_fields: &HashSet<String>,
+ blob_view_fields: &HashSet<String>,
) -> Self {
let mut normal_column_indices = Vec::new();
let mut normal_arrow_fields = Vec::new();
@@ -91,7 +92,7 @@ impl AppendDedicatedFormatFileWriter {
for (idx, field) in table_fields.iter().enumerate() {
let is_blob = field.data_type().is_blob_type();
- let is_descriptor = blob_descriptor_fields.contains(field.name());
+ let is_inline = blob_inline_fields.contains(field.name());
let is_dedicated_vector =
vector_file_format.is_some() && matches!(field.data_type(),
DataType::Vector(_));
@@ -100,7 +101,7 @@ impl AppendDedicatedFormatFileWriter {
vector_arrow_fields.push(input_schema.field(idx).clone());
vector_table_fields.push(field.clone());
vector_field_names.push(field.name().to_string());
- } else if is_blob && !is_descriptor {
+ } else if is_blob && !is_inline {
blob_writers.push(BlobFieldWriter {
writer: DataFileWriter::new(
file_io.clone(),
@@ -186,6 +187,12 @@ impl AppendDedicatedFormatFileWriter {
vector_writer,
normal_column_indices,
normal_schema,
+ blob_view_column_indices: table_fields
+ .iter()
+ .enumerate()
+ .filter(|(_, field)| blob_view_fields.contains(field.name()))
+ .map(|(idx, field)| (idx, field.name().to_string()))
+ .collect(),
}
}
@@ -194,6 +201,8 @@ impl AppendDedicatedFormatFileWriter {
return Ok(());
}
+ self.validate_blob_view_columns(batch)?;
+
// Write normal columns
let normal_columns: Vec<Arc<dyn arrow_array::Array>> = self
.normal_column_indices
@@ -250,6 +259,47 @@ impl AppendDedicatedFormatFileWriter {
Ok(())
}
+ fn validate_blob_view_columns(&self, batch: &RecordBatch) -> Result<()> {
+ for (column_index, field_name) in &self.blob_view_column_indices {
+ let Some(col) = batch
+ .column(*column_index)
+ .as_any()
+ .downcast_ref::<arrow_array::BinaryArray>()
+ else {
+ return Err(crate::Error::DataInvalid {
+ message: format!(
+ "blob-view-field '{field_name}' requires a BinaryArray
value column"
+ ),
+ source: None,
+ });
+ };
+ for row in 0..col.len() {
+ if col.is_null(row) {
+ continue;
+ }
+ let value = col.value(row);
+ if !BlobViewStruct::is_blob_view_struct(value) {
+ return Err(crate::Error::DataInvalid {
+ message: format!(
+ "blob-view-field '{field_name}' requires blob
field value to be a serialized BlobViewStruct"
+ ),
+ source: None,
+ });
+ }
+ let view = BlobViewStruct::deserialize(value)?;
+ if view.serialize()?.as_slice() != value {
+ return Err(crate::Error::DataInvalid {
+ message: format!(
+ "blob-view-field '{field_name}' contains a
non-canonical BlobViewStruct payload"
+ ),
+ source: None,
+ });
+ }
+ }
+ }
+ Ok(())
+ }
+
pub(crate) async fn prepare_commit(&mut self) -> Result<Vec<DataFileMeta>>
{
let mut results = self.normal_writer.prepare_commit().await?;
@@ -266,53 +316,3 @@ impl AppendDedicatedFormatFileWriter {
Ok(results)
}
}
-
-/// For each row in a blob column, if the value is a serialized
`BlobDescriptor`,
-/// resolve it by reading the actual data from the referenced
URI+offset+length.
-/// Raw data values are passed through unchanged.
-pub(crate) async fn resolve_blob_column(
- col: &arrow_array::BinaryArray,
- file_io: &FileIO,
-) -> Result<arrow_array::BinaryArray> {
- use crate::io::FileRead;
- use std::collections::HashMap;
-
- let mut needs_resolve = false;
- for i in 0..col.len() {
- if !col.is_null(i) && BlobDescriptor::is_blob_descriptor(col.value(i))
{
- needs_resolve = true;
- break;
- }
- }
-
- if !needs_resolve {
- return Ok(col.clone());
- }
-
- let mut readers: HashMap<String, Box<dyn FileRead>> = HashMap::new();
- let mut builder = BinaryBuilder::with_capacity(col.len(), 0);
- for i in 0..col.len() {
- if col.is_null(i) {
- builder.append_null();
- } else {
- let value = col.value(i);
- if BlobDescriptor::is_blob_descriptor(value) {
- let desc = BlobDescriptor::deserialize(value)?;
- let uri = desc.uri().to_string();
- if !readers.contains_key(&uri) {
- let input = file_io.new_input(&uri)?;
- let reader = input.reader().await?;
- readers.insert(uri.clone(), Box::new(reader));
- }
- let reader = readers.get(&uri).unwrap();
- let start = desc.offset() as u64;
- let end = start + desc.length() as u64;
- let data = reader.read(start..end).await?;
- builder.append_value(&data);
- } else {
- builder.append_value(value);
- }
- }
- }
- Ok(builder.finish())
-}
diff --git a/crates/paimon/src/table/mod.rs b/crates/paimon/src/table/mod.rs
index 28e3a6a..1865593 100644
--- a/crates/paimon/src/table/mod.rs
+++ b/crates/paimon/src/table/mod.rs
@@ -20,6 +20,7 @@
pub(crate) mod aggregator;
pub(crate) mod bin_pack;
mod bitmap_global_index_reader;
+mod blob_resolver;
mod branch_manager;
mod btree_global_index_build_builder;
mod btree_global_index_drop_builder;
diff --git a/crates/paimon/src/table/rest_env.rs
b/crates/paimon/src/table/rest_env.rs
index e7b1107..cb52f2b 100644
--- a/crates/paimon/src/table/rest_env.rs
+++ b/crates/paimon/src/table/rest_env.rs
@@ -15,11 +15,18 @@
// specific language governing permissions and limitations
// under the License.
-//! REST environment for creating RESTSnapshotCommit instances.
+//! REST environment for REST-backed table operations.
use crate::api::rest_api::RESTApi;
-use crate::catalog::Identifier;
+use crate::api::rest_error::RestError;
+use crate::catalog::{Identifier, RESTTokenFileIO};
+use crate::common::Options;
+use crate::error::Error;
+use crate::io::FileIO;
+use crate::spec::TableSchema;
use crate::table::snapshot_commit::{RESTSnapshotCommit, SnapshotCommit};
+use crate::table::Table;
+use crate::Result;
use std::sync::Arc;
/// REST environment that holds the REST API client, identifier, and uuid
@@ -29,6 +36,8 @@ pub struct RESTEnv {
identifier: Identifier,
uuid: String,
api: Arc<RESTApi>,
+ options: Options,
+ data_token_enabled: bool,
}
impl std::fmt::Debug for RESTEnv {
@@ -42,11 +51,19 @@ impl std::fmt::Debug for RESTEnv {
impl RESTEnv {
/// Create a new RESTEnv.
- pub fn new(identifier: Identifier, uuid: String, api: Arc<RESTApi>) ->
Self {
+ pub fn new(
+ identifier: Identifier,
+ uuid: String,
+ api: Arc<RESTApi>,
+ options: Options,
+ data_token_enabled: bool,
+ ) -> Self {
Self {
identifier,
uuid,
api,
+ options,
+ data_token_enabled,
}
}
@@ -60,6 +77,85 @@ impl RESTEnv {
&self.identifier
}
+ /// Load a table through the same REST catalog environment.
+ pub async fn get_table(&self, identifier: &Identifier) -> Result<Table> {
+ Self::load_table(
+ identifier,
+ self.api.clone(),
+ self.options.clone(),
+ self.data_token_enabled,
+ )
+ .await
+ }
+
+ /// Load a REST table and attach a fresh RESTEnv to it.
+ pub(crate) async fn load_table(
+ identifier: &Identifier,
+ api: Arc<RESTApi>,
+ options: Options,
+ data_token_enabled: bool,
+ ) -> Result<Table> {
+ let response = api
+ .get_table(identifier)
+ .await
+ .map_err(|e| map_rest_error_for_table(e, identifier))?;
+
+ let schema = response.schema.ok_or_else(|| Error::DataInvalid {
+ message: format!("Table {} response missing schema",
identifier.full_name()),
+ source: None,
+ })?;
+
+ let schema_id = response.schema_id.ok_or_else(|| Error::DataInvalid {
+ message: format!(
+ "Table {} response missing schema_id",
+ identifier.full_name()
+ ),
+ source: None,
+ })?;
+ let table_schema = TableSchema::new(schema_id, &schema);
+
+ let table_path = response.path.ok_or_else(|| Error::DataInvalid {
+ message: format!("Table {} response missing path",
identifier.full_name()),
+ source: None,
+ })?;
+
+ let is_external = response.is_external.ok_or_else(||
Error::DataInvalid {
+ message: format!(
+ "Table {} response missing is_external",
+ identifier.full_name()
+ ),
+ source: None,
+ })?;
+
+ let uuid = response.id.ok_or_else(|| Error::DataInvalid {
+ message: format!(
+ "Table {} response missing id (uuid)",
+ identifier.full_name()
+ ),
+ source: None,
+ })?;
+
+ let file_io = if data_token_enabled && !is_external {
+ RESTTokenFileIO::new(identifier.clone(), table_path.clone(),
options.clone())
+ .build_file_io()
+ .await?
+ } else {
+ let mut builder = FileIO::from_path(&table_path)?;
+ builder = builder.with_props(options.to_map());
+ builder.build()?
+ };
+
+ let rest_env = RESTEnv::new(identifier.clone(), uuid, api, options,
data_token_enabled);
+
+ Ok(Table::new(
+ file_io,
+ identifier.clone(),
+ table_path,
+ table_schema,
+ Some(rest_env),
+ ))
+ }
+
/// Create a `RESTSnapshotCommit` from this environment.
pub fn snapshot_commit(&self) -> Arc<dyn SnapshotCommit> {
Arc::new(RESTSnapshotCommit::new(
@@ -69,3 +165,19 @@ impl RESTEnv {
))
}
}
+
+fn map_rest_error_for_table(err: Error, identifier: &Identifier) -> Error {
+ match err {
+ Error::RestApi {
+ source: RestError::NoSuchResource { .. },
+ } => Error::TableNotExist {
+ full_name: identifier.full_name(),
+ },
+ Error::RestApi {
+ source: RestError::AlreadyExists { .. },
+ } => Error::TableAlreadyExist {
+ full_name: identifier.full_name(),
+ },
+ other => other,
+ }
+}
diff --git a/crates/paimon/src/table/table_read.rs
b/crates/paimon/src/table/table_read.rs
index 216fbd6..29e8adf 100644
--- a/crates/paimon/src/table/table_read.rs
+++ b/crates/paimon/src/table/table_read.rs
@@ -200,6 +200,9 @@ impl<'a> TableRead<'a> {
self.data_predicates.clone(),
core_options.blob_as_descriptor(),
core_options.blob_descriptor_fields(),
+ core_options.blob_view_fields(),
+ core_options.blob_view_resolve_enabled(),
+ self.table.rest_env().cloned(),
)?;
reader.read(data_splits)
}
diff --git a/crates/paimon/src/table/table_write.rs
b/crates/paimon/src/table/table_write.rs
index 1854c53..034e634 100644
--- a/crates/paimon/src/table/table_write.rs
+++ b/crates/paimon/src/table/table_write.rs
@@ -111,8 +111,10 @@ pub struct TableWrite {
bucket_assigner: BucketAssignerEnum,
/// Whether this is an overwrite operation (skip seq/index restore).
is_overwrite: bool,
- /// Blob descriptor fields (stored inline in parquet, not as separate
.blob files).
- blob_descriptor_fields: HashSet<String>,
+ /// Blob view fields (stored inline as BlobViewStruct bytes).
+ blob_view_fields: HashSet<String>,
+ /// Descriptor + view inline blob fields.
+ blob_inline_fields: HashSet<String>,
/// Whether the table has non-descriptor blob fields requiring a
dedicated-format writer.
has_blob_fields: bool,
/// Dedicated vector-store file format, when configured.
@@ -127,19 +129,21 @@ impl TableWrite {
let schema = table.schema();
let core_options = CoreOptions::new(schema.options());
let blob_descriptor_fields = core_options.blob_descriptor_fields();
+ let blob_view_fields = core_options.blob_view_fields();
+ let blob_inline_fields = core_options.blob_inline_fields();
- for name in &blob_descriptor_fields {
+ for name in &blob_inline_fields {
match schema.fields().iter().find(|f| f.name() == name) {
None => {
return Err(crate::Error::DataInvalid {
- message: format!("blob-descriptor-field '{name}' does
not exist in schema"),
+ message: format!("inline blob field '{name}' does not
exist in schema"),
source: None,
});
}
Some(f) if !f.data_type().is_blob_type() => {
return Err(crate::Error::DataInvalid {
message: format!(
- "blob-descriptor-field '{name}' is not a top-level
BLOB field"
+ "inline blob field '{name}' is not a top-level
BLOB field"
),
source: None,
});
@@ -147,6 +151,18 @@ impl TableWrite {
_ => {}
}
}
+ if let Some(name) = blob_descriptor_fields
+ .intersection(&blob_view_fields)
+ .next()
+ .cloned()
+ {
+ return Err(crate::Error::DataInvalid {
+ message: format!(
+ "Field '{name}' in 'blob-view-field' can not also be in
'blob-descriptor-field'"
+ ),
+ source: None,
+ });
+ }
let total_buckets = core_options.bucket();
let has_primary_keys = !schema.primary_keys().is_empty();
@@ -313,7 +329,7 @@ impl TableWrite {
let has_blob_fields = schema
.fields()
.iter()
- .any(|f| f.data_type().is_blob_type() &&
!blob_descriptor_fields.contains(f.name()));
+ .any(|f| f.data_type().is_blob_type() &&
!blob_inline_fields.contains(f.name()));
let has_dedicated_vector_fields = vector_file_format.is_some()
&& schema
.fields()
@@ -345,7 +361,8 @@ impl TableWrite {
commit_user,
bucket_assigner,
is_overwrite,
- blob_descriptor_fields,
+ blob_view_fields,
+ blob_inline_fields,
has_blob_fields,
vector_file_format,
has_dedicated_vector_fields,
@@ -641,7 +658,10 @@ impl TableWrite {
/// Create an append-only writer for non-PK tables.
fn create_append_writer(&self, partition_path: String, bucket: i32) ->
Result<FileWriter> {
- if self.has_blob_fields || self.has_dedicated_vector_fields {
+ if self.has_blob_fields
+ || self.has_dedicated_vector_fields
+ || !self.blob_view_fields.is_empty()
+ {
let fields = self.table.schema().fields();
let input_schema = build_target_arrow_schema(fields)?;
Ok(FileWriter::AppendDedicated(Box::new(
@@ -662,7 +682,8 @@ impl TableWrite {
&input_schema,
fields,
self.table.schema().options(),
- &self.blob_descriptor_fields,
+ &self.blob_inline_fields,
+ &self.blob_view_fields,
),
)))
} else {
diff --git a/crates/paimon/tests/rest_catalog_test.rs
b/crates/paimon/tests/rest_catalog_test.rs
index 70a86c5..0a2e20f 100644
--- a/crates/paimon/tests/rest_catalog_test.rs
+++ b/crates/paimon/tests/rest_catalog_test.rs
@@ -21,11 +21,19 @@
//! through the Catalog trait interface.
use std::collections::HashMap;
+use std::sync::Arc;
+use arrow_array::{Array, BinaryArray, Int32Array, RecordBatch, StringArray};
+use arrow_schema::{DataType as ArrowDataType, Field as ArrowField, Schema as
ArrowSchema};
+use futures::TryStreamExt;
use paimon::api::ConfigResponse;
use paimon::catalog::{Catalog, Identifier, RESTCatalog};
use paimon::common::Options;
-use paimon::spec::{BigIntType, DataType, Schema, SchemaChange, VarCharType};
+use paimon::spec::{
+ BigIntType, BlobType, BlobViewStruct, DataType, Datum, IntType,
PredicateBuilder, Schema,
+ SchemaChange, VarCharType,
+};
+use paimon::{CatalogOptions, FileSystemCatalog, Table};
mod mock_server;
use mock_server::{start_mock_server, RESTServer};
@@ -75,6 +83,89 @@ fn test_schema() -> Schema {
.expect("Failed to build schema")
}
+fn blob_schema(options: &[(&str, &str)]) -> Schema {
+ let mut builder = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column("name", DataType::VarChar(VarCharType::new(255).unwrap()))
+ .column("picture", DataType::Blob(BlobType::new()))
+ .option("data-evolution.enabled", "true")
+ .option("row-tracking.enabled", "true");
+ for (key, value) in options {
+ builder = builder.option(*key, *value);
+ }
+ builder.build().expect("Failed to build blob schema")
+}
+
+fn blob_batch(ids: Vec<i32>, names: Vec<&str>, pictures: Vec<Vec<u8>>) ->
RecordBatch {
+ let schema = Arc::new(ArrowSchema::new(vec![
+ ArrowField::new("id", ArrowDataType::Int32, false),
+ ArrowField::new("name", ArrowDataType::Utf8, true),
+ ArrowField::new("picture", ArrowDataType::Binary, true),
+ ]));
+ let picture_refs = pictures
+ .iter()
+ .map(|bytes| Some(bytes.as_slice()))
+ .collect::<Vec<_>>();
+ RecordBatch::try_new(
+ schema,
+ vec![
+ Arc::new(Int32Array::from(ids)),
+ Arc::new(StringArray::from(names)),
+ Arc::new(BinaryArray::from(picture_refs)),
+ ],
+ )
+ .unwrap()
+}
+
+async fn write_batch(table: &Table, batch: RecordBatch, commit_user: &str) {
+ let write_builder = table
+ .new_write_builder()
+ .with_commit_user(commit_user)
+ .expect("valid commit user");
+ let mut write = write_builder.new_write().expect("create writer");
+ write.write_arrow_batch(&batch).await.expect("write batch");
+ let messages = write.prepare_commit().await.expect("prepare commit");
+ write_builder
+ .new_commit()
+ .commit(messages)
+ .await
+ .expect("commit batch");
+}
+
+fn collect_blob_rows(batches: &[RecordBatch]) -> Vec<(i32, String,
Option<Vec<u8>>)> {
+ let mut rows = Vec::new();
+ for batch in batches {
+ let ids = batch
+ .column(0)
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap();
+ let names = batch
+ .column(1)
+ .as_any()
+ .downcast_ref::<StringArray>()
+ .unwrap();
+ let pictures = batch
+ .column(2)
+ .as_any()
+ .downcast_ref::<BinaryArray>()
+ .unwrap();
+ for row in 0..batch.num_rows() {
+ rows.push((
+ ids.value(row),
+ names.value(row).to_string(),
+ if pictures.is_null(row) {
+ None
+ } else {
+ Some(pictures.value(row).to_vec())
+ },
+ ));
+ }
+ }
+ rows.sort_by_key(|(id, _, _)| *id);
+ rows
+}
+
// ==================== Database Tests ====================
#[tokio::test]
@@ -254,6 +345,150 @@ async fn test_catalog_get_table() {
assert!(table.is_ok(), "failed to get table: {table:?}");
}
+#[tokio::test]
+async fn test_rest_env_get_table_reuses_catalog_environment() {
+ let ctx = setup_catalog(vec!["default"]).await;
+
+ ctx.server.add_table_with_schema(
+ "default",
+ "current_table",
+ test_schema(),
+ "file:///tmp/test_warehouse/default.db/current_table",
+ );
+ ctx.server.add_table_with_schema(
+ "default",
+ "upstream_table",
+ test_schema(),
+ "file:///tmp/test_warehouse/default.db/upstream_table",
+ );
+
+ let current = ctx
+ .catalog
+ .get_table(&Identifier::new("default", "current_table"))
+ .await
+ .expect("current table should load");
+ let upstream = current
+ .rest_env()
+ .expect("REST table should carry RESTEnv")
+ .get_table(&Identifier::new("default", "upstream_table"))
+ .await
+ .expect("upstream table should load via RESTEnv");
+
+ assert_eq!(upstream.identifier().full_name(), "default.upstream_table");
+ assert!(upstream.rest_env().is_some());
+}
+
+// This regression uses FileSystemCatalog to write real table files before
reading
+// them back through RESTCatalog. FileSystemCatalog directory listing is
skipped
+// on Windows elsewhere for the same opendal `fs` StripPrefixError.
+#[cfg(not(windows))]
+#[tokio::test]
+async fn test_blob_view_prescan_filters_invalid_filtered_out_reference() {
+ let tmp = tempfile::tempdir().unwrap();
+ let warehouse = format!("file://{}", tmp.path().display());
+
+ let mut fs_options = Options::new();
+ fs_options.set(CatalogOptions::WAREHOUSE, &warehouse);
+ let fs_catalog = FileSystemCatalog::new(fs_options).expect("create
filesystem catalog");
+ fs_catalog
+ .create_database("default", true, HashMap::new())
+ .await
+ .unwrap();
+
+ let source_id = Identifier::new("default", "blob_source");
+ let source_schema = blob_schema(&[]);
+ fs_catalog
+ .create_table(&source_id, source_schema.clone(), false)
+ .await
+ .unwrap();
+ let source = fs_catalog.get_table(&source_id).await.unwrap();
+ write_batch(
+ &source,
+ blob_batch(
+ vec![1, 2],
+ vec!["Alice", "Bob"],
+ vec![b"alice".to_vec(), b"bob".to_vec()],
+ ),
+ "source-writer",
+ )
+ .await;
+
+ let view_id = Identifier::new("default", "blob_view_target");
+ let view_schema = blob_schema(&[("blob-view-field", "picture")]);
+ fs_catalog
+ .create_table(&view_id, view_schema.clone(), false)
+ .await
+ .unwrap();
+ let view = fs_catalog.get_table(&view_id).await.unwrap();
+
+ let picture_field_id = source
+ .schema()
+ .fields()
+ .iter()
+ .find(|field| field.name() == "picture")
+ .unwrap()
+ .id();
+ let filtered_out_bad_ref = BlobViewStruct::new(source_id.clone(),
picture_field_id, 99)
+ .serialize()
+ .unwrap();
+ let kept_ref = BlobViewStruct::new(source_id.clone(), picture_field_id, 1)
+ .serialize()
+ .unwrap();
+ write_batch(
+ &view,
+ blob_batch(
+ vec![1, 2],
+ vec!["Filtered", "Kept"],
+ vec![filtered_out_bad_ref, kept_ref],
+ ),
+ "view-writer",
+ )
+ .await;
+
+ let prefix = "mock-test";
+ let mut defaults = HashMap::new();
+ defaults.insert("prefix".to_string(), prefix.to_string());
+ let server = start_mock_server(
+ "test_warehouse".to_string(),
+ warehouse.clone(),
+ ConfigResponse::new(defaults),
+ vec!["default".to_string()],
+ )
+ .await;
+ server.add_table_with_schema("default", "blob_source", source_schema,
source.location());
+ server.add_table_with_schema("default", "blob_view_target", view_schema,
view.location());
+
+ let url = server.url().expect("Failed to get server URL");
+ let mut rest_options = Options::new();
+ rest_options.set("uri", &url);
+ rest_options.set("warehouse", "test_warehouse");
+ rest_options.set("token.provider", "bear");
+ rest_options.set("token", "test_token");
+ let rest_catalog = RESTCatalog::new(rest_options, true)
+ .await
+ .expect("create rest catalog");
+
+ let rest_view = rest_catalog.get_table(&view_id).await.unwrap();
+ let predicate = PredicateBuilder::new(rest_view.schema().fields())
+ .equal("id", Datum::Int(2))
+ .unwrap();
+ let mut read_builder = rest_view.new_read_builder();
+ read_builder.with_filter(predicate);
+ let plan = read_builder.new_scan().plan().await.unwrap();
+ let read = read_builder.new_read().unwrap();
+ let batches = read
+ .to_arrow(plan.splits())
+ .unwrap()
+ .try_collect::<Vec<_>>()
+ .await
+ .unwrap();
+
+ assert_eq!(
+ collect_blob_rows(&batches),
+ vec![(2, "Kept".to_string(), Some(b"bob".to_vec()))]
+ );
+}
+
#[tokio::test]
async fn test_catalog_get_table_not_found() {
let ctx = setup_catalog(vec!["default"]).await;
diff --git a/docs/src/sql.md b/docs/src/sql.md
index 934eb27..86cf612 100644
--- a/docs/src/sql.md
+++ b/docs/src/sql.md
@@ -78,7 +78,13 @@ async fn example() -> Result<(), Box<dyn std::error::Error>>
{
}
```
-`SQLContext::new` creates a session context with the Paimon relation planner
pre-registered. Use `register_catalog(...).await` to add one or more Paimon
catalogs; registering a catalog also registers the built-in table-valued
functions (`vector_search`, `hybrid_search`, and `full_text_search` when the
`fulltext` feature is enabled) against it. It also manages session-scoped
dynamic options internally for `SET`/`RESET` support.
+`SQLContext::new` creates a session context with the Paimon relation planner
+pre-registered. Use `register_catalog(...).await` to add one or more Paimon
+catalogs; registering a catalog also registers the built-in scalar function
+`blob_view` (alias `sys.blob_view`) and the built-in table-valued functions
+(`vector_search`, `hybrid_search`, and `full_text_search` when the `fulltext`
+feature is enabled) against it. It also manages session-scoped dynamic options
+internally for `SET`/`RESET` support.
## Data Types
@@ -111,6 +117,65 @@ columns; DataFusion reads those as Arrow
`FixedSizeList<Float32>`, and vindex
index creation uses `N` as the vector dimension. `SHOW CREATE TABLE` currently
does not round-trip `VECTOR` columns.
+### Blob View
+
+Blob View stores an inline reference to a BLOB value in another table, using a
+Java-compatible `BlobViewStruct` payload. It is useful when one table should
+point at media or large binary content owned by an upstream table without
+copying the bytes at write time.
+
+Declare Blob View columns as top-level BLOB columns and list them in the
+`blob-view-field` table option:
+
+```sql
+CREATE TABLE paimon.my_db.asset_refs (
+ id INT,
+ picture BLOB
+) WITH (
+ 'data-evolution.enabled' = 'true',
+ 'row-tracking.enabled' = 'true',
+ 'blob-view-field' = 'picture'
+);
+```
+
+Use `blob_view(table, field_name_or_id, row_id)` or `sys.blob_view(...)` to
+create the reference. The table argument may be `table`, `database.table`, or
+`catalog.database.table`; the stored reference contains the resolved
+`database.table`, field id, and row id. In typical SQL, read `_ROW_ID` from a
+row-tracking source table:
+
+```sql
+CREATE TABLE paimon.my_db.assets (
+ id INT,
+ picture BLOB
+) WITH (
+ 'data-evolution.enabled' = 'true',
+ 'row-tracking.enabled' = 'true'
+);
+
+INSERT INTO paimon.my_db.asset_refs (id, picture)
+SELECT
+ id,
+ sys.blob_view('my_db.assets', 'picture', "_ROW_ID")
+FROM paimon.my_db.assets;
+```
+
+By default, RESTCatalog-backed reads resolve Blob View fields to the upstream
+BLOB value by reusing the table's REST environment. Other catalog types
+currently preserve the raw serialized `BlobViewStruct` bytes. Set the dynamic
+option `paimon.blob-view.resolve.enabled` to `false` to preserve raw references
+even for RESTCatalog-backed reads:
+
+```sql
+SET 'paimon.blob-view.resolve.enabled' = 'false';
+SELECT id, picture FROM paimon.my_db.asset_refs;
+RESET 'paimon.blob-view.resolve.enabled';
+```
+
+Like ordinary BLOB reads, `paimon.blob-as-descriptor = true` makes resolved
Blob
+View columns return serialized BLOB descriptors instead of loading the BLOB
+bytes.
+
### Variant Usage
`VARIANT` stores semi-structured data using the same logical value + metadata
binary shape as Paimon Java. Use it for JSON-like fields whose schema may
differ row by row.
@@ -1171,6 +1236,15 @@ SELECT * FROM paimon.my_db.assets;
RESET 'paimon.blob-as-descriptor';
```
+Example — preserve Blob View references instead of resolving upstream BLOB
+values on RESTCatalog-backed reads:
+
+```sql
+SET 'paimon.blob-view.resolve.enabled' = 'false';
+SELECT * FROM paimon.my_db.asset_refs;
+RESET 'paimon.blob-view.resolve.enabled';
+```
+
## Temporary Tables
You can register in-memory temporary tables under any catalog. Temporary
tables exist only for the lifetime of the `SQLContext` instance and are
automatically cleaned up when the context is dropped.