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 aaf8a5c  feat(c): expose read builder scan options and time-travel 
through the C binding (#524)
aaf8a5c is described below

commit aaf8a5cad489219b6755fa6e7200722c32139351
Author: Junrui Lee <[email protected]>
AuthorDate: Thu Jul 16 15:25:42 2026 +0800

    feat(c): expose read builder scan options and time-travel through the C 
binding (#524)
---
 bindings/c/src/table.rs | 301 ++++++++++++++++++++++++++++++++++++++++++++++--
 1 file changed, 292 insertions(+), 9 deletions(-)

diff --git a/bindings/c/src/table.rs b/bindings/c/src/table.rs
index e84fb10..4daf61e 100644
--- a/bindings/c/src/table.rs
+++ b/bindings/c/src/table.rs
@@ -15,6 +15,7 @@
 // specific language governing permissions and limitations
 // under the License.
 
+use std::collections::HashMap;
 use std::ffi::c_void;
 
 use arrow_array::ffi::{FFI_ArrowArray, FFI_ArrowSchema};
@@ -66,6 +67,66 @@ pub unsafe extern "C" fn paimon_table_free(table: *mut 
paimon_table) {
     free_table_wrapper(table, |t| t.inner);
 }
 
+/// Time-travel selector option names, in the core's resolution priority order.
+const TIME_TRAVEL_SELECTORS: [&str; 4] = [
+    "scan.timestamp-millis",
+    "scan.version",
+    "scan.snapshot-id",
+    "scan.tag-name",
+];
+
+/// Build a `ReadBuilderState` from a table and a scan-option map, resolving 
any
+/// time-travel selector at construction time.
+///
+/// Rejects more than one time-travel selector up front (the core silently 
falls
+/// back on a conflict, which would misattribute the failure). Otherwise runs 
the
+/// core's `copy_with_time_travel`, which validates unsupported scan options 
and
+/// resolves the selector; a set selector that does not resolve to a snapshot 
is
+/// an error, so a mistyped or missing selector can never silently read latest.
+unsafe fn new_read_builder_state(
+    table: &Table,
+    options: HashMap<String, String>,
+) -> Result<ReadBuilderState, *mut paimon_error> {
+    let present: Vec<&str> = TIME_TRAVEL_SELECTORS
+        .iter()
+        .copied()
+        .filter(|name| options.contains_key(*name))
+        .collect();
+    if present.len() > 1 {
+        return Err(paimon_error::new(
+            PaimonErrorCode::InvalidInput,
+            format!(
+                "Only one time-travel selector may be set, found: {}",
+                present.join(", ")
+            ),
+        ));
+    }
+    let selector = TIME_TRAVEL_SELECTORS
+        .iter()
+        .find_map(|&name| options.get(name).map(|v| (name.to_string(), 
v.clone())));
+
+    let resolved = match 
runtime().block_on(table.copy_with_time_travel(options)) {
+        Ok(t) => t,
+        Err(e) => return Err(paimon_error::from_paimon(e)),
+    };
+
+    if let Some((name, value)) = selector {
+        if !resolved.has_resolved_travel_snapshot() {
+            return Err(paimon_error::new(
+                PaimonErrorCode::InvalidInput,
+                format!("time-travel selector {name}={value} did not resolve 
to any snapshot"),
+            ));
+        }
+    }
+
+    Ok(ReadBuilderState {
+        table: resolved,
+        projected_columns: None,
+        filter: None,
+        case_sensitive: true,
+    })
+}
+
 /// Create a new ReadBuilder from a Table.
 ///
 /// # Safety
@@ -73,6 +134,23 @@ pub unsafe extern "C" fn paimon_table_free(table: *mut 
paimon_table) {
 #[no_mangle]
 pub unsafe extern "C" fn paimon_table_new_read_builder(
     table: *const paimon_table,
+) -> paimon_result_read_builder {
+    paimon_table_new_read_builder_with_options(table, std::ptr::null(), 0)
+}
+
+/// Create a ReadBuilder from a Table with scan options (e.g. time-travel
+/// selectors `scan.snapshot-id` / `scan.tag-name` / `scan.timestamp-millis` /
+/// `scan.version`). At most one time-travel selector may be set. A selector 
that
+/// does not resolve to a snapshot is an error (never a silent read-of-latest).
+///
+/// # Safety
+/// `table` must be a valid pointer. `options` must be a valid pointer to
+/// `options_len` `paimon_option` values, or null when `options_len` is 0.
+#[no_mangle]
+pub unsafe extern "C" fn paimon_table_new_read_builder_with_options(
+    table: *const paimon_table,
+    options: *const paimon_option,
+    options_len: usize,
 ) -> paimon_result_read_builder {
     if let Err(e) = check_non_null(table, "table") {
         return paimon_result_read_builder {
@@ -80,16 +158,50 @@ pub unsafe extern "C" fn paimon_table_new_read_builder(
             error: e,
         };
     }
+    if options.is_null() && options_len > 0 {
+        return paimon_result_read_builder {
+            read_builder: std::ptr::null_mut(),
+            error: paimon_error::new(
+                PaimonErrorCode::InvalidInput,
+                "null options pointer with non-zero length".to_string(),
+            ),
+        };
+    }
+    let mut map = HashMap::with_capacity(options_len);
+    if options_len > 0 {
+        let slice = std::slice::from_raw_parts(options, options_len);
+        for opt in slice {
+            let key = match validate_cstr(opt.key, "option key") {
+                Ok(s) => s,
+                Err(e) => {
+                    return paimon_result_read_builder {
+                        read_builder: std::ptr::null_mut(),
+                        error: e,
+                    }
+                }
+            };
+            let value = match validate_cstr(opt.value, "option value") {
+                Ok(s) => s,
+                Err(e) => {
+                    return paimon_result_read_builder {
+                        read_builder: std::ptr::null_mut(),
+                        error: e,
+                    }
+                }
+            };
+            map.insert(key, value);
+        }
+    }
     let table_ref = &*((*table).inner as *const Table);
-    let state = ReadBuilderState {
-        table: table_ref.clone(),
-        projected_columns: None,
-        filter: None,
-        case_sensitive: true,
-    };
-    paimon_result_read_builder {
-        read_builder: box_read_builder_state(state),
-        error: std::ptr::null_mut(),
+    match new_read_builder_state(table_ref, map) {
+        Ok(state) => paimon_result_read_builder {
+            read_builder: box_read_builder_state(state),
+            error: std::ptr::null_mut(),
+        },
+        Err(e) => paimon_result_read_builder {
+            read_builder: std::ptr::null_mut(),
+            error: e,
+        },
     }
 }
 
@@ -1611,6 +1723,18 @@ const _: unsafe extern "C" fn(
     paimon_datum,
 ) -> paimon_result_predicate = paimon_predicate_not_between;
 
+// Read builder ABI signature guards. These pin the C-linked read-builder
+// constructors so an accidental signature change fails to compile rather than
+// silently breaking header consumers. To add behavior, introduce a new
+// `paimon_table_new_read_builder_*` symbol instead of changing one of these.
+const _: unsafe extern "C" fn(*const paimon_table) -> 
paimon_result_read_builder =
+    paimon_table_new_read_builder;
+const _: unsafe extern "C" fn(
+    *const paimon_table,
+    *const paimon_option,
+    usize,
+) -> paimon_result_read_builder = paimon_table_new_read_builder_with_options;
+
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -1848,4 +1972,163 @@ mod tests {
             paimon_table_free(table);
         }
     }
+
+    /// Assert the result is a built read builder (read_builder non-null, error
+    /// null) and free it.
+    unsafe fn assert_rb_ok_and_free(r: paimon_result_read_builder) {
+        assert!(!r.read_builder.is_null(), "expected a read builder");
+        assert!(r.error.is_null(), "expected no error");
+        paimon_read_builder_free(r.read_builder);
+    }
+
+    /// Assert the result is an error (read_builder null, error non-null) and 
free
+    /// it.
+    unsafe fn assert_rb_err_and_free(r: paimon_result_read_builder) {
+        assert!(r.read_builder.is_null(), "expected no read builder");
+        assert!(!r.error.is_null(), "expected an error");
+        paimon_error_free(r.error);
+    }
+
+    /// Assert the result is an error, extract its code and message, then free 
the
+    /// error exactly once. Lets a test pin the error identity (which failure
+    /// fired) instead of only its shape.
+    unsafe fn assert_rb_err_code_message(r: paimon_result_read_builder) -> 
(i32, String) {
+        assert!(r.read_builder.is_null(), "expected no read builder");
+        assert!(!r.error.is_null(), "expected an error");
+        let err = &*r.error;
+        let code = err.code;
+        let message = if err.message.data.is_null() {
+            String::new()
+        } else {
+            let bytes = std::slice::from_raw_parts(err.message.data, 
err.message.len);
+            String::from_utf8_lossy(bytes).into_owned()
+        };
+        paimon_error_free(r.error);
+        (code, message)
+    }
+
+    /// A `paimon_option` borrowing `key`/`value` (kept alive by the caller).
+    fn opt(key: &std::ffi::CStr, value: &std::ffi::CStr) -> paimon_option {
+        paimon_option {
+            key: key.as_ptr(),
+            value: value.as_ptr(),
+        }
+    }
+
+    #[test]
+    fn empty_options_builds_like_plain_new_read_builder() {
+        unsafe {
+            let table = boxed_test_table();
+            // Null pointer + zero length.
+            assert_rb_ok_and_free(paimon_table_new_read_builder_with_options(
+                table,
+                std::ptr::null(),
+                0,
+            ));
+            // Non-null pointer to a zero-length array + zero length.
+            let empty: [paimon_option; 0] = [];
+            assert_rb_ok_and_free(paimon_table_new_read_builder_with_options(
+                table,
+                empty.as_ptr(),
+                0,
+            ));
+            // The plain entry point (delegates with an empty map).
+            assert_rb_ok_and_free(paimon_table_new_read_builder(table));
+            paimon_table_free(table);
+        }
+    }
+
+    #[test]
+    fn non_selector_option_builds() {
+        unsafe {
+            let table = boxed_test_table();
+            let k = CString::new("some.unrelated.option").unwrap();
+            let v = CString::new("value").unwrap();
+            let opts = [opt(&k, &v)];
+            assert_rb_ok_and_free(paimon_table_new_read_builder_with_options(
+                table,
+                opts.as_ptr(),
+                1,
+            ));
+            paimon_table_free(table);
+        }
+    }
+
+    #[test]
+    fn more_than_one_selector_is_rejected() {
+        unsafe {
+            let table = boxed_test_table();
+            let k1 = CString::new("scan.snapshot-id").unwrap();
+            let v1 = CString::new("1").unwrap();
+            let k2 = CString::new("scan.tag-name").unwrap();
+            let v2 = CString::new("t").unwrap();
+            let opts = [opt(&k1, &v1), opt(&k2, &v2)];
+            let (code, message) = assert_rb_err_code_message(
+                paimon_table_new_read_builder_with_options(table, 
opts.as_ptr(), 2),
+            );
+            // Binding-constructed rejection: InvalidInput naming both 
selectors.
+            assert_eq!(code, PaimonErrorCode::InvalidInput as i32);
+            assert!(
+                message.contains("scan.snapshot-id") && 
message.contains("scan.tag-name"),
+                "message should name both selectors, got: {message}"
+            );
+            paimon_table_free(table);
+        }
+    }
+
+    #[test]
+    fn unsupported_scan_option_is_rejected() {
+        unsafe {
+            let table = boxed_test_table();
+            let k = CString::new("scan.watermark").unwrap();
+            let v = CString::new("0").unwrap();
+            let opts = [opt(&k, &v)];
+            // Core's validate_scan_options rejects this before resolution; the
+            // binding surfaces core's Unsupported code.
+            let (code, message) = assert_rb_err_code_message(
+                paimon_table_new_read_builder_with_options(table, 
opts.as_ptr(), 1),
+            );
+            assert_eq!(code, PaimonErrorCode::Unsupported as i32);
+            assert!(
+                message.contains("not supported"),
+                "message should say the option is not supported, got: 
{message}"
+            );
+            paimon_table_free(table);
+        }
+    }
+
+    #[test]
+    fn malformed_selector_value_does_not_silently_read_latest() {
+        unsafe {
+            let table = boxed_test_table();
+            let k = CString::new("scan.snapshot-id").unwrap();
+            let v = CString::new("abc").unwrap();
+            let opts = [opt(&k, &v)];
+            // Core swallows the parse error and falls back; the binding 
reports
+            // the unified "did not resolve" error rather than building a
+            // latest-reading builder.
+            let (code, message) = assert_rb_err_code_message(
+                paimon_table_new_read_builder_with_options(table, 
opts.as_ptr(), 1),
+            );
+            assert_eq!(code, PaimonErrorCode::InvalidInput as i32);
+            assert!(
+                message.contains("did not resolve"),
+                "message should report the selector did not resolve, got: 
{message}"
+            );
+            paimon_table_free(table);
+        }
+    }
+
+    #[test]
+    fn null_options_with_nonzero_length_is_rejected() {
+        unsafe {
+            let table = boxed_test_table();
+            assert_rb_err_and_free(paimon_table_new_read_builder_with_options(
+                table,
+                std::ptr::null(),
+                2,
+            ));
+            paimon_table_free(table);
+        }
+    }
 }

Reply via email to