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 e1aaf1e6 feat(c): export vector-search projection over the C FFI (#608)
e1aaf1e6 is described below
commit e1aaf1e62a2a5ec7a3318a74a5b17a0d512abf69
Author: Junrui Lee <[email protected]>
AuthorDate: Sun Jul 26 19:41:28 2026 +0800
feat(c): export vector-search projection over the C FFI (#608)
---
bindings/c/src/tests.rs | 143 ++++++++++++++++++++++++++++++++++++++++
bindings/c/src/types.rs | 3 +
bindings/c/src/vector_search.rs | 64 ++++++++++++++++++
3 files changed, 210 insertions(+)
diff --git a/bindings/c/src/tests.rs b/bindings/c/src/tests.rs
index 58fb27ab..9c35fb98 100644
--- a/bindings/c/src/tests.rs
+++ b/bindings/c/src/tests.rs
@@ -2474,3 +2474,146 @@ fn plan_from_split_bytes_rejects_garbage() {
assert!(!r.error.is_null());
unsafe { paimon_error_free(r.error) };
}
+
+// --- Vector-search projection through execute_read ------------------------
+
+/// Core Rust reference: sorted output column names materialized by
`execute_read`
+/// under an optional projection.
+fn rust_execute_read_column_names(
+ table: &Table,
+ column: &str,
+ query: Vec<f32>,
+ limit: usize,
+ projection: Option<&[&str]>,
+) -> Vec<String> {
+ crate::runtime().block_on(async {
+ let mut builder = table.new_vector_search_builder();
+ builder
+ .with_vector_column(column)
+ .with_query_vector(query)
+ .with_limit(limit);
+ if let Some(cols) = projection {
+ builder.with_projection(cols);
+ }
+ let mut stream = builder.execute_read().await.unwrap();
+ let mut names: Vec<String> = Vec::new();
+ while let Some(b) = stream.try_next().await.unwrap() {
+ names = b
+ .schema()
+ .fields()
+ .iter()
+ .map(|f| f.name().to_string())
+ .collect();
+ }
+ names.sort();
+ names
+ })
+}
+
+/// C path: sorted output column names materialized by a configured builder's
+/// `execute_read`. Frees the builder and reader.
+unsafe fn c_execute_read_column_names(builder: *mut
paimon_vector_search_builder) -> Vec<String> {
+ let result = paimon_vector_search_builder_execute_read(builder);
+ paimon_vector_search_builder_free(builder);
+ assert!(result.error.is_null(), "execute_read should not error");
+ assert!(!result.reader.is_null());
+
+ let mut names: Vec<String> = Vec::new();
+ loop {
+ let next = paimon_record_batch_reader_next(result.reader);
+ assert!(next.error.is_null());
+ if next.batch.array.is_null() {
+ break; // EOF
+ }
+ let batch = import_batch(&next.batch);
+ names = batch
+ .schema()
+ .fields()
+ .iter()
+ .map(|f| f.name().to_string())
+ .collect();
+ paimon_arrow_batch_free(next.batch);
+ }
+ paimon_record_batch_reader_free(result.reader);
+ names.sort();
+ names
+}
+
+#[test]
+fn vector_search_pk_projection_restricts_columns() {
+ // Projecting a subset through execute_read must materialize only those
user
+ // columns plus the score column — the unprojected vector column is
excluded.
+ let path = "memory:/vsearch_pk_projection";
+ let (query, vectors) = pk_fixture_smoke();
+ let table = build_pk_vector_table(path, &vectors);
+
+ // Reference: unprojected read materializes every user column (id,
embedding);
+ // projecting ["id"] yields id + score only. Sorted,
`__paimon_search_score`
+ // (leading underscore) precedes `id`.
+ let full = rust_execute_read_column_names(&table, VECTOR_COLUMN,
query.to_vec(), 3, None);
+ assert!(
+ full.contains(&"id".to_string()) &&
full.contains(&VECTOR_COLUMN.to_string()),
+ "unprojected read must materialize every user column, got {full:?}"
+ );
+ let rust_projected =
+ rust_execute_read_column_names(&table, VECTOR_COLUMN, query.to_vec(),
3, Some(&["id"]));
+ assert_eq!(
+ rust_projected,
+ vec![SCORE_COLUMN.to_string(), "id".to_string()],
+ "reference projection ['id'] must yield id + score only"
+ );
+
+ let handle = unsafe { wrap_table(table) };
+ unsafe {
+ let builder = c_vector_builder(handle, VECTOR_COLUMN, &query, 3,
ptr::null_mut());
+ let id = CString::new("id").unwrap();
+ let cols = [id.as_ptr(), ptr::null()];
+ assert!(
+ paimon_vector_search_builder_with_projection(builder,
cols.as_ptr()).is_null(),
+ "with_projection must accept a valid column"
+ );
+ let c_names = c_execute_read_column_names(builder);
+ assert_eq!(
+ c_names, rust_projected,
+ "C projected columns must match the Rust reference (id + score, no
vector column)"
+ );
+ assert!(
+ !c_names.contains(&VECTOR_COLUMN.to_string()),
+ "projected read must exclude the unprojected vector column"
+ );
+ unwrap_table(handle);
+ }
+}
+
+#[test]
+fn vector_search_projection_unknown_column_errors_at_execute_read() {
+ // The C setter defers column-name validation (the core vector builder's
+ // with_projection is infallible). An unknown projected column must
therefore
+ // surface as an error from execute_read, not a silent empty/EOF reader.
+ let path = "memory:/vsearch_pk_projection_unknown";
+ let (query, vectors) = pk_fixture_smoke();
+ let table = build_pk_vector_table(path, &vectors);
+ let handle = unsafe { wrap_table(table) };
+ unsafe {
+ let builder = c_vector_builder(handle, VECTOR_COLUMN, &query, 3,
ptr::null_mut());
+ let bad = CString::new("does_not_exist").unwrap();
+ let cols = [bad.as_ptr(), ptr::null()];
+ // The setter itself accepts the name (validation is deferred).
+ assert!(
+ paimon_vector_search_builder_with_projection(builder,
cols.as_ptr()).is_null(),
+ "with_projection defers validation, so the setter must not error"
+ );
+ let result = paimon_vector_search_builder_execute_read(builder);
+ paimon_vector_search_builder_free(builder);
+ assert!(
+ result.reader.is_null(),
+ "an unknown projected column must not yield a reader"
+ );
+ assert!(
+ !result.error.is_null(),
+ "an unknown projected column must fail loud at execute_read"
+ );
+ paimon_error_free(result.error);
+ unwrap_table(handle);
+ }
+}
diff --git a/bindings/c/src/types.rs b/bindings/c/src/types.rs
index 067353fe..1dfd9c3f 100644
--- a/bindings/c/src/types.rs
+++ b/bindings/c/src/types.rs
@@ -143,6 +143,9 @@ pub(crate) struct VectorSearchState {
pub limit: Option<usize>,
pub options: std::collections::HashMap<String, String>,
pub filter: Option<Predicate>,
+ // Optional column projection applied by `execute_read` (plus the
always-appended
+ // `__paimon_search_score`). `None` materializes every user column.
+ pub projection: Option<Vec<String>>,
}
/// A typed literal value for predicate comparison, passed across FFI.
diff --git a/bindings/c/src/vector_search.rs b/bindings/c/src/vector_search.rs
index 3da28248..4a3e0ecc 100644
--- a/bindings/c/src/vector_search.rs
+++ b/bindings/c/src/vector_search.rs
@@ -60,6 +60,7 @@ pub unsafe extern "C" fn
paimon_table_new_vector_search_builder(
limit: None,
options: HashMap::new(),
filter: None,
+ projection: None,
};
let inner = Box::into_raw(Box::new(state)) as *mut c_void;
paimon_result_vector_search_builder {
@@ -214,6 +215,61 @@ pub unsafe extern "C" fn
paimon_vector_search_builder_with_filter(
std::ptr::null_mut()
}
+/// Restrict the columns materialized by
`paimon_vector_search_builder_execute_read`
+/// to `columns` (plus the always-appended `__paimon_search_score`). Without
this
+/// call `execute_read` materializes every user table column. Only affects
+/// `execute_read`.
+///
+/// `columns` is a null-terminated array of null-terminated C strings; output
+/// order follows the caller-specified order. An empty list is a valid
zero-column
+/// projection (only the score column is materialized). Pass null to clear any
+/// previously set projection.
+///
+/// Unlike `paimon_read_builder_with_projection`, this does not validate column
+/// names eagerly: the vector builder resolves the projection against the
schema
+/// when the search runs, so an unknown column surfaces as an error from
+/// `paimon_vector_search_builder_execute_read`.
+///
+/// # Safety
+/// `b` must be a valid pointer from `paimon_table_new_vector_search_builder`,
or
+/// null (returns error). `columns` must be a null-terminated array of
+/// null-terminated C strings, or null to clear the projection.
+#[no_mangle]
+pub unsafe extern "C" fn paimon_vector_search_builder_with_projection(
+ b: *mut paimon_vector_search_builder,
+ columns: *const *const c_char,
+) -> *mut paimon_error {
+ if let Err(e) = check_non_null(b, "b") {
+ return e;
+ }
+
+ let state = &mut *((*b).inner as *mut VectorSearchState);
+
+ if columns.is_null() {
+ state.projection = None;
+ return std::ptr::null_mut();
+ }
+
+ let mut col_names = Vec::new();
+ let mut ptr = columns;
+ while !(*ptr).is_null() {
+ let c_str = std::ffi::CStr::from_ptr(*ptr);
+ match c_str.to_str() {
+ Ok(s) => col_names.push(s.to_string()),
+ Err(e) => {
+ return paimon_error::new(
+ PaimonErrorCode::InvalidInput,
+ format!("Invalid UTF-8 in projection column name: {e}"),
+ );
+ }
+ }
+ ptr = ptr.add(1);
+ }
+
+ state.projection = Some(col_names);
+ std::ptr::null_mut()
+}
+
/// Free a paimon_vector_search_builder.
///
/// # Safety
@@ -264,6 +320,10 @@ pub unsafe extern "C" fn
paimon_vector_search_builder_execute_read(
if let Some(f) = &state.filter {
builder.with_filter(f.clone());
}
+ if let Some(cols) = &state.projection {
+ let col_refs: Vec<&str> = cols.iter().map(String::as_str).collect();
+ builder.with_projection(&col_refs);
+ }
match runtime().block_on(builder.execute_read()) {
Ok(stream) => {
@@ -317,6 +377,10 @@ const _: unsafe extern "C" fn(
*mut paimon_vector_search_builder,
*mut paimon_predicate,
) -> *mut paimon_error = paimon_vector_search_builder_with_filter;
+const _: unsafe extern "C" fn(
+ *mut paimon_vector_search_builder,
+ *const *const c_char,
+) -> *mut paimon_error = paimon_vector_search_builder_with_projection;
const _: unsafe extern "C" fn(*mut paimon_vector_search_builder) =
paimon_vector_search_builder_free;
const _: unsafe extern "C" fn(