Skip to content

Commit 83f2db7

Browse files
committed
feat(table): build a catalog-free Table from a portable TableDescriptor
Add TableDescriptor (path + full TableSchema + options) — a slim, versioned, cross-language JSON wire form for rebuilding a read-only table without a catalog lookup — plus Table::from_descriptor_json and the C FFI paimon_table_from_descriptor. Includes a golden byte-identical to the Java TableDescriptorSerializer output for cross-language validation.
1 parent 2ef0702 commit 83f2db7

6 files changed

Lines changed: 567 additions & 3 deletions

File tree

.licenserc.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ header:
2727
- ".gitattributes"
2828
- ".github/PULL_REQUEST_TEMPLATE.md"
2929
- "crates/paimon/tests/**/*.json"
30+
- "crates/paimon/src/table/goldens/*.json"
3031
- "crates/paimon/testdata/**"
3132
- "third-party-licenses/openssl-1.1.1.LICENSE"
3233
- "**/go.sum"

bindings/c/src/table.rs

Lines changed: 99 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
use std::collections::HashMap;
1919
use std::ffi::c_void;
20+
use std::ptr;
2021

2122
use arrow_array::ffi::{FFI_ArrowArray, FFI_ArrowSchema};
2223
use arrow_array::{Array, StructArray};
@@ -27,8 +28,9 @@ use paimon::Plan;
2728

2829
use crate::error::{check_non_null, paimon_error, validate_cstr, PaimonErrorCode};
2930
use crate::result::{
30-
paimon_result_new_read, paimon_result_next_batch, paimon_result_plan, paimon_result_predicate,
31-
paimon_result_read_builder, paimon_result_record_batch_reader, paimon_result_table_scan,
31+
paimon_result_get_table, paimon_result_new_read, paimon_result_next_batch, paimon_result_plan,
32+
paimon_result_predicate, paimon_result_read_builder, paimon_result_record_batch_reader,
33+
paimon_result_table_scan,
3234
};
3335
use crate::runtime;
3436
use crate::types::*;
@@ -61,12 +63,96 @@ unsafe fn box_table_read_state(state: TableReadState) -> *mut paimon_table_read
6163
/// Free a paimon_table.
6264
///
6365
/// # Safety
64-
/// Only call with a table returned from `paimon_catalog_get_table`.
66+
/// Only call with a table returned from `paimon_catalog_get_table` or
67+
/// `paimon_table_from_descriptor`.
6568
#[no_mangle]
6669
pub unsafe extern "C" fn paimon_table_free(table: *mut paimon_table) {
6770
free_table_wrapper(table, |t| t.inner);
6871
}
6972

