|
15 | 15 | // specific language governing permissions and limitations |
16 | 16 | // under the License. |
17 | 17 |
|
| 18 | +use std::collections::HashMap; |
18 | 19 | use std::ffi::c_void; |
19 | 20 |
|
20 | 21 | use arrow_array::ffi::{FFI_ArrowArray, FFI_ArrowSchema}; |
@@ -66,30 +67,141 @@ pub unsafe extern "C" fn paimon_table_free(table: *mut paimon_table) { |
66 | 67 | free_table_wrapper(table, |t| t.inner); |
67 | 68 | } |
68 | 69 |
|
| 70 | +/// Time-travel selector option names, in the core's resolution priority order. |
| 71 | +const TIME_TRAVEL_SELECTORS: [&str; 4] = [ |
| 72 | + "scan.timestamp-millis", |
| 73 | + "scan.version", |
| 74 | + "scan.snapshot-id", |
| 75 | + "scan.tag-name", |
| 76 | +]; |
| 77 | + |
| 78 | +/// Build a `ReadBuilderState` from a table and a scan-option map, resolving any |
| 79 | +/// time-travel selector at construction time. |
| 80 | +/// |
| 81 | +/// Rejects more than one time-travel selector up front (the core silently falls |
| 82 | +/// back on a conflict, which would misattribute the failure). Otherwise runs the |
| 83 | +/// core's `copy_with_time_travel`, which validates unsupported scan options and |
| 84 | +/// resolves the selector; a set selector that does not resolve to a snapshot is |
| 85 | +/// an error, so a mistyped or missing selector can never silently read latest. |
| 86 | +unsafe fn new_read_builder_state( |
| 87 | + table: &Table, |
| 88 | + options: HashMap<String, String>, |
| 89 | +) -> Result<ReadBuilderState, *mut paimon_error> { |
| 90 | + let present: Vec<&str> = TIME_TRAVEL_SELECTORS |
| 91 | + .iter() |
| 92 | + .copied() |
| 93 | + .filter(|name| options.contains_key(*name)) |
| 94 | + .collect(); |
| 95 | + if present.len() > 1 { |
| 96 | + return Err(paimon_error::new( |
| 97 | + PaimonErrorCode::InvalidInput, |
| 98 | + format!( |
| 99 | + "Only one time-travel selector may be set, found: {}", |
| 100 | + present.join(", ") |
| 101 | + ), |
| 102 | + )); |
| 103 | + } |
| 104 | + let selector = TIME_TRAVEL_SELECTORS |
| 105 | + .iter() |
| 106 | + .find_map(|&name| options.get(name).map(|v| (name.to_string(), v.clone()))); |
| 107 | + |
| 108 | + let resolved = match runtime().block_on(table.copy_with_time_travel(options)) { |
| 109 | + Ok(t) => t, |
| 110 | + Err(e) => return Err(paimon_error::from_paimon(e)), |
| 111 | + }; |
| 112 | + |
| 113 | + if let Some((name, value)) = selector { |
| 114 | + if !resolved.has_resolved_travel_snapshot() { |
| 115 | + return Err(paimon_error::new( |
| 116 | + PaimonErrorCode::InvalidInput, |
| 117 | + format!("time-travel selector {name}={value} did not resolve to any snapshot"), |
| 118 | + )); |
| 119 | + } |
| 120 | + } |
| 121 | + |
| 122 | + Ok(ReadBuilderState { |
| 123 | + table: resolved, |
| 124 | + projected_columns: None, |
| 125 | + filter: None, |
| 126 | + case_sensitive: true, |
| 127 | + }) |
| 128 | +} |
| 129 | + |
69 | 130 | /// Create a new ReadBuilder from a Table. |
70 | 131 | /// |
71 | 132 | /// # Safety |
72 | 133 | /// `table` must be a valid pointer from `paimon_catalog_get_table`, or null (returns error). |
73 | 134 | #[no_mangle] |
74 | 135 | pub unsafe extern "C" fn paimon_table_new_read_builder( |
75 | 136 | table: *const paimon_table, |
| 137 | +) -> paimon_result_read_builder { |
| 138 | + paimon_table_new_read_builder_with_options(table, std::ptr::null(), 0) |
| 139 | +} |
| 140 | + |
| 141 | +/// Create a ReadBuilder from a Table with scan options (e.g. time-travel |
| 142 | +/// selectors `scan.snapshot-id` / `scan.tag-name` / `scan.timestamp-millis` / |
| 143 | +/// `scan.version`). At most one time-travel selector may be set. A selector that |
| 144 | +/// does not resolve to a snapshot is an error (never a silent read-of-latest). |
| 145 | +/// |
| 146 | +/// # Safety |
| 147 | +/// `table` must be a valid pointer. `options` must be a valid pointer to |
| 148 | +/// `options_len` `paimon_option` values, or null when `options_len` is 0. |
| 149 | +#[no_mangle] |
| 150 | +pub unsafe extern "C" fn paimon_table_new_read_builder_with_options( |
| 151 | + table: *const paimon_table, |
| 152 | + options: *const paimon_option, |
| 153 | + options_len: usize, |
76 | 154 | ) -> paimon_result_read_builder { |
77 | 155 | if let Err(e) = check_non_null(table, "table") { |
78 | 156 | return paimon_result_read_builder { |
79 | 157 | read_builder: std::ptr::null_mut(), |
80 | 158 | error: e, |
81 | 159 | }; |
82 | 160 | } |
| 161 | + if options.is_null() && options_len > 0 { |
| 162 | + return paimon_result_read_builder { |
| 163 | + read_builder: std::ptr::null_mut(), |
| 164 | + error: paimon_error::new( |
| 165 | + PaimonErrorCode::InvalidInput, |
| 166 | + "null options pointer with non-zero length".to_string(), |
| 167 | + ), |
| 168 | + }; |
| 169 | + } |
| 170 | + let mut map = HashMap::with_capacity(options_len); |
| 171 | + if options_len > 0 { |
| 172 | + let slice = std::slice::from_raw_parts(options, options_len); |
| 173 | + for opt in slice { |
| 174 | + let key = match validate_cstr(opt.key, "option key") { |
| 175 | + Ok(s) => s, |
| 176 | + Err(e) => { |
| 177 | + return paimon_result_read_builder { |
| 178 | + read_builder: std::ptr::null_mut(), |
| 179 | + error: e, |
| 180 | + } |
| 181 | + } |
| 182 | + }; |
| 183 | + let value = match validate_cstr(opt.value, "option value") { |
| 184 | + Ok(s) => s, |
| 185 | + Err(e) => { |
| 186 | + return paimon_result_read_builder { |
| 187 | + read_builder: std::ptr::null_mut(), |
| 188 | + error: e, |
| 189 | + } |
| 190 | + } |
| 191 | + }; |
| 192 | + map.insert(key, value); |
| 193 | + } |
| 194 | + } |
83 | 195 | let table_ref = &*((*table).inner as *const Table); |
84 | | - let state = ReadBuilderState { |
85 | | - table: table_ref.clone(), |
86 | | - projected_columns: None, |
87 | | - filter: None, |
88 | | - case_sensitive: true, |
89 | | - }; |
90 | | - paimon_result_read_builder { |
91 | | - read_builder: box_read_builder_state(state), |
92 | | - error: std::ptr::null_mut(), |
| 196 | + match new_read_builder_state(table_ref, map) { |
| 197 | + Ok(state) => paimon_result_read_builder { |
| 198 | + read_builder: box_read_builder_state(state), |
| 199 | + error: std::ptr::null_mut(), |
| 200 | + }, |
| 201 | + Err(e) => paimon_result_read_builder { |
| 202 | + read_builder: std::ptr::null_mut(), |
| 203 | + error: e, |
| 204 | + }, |
93 | 205 | } |
94 | 206 | } |
95 | 207 |
|
@@ -1611,6 +1723,18 @@ const _: unsafe extern "C" fn( |
1611 | 1723 | paimon_datum, |
1612 | 1724 | ) -> paimon_result_predicate = paimon_predicate_not_between; |
1613 | 1725 |
|
| 1726 | +// Read builder ABI signature guards. These pin the C-linked read-builder |
| 1727 | +// constructors so an accidental signature change fails to compile rather than |
| 1728 | +// silently breaking header consumers. To add behavior, introduce a new |
| 1729 | +// `paimon_table_new_read_builder_*` symbol instead of changing one of these. |
| 1730 | +const _: unsafe extern "C" fn(*const paimon_table) -> paimon_result_read_builder = |
| 1731 | + paimon_table_new_read_builder; |
| 1732 | +const _: unsafe extern "C" fn( |
| 1733 | + *const paimon_table, |
| 1734 | + *const paimon_option, |
| 1735 | + usize, |
| 1736 | +) -> paimon_result_read_builder = paimon_table_new_read_builder_with_options; |
| 1737 | + |
1614 | 1738 | #[cfg(test)] |
1615 | 1739 | mod tests { |
1616 | 1740 | use super::*; |
@@ -1848,4 +1972,163 @@ mod tests { |
1848 | 1972 | paimon_table_free(table); |
1849 | 1973 | } |
1850 | 1974 | } |
| 1975 | + |
| 1976 | + /// Assert the result is a built read builder (read_builder non-null, error |
| 1977 | + /// null) and free it. |
| 1978 | + unsafe fn assert_rb_ok_and_free(r: paimon_result_read_builder) { |
| 1979 | + assert!(!r.read_builder.is_null(), "expected a read builder"); |
| 1980 | + assert!(r.error.is_null(), "expected no error"); |
| 1981 | + paimon_read_builder_free(r.read_builder); |
| 1982 | + } |
| 1983 | + |
| 1984 | + /// Assert the result is an error (read_builder null, error non-null) and free |
| 1985 | + /// it. |
| 1986 | + unsafe fn assert_rb_err_and_free(r: paimon_result_read_builder) { |
| 1987 | + assert!(r.read_builder.is_null(), "expected no read builder"); |
| 1988 | + assert!(!r.error.is_null(), "expected an error"); |
| 1989 | + paimon_error_free(r.error); |
| 1990 | + } |
| 1991 | + |
| 1992 | + /// Assert the result is an error, extract its code and message, then free the |
| 1993 | + /// error exactly once. Lets a test pin the error identity (which failure |
| 1994 | + /// fired) instead of only its shape. |
| 1995 | + unsafe fn assert_rb_err_code_message(r: paimon_result_read_builder) -> (i32, String) { |
| 1996 | + assert!(r.read_builder.is_null(), "expected no read builder"); |
| 1997 | + assert!(!r.error.is_null(), "expected an error"); |
| 1998 | + let err = &*r.error; |
| 1999 | + let code = err.code; |
| 2000 | + let message = if err.message.data.is_null() { |
| 2001 | + String::new() |
| 2002 | + } else { |
| 2003 | + let bytes = std::slice::from_raw_parts(err.message.data, err.message.len); |
| 2004 | + String::from_utf8_lossy(bytes).into_owned() |
| 2005 | + }; |
| 2006 | + paimon_error_free(r.error); |
| 2007 | + (code, message) |
| 2008 | + } |
| 2009 | + |
| 2010 | + /// A `paimon_option` borrowing `key`/`value` (kept alive by the caller). |
| 2011 | + fn opt(key: &std::ffi::CStr, value: &std::ffi::CStr) -> paimon_option { |
| 2012 | + paimon_option { |
| 2013 | + key: key.as_ptr(), |
| 2014 | + value: value.as_ptr(), |
| 2015 | + } |
| 2016 | + } |
| 2017 | + |
| 2018 | + #[test] |
| 2019 | + fn empty_options_builds_like_plain_new_read_builder() { |
| 2020 | + unsafe { |
| 2021 | + let table = boxed_test_table(); |
| 2022 | + // Null pointer + zero length. |
| 2023 | + assert_rb_ok_and_free(paimon_table_new_read_builder_with_options( |
| 2024 | + table, |
| 2025 | + std::ptr::null(), |
| 2026 | + 0, |
| 2027 | + )); |
| 2028 | + // Non-null pointer to a zero-length array + zero length. |
| 2029 | + let empty: [paimon_option; 0] = []; |
| 2030 | + assert_rb_ok_and_free(paimon_table_new_read_builder_with_options( |
| 2031 | + table, |
| 2032 | + empty.as_ptr(), |
| 2033 | + 0, |
| 2034 | + )); |
| 2035 | + // The plain entry point (delegates with an empty map). |
| 2036 | + assert_rb_ok_and_free(paimon_table_new_read_builder(table)); |
| 2037 | + paimon_table_free(table); |
| 2038 | + } |
| 2039 | + } |
| 2040 | + |
| 2041 | + #[test] |
| 2042 | + fn non_selector_option_builds() { |
| 2043 | + unsafe { |
| 2044 | + let table = boxed_test_table(); |
| 2045 | + let k = CString::new("some.unrelated.option").unwrap(); |
| 2046 | + let v = CString::new("value").unwrap(); |
| 2047 | + let opts = [opt(&k, &v)]; |
| 2048 | + assert_rb_ok_and_free(paimon_table_new_read_builder_with_options( |
| 2049 | + table, |
| 2050 | + opts.as_ptr(), |
| 2051 | + 1, |
| 2052 | + )); |
| 2053 | + paimon_table_free(table); |
| 2054 | + } |
| 2055 | + } |
| 2056 | + |
| 2057 | + #[test] |
| 2058 | + fn more_than_one_selector_is_rejected() { |
| 2059 | + unsafe { |
| 2060 | + let table = boxed_test_table(); |
| 2061 | + let k1 = CString::new("scan.snapshot-id").unwrap(); |
| 2062 | + let v1 = CString::new("1").unwrap(); |
| 2063 | + let k2 = CString::new("scan.tag-name").unwrap(); |
| 2064 | + let v2 = CString::new("t").unwrap(); |
| 2065 | + let opts = [opt(&k1, &v1), opt(&k2, &v2)]; |
| 2066 | + let (code, message) = assert_rb_err_code_message( |
| 2067 | + paimon_table_new_read_builder_with_options(table, opts.as_ptr(), 2), |
| 2068 | + ); |
| 2069 | + // Binding-constructed rejection: InvalidInput naming both selectors. |
| 2070 | + assert_eq!(code, PaimonErrorCode::InvalidInput as i32); |
| 2071 | + assert!( |
| 2072 | + message.contains("scan.snapshot-id") && message.contains("scan.tag-name"), |
| 2073 | + "message should name both selectors, got: {message}" |
| 2074 | + ); |
| 2075 | + paimon_table_free(table); |
| 2076 | + } |
| 2077 | + } |
| 2078 | + |
| 2079 | + #[test] |
| 2080 | + fn unsupported_scan_option_is_rejected() { |
| 2081 | + unsafe { |
| 2082 | + let table = boxed_test_table(); |
| 2083 | + let k = CString::new("scan.watermark").unwrap(); |
| 2084 | + let v = CString::new("0").unwrap(); |
| 2085 | + let opts = [opt(&k, &v)]; |
| 2086 | + // Core's validate_scan_options rejects this before resolution; the |
| 2087 | + // binding surfaces core's Unsupported code. |
| 2088 | + let (code, message) = assert_rb_err_code_message( |
| 2089 | + paimon_table_new_read_builder_with_options(table, opts.as_ptr(), 1), |
| 2090 | + ); |
| 2091 | + assert_eq!(code, PaimonErrorCode::Unsupported as i32); |
| 2092 | + assert!( |
| 2093 | + message.contains("not supported"), |
| 2094 | + "message should say the option is not supported, got: {message}" |
| 2095 | + ); |
| 2096 | + paimon_table_free(table); |
| 2097 | + } |
| 2098 | + } |
| 2099 | + |
| 2100 | + #[test] |
| 2101 | + fn malformed_selector_value_does_not_silently_read_latest() { |
| 2102 | + unsafe { |
| 2103 | + let table = boxed_test_table(); |
| 2104 | + let k = CString::new("scan.snapshot-id").unwrap(); |
| 2105 | + let v = CString::new("abc").unwrap(); |
| 2106 | + let opts = [opt(&k, &v)]; |
| 2107 | + // Core swallows the parse error and falls back; the binding reports |
| 2108 | + // the unified "did not resolve" error rather than building a |
| 2109 | + // latest-reading builder. |
| 2110 | + let (code, message) = assert_rb_err_code_message( |
| 2111 | + paimon_table_new_read_builder_with_options(table, opts.as_ptr(), 1), |
| 2112 | + ); |
| 2113 | + assert_eq!(code, PaimonErrorCode::InvalidInput as i32); |
| 2114 | + assert!( |
| 2115 | + message.contains("did not resolve"), |
| 2116 | + "message should report the selector did not resolve, got: {message}" |
| 2117 | + ); |
| 2118 | + paimon_table_free(table); |
| 2119 | + } |
| 2120 | + } |
| 2121 | + |
| 2122 | + #[test] |
| 2123 | + fn null_options_with_nonzero_length_is_rejected() { |
| 2124 | + unsafe { |
| 2125 | + let table = boxed_test_table(); |
| 2126 | + assert_rb_err_and_free(paimon_table_new_read_builder_with_options( |
| 2127 | + table, |
| 2128 | + std::ptr::null(), |
| 2129 | + 2, |
| 2130 | + )); |
| 2131 | + paimon_table_free(table); |
| 2132 | + } |
| 2133 | + } |
1851 | 2134 | } |
0 commit comments