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 739d6f5  feat(datafusion): add BlobDescriptor SQL helper functions 
(#525)
739d6f5 is described below

commit 739d6f5afecfe54716f1feba1f2b336162d00bf1
Author: QuakeWang <[email protected]>
AuthorDate: Thu Jul 16 15:25:20 2026 +0800

    feat(datafusion): add BlobDescriptor SQL helper functions (#525)
---
 .../datafusion/src/blob_descriptor_functions.rs    | 222 +++++++++++++++++++++
 crates/integrations/datafusion/src/lib.rs          |   1 +
 crates/integrations/datafusion/src/sql_context.rs  |   1 +
 .../datafusion/tests/blob_descriptor_functions.rs  | 183 +++++++++++++++++
 crates/paimon/src/spec/blob_descriptor.rs          |  21 ++
 docs/src/sql.md                                    |  34 +++-
 6 files changed, 455 insertions(+), 7 deletions(-)

diff --git a/crates/integrations/datafusion/src/blob_descriptor_functions.rs 
b/crates/integrations/datafusion/src/blob_descriptor_functions.rs
new file mode 100644
index 0000000..de00041
--- /dev/null
+++ b/crates/integrations/datafusion/src/blob_descriptor_functions.rs
@@ -0,0 +1,222 @@
+// 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::sync::Arc;
+
+use datafusion::arrow::array::{
+    Array, BinaryArray, BinaryBuilder, BinaryViewArray, LargeBinaryArray, 
LargeStringArray,
+    StringArray, StringBuilder, StringViewArray,
+};
+use datafusion::arrow::datatypes::DataType as ArrowDataType;
+use datafusion::common::types::logical_binary;
+use datafusion::common::utils::take_function_args;
+use datafusion::common::{DataFusionError, Result as DFResult, ScalarValue};
+use datafusion::logical_expr::{
+    Coercion, ColumnarValue, ScalarFunctionArgs, ScalarUDF, ScalarUDFImpl, 
Signature,
+    TypeSignatureClass, Volatility,
+};
+use datafusion::prelude::SessionContext;
+use paimon::spec::BlobDescriptor;
+
+use crate::error::to_datafusion_error;
+
+const PATH_TO_DESCRIPTOR: &str = "path_to_descriptor";
+const DESCRIPTOR_TO_STRING: &str = "descriptor_to_string";
+
+pub(crate) fn register_blob_descriptor_functions(ctx: &SessionContext) {
+    ctx.register_udf(ScalarUDF::from(PathToDescriptorFunc::new()));
+    ctx.register_udf(ScalarUDF::from(DescriptorToStringFunc::new()));
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+struct PathToDescriptorFunc {
+    signature: Signature,
+    aliases: Vec<String>,
+}
+
+impl PathToDescriptorFunc {
+    fn new() -> Self {
+        Self {
+            signature: Signature::string(1, Volatility::Immutable),
+            aliases: vec!["sys.path_to_descriptor".to_string()],
+        }
+    }
+}
+
+impl ScalarUDFImpl for PathToDescriptorFunc {
+    fn name(&self) -> &str {
+        PATH_TO_DESCRIPTOR
+    }
+
+    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 invoke_with_args(&self, args: ScalarFunctionArgs) -> 
DFResult<ColumnarValue> {
+        let [input] = take_function_args(self.name(), args.args)?;
+        match input {
+            ColumnarValue::Scalar(value) => path_scalar(value),
+            ColumnarValue::Array(array) => path_array(array.as_ref()),
+        }
+    }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+struct DescriptorToStringFunc {
+    signature: Signature,
+    aliases: Vec<String>,
+}
+
+impl DescriptorToStringFunc {
+    fn new() -> Self {
+        Self {
+            signature: Signature::coercible(
+                vec![Coercion::new_exact(TypeSignatureClass::Native(
+                    logical_binary(),
+                ))],
+                Volatility::Immutable,
+            ),
+            aliases: vec!["sys.descriptor_to_string".to_string()],
+        }
+    }
+}
+
+impl ScalarUDFImpl for DescriptorToStringFunc {
+    fn name(&self) -> &str {
+        DESCRIPTOR_TO_STRING
+    }
+
+    fn aliases(&self) -> &[String] {
+        &self.aliases
+    }
+
+    fn signature(&self) -> &Signature {
+        &self.signature
+    }
+
+    fn return_type(&self, _arg_types: &[ArrowDataType]) -> 
DFResult<ArrowDataType> {
+        Ok(ArrowDataType::Utf8)
+    }
+
+    fn invoke_with_args(&self, args: ScalarFunctionArgs) -> 
DFResult<ColumnarValue> {
+        let [input] = take_function_args(self.name(), args.args)?;
+        match input {
+            ColumnarValue::Scalar(value) => descriptor_scalar(value),
+            ColumnarValue::Array(array) => descriptor_array(array.as_ref()),
+        }
+    }
+}
+
+fn serialize_path(path: &str) -> Vec<u8> {
+    BlobDescriptor::new(path.to_string(), 0, -1).serialize()
+}
+
+fn descriptor_string(bytes: &[u8]) -> DFResult<String> {
+    BlobDescriptor::deserialize(bytes)
+        .map(|descriptor| descriptor.to_string())
+        .map_err(to_datafusion_error)
+}
+
+fn path_scalar(value: ScalarValue) -> DFResult<ColumnarValue> {
+    let path = match value {
+        ScalarValue::Utf8(path) | ScalarValue::LargeUtf8(path) | 
ScalarValue::Utf8View(path) => {
+            path
+        }
+        ScalarValue::Null => None,
+        other => return unexpected_type(PATH_TO_DESCRIPTOR, 
&other.data_type()),
+    };
+    Ok(ColumnarValue::Scalar(ScalarValue::Binary(
+        path.as_deref().map(serialize_path),
+    )))
+}
+
+fn path_array(input: &dyn Array) -> DFResult<ColumnarValue> {
+    if let Some(values) = input.as_any().downcast_ref::<StringArray>() {
+        return Ok(descriptor_array_from_paths(values.iter()));
+    }
+    if let Some(values) = input.as_any().downcast_ref::<LargeStringArray>() {
+        return Ok(descriptor_array_from_paths(values.iter()));
+    }
+    if let Some(values) = input.as_any().downcast_ref::<StringViewArray>() {
+        return Ok(descriptor_array_from_paths(values.iter()));
+    }
+    unexpected_type(PATH_TO_DESCRIPTOR, input.data_type())
+}
+
+fn descriptor_array_from_paths<'a>(paths: impl Iterator<Item = Option<&'a 
str>>) -> ColumnarValue {
+    let mut builder = BinaryBuilder::new();
+    for path in paths {
+        match path {
+            Some(path) => builder.append_value(serialize_path(path)),
+            None => builder.append_null(),
+        }
+    }
+    ColumnarValue::Array(Arc::new(builder.finish()))
+}
+
+fn descriptor_scalar(value: ScalarValue) -> DFResult<ColumnarValue> {
+    let bytes = match value {
+        ScalarValue::Binary(bytes)
+        | ScalarValue::LargeBinary(bytes)
+        | ScalarValue::BinaryView(bytes) => bytes,
+        ScalarValue::Null => None,
+        other => return unexpected_type(DESCRIPTOR_TO_STRING, 
&other.data_type()),
+    };
+    Ok(ColumnarValue::Scalar(ScalarValue::Utf8(
+        bytes.as_deref().map(descriptor_string).transpose()?,
+    )))
+}
+
+fn descriptor_array(input: &dyn Array) -> DFResult<ColumnarValue> {
+    if let Some(values) = input.as_any().downcast_ref::<BinaryArray>() {
+        return strings_from_descriptors(values.iter());
+    }
+    if let Some(values) = input.as_any().downcast_ref::<LargeBinaryArray>() {
+        return strings_from_descriptors(values.iter());
+    }
+    if let Some(values) = input.as_any().downcast_ref::<BinaryViewArray>() {
+        return strings_from_descriptors(values.iter());
+    }
+    unexpected_type(DESCRIPTOR_TO_STRING, input.data_type())
+}
+
+fn strings_from_descriptors<'a>(
+    descriptors: impl Iterator<Item = Option<&'a [u8]>>,
+) -> DFResult<ColumnarValue> {
+    let mut builder = StringBuilder::new();
+    for bytes in descriptors {
+        match bytes {
+            Some(bytes) => builder.append_value(descriptor_string(bytes)?),
+            None => builder.append_null(),
+        }
+    }
+    Ok(ColumnarValue::Array(Arc::new(builder.finish())))
+}
+
+fn unexpected_type<T>(function: &str, data_type: &ArrowDataType) -> 
DFResult<T> {
+    Err(DataFusionError::Execution(format!(
+        "{function} received unexpected argument type {data_type}"
+    )))
+}
diff --git a/crates/integrations/datafusion/src/lib.rs 
b/crates/integrations/datafusion/src/lib.rs
index a60d52b..05e4037 100644
--- a/crates/integrations/datafusion/src/lib.rs
+++ b/crates/integrations/datafusion/src/lib.rs
@@ -36,6 +36,7 @@
 //! This version supports partition predicate pushdown by extracting
 //! translatable partition-only conjuncts from DataFusion filters.
 
+mod blob_descriptor_functions;
 mod blob_reader;
 mod blob_view;
 mod catalog;
diff --git a/crates/integrations/datafusion/src/sql_context.rs 
b/crates/integrations/datafusion/src/sql_context.rs
index 5523229..f9d81c8 100644
--- a/crates/integrations/datafusion/src/sql_context.rs
+++ b/crates/integrations/datafusion/src/sql_context.rs
@@ -117,6 +117,7 @@ impl SQLContext {
             ))
             .build();
         let ctx = SessionContext::new_with_state(state);
+        
crate::blob_descriptor_functions::register_blob_descriptor_functions(&ctx);
         crate::variant_functions::register_variant_functions(&ctx);
         Self {
             ctx,
diff --git a/crates/integrations/datafusion/tests/blob_descriptor_functions.rs 
b/crates/integrations/datafusion/tests/blob_descriptor_functions.rs
new file mode 100644
index 0000000..029dcc4
--- /dev/null
+++ b/crates/integrations/datafusion/tests/blob_descriptor_functions.rs
@@ -0,0 +1,183 @@
+// 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 datafusion::arrow::array::{Array, BinaryArray, StringArray};
+use paimon_datafusion::SQLContext;
+
+const JAVA_V2_HEX: &str =
+    
"0243534544424f4c420d00000066696c653a2f2f2f746d702f610000000000000000ffffffffffffffff";
+const JAVA_V2_STRING: &str = "BlobDescriptor{version=2, uri='file:///tmp/a', 
offset=0, length=-1}";
+const JAVA_V1_HEX: &str = 
"010a0000002f746573742f706174686400000000000000c800000000000000";
+const JAVA_V1_STRING: &str = "BlobDescriptor{version=1, uri='/test/path', 
offset=100, length=200}";
+
+fn to_hex(bytes: &[u8]) -> String {
+    bytes.iter().map(|byte| format!("{byte:02x}")).collect()
+}
+
+async fn query_error(ctx: &SQLContext, sql: &str) -> String {
+    match ctx.sql(sql).await {
+        Err(error) => error.to_string(),
+        Ok(dataframe) => dataframe
+            .collect()
+            .await
+            .expect_err("query should fail")
+            .to_string(),
+    }
+}
+
+#[tokio::test]
+async fn 
test_blob_descriptor_functions_are_registered_with_aliases_and_java_format() {
+    let ctx = SQLContext::new();
+    let batches = ctx
+        .sql(
+            "SELECT \
+             path_to_descriptor('file:///tmp/a'), \
+             sys.path_to_descriptor('file:///tmp/a'), \
+             descriptor_to_string(path_to_descriptor('file:///tmp/a')), \
+             
sys.descriptor_to_string(sys.path_to_descriptor('file:///tmp/a'))",
+        )
+        .await
+        .unwrap()
+        .collect()
+        .await
+        .unwrap();
+
+    let batch = &batches[0];
+    for column in 0..2 {
+        let descriptors = batch
+            .column(column)
+            .as_any()
+            .downcast_ref::<BinaryArray>()
+            .unwrap();
+        assert_eq!(to_hex(descriptors.value(0)), JAVA_V2_HEX);
+    }
+    for column in 2..4 {
+        let strings = batch
+            .column(column)
+            .as_any()
+            .downcast_ref::<StringArray>()
+            .unwrap();
+        assert_eq!(strings.value(0), JAVA_V2_STRING);
+    }
+}
+
+#[tokio::test]
+async fn test_blob_descriptor_functions_propagate_nulls() {
+    let ctx = SQLContext::new();
+    let batches = ctx
+        .sql(
+            "SELECT id, \
+             path_to_descriptor(path), \
+             descriptor_to_string(path_to_descriptor(path)) \
+             FROM (VALUES \
+               (1, 'file:///tmp/a'), \
+               (2, CAST(NULL AS VARCHAR)), \
+               (3, 'file:///tmp/b') \
+             ) AS inputs(id, path) \
+             ORDER BY id",
+        )
+        .await
+        .unwrap()
+        .collect()
+        .await
+        .unwrap();
+
+    let batch = &batches[0];
+    let descriptors = batch
+        .column(1)
+        .as_any()
+        .downcast_ref::<BinaryArray>()
+        .unwrap();
+    let strings = batch
+        .column(2)
+        .as_any()
+        .downcast_ref::<StringArray>()
+        .unwrap();
+    assert_eq!(to_hex(descriptors.value(0)), JAVA_V2_HEX);
+    assert!(descriptors.is_null(1));
+    assert!(strings.is_null(1));
+    assert_eq!(
+        strings.value(2),
+        "BlobDescriptor{version=2, uri='file:///tmp/b', offset=0, length=-1}"
+    );
+
+    let alias_nulls = ctx
+        .sql(
+            "SELECT \
+             path_to_descriptor(NULL), \
+             sys.path_to_descriptor(NULL), \
+             descriptor_to_string(NULL), \
+             sys.descriptor_to_string(NULL)",
+        )
+        .await
+        .unwrap()
+        .collect()
+        .await
+        .unwrap();
+    let batch = &alias_nulls[0];
+    for column in 0..4 {
+        assert!(batch.column(column).is_null(0));
+    }
+}
+
+#[tokio::test]
+async fn test_descriptor_to_string_supports_java_v1() {
+    let ctx = SQLContext::new();
+    let sql = format!(
+        "SELECT descriptor_to_string(X'{JAVA_V1_HEX}'), \
+         sys.descriptor_to_string(X'{JAVA_V1_HEX}')"
+    );
+    let batches = ctx.sql(&sql).await.unwrap().collect().await.unwrap();
+    let batch = &batches[0];
+    for column in 0..2 {
+        let strings = batch
+            .column(column)
+            .as_any()
+            .downcast_ref::<StringArray>()
+            .unwrap();
+        assert_eq!(strings.value(0), JAVA_V1_STRING);
+    }
+}
+
+#[tokio::test]
+async fn test_blob_descriptor_functions_reject_invalid_arguments() {
+    let ctx = SQLContext::new();
+    for (sql, function) in [
+        ("SELECT path_to_descriptor()", "path_to_descriptor"),
+        ("SELECT path_to_descriptor(1)", "path_to_descriptor"),
+        (
+            "SELECT descriptor_to_string('not binary')",
+            "descriptor_to_string",
+        ),
+        (
+            "SELECT descriptor_to_string(X'00', X'01')",
+            "descriptor_to_string",
+        ),
+    ] {
+        let error = query_error(&ctx, sql).await;
+        assert!(
+            error.contains(function),
+            "expected error for {function}, got: {error}"
+        );
+    }
+
+    let error = query_error(&ctx, "SELECT descriptor_to_string(X'00')").await;
+    assert!(
+        error.contains("BlobDescriptor bytes too short"),
+        "unexpected malformed descriptor error: {error}"
+    );
+}
diff --git a/crates/paimon/src/spec/blob_descriptor.rs 
b/crates/paimon/src/spec/blob_descriptor.rs
index 93c8449..f65b293 100644
--- a/crates/paimon/src/spec/blob_descriptor.rs
+++ b/crates/paimon/src/spec/blob_descriptor.rs
@@ -15,6 +15,8 @@
 // specific language governing permissions and limitations
 // under the License.
 