73+
/// Build a catalog-free `paimon_table` (intended for scan/read) from an
74+
/// FE-provided `TableDescriptor` JSON, without any catalog lookup.
75+
///
76+
/// `descriptor_json` is a NUL-terminated UTF-8 JSON string (see the Rust
77+
/// `paimon::table::TableDescriptor`). `options` is an array of `options_len`
78+
/// storage/runtime `paimon_option` values (credentials, endpoints), or null
79+
/// when `options_len` is 0; they configure `FileIO` only and never change table
80+
/// semantics. The returned table is intended for scan/read.
81+
///
82+
/// On success `table` is non-null and `error` is null; free it with
83+
/// `paimon_table_free`. On failure `table` is null and `error` is non-null.
84+
///
85+
/// # Safety
86+
/// `descriptor_json` must be a valid C string. `options` must point to
87+
/// `options_len` valid `paimon_option` values, or be null when `options_len` is 0.
88+
#[no_mangle]
89+
pub unsafe extern "C" fn paimon_table_from_descriptor(
90+
descriptor_json: *const std::ffi::c_char,
91+
options: *const paimon_option,
92+
options_len: usize,
93+
) -> paimon_result_get_table {
94+
let json = match validate_cstr(descriptor_json, "descriptor_json") {
95+
Ok(s) => s,
96+
Err(e) => {
97+
return paimon_result_get_table {
98+
table: ptr::null_mut(),
99+
error: e,
100+
}
101+
}
102+
};
103+
104+
if options.is_null() && options_len > 0 {
105+
return paimon_result_get_table {
106+
table: ptr::null_mut(),
107+
error: paimon_error::new(
108+
PaimonErrorCode::InvalidInput,
109+
"null options pointer with non-zero length".to_string(),
110+
),
111+
};
112+
}
113+
let mut opts: HashMap<String, String> = HashMap::new();
114+
if options_len > 0 {
115+
let options_slice = std::slice::from_raw_parts(options, options_len);
116+
for opt in options_slice {
117+
let key = match validate_cstr(opt.key, "option key") {
118+
Ok(s) => s,
119+
Err(e) => {
120+
return paimon_result_get_table {
121+
table: ptr::null_mut(),
122+
error: e,
123+
}
124+
}
125+
};
126+
let value = match validate_cstr(opt.value, "option value") {
127+
Ok(s) => s,
128+
Err(e) => {
129+
return paimon_result_get_table {
130+
table: ptr::null_mut(),
131+
error: e,
132+
}
133+
}
134+
};
135+
opts.insert(key, value);
136+
}
137+
}
138+
139+
match Table::from_descriptor_json(&json, opts) {
140+
Ok(table) => {
141+
let wrapper = Box::new(paimon_table {
142+
inner: Box::into_raw(Box::new(table)) as *mut c_void,
143+
});
144+
paimon_result_get_table {
145+
table: Box::into_raw(wrapper),
146+
error: ptr::null_mut(),
147+
}
148+
}
149+
Err(e) => paimon_result_get_table {
150+
table: ptr::null_mut(),
151+
error: paimon_error::from_paimon(e),
152+
},
153+
}
154+
}
155+
70156
/// Time-travel selector option names, in the core's resolution priority order.
71157
const TIME_TRAVEL_SELECTORS: [&str; 4] = [
72158
"scan.timestamp-millis",
@@ -1787,6 +1873,16 @@ const _: unsafe extern "C" fn(
17871873
const _: unsafe extern "C" fn(*const u8, usize) -> paimon_result_plan =
17881874
paimon_plan_from_split_bytes;
17891875

1876+
// Descriptor table constructor ABI signature guard. Pins the catalog-free table
1877+
// constructor so an accidental signature change fails to compile rather than
1878+
// silently breaking header consumers. To add behavior, introduce a new symbol
1879+
// instead of changing this one.
1880+
const _: unsafe extern "C" fn(
1881+
*const std::ffi::c_char,
1882+
*const paimon_option,
1883+
usize,
1884+
) -> paimon_result_get_table = paimon_table_from_descriptor;
1885+
17901886
#[cfg(test)]
17911887
mod tests {
17921888
use super::*;

bindings/c/src/tests.rs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2474,3 +2474,55 @@ fn plan_from_split_bytes_rejects_garbage() {
24742474
assert!(!r.error.is_null());
24752475
unsafe { paimon_error_free(r.error) };
24762476
}
2477+
2478+
// A minimal but valid v1 TableDescriptor JSON (scalar schema; the descriptor
2479+
// parser's complex-type coverage lives in the paimon crate's unit tests).
2480+
const DESCRIPTOR_V1_JSON: &str = r#"{
2481+
"version": 1,
2482+
"path": "file:///tmp/paimon-c-test/db.db/t",
2483+
"database": "db",
2484+
"name": "t",
2485+
"tableSchema": {
2486+
"version": 3,
2487+
"id": 0,
2488+
"fields": [ { "id": 0, "name": "id", "type": "INT NOT NULL" } ],
2489+
"highestFieldId": 0,
2490+
"partitionKeys": [],
2491+
"primaryKeys": ["id"],
2492+
"options": {},
2493+
"timeMillis": 1
2494+
}
2495+
}"#;
2496+
2497+
#[test]
2498+
fn table_from_descriptor_builds_table() {
2499+
let json = CString::new(DESCRIPTOR_V1_JSON).unwrap();
2500+
unsafe {
2501+
let result = paimon_table_from_descriptor(json.as_ptr(), ptr::null(), 0);
2502+
assert!(result.error.is_null(), "from_descriptor should not error");
2503+
assert!(!result.table.is_null(), "table handle must be non-null");
2504+
paimon_table_free(result.table);
2505+
}
2506+
}
2507+
2508+
#[test]
2509+
fn table_from_descriptor_rejects_unknown_version() {
2510+
let json =
2511+
CString::new(DESCRIPTOR_V1_JSON.replace("\"version\": 1", "\"version\": 2")).unwrap();
2512+
unsafe {
2513+
let result = paimon_table_from_descriptor(json.as_ptr(), ptr::null(), 0);
2514+
assert!(!result.error.is_null(), "unknown version must error");
2515+
assert!(result.table.is_null());
2516+
paimon_error_free(result.error);
2517+
}
2518+
}
2519+
2520+
#[test]
2521+
fn table_from_descriptor_rejects_null_json() {
2522+
unsafe {
2523+
let result = paimon_table_from_descriptor(ptr::null(), ptr::null(), 0);
2524+
assert!(!result.error.is_null(), "null json must error");
2525+
assert!(result.table.is_null());
2526+
paimon_error_free(result.error);
2527+
}
2528+
}

0 commit comments

Comments
 (0)