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 25a04aac feat(c): build a catalog-free Table from a resolved schema
JSON (#595)
25a04aac is described below
commit 25a04aac6ddc0ac523fbee4393cc50c8b462387c
Author: Junrui Lee <[email protected]>
AuthorDate: Thu Jul 30 09:09:31 2026 +0800
feat(c): build a catalog-free Table from a resolved schema JSON (#595)
---
Cargo.lock | 1 +
bindings/c/Cargo.toml | 1 +
bindings/c/src/table.rs | 181 ++++++++++++++++++++++-
bindings/c/src/tests.rs | 308 +++++++++++++++++++++++++++++++++++++++
bindings/c/src/vector_search.rs | 3 +-
bindings/c/src/write.rs | 3 +-
crates/paimon/src/spec/schema.rs | 280 +++++++++++++++++++++++++++++++++++
crates/paimon/src/table/mod.rs | 40 +++++
8 files changed, 809 insertions(+), 8 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index 7ee0257e..47eb4598 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4595,6 +4595,7 @@ dependencies = [
"futures",
"paimon",
"paimon-vindex-core",
+ "serde_json",
"tokio",
]
diff --git a/bindings/c/Cargo.toml b/bindings/c/Cargo.toml
index f02c3e83..2340f970 100644
--- a/bindings/c/Cargo.toml
+++ b/bindings/c/Cargo.toml
@@ -37,6 +37,7 @@ futures = "0.3"
arrow = { workspace = true }
arrow-array = { workspace = true }
arrow-schema = { workspace = true }
+serde_json = "1.0.120"
[dev-dependencies]
# Test-only: the vector-search integration tests build a real primary-key
vindex
diff --git a/bindings/c/src/table.rs b/bindings/c/src/table.rs
index 05f82e31..0ccf6d34 100644
--- a/bindings/c/src/table.rs
+++ b/bindings/c/src/table.rs
@@ -16,19 +16,22 @@
// under the License.
use std::collections::HashMap;
-use std::ffi::c_void;
+use std::ffi::{c_char, c_void};
use arrow_array::ffi::{FFI_ArrowArray, FFI_ArrowSchema};
use arrow_array::{Array, StructArray};
use futures::StreamExt;
-use paimon::spec::{DataField, DataType, Datum, Predicate, PredicateBuilder};
+use paimon::catalog::{Identifier, DEFAULT_MAIN_BRANCH};
+use paimon::io::FileIO;
+use paimon::spec::{DataField, DataType, Datum, Predicate, PredicateBuilder,
TableSchema};
use paimon::table::{ArrowRecordBatchStream, DataSplit, Table};
use paimon::Plan;
use crate::error::{check_non_null, paimon_error, validate_cstr,
PaimonErrorCode};
use crate::result::{
- paimon_result_new_read, paimon_result_next_batch, paimon_result_plan,
paimon_result_predicate,
- paimon_result_read_builder, paimon_result_record_batch_reader,
paimon_result_table_scan,
+ paimon_result_get_table, paimon_result_new_read, paimon_result_next_batch,
paimon_result_plan,
+ paimon_result_predicate, paimon_result_read_builder,
paimon_result_record_batch_reader,
+ paimon_result_table_scan,
};
use crate::runtime;
use crate::types::*;
@@ -58,10 +61,166 @@ unsafe fn box_table_read_state(state: TableReadState) ->
*mut paimon_table_read
// ======================= Table ===============================
+/// Create a table directly from a resolved Paimon table schema JSON.
+///
+/// This constructor does not create a catalog or derive a warehouse. Storage
+/// options are used only to build FileIO; they are not merged into the
supplied
+/// table schema. `branch` selects the branch-scoped managers while preserving
+/// the supplied schema; pass null to default to the `main` branch.
+///
+/// # Safety
+/// All string pointers except `branch` must be valid null-terminated C
strings.
+/// `branch` may be null to select the default `main` branch, or a valid
+/// null-terminated C string. `storage_options` must point to
+/// `storage_options_len` valid `paimon_option` values, or be null when
+/// `storage_options_len` is 0.
+#[no_mangle]
+pub unsafe extern "C" fn paimon_table_from_schema_json(
+ table_path: *const c_char,
+ table_schema_json: *const c_char,
+ database: *const c_char,
+ table_name: *const c_char,
+ branch: *const c_char,
+ storage_options: *const paimon_option,
+ storage_options_len: usize,
+) -> paimon_result_get_table {
+ let table_path = match validate_cstr(table_path, "table_path") {
+ Ok(value) => value,
+ Err(error) => {
+ return paimon_result_get_table {
+ table: std::ptr::null_mut(),
+ error,
+ }
+ }
+ };
+ let table_schema_json = match validate_cstr(table_schema_json,
"table_schema_json") {
+ Ok(value) => value,
+ Err(error) => {
+ return paimon_result_get_table {
+ table: std::ptr::null_mut(),
+ error,
+ }
+ }
+ };
+ let database = match validate_cstr(database, "database") {
+ Ok(value) => value,
+ Err(error) => {
+ return paimon_result_get_table {
+ table: std::ptr::null_mut(),
+ error,
+ }
+ }
+ };
+ let table_name = match validate_cstr(table_name, "table_name") {
+ Ok(value) => value,
+ Err(error) => {
+ return paimon_result_get_table {
+ table: std::ptr::null_mut(),
+ error,
+ }
+ }
+ };
+ let branch = if branch.is_null() {
+ DEFAULT_MAIN_BRANCH.to_string()
+ } else {
+ match validate_cstr(branch, "branch") {
+ Ok(value) => value,
+ Err(error) => {
+ return paimon_result_get_table {
+ table: std::ptr::null_mut(),
+ error,
+ }
+ }
+ }
+ };
+ if storage_options.is_null() && storage_options_len > 0 {
+ return paimon_result_get_table {
+ table: std::ptr::null_mut(),
+ error: paimon_error::new(
+ PaimonErrorCode::InvalidInput,
+ "null storage_options pointer with non-zero
length".to_string(),
+ ),
+ };
+ }
+
+ let schema = match serde_json::from_str::<TableSchema>(&table_schema_json)
{
+ Ok(schema) => schema,
+ Err(error) => {
+ return paimon_result_get_table {
+ table: std::ptr::null_mut(),
+ error: paimon_error::new(
+ PaimonErrorCode::InvalidInput,
+ format!("Failed to parse table schema JSON: {error}"),
+ ),
+ }
+ }
+ };
+
+ let mut options = HashMap::with_capacity(storage_options_len);
+ if storage_options_len > 0 {
+ for option in std::slice::from_raw_parts(storage_options,
storage_options_len) {
+ let key = match validate_cstr(option.key, "storage option key") {
+ Ok(value) => value,
+ Err(error) => {
+ return paimon_result_get_table {
+ table: std::ptr::null_mut(),
+ error,
+ }
+ }
+ };
+ let value = match validate_cstr(option.value, "storage option
value") {
+ Ok(value) => value,
+ Err(error) => {
+ return paimon_result_get_table {
+ table: std::ptr::null_mut(),
+ error,
+ }
+ }
+ };
+ options.insert(key, value);
+ }
+ }
+
+ let file_io = match FileIO::from_path(&table_path)
+ .and_then(|builder| builder.with_props(options.iter()).build())
+ {
+ Ok(file_io) => file_io,
+ Err(error) => {
+ return paimon_result_get_table {
+ table: std::ptr::null_mut(),
+ error: paimon_error::from_paimon(error),
+ }
+ }
+ };
+ let table = match Table::from_resolved_schema(
+ file_io,
+ Identifier::new(database, table_name),
+ table_path,
+ schema,
+ branch,
+ ) {
+ Ok(table) => table,
+ Err(error) => {
+ return paimon_result_get_table {
+ table: std::ptr::null_mut(),
+ error: paimon_error::from_paimon(error),
+ }
+ }
+ };
+ let wrapper = Box::new(paimon_table {
+ inner: Box::into_raw(Box::new(table)) as *mut c_void,
+ });
+ paimon_result_get_table {
+ table: Box::into_raw(wrapper),
+ error: std::ptr::null_mut(),
+ }
+}
+
/// Free a paimon_table.
///
/// # Safety
-/// Only call with a table returned from `paimon_catalog_get_table`.
+/// Only call with a table returned from `paimon_catalog_get_table` or
+/// `paimon_table_from_schema_json`.
#[no_mangle]
pub unsafe extern "C" fn paimon_table_free(table: *mut paimon_table) {
free_table_wrapper(table, |t| t.inner);
@@ -130,7 +289,8 @@ unsafe fn new_read_builder_state(
/// Create a new ReadBuilder from a Table.
///
/// # Safety
-/// `table` must be a valid pointer from `paimon_catalog_get_table`, or null
(returns error).
+/// `table` must be a valid pointer from `paimon_catalog_get_table` or
+/// `paimon_table_from_schema_json`, or null (returns error).
#[no_mangle]
pub unsafe extern "C" fn paimon_table_new_read_builder(
table: *const paimon_table,
@@ -1773,6 +1933,15 @@ const _: unsafe extern "C" fn(
// 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 c_char,
+ *const c_char,
+ *const c_char,
+ *const c_char,
+ *const c_char,
+ *const paimon_option,
+ usize,
+) -> paimon_result_get_table = paimon_table_from_schema_json;
const _: unsafe extern "C" fn(*const paimon_table) ->
paimon_result_read_builder =
paimon_table_new_read_builder;
const _: unsafe extern "C" fn(
diff --git a/bindings/c/src/tests.rs b/bindings/c/src/tests.rs
index 9c35fb98..d29c7eb8 100644
--- a/bindings/c/src/tests.rs
+++ b/bindings/c/src/tests.rs
@@ -85,6 +85,10 @@ unsafe fn unwrap_table(table: *mut paimon_table) {
}
}
+unsafe fn table_ref<'a>(table: *const paimon_table) -> &'a Table {
+ &*((*table).inner as *const Table)
+}
+
fn make_batch(ids: Vec<i32>, names: Vec<&str>) -> RecordBatch {
let schema = Arc::new(ArrowSchema::new(vec![
ArrowField::new("id", ArrowDataType::Int32, false),
@@ -293,6 +297,310 @@ unsafe fn read_rows_ffi(table: *const paimon_table) ->
Vec<(i32, String)> {
rows
}
+// =========================================================================
+// Catalog-free table construction tests
+// =========================================================================
+
+#[test]
+fn test_table_from_schema_json_preserves_resolved_schema_and_branch() {
+ let path = CString::new("memory:/test_resolved_branch").unwrap();
+ let database = CString::new("default").unwrap();
+ let table_name = CString::new("test").unwrap();
+ let branch = CString::new("dev").unwrap();
+ let schema = simple_table_schema();
+ let schema_json =
CString::new(serde_json::to_string(&schema).unwrap()).unwrap();
+ let option_key = CString::new("storage-only-option").unwrap();
+ let option_value = CString::new("secret").unwrap();
+ let storage_options = [paimon_option {
+ key: option_key.as_ptr(),
+ value: option_value.as_ptr(),
+ }];
+
+ unsafe {
+ let result = paimon_table_from_schema_json(
+ path.as_ptr(),
+ schema_json.as_ptr(),
+ database.as_ptr(),
+ table_name.as_ptr(),
+ branch.as_ptr(),
+ storage_options.as_ptr(),
+ storage_options.len(),
+ );
+
+ assert!(result.error.is_null());
+ assert!(!result.table.is_null());
+ let table = table_ref(result.table);
+ assert_eq!(table.location(), "memory:/test_resolved_branch");
+ assert_eq!(table.identifier(), &Identifier::new("default", "test"));
+ // The resolved schema is preserved as-is (no normalization or
mutation).
+ assert_eq!(table.schema(), &schema);
+ assert_eq!(table.branch(), "dev");
+ assert!(table.is_branch_reference());
+ assert_eq!(
+ table.schema_manager().schema_path(schema.id()),
+ "memory:/test_resolved_branch/branch/branch-dev/schema/schema-0"
+ );
+ assert_eq!(
+ table.snapshot_manager().snapshot_path(1),
+
"memory:/test_resolved_branch/branch/branch-dev/snapshot/snapshot-1"
+ );
+ assert_eq!(
+ table.tag_manager().tag_path("release"),
+ "memory:/test_resolved_branch/branch/branch-dev/tag/tag-release"
+ );
+ assert!(!table.schema().options().contains_key("storage-only-option"));
+
+ let read_builder = paimon_table_new_read_builder(result.table);
+ assert!(read_builder.error.is_null());
+ assert!(!read_builder.read_builder.is_null());
+ paimon_read_builder_free(read_builder.read_builder);
+
+ paimon_table_free(result.table);
+ }
+}
+
+#[test]
+fn test_table_from_schema_json_rejects_missing_primary_key_column() {
+ // A syntactically valid schema whose primaryKeys reference a non-existent
+ // column must be rejected at construction, not panic later (e.g. the write
+ // path unwraps a PartitionComputer built from missing columns).
+ let path = CString::new("memory:/test_resolved_bad_pk").unwrap();
+ let database = CString::new("default").unwrap();
+ let table_name = CString::new("test").unwrap();
+ let branch = CString::new("main").unwrap();
+
+ // simple_table_schema has columns id, name; declare a PK on a missing
column.
+ let base = simple_table_schema();
+ let bad_json = serde_json::to_value(&base).unwrap();
+ let mut bad_json = bad_json;
+ bad_json["primaryKeys"] = serde_json::json!(["missing"]);
+ let schema_json =
CString::new(serde_json::to_string(&bad_json).unwrap()).unwrap();
+
+ unsafe {
+ let result = paimon_table_from_schema_json(
+ path.as_ptr(),
+ schema_json.as_ptr(),
+ database.as_ptr(),
+ table_name.as_ptr(),
+ branch.as_ptr(),
+ std::ptr::null(),
+ 0,
+ );
+ assert!(result.table.is_null());
+ assert!(!result.error.is_null());
+ assert_eq!((*result.error).code, PaimonErrorCode::InvalidInput as i32);
+ paimon_error_free(result.error);
+ }
+}
+
+#[test]
+fn test_table_from_schema_json_rejects_reserved_field_name() {
+ // A user column colliding with a system field name (_ROW_ID) would be
+ // silently replaced by the system row number on read; reject at
construction.
+ let path = CString::new("memory:/test_resolved_reserved_field").unwrap();
+ let database = CString::new("default").unwrap();
+ let table_name = CString::new("test").unwrap();
+ let branch = CString::new("main").unwrap();
+
+ let base = simple_table_schema();
+ let mut bad_json = serde_json::to_value(&base).unwrap();
+ // Rename the second field ("name") to the reserved system name "_ROW_ID".
+ bad_json["fields"][1]["name"] = serde_json::json!("_ROW_ID");
+ let schema_json =
CString::new(serde_json::to_string(&bad_json).unwrap()).unwrap();
+
+ unsafe {
+ let result = paimon_table_from_schema_json(
+ path.as_ptr(),
+ schema_json.as_ptr(),
+ database.as_ptr(),
+ table_name.as_ptr(),
+ branch.as_ptr(),
+ std::ptr::null(),
+ 0,
+ );
+ assert!(result.table.is_null());
+ assert!(!result.error.is_null());
+ assert_eq!((*result.error).code, PaimonErrorCode::InvalidInput as i32);
+ paimon_error_free(result.error);
+ }
+}
+
+#[test]
+fn test_table_from_schema_json_main_branch_uses_main_schema_directory() {
+ let path = CString::new("memory:/test_resolved_main").unwrap();
+ let database = CString::new("default").unwrap();
+ let table_name = CString::new("test").unwrap();
+ let branch = CString::new("main").unwrap();
+ let schema = simple_table_schema();
+ let schema_json =
CString::new(serde_json::to_string(&schema).unwrap()).unwrap();
+
+ unsafe {
+ let result = paimon_table_from_schema_json(
+ path.as_ptr(),
+ schema_json.as_ptr(),
+ database.as_ptr(),
+ table_name.as_ptr(),
+ branch.as_ptr(),
+ std::ptr::null(),
+ 0,
+ );
+
+ assert!(result.error.is_null());
+ assert!(!result.table.is_null());
+ let table = table_ref(result.table);
+ assert_eq!(table.branch(), "main");
+ assert!(!table.is_branch_reference());
+ assert_eq!(
+ table.schema_manager().schema_path(schema.id()),
+ "memory:/test_resolved_main/schema/schema-0"
+ );
+
+ paimon_table_free(result.table);
+ }
+}
+
+#[test]
+fn test_table_from_schema_json_null_branch_defaults_to_main() {
+ let path = CString::new("memory:/test_resolved_null_branch").unwrap();
+ let database = CString::new("default").unwrap();
+ let table_name = CString::new("test").unwrap();
+ let schema = simple_table_schema();
+ let schema_json =
CString::new(serde_json::to_string(&schema).unwrap()).unwrap();
+
+ unsafe {
+ let result = paimon_table_from_schema_json(
+ path.as_ptr(),
+ schema_json.as_ptr(),
+ database.as_ptr(),
+ table_name.as_ptr(),
+ std::ptr::null(),
+ std::ptr::null(),
+ 0,
+ );
+
+ assert!(result.error.is_null());
+ assert!(!result.table.is_null());
+ let table = table_ref(result.table);
+ assert_eq!(table.branch(), "main");
+ assert!(!table.is_branch_reference());
+ assert_eq!(
+ table.schema_manager().schema_path(schema.id()),
+ "memory:/test_resolved_null_branch/schema/schema-0"
+ );
+
+ paimon_table_free(result.table);
+ }
+}
+
+#[test]
+fn test_table_from_schema_json_rejects_invalid_input() {
+ let path = CString::new("memory:/test_resolved_invalid").unwrap();
+ let malformed_schema = CString::new("not-json").unwrap();
+ let database = CString::new("default").unwrap();
+ let table_name = CString::new("test").unwrap();
+ let branch = CString::new("main").unwrap();
+
+ unsafe {
+ let malformed = paimon_table_from_schema_json(
+ path.as_ptr(),
+ malformed_schema.as_ptr(),
+ database.as_ptr(),
+ table_name.as_ptr(),
+ branch.as_ptr(),
+ std::ptr::null(),
+ 0,
+ );
+ assert!(malformed.table.is_null());
+ assert!(!malformed.error.is_null());
+ assert_eq!(
+ (*malformed.error).code,
+ PaimonErrorCode::InvalidInput as i32
+ );
+ paimon_error_free(malformed.error);
+
+ let schema = simple_table_schema();
+ let schema_json =
CString::new(serde_json::to_string(&schema).unwrap()).unwrap();
+ let null_path = paimon_table_from_schema_json(
+ std::ptr::null(),
+ schema_json.as_ptr(),
+ database.as_ptr(),
+ table_name.as_ptr(),
+ branch.as_ptr(),
+ std::ptr::null(),
+ 0,
+ );
+ assert!(null_path.table.is_null());
+ assert!(!null_path.error.is_null());
+ assert_eq!(
+ (*null_path.error).code,
+ PaimonErrorCode::InvalidInput as i32
+ );
+ paimon_error_free(null_path.error);
+
+ let null_options = paimon_table_from_schema_json(
+ path.as_ptr(),
+ schema_json.as_ptr(),
+ database.as_ptr(),
+ table_name.as_ptr(),
+ branch.as_ptr(),
+ std::ptr::null(),
+ 1,
+ );
+ assert!(null_options.table.is_null());
+ assert!(!null_options.error.is_null());
+ assert_eq!(
+ (*null_options.error).code,
+ PaimonErrorCode::InvalidInput as i32
+ );
+ paimon_error_free(null_options.error);
+
+ let invalid_branch = CString::new("../dev").unwrap();
+ let unsafe_branch = paimon_table_from_schema_json(
+ path.as_ptr(),
+ schema_json.as_ptr(),
+ database.as_ptr(),
+ table_name.as_ptr(),
+ invalid_branch.as_ptr(),
+ std::ptr::null(),
+ 0,
+ );
+ assert!(unsafe_branch.table.is_null());
+ assert!(!unsafe_branch.error.is_null());
+ assert_eq!(
+ (*unsafe_branch.error).code,
+ PaimonErrorCode::InvalidInput as i32
+ );
+ paimon_error_free(unsafe_branch.error);
+ }
+}
+
+#[test]
+fn test_table_from_schema_json_rejects_invalid_identifier() {
+ let path = CString::new("memory:/test_resolved_bad_ident").unwrap();
+ let schema = simple_table_schema();
+ let schema_json =
CString::new(serde_json::to_string(&schema).unwrap()).unwrap();
+ let branch = CString::new("main").unwrap();
+ // Path separators are rejected by Identifier::validate.
+ let database = CString::new("default").unwrap();
+ let table_name = CString::new("nested/table").unwrap();
+
+ unsafe {
+ let result = paimon_table_from_schema_json(
+ path.as_ptr(),
+ schema_json.as_ptr(),
+ database.as_ptr(),
+ table_name.as_ptr(),
+ branch.as_ptr(),
+ std::ptr::null(),
+ 0,
+ );
+ assert!(result.table.is_null());
+ assert!(!result.error.is_null());
+ assert_eq!((*result.error).code, PaimonErrorCode::InvalidInput as i32);
+ paimon_error_free(result.error);
+ }
+}
+
// =========================================================================
// Read path tests
// =========================================================================
diff --git a/bindings/c/src/vector_search.rs b/bindings/c/src/vector_search.rs
index 4a3e0ecc..80d79efe 100644
--- a/bindings/c/src/vector_search.rs
+++ b/bindings/c/src/vector_search.rs
@@ -41,7 +41,8 @@ use crate::types::*;
/// Create a new vector-search builder from a Table.
///
/// # Safety
-/// `table` must be a valid pointer from `paimon_catalog_get_table`, or null
(returns error).
+/// `table` must be a valid pointer from `paimon_catalog_get_table` or
+/// `paimon_table_from_schema_json`, or null (returns error).
#[no_mangle]
pub unsafe extern "C" fn paimon_table_new_vector_search_builder(
table: *const paimon_table,
diff --git a/bindings/c/src/write.rs b/bindings/c/src/write.rs
index 75792349..8e963799 100644
--- a/bindings/c/src/write.rs
+++ b/bindings/c/src/write.rs
@@ -76,7 +76,8 @@ unsafe fn new_write_builder(
/// used by both `new_write()` and `new_commit()` for duplicate-commit
detection.
///
/// # Safety
-/// `table` must be a valid pointer from `paimon_catalog_get_table`, or null
(returns error).
+/// `table` must be a valid pointer from `paimon_catalog_get_table` or
+/// `paimon_table_from_schema_json`, or null (returns error).
#[no_mangle]
pub unsafe extern "C" fn paimon_table_new_write_builder(
table: *const paimon_table,
diff --git a/crates/paimon/src/spec/schema.rs b/crates/paimon/src/spec/schema.rs
index 90d74ded..b3c48d13 100644
--- a/crates/paimon/src/spec/schema.rs
+++ b/crates/paimon/src/spec/schema.rs
@@ -164,6 +164,97 @@ impl TableSchema {
new_schema
}
+ /// Validate the structural invariants of an externally supplied, already
+ /// resolved schema, without normalizing or mutating it.
+ ///
+ /// This is the safety check for
[`crate::table::Table::from_resolved_schema`],
+ /// whose input is untrusted JSON. It rejects the malformed shapes that
would
+ /// otherwise panic or silently read the wrong column downstream:
+ /// - duplicate top-level field names (a projected name could resolve to a
+ /// different field than a predicate on the same name),
+ /// - duplicate field ids (including nested), which break schema-evolution
+ /// column mapping,
+ /// - primary-key or partition columns that do not exist (e.g. the
+ /// `PartitionComputer` unwraps on a missing partition column),
+ /// - primary keys that are exactly the partition columns (the read path
+ /// selects the key-value merge path from the raw primary keys but feeds
+ /// the reader the *trimmed* keys, which are then empty, panicking on a
+ /// zero-column key),
+ /// - reserved system field names / ids (e.g. a user column named `_ROW_ID`
+ /// would be silently replaced by the system row number on read).
+ ///
+ /// It intentionally does NOT run create-time policy checks (merge-engine,
+ /// changelog, aggregation, rowkind, blob strategy, bucket-key existence,
…):
+ /// those normalize the schema or reject shapes that are valid to *read*,
+ /// which is not this entry point's contract.
+ pub(crate) fn validate_resolved_structure(&self) -> crate::Result<()> {
+ let field_names: Vec<String> = self.fields.iter().map(|f|
f.name().to_string()).collect();
+ Schema::validate_no_duplicate_fields(&field_names)?;
+ Schema::validate_primary_keys(&field_names, &self.primary_keys)?;
+ Schema::validate_partition_keys(&field_names, &self.partition_keys)?;
+ Schema::validate_primary_keys_not_partition_only(&self.partition_keys,
&self.primary_keys)?;
+ self.validate_no_duplicate_field_ids()?;
+ self.validate_no_reserved_fields()?;
+ Ok(())
+ }
+
+ /// Reject reserved system field names, the `_KEY_` key-field prefix, and
+ /// reserved system field ids, mirroring Java `SpecialFields`. A user
column
+ /// colliding with a system field (e.g. `_ROW_ID`) is otherwise excluded
+ /// from the physical read and silently filled with the system value.
+ fn validate_no_reserved_fields(&self) -> crate::Result<()> {
+ // Java SpecialFields.SYSTEM_FIELD_NAMES.
+ const SYSTEM_FIELD_NAMES: [&str; 5] = [
+ SEQUENCE_NUMBER_FIELD_NAME,
+ VALUE_KIND_FIELD_NAME,
+ "_LEVEL",
+ ROW_KIND_FIELD_NAME,
+ ROW_ID_FIELD_NAME,
+ ];
+ const KEY_FIELD_PREFIX: &str = "_KEY_";
+ // Java SpecialFields.SYSTEM_FIELD_ID_START = Integer.MAX_VALUE / 2.
+ const SYSTEM_FIELD_ID_START: i32 = i32::MAX / 2;
+
+ for field in &self.fields {
+ let name = field.name();
+ if name.starts_with(KEY_FIELD_PREFIX) ||
SYSTEM_FIELD_NAMES.contains(&name) {
+ return Err(crate::Error::ConfigInvalid {
+ message: format!(
+ "Field name '{name}' is reserved for system use and
cannot be used in a table schema"
+ ),
+ });
+ }
+ if field.id() >= SYSTEM_FIELD_ID_START {
+ return Err(crate::Error::DataInvalid {
+ message: format!(
+ "Field '{name}' uses reserved system field id {}",
+ field.id()
+ ),
+ source: None,
+ });
+ }
+ }
+ Ok(())
+ }
+
+ /// Reject duplicate field ids, including ids nested inside complex types.
+ /// Field ids key schema-evolution column mapping, so a collision would map
+ /// two logical columns onto one id.
+ fn validate_no_duplicate_field_ids(&self) -> crate::Result<()> {
+ let mut seen = HashSet::new();
+ let mut ids = Vec::new();
+ collect_field_ids(&self.fields, &mut ids);
+ for id in ids {
+ if !seen.insert(id) {
+ return Err(crate::Error::DataInvalid {
+ message: format!("Table schema must not contain duplicate
field id: {id}"),
+ source: None,
+ });
+ }
+ }
+ Ok(())
+ }
+
/// Apply a list of schema changes and return a new schema with
incremented ID.
///
/// Column-level changes operate on **top-level** columns only: a
@@ -652,6 +743,28 @@ fn highest_nested_field_id(data_type: &DataType) -> i32 {
}
}
+/// Collect the field ids of `fields` and of any fields nested inside their
+/// complex types, in traversal order. Used to detect duplicate ids.
+fn collect_field_ids(fields: &[DataField], out: &mut Vec<i32>) {
+ for field in fields {
+ out.push(field.id());
+ collect_nested_field_ids(field.data_type(), out);
+ }
+}
+
+fn collect_nested_field_ids(data_type: &DataType, out: &mut Vec<i32>) {
+ match data_type {
+ DataType::Array(t) => collect_nested_field_ids(t.element_type(), out),
+ DataType::Multiset(t) => collect_nested_field_ids(t.element_type(),
out),
+ DataType::Map(t) => {
+ collect_nested_field_ids(t.key_type(), out);
+ collect_nested_field_ids(t.value_type(), out);
+ }
+ DataType::Row(t) => collect_field_ids(t.fields(), out),
+ _ => {}
+ }
+}
+
/// Reassign the IDs of all row fields nested inside a data type from the
/// table-wide highest field ID, so they cannot collide with existing fields.
///
@@ -2666,6 +2779,173 @@ mod tests {
DataType::Vector(VectorType::try_new(true, 4,
DataType::Float(FloatType::new())).unwrap())
}
+ // Build a raw TableSchema without going through Schema::build validation,
+ // so we can exercise validate_resolved_structure against malformed input.
+ fn raw_table_schema(
+ fields: Vec<DataField>,
+ partition_keys: Vec<String>,
+ primary_keys: Vec<String>,
+ ) -> TableSchema {
+ let highest_field_id = TableSchema::current_highest_field_id(&fields);
+ TableSchema {
+ version: TableSchema::CURRENT_VERSION,
+ id: 0,
+ fields,
+ highest_field_id,
+ partition_keys,
+ primary_keys,
+ options: HashMap::new(),
+ comment: None,
+ time_millis: 0,
+ }
+ }
+
+ #[test]
+ fn test_validate_resolved_structure_accepts_valid_schema() {
+ let schema = raw_table_schema(
+ vec![
+ DataField::new(0, "id".to_string(),
DataType::Int(IntType::new())),
+ DataField::new(1, "name".to_string(),
DataType::Int(IntType::new())),
+ ],
+ vec![],
+ vec!["id".to_string()],
+ );
+ assert!(schema.validate_resolved_structure().is_ok());
+ }
+
+ #[test]
+ fn test_validate_resolved_structure_rejects_missing_primary_key() {
+ let schema = raw_table_schema(
+ vec![DataField::new(
+ 0,
+ "id".to_string(),
+ DataType::Int(IntType::new()),
+ )],
+ vec![],
+ vec!["missing".to_string()],
+ );
+ let err = schema.validate_resolved_structure().unwrap_err();
+ assert!(
+ matches!(err, crate::Error::ConfigInvalid { .. }),
+ "missing PK column should be rejected, got {err:?}"
+ );
+ }
+
+ #[test]
+ fn test_validate_resolved_structure_rejects_missing_partition_key() {
+ let schema = raw_table_schema(
+ vec![DataField::new(
+ 0,
+ "id".to_string(),
+ DataType::Int(IntType::new()),
+ )],
+ vec!["missing".to_string()],
+ vec![],
+ );
+ let err = schema.validate_resolved_structure().unwrap_err();
+ assert!(
+ matches!(err, crate::Error::ConfigInvalid { .. }),
+ "missing partition column should be rejected, got {err:?}"
+ );
+ }
+
+ #[test]
+ fn test_validate_resolved_structure_rejects_duplicate_field_names() {
+ let schema = raw_table_schema(
+ vec![
+ DataField::new(0, "id".to_string(),
DataType::Int(IntType::new())),
+ DataField::new(1, "id".to_string(),
DataType::Int(IntType::new())),
+ ],
+ vec![],
+ vec![],
+ );
+ let err = schema.validate_resolved_structure().unwrap_err();
+ assert!(
+ matches!(err, crate::Error::ConfigInvalid { .. }),
+ "duplicate field names should be rejected, got {err:?}"
+ );
+ }
+
+ #[test]
+ fn test_validate_resolved_structure_rejects_duplicate_field_ids() {
+ let schema = raw_table_schema(
+ vec![
+ DataField::new(0, "id".to_string(),
DataType::Int(IntType::new())),
+ DataField::new(0, "name".to_string(),
DataType::Int(IntType::new())),
+ ],
+ vec![],
+ vec![],
+ );
+ let err = schema.validate_resolved_structure().unwrap_err();
+ assert!(
+ matches!(err, crate::Error::DataInvalid { .. }),
+ "duplicate field ids should be rejected, got {err:?}"
+ );
+ }
+
+ #[test]
+ fn test_validate_resolved_structure_rejects_partition_only_primary_key() {
+ // PK == partition columns: the read path selects the KV/merge path
from
+ // the raw primary keys but feeds the reader the trimmed keys (empty),
+ // which panics on a zero-column key. Must be rejected up front.
+ let schema = raw_table_schema(
+ vec![
+ DataField::new(0, "p".to_string(),
DataType::Int(IntType::new())),
+ DataField::new(1, "v".to_string(),
DataType::Int(IntType::new())),
+ ],
+ vec!["p".to_string()],
+ vec!["p".to_string()],
+ );
+ let err = schema.validate_resolved_structure().unwrap_err();
+ assert!(
+ matches!(err, crate::Error::ConfigInvalid { .. }),
+ "partition-only primary key should be rejected, got {err:?}"
+ );
+ }
+
+ #[test]
+ fn test_validate_resolved_structure_rejects_reserved_field_name() {
+ for reserved in [
+ "_ROW_ID",
+ "_SEQUENCE_NUMBER",
+ "_VALUE_KIND",
+ "_LEVEL",
+ "_KEY_x",
+ ] {
+ let schema = raw_table_schema(
+ vec![
+ DataField::new(0, "id".to_string(),
DataType::Int(IntType::new())),
+ DataField::new(1, reserved.to_string(),
DataType::Int(IntType::new())),
+ ],
+ vec![],
+ vec![],
+ );
+ let err = schema.validate_resolved_structure().unwrap_err();
+ assert!(
+ matches!(err, crate::Error::ConfigInvalid { .. }),
+ "reserved field name {reserved:?} should be rejected, got
{err:?}"
+ );
+ }
+ }
+
+ #[test]
+ fn test_validate_resolved_structure_rejects_reserved_field_id() {
+ let schema = raw_table_schema(
+ vec![DataField::new(
+ i32::MAX / 2,
+ "id".to_string(),
+ DataType::Int(IntType::new()),
+ )],
+ vec![],
+ vec![],
+ );
+ let err = schema.validate_resolved_structure().unwrap_err();
+ assert!(
+ matches!(err, crate::Error::DataInvalid { .. }),
+ "reserved system field id should be rejected, got {err:?}"
+ );
+ }
+
#[test]
fn test_vector_rejected_as_primary_key() {
let err = Schema::builder()
diff --git a/crates/paimon/src/table/mod.rs b/crates/paimon/src/table/mod.rs
index be680cb0..4078e3a2 100644
--- a/crates/paimon/src/table/mod.rs
+++ b/crates/paimon/src/table/mod.rs
@@ -189,6 +189,46 @@ impl Table {
}
}
+ /// Create a table from an already resolved schema without loading a
catalog.
+ ///
+ /// The supplied schema is preserved as-is (no normalization). Its
structural
+ /// invariants are validated — primary-key/partition columns must exist and
+ /// field names/ids must be unique — so a malformed external schema is
+ /// rejected here instead of panicking or reading the wrong column later.
The
+ /// branch only selects the branch-scoped managers used by subsequent
reads.
+ pub fn from_resolved_schema(
+ file_io: FileIO,
+ identifier: Identifier,
+ location: String,
+ schema: TableSchema,
+ branch: impl Into<String>,
+ ) -> Result<Self> {
+ let branch = branch.into();
+ identifier.validate()?;
+ validate_branch_name(&branch)?;
+ schema.validate_resolved_structure()?;
+ let schema_manager = SchemaManager::new(file_io.clone(),
location.clone());
+ let schema_manager = if branch == DEFAULT_MAIN_BRANCH {
+ schema_manager
+ } else {
+ schema_manager.with_branch(&branch)
+ };
+ let branch_reference = branch != DEFAULT_MAIN_BRANCH;
+
+ Ok(Self {
+ file_io,
+ identifier,
+ location,
+ schema,
+ schema_manager,
+ branch,
+ branch_reference,
+ rest_env: None,
+ time_traveled: false,
+ travel_snapshot: None,
+ })
+ }
+
/// Get the table's identifier.
pub fn identifier(&self) -> &Identifier {
&self.identifier