+use std::fmt::{Display, Formatter};
+
 use crate::Error;
 
 const CURRENT_VERSION: u8 = 2;
@@ -28,6 +30,16 @@ pub struct BlobDescriptor {
     length: i64,
 }
 
+impl Display for BlobDescriptor {
+    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+        write!(
+            f,
+            "BlobDescriptor{{version={}, uri='{}', offset={}, length={}}}",
+            self.version, self.uri, self.offset, self.length
+        )
+    }
+}
+
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
 pub(crate) struct BlobRangeSpec {
     offset: u64,
@@ -221,6 +233,15 @@ mod tests {
         assert_eq!(desc, deserialized);
     }
 
+    #[test]
+    fn test_display_matches_java() {
+        let desc = BlobDescriptor::new("file:///tmp/a".to_string(), 0, -1);
+        assert_eq!(
+            desc.to_string(),
+            "BlobDescriptor{version=2, uri='file:///tmp/a', offset=0, 
length=-1}"
+        );
+    }
+
     #[test]
     fn test_is_blob_descriptor() {
         let desc = BlobDescriptor::new("file:///tmp/test.blob".to_string(), 0, 
1024);
diff --git a/docs/src/sql.md b/docs/src/sql.md
index d550c73..01635ba 100644
--- a/docs/src/sql.md
+++ b/docs/src/sql.md
@@ -68,13 +68,14 @@ 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 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.
+`SQLContext::new` creates a session context with the Paimon relation planner 
and
+the catalog-independent `path_to_descriptor` and `descriptor_to_string` scalar
+functions 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.
 
 ### REST Catalog Views and SQL Functions
 
@@ -300,6 +301,25 @@ The offset must be non-negative, and lengths below `-1` 
are invalid.
 
 The same directives are supported by `ALTER TABLE ... ADD COLUMN`.
 
+### Blob Descriptor Functions
+
+`path_to_descriptor(path)` converts a string path into Java-compatible
+`BlobDescriptor` bytes with offset `0` and length `-1`. Its alias is
+`sys.path_to_descriptor(path)`. The function only serializes the path; it does
+not access the referenced object or validate that it exists.
+
+`descriptor_to_string(descriptor)` converts serialized descriptor bytes to the
+same string representation used by Java Paimon. Its alias is
+`sys.descriptor_to_string(descriptor)`. Invalid descriptor bytes return an
+error. Both functions return `NULL` for `NULL` input.
+
+```sql
+SELECT sys.descriptor_to_string(
+    sys.path_to_descriptor('file:///tmp/image.png')
+);
+-- BlobDescriptor{version=2, uri='file:///tmp/image.png', offset=0, length=-1}
+```
+
 ### Blob View
 
 Blob View stores an inline reference to a BLOB value in another table, using a

Reply via email to