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 40500dc feat(datafusion): support reading branch tables (#482)
40500dc is described below
commit 40500dc69a872a18c9c0142399d47949afe6270c
Author: shyjsarah <[email protected]>
AuthorDate: Fri Jul 10 15:42:20 2026 +0800
feat(datafusion): support reading branch tables (#482)
---
crates/integrations/datafusion/src/blob_view.rs | 6 +-
crates/integrations/datafusion/src/catalog.rs | 46 +-
crates/integrations/datafusion/src/delete.rs | 8 +-
.../datafusion/src/full_text_search.rs | 6 +-
.../integrations/datafusion/src/hybrid_search.rs | 6 +-
crates/integrations/datafusion/src/lib.rs | 1 +
crates/integrations/datafusion/src/merge_into.rs | 6 +-
.../datafusion/src/physical_plan/sink.rs | 2 +-
crates/integrations/datafusion/src/sql_context.rs | 83 +++-
.../datafusion/src/system_tables/manifests.rs | 21 +-
.../datafusion/src/system_tables/mod.rs | 129 ++---
.../datafusion/src/system_tables/partitions.rs | 34 +-
.../datafusion/src/system_tables/snapshots.rs | 7 +-
.../datafusion/src/system_tables/table_indexes.rs | 17 +-
.../datafusion/src/system_tables/tags.rs | 7 +-
crates/integrations/datafusion/src/table/mod.rs | 6 +
crates/integrations/datafusion/src/table_loader.rs | 69 +++
crates/integrations/datafusion/src/update.rs | 8 +-
.../integrations/datafusion/src/vector_search.rs | 6 +-
crates/integrations/datafusion/tests/blob_tests.rs | 70 +++
.../integrations/datafusion/tests/read_tables.rs | 178 ++++++-
.../datafusion/tests/sql_context_tests.rs | 523 ++++++++++++++++++++-
crates/paimon/src/catalog/mod.rs | 139 ++++++
crates/paimon/src/catalog/partition_listing.rs | 18 +-
.../src/table/btree_global_index_build_builder.rs | 2 +
crates/paimon/src/table/format_write_builder.rs | 5 +
.../paimon/src/table/full_text_search_builder.rs | 12 +-
.../paimon/src/table/global_index_drop_builder.rs | 2 +
.../paimon/src/table/lumina_index_build_builder.rs | 2 +
crates/paimon/src/table/mod.rs | 101 +++-
crates/paimon/src/table/snapshot_manager.rs | 57 ++-
crates/paimon/src/table/table_commit.rs | 14 +
crates/paimon/src/table/table_scan.rs | 11 +-
crates/paimon/src/table/time_travel.rs | 35 +-
crates/paimon/src/table/vector_search_builder.rs | 12 +-
.../paimon/src/table/vindex_index_build_builder.rs | 2 +
crates/paimon/src/table/write_builder.rs | 85 ++++
docs/src/sql.md | 15 +-
38 files changed, 1521 insertions(+), 230 deletions(-)
diff --git a/crates/integrations/datafusion/src/blob_view.rs
b/crates/integrations/datafusion/src/blob_view.rs
index 345da1f..a91d4db 100644
--- a/crates/integrations/datafusion/src/blob_view.rs
+++ b/crates/integrations/datafusion/src/blob_view.rs
@@ -36,6 +36,7 @@ use paimon::spec::BlobViewStruct;
use crate::error::to_datafusion_error;
use crate::runtime::block_on_with_runtime;
use crate::table_function_args::parse_table_identifier;
+use crate::table_loader::load_data_table_for_read;
const FUNCTION_NAME: &str = "blob_view";
@@ -68,10 +69,9 @@ impl BlobViewFunc {
let identifier = parse_table_identifier(FUNCTION_NAME, table_name,
&self.default_database)?;
let catalog = Arc::clone(&self.catalog);
let table = block_on_with_runtime(
- async move { catalog.get_table(&identifier).await },
+ async move { load_data_table_for_read(&catalog, &identifier,
FUNCTION_NAME).await },
"blob_view: catalog access thread panicked",
- )
- .map_err(to_datafusion_error)?;
+ )?;
let field = table
.schema()
diff --git a/crates/integrations/datafusion/src/catalog.rs
b/crates/integrations/datafusion/src/catalog.rs
index e74d3ba..7e32bae 100644
--- a/crates/integrations/datafusion/src/catalog.rs
+++ b/crates/integrations/datafusion/src/catalog.rs
@@ -364,13 +364,13 @@ impl SchemaProvider for PaimonSchemaProvider {
}
}
- let (base, system_name) = system_tables::split_object_name(name);
- if let Some(system_name) = system_name {
+ let object = system_tables::parse_object_name_for_datafusion(name)?;
+ if let Some(system_name) = object.system_table().map(str::to_string) {
return await_with_runtime(system_tables::load(
Arc::clone(&self.catalog),
self.database.clone(),
- base.to_string(),
- system_name.to_string(),
+ object,
+ system_name,
))
.await;
}
@@ -378,10 +378,17 @@ impl SchemaProvider for PaimonSchemaProvider {
let catalog = Arc::clone(&self.catalog);
let dynamic_options = Arc::clone(&self.dynamic_options);
let blob_reader_registry = self.blob_reader_registry.clone();
- let identifier = Identifier::new(self.database.clone(), base);
+ let identifier = Identifier::new(self.database.clone(),
object.table().to_string());
+ let branch = object.branch().map(str::to_string);
await_with_runtime(async move {
match catalog.get_table(&identifier).await {
- Ok(table) => {
+ Ok(mut table) => {
+ if let Some(branch) = branch.as_deref() {
+ table = table
+ .copy_with_branch(branch)
+ .await
+ .map_err(to_datafusion_error)?;
+ }
let opts = dynamic_options.read().unwrap().clone();
let provider = if opts.is_empty() {
PaimonTableProvider::try_new_with_blob_reader_registry(
@@ -419,19 +426,38 @@ impl SchemaProvider for PaimonSchemaProvider {
}
}
- let (base, system_name) = system_tables::split_object_name(name);
- if let Some(system_name) = system_name {
+ let object = match
system_tables::parse_object_name_for_datafusion(name) {
+ Ok(object) => object,
+ Err(e) => {
+ log::error!("failed to parse Paimon object name '{name}':
{e}");
+ return false;
+ }
+ };
+ if let Some(system_name) = object.system_table() {
if !system_tables::is_registered(system_name) {
return false;
}
}
let catalog = Arc::clone(&self.catalog);
- let identifier = Identifier::new(self.database.clone(),
base.to_string());
+ let identifier = Identifier::new(self.database.clone(),
object.table().to_string());
+ let branch = object.branch().map(str::to_string);
+ let is_branches_table = object
+ .system_table()
+ .is_some_and(|name| name.eq_ignore_ascii_case("branches"));
block_on_with_runtime(
async move {
match catalog.get_table(&identifier).await {
- Ok(_) => true,
+ Ok(table) => {
+ if let Some(branch) = branch.as_deref() {
+ if is_branches_table {
+ return true;
+ }
+ table.copy_with_branch(branch).await.is_ok()
+ } else {
+ true
+ }
+ }
Err(paimon::Error::TableNotExist { .. }) => false,
Err(e) => {
log::error!("failed to check table '{}': {e}",
identifier);
diff --git a/crates/integrations/datafusion/src/delete.rs
b/crates/integrations/datafusion/src/delete.rs
index 832acd9..11a14c0 100644
--- a/crates/integrations/datafusion/src/delete.rs
+++ b/crates/integrations/datafusion/src/delete.rs
@@ -114,7 +114,8 @@ async fn execute_data_evolution_delete_once(
let messages = writer.prepare_commit().await.map_err(to_datafusion_error)?;
if !messages.is_empty() {
- wb.new_commit()
+ wb.try_new_commit()
+ .map_err(to_datafusion_error)?
.commit(messages)
.await
.map_err(to_datafusion_error)?;
@@ -165,7 +166,10 @@ async fn execute_cow_delete_once(
let messages = writer.prepare_commit().await.map_err(to_datafusion_error)?;
if !messages.is_empty() {
- let commit = table.new_write_builder().new_commit();
+ let commit = table
+ .new_write_builder()
+ .try_new_commit()
+ .map_err(to_datafusion_error)?;
commit.commit(messages).await.map_err(to_datafusion_error)?;
}
diff --git a/crates/integrations/datafusion/src/full_text_search.rs
b/crates/integrations/datafusion/src/full_text_search.rs
index d5f74d3..27c8db1 100644
--- a/crates/integrations/datafusion/src/full_text_search.rs
+++ b/crates/integrations/datafusion/src/full_text_search.rs
@@ -46,6 +46,7 @@ use crate::table::{PaimonScanBuilder, PaimonTableProvider};
use crate::table_function_args::{
extract_int_literal, extract_string_literal, parse_table_identifier,
};
+use crate::table_loader::load_data_table_for_read;
const FUNCTION_NAME: &str = "full_text_search";
@@ -110,10 +111,9 @@ impl TableFunctionImpl for FullTextSearchFunction {
let catalog = Arc::clone(&self.catalog);
let table = block_on_with_runtime(
- async move { catalog.get_table(&identifier).await },
+ async move { load_data_table_for_read(&catalog, &identifier,
FUNCTION_NAME).await },
"full_text_search: catalog access thread panicked",
- )
- .map_err(to_datafusion_error)?;
+ )?;
let inner = PaimonTableProvider::try_new(table)?;
diff --git a/crates/integrations/datafusion/src/hybrid_search.rs
b/crates/integrations/datafusion/src/hybrid_search.rs
index e608366..2d5f997 100644
--- a/crates/integrations/datafusion/src/hybrid_search.rs
+++ b/crates/integrations/datafusion/src/hybrid_search.rs
@@ -51,6 +51,7 @@ use crate::table::{PaimonScanBuilder, PaimonTableProvider};
use crate::table_function_args::{
extract_int_literal, extract_string_literal, parse_table_identifier,
};
+use crate::table_loader::load_data_table_for_read;
const FUNCTION_NAME: &str = "hybrid_search";
@@ -123,10 +124,9 @@ impl TableFunctionImpl for HybridSearchFunction {
parse_table_identifier(FUNCTION_NAME, &table_name,
&self.default_database)?;
let catalog = Arc::clone(&self.catalog);
let table = block_on_with_runtime(
- async move { catalog.get_table(&identifier).await },
+ async move { load_data_table_for_read(&catalog, &identifier,
FUNCTION_NAME).await },
"hybrid_search: catalog access thread panicked",
- )
- .map_err(to_datafusion_error)?;
+ )?;
Ok(Arc::new(HybridSearchTableProvider {
inner: PaimonTableProvider::try_new(table)?,
diff --git a/crates/integrations/datafusion/src/lib.rs
b/crates/integrations/datafusion/src/lib.rs
index 675fda4..9c1d914 100644
--- a/crates/integrations/datafusion/src/lib.rs
+++ b/crates/integrations/datafusion/src/lib.rs
@@ -55,6 +55,7 @@ mod sql_context;
mod system_tables;
mod table;
mod table_function_args;
+mod table_loader;
mod update;
mod variant_functions;
mod variant_pushdown;
diff --git a/crates/integrations/datafusion/src/merge_into.rs
b/crates/integrations/datafusion/src/merge_into.rs
index 124dd65..b73b155 100644
--- a/crates/integrations/datafusion/src/merge_into.rs
+++ b/crates/integrations/datafusion/src/merge_into.rs
@@ -396,7 +396,8 @@ async fn execute_cow_merge_once(
all_messages.extend(insert_messages);
if !all_messages.is_empty() {
- wb.new_commit()
+ wb.try_new_commit()
+ .map_err(to_datafusion_error)?
.commit(all_messages)
.await
.map_err(to_datafusion_error)?;
@@ -771,7 +772,8 @@ async fn execute_merge_into_once(
// 6. Commit all messages atomically
if !all_messages.is_empty() {
- wb.new_commit()
+ wb.try_new_commit()
+ .map_err(to_datafusion_error)?
.commit(all_messages)
.await
.map_err(to_datafusion_error)?;
diff --git a/crates/integrations/datafusion/src/physical_plan/sink.rs
b/crates/integrations/datafusion/src/physical_plan/sink.rs
index f021ba3..3d40e2a 100644
--- a/crates/integrations/datafusion/src/physical_plan/sink.rs
+++ b/crates/integrations/datafusion/src/physical_plan/sink.rs
@@ -92,7 +92,7 @@ impl DataSink for PaimonDataSink {
}
let messages = tw.prepare_commit().await.map_err(to_datafusion_error)?;
- let commit = wb.new_commit();
+ let commit = wb.try_new_commit().map_err(to_datafusion_error)?;
if self.overwrite {
commit
diff --git a/crates/integrations/datafusion/src/sql_context.rs
b/crates/integrations/datafusion/src/sql_context.rs
index da74a06..1513ea9 100644
--- a/crates/integrations/datafusion/src/sql_context.rs
+++ b/crates/integrations/datafusion/src/sql_context.rs
@@ -57,7 +57,7 @@ use datafusion::sql::sqlparser::ast::{
use datafusion::sql::sqlparser::dialect::GenericDialect;
use datafusion::sql::sqlparser::parser::Parser;
use futures::StreamExt;
-use paimon::catalog::{Catalog, Identifier};
+use paimon::catalog::{parse_object_name, Catalog, Identifier};
use paimon::spec::{
ArrayType as PaimonArrayType, BigIntType, BinaryType, BlobType,
BooleanType, CharType,
DataField as PaimonDataField, DataType as PaimonDataType, DateType, Datum,
DecimalType,
@@ -67,6 +67,7 @@ use paimon::spec::{
};
use crate::error::to_datafusion_error;
+use crate::table_loader::load_table_for_read;
use crate::{BlobReaderRegistry, DynamicOptions};
/// A SQL context that supports registering multiple Paimon catalogs and
executing SQL.
@@ -510,10 +511,8 @@ impl SQLContext {
let (catalog, _catalog_name, identifier) =
self.resolve_table_name_from_ref(&table_ref)?;
- let paimon_table = catalog
- .get_table(&identifier)
- .await
- .map_err(|e| DataFusionError::External(Box::new(e)))?;
+ let (paimon_table, base_identifier, system_name) =
+ load_table_for_read(&catalog, &identifier).await?;
// Merge dynamic options with time-travel options
let mut options = self.dynamic_options.read().unwrap().clone();
@@ -523,10 +522,22 @@ impl SQLContext {
.copy_with_time_travel(options)
.await
.map_err(|e| DataFusionError::External(Box::new(e)))?;
- let provider =
Arc::new(PaimonTableProvider::try_new_with_blob_reader_registry(
- table_with_options,
- self.blob_reader_registry.clone(),
- )?);
+ let provider: Arc<dyn TableProvider> = if let Some(system_name) =
system_name {
+ crate::system_tables::provider_for_table(
+ Arc::clone(&catalog),
+ base_identifier,
+ table_with_options,
+ &system_name,
+ )?
+ .ok_or_else(|| {
+ DataFusionError::Plan(format!("Unknown Paimon system
table: {system_name}"))
+ })?
+ } else {
+
Arc::new(PaimonTableProvider::try_new_with_blob_reader_registry(
+ table_with_options,
+ self.blob_reader_registry.clone(),
+ )?)
+ };
let uuid_name = format!("__paimon_tt_{}",
uuid::Uuid::new_v4().as_simple());
self.register_temp_table(uuid_name.as_str(), provider)?;
@@ -540,10 +551,8 @@ impl SQLContext {
let (catalog, _catalog_name, identifier) =
self.resolve_table_name_from_ref(&table_ref)?;
- let paimon_table = catalog
- .get_table(&identifier)
- .await
- .map_err(|e| DataFusionError::External(Box::new(e)))?;
+ let (paimon_table, base_identifier, system_name) =
+ load_table_for_read(&catalog, &identifier).await?;
let millis = Self::parse_timestamp_to_millis(&info.timestamp)?;
@@ -555,10 +564,22 @@ impl SQLContext {
.copy_with_time_travel(options)
.await
.map_err(|e| DataFusionError::External(Box::new(e)))?;
- let provider =
Arc::new(PaimonTableProvider::try_new_with_blob_reader_registry(
- table_with_options,
- self.blob_reader_registry.clone(),
- )?);
+ let provider: Arc<dyn TableProvider> = if let Some(system_name) =
system_name {
+ crate::system_tables::provider_for_table(
+ Arc::clone(&catalog),
+ base_identifier,
+ table_with_options,
+ &system_name,
+ )?
+ .ok_or_else(|| {
+ DataFusionError::Plan(format!("Unknown Paimon system
table: {system_name}"))
+ })?
+ } else {
+
Arc::new(PaimonTableProvider::try_new_with_blob_reader_registry(
+ table_with_options,
+ self.blob_reader_registry.clone(),
+ )?)
+ };
let uuid_name = format!("__paimon_tt_{}",
uuid::Uuid::new_v4().as_simple());
self.register_temp_table(uuid_name.as_str(), provider)?;
@@ -901,6 +922,7 @@ impl SQLContext {
operations: &[AlterTableOperation],
if_exists: bool,
) -> DFResult<DataFrame> {
+ Self::ensure_main_branch_write_target(name, "ALTER TABLE")?;
let identifier = self.resolve_table_name(name)?;
let mut changes = Vec::new();
@@ -1030,6 +1052,7 @@ impl SQLContext {
)))
}
};
+ Self::ensure_main_branch_write_target(&table_name, "MERGE INTO")?;
let (catalog, _catalog_name, identifier) =
self.resolve_catalog_and_table(&table_name)?;
let table = catalog
@@ -1050,6 +1073,7 @@ impl SQLContext {
)))
}
};
+ Self::ensure_main_branch_write_target(&table_name, "UPDATE")?;
let (catalog, _catalog_name, identifier) =
self.resolve_catalog_and_table(&table_name)?;
let table = catalog
@@ -1077,6 +1101,7 @@ impl SQLContext {
)))
}
};
+ Self::ensure_main_branch_write_target(&table_name, "DELETE")?;
let (catalog, _catalog_name, identifier) =
self.resolve_catalog_and_table(&table_name)?;
let table = catalog
@@ -1098,6 +1123,7 @@ impl SQLContext {
)))
}
};
+ Self::ensure_main_branch_write_target(&table_name, "INSERT
OVERWRITE")?;
let (catalog, _catalog_name, identifier) =
self.resolve_catalog_and_table(&table_name)?;
let table = catalog
.get_table(&identifier)
@@ -1217,7 +1243,7 @@ impl SQLContext {
}
let messages = tw.prepare_commit().await.map_err(to_datafusion_error)?;
- let commit = wb.new_commit();
+ let commit = wb.try_new_commit().map_err(to_datafusion_error)?;
let overwrite_partitions = if static_partitions.is_empty() {
None
@@ -1242,6 +1268,7 @@ impl SQLContext {
let target = truncate.table_names.first().ok_or_else(|| {
DataFusionError::Plan("TRUNCATE TABLE requires a table
name".to_string())
})?;
+ Self::ensure_main_branch_write_target(&target.name, "TRUNCATE TABLE")?;
let (catalog, _catalog_name, identifier) =
self.resolve_catalog_and_table(&target.name)?;
let table = match catalog.get_table(&identifier).await {
Ok(t) => t,
@@ -1252,7 +1279,7 @@ impl SQLContext {
};
let wb = table.new_write_builder();
- let commit = wb.new_commit();
+ let commit = wb.try_new_commit().map_err(to_datafusion_error)?;
if let Some(partitions) = &truncate.partitions {
if partitions.is_empty() {
@@ -1337,7 +1364,7 @@ impl SQLContext {
)?;
let wb = table.new_write_builder();
- let commit = wb.new_commit();
+ let commit = wb.try_new_commit().map_err(to_datafusion_error)?;
commit
.truncate_partitions(partition_values)
.await
@@ -1426,6 +1453,22 @@ impl SQLContext {
}
}
+ fn ensure_main_branch_write_target(name: &ObjectName, operation: &str) ->
DFResult<()> {
+ let object = name
+ .0
+ .last()
+ .and_then(|part| part.as_ident())
+ .map(|ident| ident.value.as_str())
+ .ok_or_else(|| DataFusionError::Plan(format!("Invalid table
reference: {name}")))?;
+ let parsed = parse_object_name(object).map_err(to_datafusion_error)?;
+ if let Some(branch) = parsed.branch() {
+ return Err(DataFusionError::NotImplemented(format!(
+ "{operation} on Paimon branch '{branch}' is not supported"
+ )));
+ }
+ Ok(())
+ }
+
/// Resolve an ObjectName to just the Identifier (for backward compat in
handle_alter_table).
fn resolve_table_name(&self, name: &ObjectName) -> DFResult<Identifier> {
let (_catalog, _catalog_name, identifier) =
self.resolve_catalog_and_table(name)?;
diff --git a/crates/integrations/datafusion/src/system_tables/manifests.rs
b/crates/integrations/datafusion/src/system_tables/manifests.rs
index 4e438ae..6f8748f 100644
--- a/crates/integrations/datafusion/src/system_tables/manifests.rs
+++ b/crates/integrations/datafusion/src/system_tables/manifests.rs
@@ -29,7 +29,7 @@ use datafusion::error::Result as DFResult;
use datafusion::logical_expr::Expr;
use datafusion::physical_plan::ExecutionPlan;
use paimon::spec::{BinaryRow, DataField, ManifestFileMeta, ManifestList};
-use paimon::table::{SnapshotManager, Table};
+use paimon::table::Table;
use super::row_string_cast::format_row_as_java_cast_string;
use crate::error::to_datafusion_error;
@@ -160,17 +160,22 @@ impl TableProvider for ManifestsTable {
async fn collect_manifests(table: &Table) ->
paimon::Result<Vec<ManifestFileMeta>> {
let file_io = table.file_io();
- let sm = SnapshotManager::new(file_io.clone(),
table.location().to_string());
- let snapshot = match sm.get_latest_snapshot().await? {
- Some(s) => s,
- None => return Ok(Vec::new()),
+ let snapshot_sm = table.snapshot_manager();
+ let manifest_sm =
+ paimon::table::SnapshotManager::new(file_io.clone(),
table.location().to_string());
+ let snapshot = match table.travel_snapshot().cloned() {
+ Some(snapshot) => snapshot,
+ None => match snapshot_sm.get_latest_snapshot().await? {
+ Some(snapshot) => snapshot,
+ None => return Ok(Vec::new()),
+ },
};
- let base_path = sm.manifest_path(snapshot.base_manifest_list());
- let delta_path = sm.manifest_path(snapshot.delta_manifest_list());
+ let base_path = manifest_sm.manifest_path(snapshot.base_manifest_list());
+ let delta_path = manifest_sm.manifest_path(snapshot.delta_manifest_list());
let changelog_path = snapshot
.changelog_manifest_list()
- .map(|c| sm.manifest_path(c));
+ .map(|c| manifest_sm.manifest_path(c));
let base_fut = ManifestList::read(file_io, &base_path);
let delta_fut = ManifestList::read(file_io, &delta_path);
let changelog_fut = async {
diff --git a/crates/integrations/datafusion/src/system_tables/mod.rs
b/crates/integrations/datafusion/src/system_tables/mod.rs
index c46d3cd..392db7d 100644
--- a/crates/integrations/datafusion/src/system_tables/mod.rs
+++ b/crates/integrations/datafusion/src/system_tables/mod.rs
@@ -24,7 +24,7 @@ use std::sync::Arc;
use datafusion::datasource::TableProvider;
use datafusion::error::{DataFusionError, Result as DFResult};
-use paimon::catalog::{Catalog, Identifier, SYSTEM_BRANCH_PREFIX,
SYSTEM_TABLE_SPLITTER};
+use paimon::catalog::{parse_object_name, Catalog, Identifier,
ParsedObjectName};
use paimon::table::Table;
use crate::error::to_datafusion_error;
@@ -74,36 +74,16 @@ const SYSTEM_TABLE_NAMES: &[&str] = &[
"tags",
];
-/// Parse a Paimon object name into `(base_table, optional system_table_name)`.
+/// Parse a Paimon object name into table, branch, and optional system table.
///
/// Mirrors Java
[Identifier.splitObjectName](https://github.com/apache/paimon/blob/release-1.3/paimon-api/src/main/java/org/apache/paimon/catalog/Identifier.java).
///
-/// - `t` → `("t", None)`
-/// - `t$options` → `("t", Some("options"))`
-/// - `t$branch_main` → `("t", None)` (branch reference, not a system table)
-/// - `t$branch_main$options` → `("t", Some("options"))` (branch + system
table)
-pub(crate) fn split_object_name(name: &str) -> (&str, Option<&str>) {
- let mut parts = name.splitn(3, SYSTEM_TABLE_SPLITTER);
- let base = parts.next().unwrap_or(name);
- match (parts.next(), parts.next()) {
- (None, _) => (base, None),
- (Some(second), None) => {
- if second.starts_with(SYSTEM_BRANCH_PREFIX) {
- (base, None)
- } else {
- (base, Some(second))
- }
- }
- (Some(second), Some(third)) => {
- if second.starts_with(SYSTEM_BRANCH_PREFIX) {
- (base, Some(third))
- } else {
- // `$` is legal in table names, so `t$foo$bar` falls through as
- // plain `t` and errors later as "table not found".
- (base, None)
- }
- }
- }
+/// - `t` → table `t`
+/// - `t$options` → table `t`, system table `options`
+/// - `t$branch_b1` → table `t`, branch `b1`
+/// - `t$branch_b1$options` → table `t`, branch `b1`, system table `options`
+pub(crate) fn parse_object_name_for_datafusion(name: &str) ->
DFResult<ParsedObjectName> {
+ parse_object_name(name).map_err(to_datafusion_error)
}
/// Returns true if `name` is a recognised Paimon system table suffix.
@@ -121,6 +101,27 @@ fn wrap_to_system_table(name: &str, base_table: Table) ->
Option<DFResult<Arc<dy
.map(|(_, build)| build(base_table))
}
+pub(crate) fn provider_for_table(
+ catalog: Arc<dyn Catalog>,
+ identifier: Identifier,
+ table: Table,
+ system_name: &str,
+) -> DFResult<Option<Arc<dyn TableProvider>>> {
+ if !is_registered(system_name) {
+ return Ok(None);
+ }
+ // Fail closed: system tables expose file metadata the client can't
authorize.
+ paimon::spec::CoreOptions::new(table.schema().options())
+ .ensure_read_authorized()
+ .map_err(to_datafusion_error)?;
+ if system_name.eq_ignore_ascii_case("partitions") {
+ return partitions::build(catalog, identifier, table).map(Some);
+ }
+ wrap_to_system_table(system_name, table)
+ .expect("is_registered guarantees a builder")
+ .map(Some)
+}
+
/// Loads `<base>$<system_name>` from the catalog and wraps it as a system
/// table provider.
///
@@ -130,29 +131,29 @@ fn wrap_to_system_table(name: &str, base_table: Table) ->
Option<DFResult<Arc<dy
pub(crate) async fn load(
catalog: Arc<dyn Catalog>,
database: String,
- base: String,
+ object: ParsedObjectName,
system_name: String,
) -> DFResult<Option<Arc<dyn TableProvider>>> {
if !is_registered(&system_name) {
return Ok(None);
}
- let identifier = Identifier::new(database, base.clone());
+ let identifier = Identifier::new(database, object.table().to_string());
match catalog.get_table(&identifier).await {
- Ok(table) => {
- // Fail closed: system tables expose file metadata the client
can't authorize.
- paimon::spec::CoreOptions::new(table.schema().options())
- .ensure_read_authorized()
- .map_err(to_datafusion_error)?;
- if system_name.eq_ignore_ascii_case("partitions") {
- return partitions::build(catalog, identifier, table).map(Some);
+ Ok(mut table) => {
+ if let Some(branch) = object.branch() {
+ if !system_name.eq_ignore_ascii_case("branches") {
+ table = table
+ .copy_with_branch(branch)
+ .await
+ .map_err(to_datafusion_error)?;
+ }
}
- wrap_to_system_table(&system_name, table)
- .expect("is_registered guarantees a builder")
- .map(Some)
+ provider_for_table(catalog, identifier, table, &system_name)
}
Err(paimon::Error::TableNotExist { .. }) =>
Err(DataFusionError::Plan(format!(
"Cannot read system table `${system_name}`: \
- base table `{base}` does not exist"
+ base table `{}` does not exist",
+ object.table()
))),
Err(e) => Err(to_datafusion_error(e)),
}
@@ -160,7 +161,7 @@ pub(crate) async fn load(
#[cfg(test)]
mod tests {
- use super::{is_registered, split_object_name, SYSTEM_TABLE_NAMES, TABLES};
+ use super::{is_registered, parse_object_name_for_datafusion,
SYSTEM_TABLE_NAMES, TABLES};
/// Guards against the two registries drifting: anything in `TABLES` must
/// also be in `SYSTEM_TABLE_NAMES`, and the only name allowed to be in
@@ -214,40 +215,54 @@ mod tests {
#[test]
fn plain_table_name() {
- assert_eq!(split_object_name("orders"), ("orders", None));
+ let parsed = parse_object_name_for_datafusion("orders").unwrap();
+ assert_eq!(parsed.table(), "orders");
+ assert_eq!(parsed.branch(), None);
+ assert_eq!(parsed.system_table(), None);
}
#[test]
fn system_table_only() {
- assert_eq!(
- split_object_name("orders$options"),
- ("orders", Some("options"))
- );
+ let parsed =
parse_object_name_for_datafusion("orders$options").unwrap();
+ assert_eq!(parsed.table(), "orders");
+ assert_eq!(parsed.branch(), None);
+ assert_eq!(parsed.system_table(), Some("options"));
}
#[test]
fn branch_reference_is_not_a_system_table() {
- assert_eq!(split_object_name("orders$branch_main"), ("orders", None));
+ let parsed =
parse_object_name_for_datafusion("orders$branch_main").unwrap();
+ assert_eq!(parsed.table(), "orders");
+ assert_eq!(parsed.branch(), Some("main"));
+ assert_eq!(parsed.system_table(), None);
+ }
+
+ #[test]
+ fn branch_reference_does_not_drop_branch_name() {
+ let parsed =
parse_object_name_for_datafusion("orders$branch_b1").unwrap();
+ assert_eq!(parsed.table(), "orders");
+ assert_eq!(parsed.branch(), Some("b1"));
+ assert_eq!(parsed.system_table(), None);
}
#[test]
fn branch_plus_system_table() {
- assert_eq!(
- split_object_name("orders$branch_main$options"),
- ("orders", Some("options"))
- );
+ let parsed =
parse_object_name_for_datafusion("orders$branch_main$options").unwrap();
+ assert_eq!(parsed.table(), "orders");
+ assert_eq!(parsed.branch(), Some("main"));
+ assert_eq!(parsed.system_table(), Some("options"));
}
#[test]
fn three_parts_without_branch_prefix_is_not_a_system_table() {
- assert_eq!(split_object_name("orders$foo$bar"), ("orders", None));
+ assert!(parse_object_name_for_datafusion("orders$foo$bar").is_err());
}
#[test]
fn system_table_name_preserves_case() {
- assert_eq!(
- split_object_name("orders$Options"),
- ("orders", Some("Options"))
- );
+ let parsed =
parse_object_name_for_datafusion("orders$Options").unwrap();
+ assert_eq!(parsed.table(), "orders");
+ assert_eq!(parsed.branch(), None);
+ assert_eq!(parsed.system_table(), Some("Options"));
}
}
diff --git a/crates/integrations/datafusion/src/system_tables/partitions.rs
b/crates/integrations/datafusion/src/system_tables/partitions.rs
index f43501b..97cf51b 100644
--- a/crates/integrations/datafusion/src/system_tables/partitions.rs
+++ b/crates/integrations/datafusion/src/system_tables/partitions.rs
@@ -31,7 +31,7 @@ use datafusion::datasource::{TableProvider, TableType};
use datafusion::error::{DataFusionError, Result as DFResult};
use datafusion::logical_expr::Expr;
use datafusion::physical_plan::ExecutionPlan;
-use paimon::catalog::{Catalog, Identifier};
+use paimon::catalog::{list_partitions_from_file_system, Catalog, Identifier};
use paimon::spec::{CoreOptions, Partition};
use paimon::table::Table;
@@ -50,6 +50,7 @@ pub(super) fn build(
Ok(Arc::new(PartitionsTable {
catalog,
identifier,
+ table,
partition_keys,
default_partition_name,
}))
@@ -87,6 +88,7 @@ fn partitions_schema() -> SchemaRef {
struct PartitionsTable {
catalog: Arc<dyn Catalog>,
identifier: Identifier,
+ table: Table,
partition_keys: Vec<String>,
default_partition_name: String,
}
@@ -116,12 +118,30 @@ impl TableProvider for PartitionsTable {
_filters: &[Expr],
_limit: Option<usize>,
) -> DFResult<Arc<dyn ExecutionPlan>> {
- let catalog = self.catalog.clone();
- let identifier = self.identifier.clone();
- let partitions = crate::runtime::await_with_runtime(async move {
- catalog.list_partitions(&identifier).await
- })
- .await
+ let table = self.table.clone();
+ let partitions = if table.travel_snapshot().is_some() {
+ crate::runtime::await_with_runtime(async move {
+ list_partitions_from_file_system(&table).await
+ })
+ .await
+ } else {
+ let catalog = self.catalog.clone();
+ let identifier = if table.is_main_branch() {
+ self.identifier.clone()
+ } else {
+ Identifier::new(
+ self.identifier.database(),
+ format!("{}$branch_{}", self.identifier.object(),
table.branch()),
+ )
+ };
+ crate::runtime::await_with_runtime(async move {
+ match catalog.list_partitions(&identifier).await {
+ Ok(partitions) => Ok(partitions),
+ Err(_) => list_partitions_from_file_system(&table).await,
+ }
+ })
+ .await
+ }
.map_err(to_datafusion_error)?;
let mut rows: Vec<(String, Partition)> = partitions
diff --git a/crates/integrations/datafusion/src/system_tables/snapshots.rs
b/crates/integrations/datafusion/src/system_tables/snapshots.rs
index 17f3ca8..da3428f 100644
--- a/crates/integrations/datafusion/src/system_tables/snapshots.rs
+++ b/crates/integrations/datafusion/src/system_tables/snapshots.rs
@@ -28,7 +28,7 @@ use datafusion::datasource::{TableProvider, TableType};
use datafusion::error::Result as DFResult;
use datafusion::logical_expr::Expr;
use datafusion::physical_plan::ExecutionPlan;
-use paimon::table::{SnapshotManager, Table};
+use paimon::table::Table;
use crate::error::to_datafusion_error;
@@ -86,10 +86,7 @@ impl TableProvider for SnapshotsTable {
_filters: &[Expr],
_limit: Option<usize>,
) -> DFResult<Arc<dyn ExecutionPlan>> {
- let sm = SnapshotManager::new(
- self.table.file_io().clone(),
- self.table.location().to_string(),
- );
+ let sm = self.table.snapshot_manager();
let snapshots = crate::runtime::await_with_runtime(async move {
sm.list_all().await })
.await
.map_err(to_datafusion_error)?;
diff --git a/crates/integrations/datafusion/src/system_tables/table_indexes.rs
b/crates/integrations/datafusion/src/system_tables/table_indexes.rs
index 43a8fd0..b48a8a1 100644
--- a/crates/integrations/datafusion/src/system_tables/table_indexes.rs
+++ b/crates/integrations/datafusion/src/system_tables/table_indexes.rs
@@ -34,7 +34,7 @@ use datafusion::physical_plan::ExecutionPlan;
use paimon::spec::{
BinaryRow, DataField, DeletionVectorMeta, FileKind, IndexManifest,
IndexManifestEntry,
};
-use paimon::table::{SnapshotManager, Table};
+use paimon::table::Table;
use super::row_string_cast::format_row_as_java_cast_string;
use crate::error::to_datafusion_error;
@@ -187,16 +187,21 @@ impl TableProvider for TableIndexesTable {
async fn collect_index_entries(table: &Table) ->
paimon::Result<Vec<IndexManifestEntry>> {
let file_io = table.file_io();
- let sm = SnapshotManager::new(file_io.clone(),
table.location().to_string());
- let snapshot = match sm.get_latest_snapshot().await? {
- Some(s) => s,
- None => return Ok(Vec::new()),
+ let snapshot_sm = table.snapshot_manager();
+ let manifest_sm =
+ paimon::table::SnapshotManager::new(file_io.clone(),
table.location().to_string());
+ let snapshot = match table.travel_snapshot().cloned() {
+ Some(snapshot) => snapshot,
+ None => match snapshot_sm.get_latest_snapshot().await? {
+ Some(snapshot) => snapshot,
+ None => return Ok(Vec::new()),
+ },
};
let Some(index_manifest_name) = snapshot.index_manifest() else {
return Ok(Vec::new());
};
- let path = sm.manifest_path(index_manifest_name);
+ let path = manifest_sm.manifest_path(index_manifest_name);
if !file_io.exists(&path).await? {
return Ok(Vec::new());
}
diff --git a/crates/integrations/datafusion/src/system_tables/tags.rs
b/crates/integrations/datafusion/src/system_tables/tags.rs
index 41f51ca..5b9a22b 100644
--- a/crates/integrations/datafusion/src/system_tables/tags.rs
+++ b/crates/integrations/datafusion/src/system_tables/tags.rs
@@ -30,7 +30,7 @@ use datafusion::datasource::{TableProvider, TableType};
use datafusion::error::Result as DFResult;
use datafusion::logical_expr::Expr;
use datafusion::physical_plan::ExecutionPlan;
-use paimon::table::{Table, TagManager};
+use paimon::table::Table;
use crate::error::to_datafusion_error;
@@ -85,10 +85,7 @@ impl TableProvider for TagsTable {
_filters: &[Expr],
_limit: Option<usize>,
) -> DFResult<Arc<dyn ExecutionPlan>> {
- let tm = TagManager::new(
- self.table.file_io().clone(),
- self.table.location().to_string(),
- );
+ let tm = self.table.tag_manager();
let tags = crate::runtime::await_with_runtime(async move {
tm.list_all().await })
.await
.map_err(to_datafusion_error)?;
diff --git a/crates/integrations/datafusion/src/table/mod.rs
b/crates/integrations/datafusion/src/table/mod.rs
index b10eb26..9b698f4 100644
--- a/crates/integrations/datafusion/src/table/mod.rs
+++ b/crates/integrations/datafusion/src/table/mod.rs
@@ -402,6 +402,12 @@ impl TableProvider for PaimonTableProvider {
input: Arc<dyn ExecutionPlan>,
insert_op: InsertOp,
) -> DFResult<Arc<dyn ExecutionPlan>> {
+ if self.table.is_branch_reference() {
+ return
Err(datafusion::error::DataFusionError::NotImplemented(format!(
+ "Writing to Paimon branch '{}' is not supported",
+ self.table.branch()
+ )));
+ }
let overwrite = match insert_op {
InsertOp::Append => false,
InsertOp::Overwrite => true,
diff --git a/crates/integrations/datafusion/src/table_loader.rs
b/crates/integrations/datafusion/src/table_loader.rs
new file mode 100644
index 0000000..f1d61f9
--- /dev/null
+++ b/crates/integrations/datafusion/src/table_loader.rs
@@ -0,0 +1,69 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use std::sync::Arc;
+
+use datafusion::error::{DataFusionError, Result as DFResult};
+use paimon::catalog::{Catalog, Identifier};
+use paimon::table::Table;
+
+use crate::error::to_datafusion_error;
+
+pub(crate) async fn load_table_for_read(
+ catalog: &Arc<dyn Catalog>,
+ identifier: &Identifier,
+) -> DFResult<(Table, Identifier, Option<String>)> {
+ let parsed = identifier
+ .parsed_object_name()
+ .map_err(to_datafusion_error)?;
+ let base_identifier = Identifier::new(
+ identifier.database().to_string(),
+ parsed.table().to_string(),
+ );
+ let mut table = catalog
+ .get_table(&base_identifier)
+ .await
+ .map_err(to_datafusion_error)?;
+ let system_table = parsed.system_table().map(str::to_string);
+ if let Some(branch) = parsed.branch() {
+ let is_branches_table = system_table
+ .as_deref()
+ .is_some_and(|name| name.eq_ignore_ascii_case("branches"));
+ if is_branches_table {
+ return Ok((table, base_identifier, system_table));
+ }
+ table = table
+ .copy_with_branch(branch)
+ .await
+ .map_err(to_datafusion_error)?;
+ }
+ Ok((table, base_identifier, system_table))
+}
+
+pub(crate) async fn load_data_table_for_read(
+ catalog: &Arc<dyn Catalog>,
+ identifier: &Identifier,
+ caller: &str,
+) -> DFResult<Table> {
+ let (table, _, system_table) = load_table_for_read(catalog,
identifier).await?;
+ if system_table.is_some() {
+ return Err(DataFusionError::Plan(format!(
+ "{caller} requires a data table"
+ )));
+ }
+ Ok(table)
+}
diff --git a/crates/integrations/datafusion/src/update.rs
b/crates/integrations/datafusion/src/update.rs
index a987aaa..e78037a 100644
--- a/crates/integrations/datafusion/src/update.rs
+++ b/crates/integrations/datafusion/src/update.rs
@@ -157,7 +157,8 @@ async fn execute_update_once(
.await
.map_err(to_datafusion_error)?;
if !messages.is_empty() {
- wb.new_commit()
+ wb.try_new_commit()
+ .map_err(to_datafusion_error)?
.commit(messages)
.await
.map_err(to_datafusion_error)?;
@@ -220,7 +221,10 @@ async fn execute_cow_update_once(
let messages = writer.prepare_commit().await.map_err(to_datafusion_error)?;
if !messages.is_empty() {
- let commit = table.new_write_builder().new_commit();
+ let commit = table
+ .new_write_builder()
+ .try_new_commit()
+ .map_err(to_datafusion_error)?;
commit.commit(messages).await.map_err(to_datafusion_error)?;
}
diff --git a/crates/integrations/datafusion/src/vector_search.rs
b/crates/integrations/datafusion/src/vector_search.rs
index 694f21b..d101cce 100644
--- a/crates/integrations/datafusion/src/vector_search.rs
+++ b/crates/integrations/datafusion/src/vector_search.rs
@@ -37,6 +37,7 @@ use crate::table::{PaimonScanBuilder, PaimonTableProvider};
use crate::table_function_args::{
extract_int_literal, extract_string_literal, parse_table_identifier,
};
+use crate::table_loader::load_data_table_for_read;
const FUNCTION_NAME: &str = "vector_search";
@@ -96,10 +97,9 @@ impl TableFunctionImpl for VectorSearchFunction {
let catalog = Arc::clone(&self.catalog);
let table = block_on_with_runtime(
- async move { catalog.get_table(&identifier).await },
+ async move { load_data_table_for_read(&catalog, &identifier,
FUNCTION_NAME).await },
"vector_search: catalog access thread panicked",
- )
- .map_err(to_datafusion_error)?;
+ )?;
let inner = PaimonTableProvider::try_new(table)?;
let query_vector_json =
diff --git a/crates/integrations/datafusion/tests/blob_tests.rs
b/crates/integrations/datafusion/tests/blob_tests.rs
index 10d4624..84fad62 100644
--- a/crates/integrations/datafusion/tests/blob_tests.rs
+++ b/crates/integrations/datafusion/tests/blob_tests.rs
@@ -23,7 +23,10 @@ mod common;
use arrow_array::{Array, BinaryArray, Int32Array, RecordBatch, StringArray};
use common::{create_sql_context, create_test_env, exec};
+use paimon::catalog::Identifier;
use paimon::spec::{BlobDescriptor, BlobViewStruct};
+use paimon::table::BranchManager;
+use paimon::Catalog;
use paimon_datafusion::SQLContext;
// ======================= Helpers =======================
@@ -872,6 +875,73 @@ async fn
test_blob_view_without_rest_env_preserves_reference() {
drop(tmp);
}
+#[tokio::test]
+async fn test_blob_view_preserves_branch_reference() {
+ let (_tmp, catalog) = create_test_env();
+ let sql_context = create_sql_context(catalog.clone()).await;
+ sql_context
+ .sql("CREATE SCHEMA paimon.test_db")
+ .await
+ .unwrap();
+ sql_context
+ .sql(
+ "CREATE TABLE paimon.test_db.src (\
+ id INT, \
+ picture BLOB\
+ ) WITH (\
+ 'data-evolution.enabled' = 'true', \
+ 'row-tracking.enabled' = 'true'\
+ )",
+ )
+ .await
+ .unwrap();
+ exec(
+ &sql_context,
+ "INSERT INTO paimon.test_db.src (id, picture) VALUES (1,
X'616C696365')",
+ )
+ .await;
+
+ let table = catalog
+ .get_table(&Identifier::new("test_db", "src"))
+ .await
+ .expect("load table");
+ let snapshot = table
+ .snapshot_manager()
+ .get_latest_snapshot()
+ .await
+ .expect("load latest snapshot")
+ .expect("latest snapshot");
+ table
+ .tag_manager()
+ .create("branch-source", &snapshot)
+ .await
+ .expect("create tag");
+ BranchManager::new(table.file_io().clone(), table.location().to_string())
+ .create_branch_from_tag("b1", "branch-source")
+ .await
+ .expect("create branch");
+
+ let batches = sql_context
+ .sql(
+ "SELECT blob_view('test_db.src$branch_b1', 'picture', \"_ROW_ID\")
AS picture \
+ FROM paimon.test_db.src",
+ )
+ .await
+ .expect("blob_view SQL should parse")
+ .collect()
+ .await
+ .expect("branch blob_view should execute");
+ let value = batches[0]
+ .column(0)
+ .as_any()
+ .downcast_ref::<BinaryArray>()
+ .expect("binary blob view")
+ .value(0);
+ let view = BlobViewStruct::deserialize(value).expect("deserialize blob
view");
+ assert_eq!(view.identifier().full_name(), "test_db.src$branch_b1");
+ assert_eq!(view.row_id(), 0);
+}
+
#[tokio::test]
async fn test_blob_view_resolve_disabled_preserves_reference() {
let (_tmp, sql_context) = setup(
diff --git a/crates/integrations/datafusion/tests/read_tables.rs
b/crates/integrations/datafusion/tests/read_tables.rs
index cb9a68f..55db55b 100644
--- a/crates/integrations/datafusion/tests/read_tables.rs
+++ b/crates/integrations/datafusion/tests/read_tables.rs
@@ -1399,6 +1399,8 @@ mod fulltext_tests {
use std::sync::Arc;
use datafusion::arrow::array::{Int32Array, StringArray};
+ use paimon::catalog::Identifier;
+ use paimon::table::BranchManager;
use paimon::{Catalog, CatalogOptions, FileSystemCatalog, Options};
use paimon_datafusion::{register_full_text_search, SQLContext};
@@ -1420,19 +1422,18 @@ mod fulltext_tests {
(tmp, warehouse)
}
- async fn create_fulltext_context() -> (SQLContext, tempfile::TempDir) {
+ async fn create_fulltext_context() -> (SQLContext, Arc<FileSystemCatalog>,
tempfile::TempDir) {
let (tmp, warehouse) = extract_test_warehouse();
let mut options = Options::new();
options.set(CatalogOptions::WAREHOUSE, warehouse);
- let catalog = FileSystemCatalog::new(options).expect("Failed to create
catalog");
- let catalog: Arc<dyn Catalog> = Arc::new(catalog);
+ let catalog = Arc::new(FileSystemCatalog::new(options).expect("Failed
to create catalog"));
let mut ctx = SQLContext::new();
ctx.register_catalog("paimon", catalog.clone())
.await
.expect("Failed to register catalog");
- register_full_text_search(ctx.ctx(), catalog, "default");
- (ctx, tmp)
+ register_full_text_search(ctx.ctx(), catalog.clone(), "default");
+ (ctx, catalog, tmp)
}
fn extract_id_content_rows(
@@ -1459,7 +1460,7 @@ mod fulltext_tests {
/// Search for 'paimon' — rows 0, 2, 4 mention "paimon".
#[tokio::test]
async fn test_full_text_search_paimon() {
- let (ctx, _tmp) = create_fulltext_context().await;
+ let (ctx, _catalog, _tmp) = create_fulltext_context().await;
let batches = ctx
.sql("SELECT id, content FROM
full_text_search('paimon.default.test_tantivy_fulltext', 'content', 'paimon',
10)")
.await
@@ -1477,10 +1478,49 @@ mod fulltext_tests {
);
}
+ #[tokio::test]
+ async fn test_full_text_search_branch() {
+ let (ctx, catalog, _tmp) = create_fulltext_context().await;
+ let identifier = Identifier::new("default", "test_tantivy_fulltext");
+ let table = catalog.get_table(&identifier).await.expect("load table");
+ let snapshot_manager = table.snapshot_manager();
+ let snapshot = snapshot_manager
+ .get_latest_snapshot()
+ .await
+ .expect("load latest snapshot")
+ .expect("latest snapshot");
+ table
+ .tag_manager()
+ .create("branch-source", &snapshot)
+ .await
+ .expect("create tag");
+ BranchManager::new(table.file_io().clone(),
table.location().to_string())
+ .create_branch_from_tag("b1", "branch-source")
+ .await
+ .expect("create branch");
+ table
+ .file_io()
+ .delete_dir(&snapshot_manager.snapshot_dir())
+ .await
+ .expect("delete main snapshots");
+
+ let batches = ctx
+ .sql("SELECT id, content FROM
full_text_search('paimon.default.test_tantivy_fulltext$branch_b1', 'content',
'paimon', 10)")
+ .await
+ .expect("SQL should parse")
+ .collect()
+ .await
+ .expect("branch query should execute");
+
+ let rows = extract_id_content_rows(&batches);
+ let ids: Vec<i32> = rows.iter().map(|(id, _)| *id).collect();
+ assert_eq!(ids, vec![0, 2, 4]);
+ }
+
/// Search for 'tantivy' — only row 1.
#[tokio::test]
async fn test_full_text_search_tantivy() {
- let (ctx, _tmp) = create_fulltext_context().await;
+ let (ctx, _catalog, _tmp) = create_fulltext_context().await;
let batches = ctx
.sql("SELECT id, content FROM
full_text_search('paimon.default.test_tantivy_fulltext', 'content', 'tantivy',
10)")
.await
@@ -1497,7 +1537,7 @@ mod fulltext_tests {
/// Search for 'search' — rows 1, 3 mention "full-text search".
#[tokio::test]
async fn test_full_text_search_search() {
- let (ctx, _tmp) = create_fulltext_context().await;
+ let (ctx, _catalog, _tmp) = create_fulltext_context().await;
let batches = ctx
.sql("SELECT id, content FROM
full_text_search('paimon.default.test_tantivy_fulltext', 'content', 'search',
10)")
.await
@@ -1526,6 +1566,7 @@ mod vector_search_tests {
use datafusion::datasource::MemTable;
use paimon::catalog::Identifier;
use paimon::spec::{ArrayType, DataType, FloatType, IntType, Schema};
+ use paimon::table::BranchManager;
use paimon::{Catalog, CatalogOptions, FileSystemCatalog, Options};
use paimon_datafusion::{register_vector_search, SQLContext};
@@ -1547,26 +1588,29 @@ mod vector_search_tests {
(tmp, warehouse)
}
- async fn create_vector_search_context(archive_name: &str) -> (SQLContext,
tempfile::TempDir) {
+ async fn create_vector_search_context(
+ archive_name: &str,
+ ) -> (SQLContext, Arc<FileSystemCatalog>, tempfile::TempDir) {
let (tmp, warehouse) = extract_test_warehouse(archive_name);
let mut options = Options::new();
options.set(CatalogOptions::WAREHOUSE, warehouse);
- let catalog = FileSystemCatalog::new(options).expect("Failed to create
catalog");
- let catalog: Arc<dyn Catalog> = Arc::new(catalog);
+ let catalog = Arc::new(FileSystemCatalog::new(options).expect("Failed
to create catalog"));
let mut ctx = SQLContext::new();
ctx.register_catalog("paimon", catalog.clone())
.await
.expect("Failed to register catalog");
- register_vector_search(ctx.ctx(), catalog, "default");
- (ctx, tmp)
+ register_vector_search(ctx.ctx(), catalog.clone(), "default");
+ (ctx, catalog, tmp)
}
- async fn create_lumina_vector_search_context() -> (SQLContext,
tempfile::TempDir) {
+ async fn create_lumina_vector_search_context(
+ ) -> (SQLContext, Arc<FileSystemCatalog>, tempfile::TempDir) {
create_vector_search_context("test_lumina_vector.tar.gz").await
}
- async fn create_java_vindex_vector_search_context() -> (SQLContext,
tempfile::TempDir) {
+ async fn create_java_vindex_vector_search_context(
+ ) -> (SQLContext, Arc<FileSystemCatalog>, tempfile::TempDir) {
create_vector_search_context("test_java_vindex_vector.tar.gz").await
}
@@ -1748,7 +1792,7 @@ mod vector_search_tests {
#[tokio::test]
async fn test_vector_search_top3() {
- let (ctx, _tmp) = create_lumina_vector_search_context().await;
+ let (ctx, _catalog, _tmp) =
create_lumina_vector_search_context().await;
let batches = ctx
.sql("SELECT id FROM
vector_search('paimon.default.test_lumina_vector', 'embedding', '[1.0, 0.0,
0.0, 0.0]', 3)")
.await
@@ -1762,9 +1806,48 @@ mod vector_search_tests {
assert!(ids.contains(&0), "exact match [1,0,0,0] should be in top 3");
}
+ #[tokio::test]
+ async fn test_vector_search_branch() {
+ let (ctx, catalog, _tmp) =
create_java_vindex_vector_search_context().await;
+ let identifier = Identifier::new("default", "test_java_vindex_vector");
+ let table = catalog.get_table(&identifier).await.expect("load table");
+ let snapshot_manager = table.snapshot_manager();
+ let snapshot = snapshot_manager
+ .get_latest_snapshot()
+ .await
+ .expect("load latest snapshot")
+ .expect("latest snapshot");
+ table
+ .tag_manager()
+ .create("branch-source", &snapshot)
+ .await
+ .expect("create tag");
+ BranchManager::new(table.file_io().clone(),
table.location().to_string())
+ .create_branch_from_tag("b1", "branch-source")
+ .await
+ .expect("create branch");
+ table
+ .file_io()
+ .delete_dir(&snapshot_manager.snapshot_dir())
+ .await
+ .expect("delete main snapshots");
+
+ let batches = ctx
+ .sql("SELECT id FROM
vector_search('paimon.default.test_java_vindex_vector$branch_b1', 'embedding',
'[1.0, 0.0, 0.0, 0.0]', 3)")
+ .await
+ .expect("SQL should parse")
+ .collect()
+ .await
+ .expect("branch query should execute");
+
+ let ids = extract_ids(&batches);
+ assert_eq!(ids.len(), 3);
+ assert!(ids.contains(&0));
+ }
+
#[tokio::test]
async fn test_vector_search_top6_returns_all() {
- let (ctx, _tmp) = create_lumina_vector_search_context().await;
+ let (ctx, _catalog, _tmp) =
create_lumina_vector_search_context().await;
let batches = ctx
.sql("SELECT id FROM
vector_search('paimon.default.test_lumina_vector', 'embedding', '[1.0, 0.0,
0.0, 0.0]', 6)")
.await
@@ -1779,7 +1862,7 @@ mod vector_search_tests {
#[tokio::test]
async fn test_vector_search_without_matching_index_returns_empty() {
- let (ctx, _tmp) = create_lumina_vector_search_context().await;
+ let (ctx, _catalog, _tmp) =
create_lumina_vector_search_context().await;
let batches = ctx
.sql("SELECT id FROM
vector_search('paimon.default.test_lumina_vector', 'missing_embedding',
'[1.0]', 10)")
.await
@@ -1797,7 +1880,7 @@ mod vector_search_tests {
#[tokio::test]
async fn test_vector_search_java_vindex_table() {
- let (ctx, _tmp) = create_java_vindex_vector_search_context().await;
+ let (ctx, _catalog, _tmp) =
create_java_vindex_vector_search_context().await;
let batches = ctx
.sql("SELECT id FROM
vector_search('paimon.default.test_java_vindex_vector', 'embedding', '[1.0,
0.0, 0.0, 0.0]', 3)")
.await
@@ -1812,7 +1895,7 @@ mod vector_search_tests {
#[tokio::test]
async fn test_vector_search_lateral_join_uses_query_vectors() {
- let (ctx, _tmp) = create_java_vindex_vector_search_context().await;
+ let (ctx, _catalog, _tmp) =
create_java_vindex_vector_search_context().await;
let query_batch = build_vector_batch(
vec![10, 20],
vec![vec![1.0, 0.0, 0.0, 0.0], vec![0.0, 1.0, 0.0, 0.0]],
@@ -2018,6 +2101,8 @@ mod hybrid_search_tests {
use std::sync::Arc;
use datafusion::arrow::array::Int32Array;
+ use paimon::catalog::Identifier;
+ use paimon::table::BranchManager;
use paimon::{Catalog, CatalogOptions, FileSystemCatalog, Options};
use paimon_datafusion::SQLContext;
@@ -2039,18 +2124,18 @@ mod hybrid_search_tests {
(tmp, warehouse)
}
- async fn create_hybrid_search_context() -> (SQLContext, tempfile::TempDir)
{
+ async fn create_hybrid_search_context(
+ ) -> (SQLContext, Arc<FileSystemCatalog>, tempfile::TempDir) {
let (tmp, warehouse) =
extract_test_warehouse("test_java_vindex_vector.tar.gz");
let mut options = Options::new();
options.set(CatalogOptions::WAREHOUSE, warehouse);
- let catalog = FileSystemCatalog::new(options).expect("Failed to create
catalog");
- let catalog: Arc<dyn Catalog> = Arc::new(catalog);
+ let catalog = Arc::new(FileSystemCatalog::new(options).expect("Failed
to create catalog"));
let mut ctx = SQLContext::new();
ctx.register_catalog("paimon", catalog.clone())
.await
.expect("Failed to register catalog");
- (ctx, tmp)
+ (ctx, catalog, tmp)
}
fn extract_ids(batches: &[datafusion::arrow::record_batch::RecordBatch])
-> Vec<i32> {
@@ -2070,7 +2155,7 @@ mod hybrid_search_tests {
#[tokio::test]
async fn test_hybrid_search_multiple_vector_routes_spark_shape() {
- let (ctx, _tmp) = create_hybrid_search_context().await;
+ let (ctx, _catalog, _tmp) = create_hybrid_search_context().await;
let batches = ctx
.sql(
"SELECT id FROM hybrid_search( \
@@ -2097,4 +2182,47 @@ mod hybrid_search_tests {
assert_eq!(extract_ids(&batches), vec![0, 1, 2]);
}
+
+ #[tokio::test]
+ async fn test_hybrid_search_branch() {
+ let (ctx, catalog, _tmp) = create_hybrid_search_context().await;
+ let identifier = Identifier::new("default", "test_java_vindex_vector");
+ let table = catalog.get_table(&identifier).await.expect("load table");
+ let snapshot = table
+ .snapshot_manager()
+ .get_latest_snapshot()
+ .await
+ .expect("load latest snapshot")
+ .expect("latest snapshot");
+ table
+ .tag_manager()
+ .create("branch-source", &snapshot)
+ .await
+ .expect("create tag");
+ BranchManager::new(table.file_io().clone(),
table.location().to_string())
+ .create_branch_from_tag("b1", "branch-source")
+ .await
+ .expect("create branch");
+
+ let batches = ctx
+ .sql(
+ "SELECT id FROM hybrid_search( \
+ 'paimon.default.test_java_vindex_vector$branch_b1', \
+ array(named_struct( \
+ 'field', 'embedding', \
+ 'query_vector', array(1.0, 0.0, 0.0, 0.0), \
+ 'limit', 3, \
+ 'weight', 1.0)), \
+ array(), \
+ 3, \
+ 'rrf')",
+ )
+ .await
+ .expect("hybrid_search SQL should parse")
+ .collect()
+ .await
+ .expect("branch query should execute");
+
+ assert_eq!(extract_ids(&batches), vec![0, 1, 2]);
+ }
}
diff --git a/crates/integrations/datafusion/tests/sql_context_tests.rs
b/crates/integrations/datafusion/tests/sql_context_tests.rs
index ef9d4eb..d8934ee 100644
--- a/crates/integrations/datafusion/tests/sql_context_tests.rs
+++ b/crates/integrations/datafusion/tests/sql_context_tests.rs
@@ -17,17 +17,20 @@
//! SQL context integration tests for paimon-datafusion.
-use std::sync::Arc;
+use std::sync::atomic::{AtomicBool, Ordering};
+use std::sync::{Arc, Mutex};
-use datafusion::arrow::array::Array;
+use async_trait::async_trait;
+use datafusion::arrow::array::{Array, Int64Array};
use datafusion::catalog::CatalogProvider;
use datafusion::datasource::MemTable;
-use paimon::catalog::Identifier;
+use paimon::catalog::{list_partitions_from_file_system, Identifier};
use paimon::spec::{
ArrayType, BinaryType, BlobType, CharType, DataType, FloatType, IntType,
LocalZonedTimestampType, MapType, MultisetType, SchemaChange, TimeType,
VarBinaryType,
VarCharType, VectorType,
};
+use paimon::table::{BranchManager, SnapshotManager, TagManager};
use paimon::{Catalog, CatalogOptions, FileSystemCatalog, Options};
use paimon_datafusion::{PaimonCatalogProvider, SQLContext};
use tempfile::TempDir;
@@ -47,6 +50,207 @@ async fn create_sql_context(catalog:
Arc<FileSystemCatalog>) -> SQLContext {
ctx
}
+struct PartitionCatalog {
+ inner: Arc<FileSystemCatalog>,
+ fail_list_partitions: AtomicBool,
+ partition_identifiers: Mutex<Vec<Identifier>>,
+}
+
+impl PartitionCatalog {
+ fn new(inner: Arc<FileSystemCatalog>) -> Self {
+ Self {
+ inner,
+ fail_list_partitions: AtomicBool::new(false),
+ partition_identifiers: Mutex::new(Vec::new()),
+ }
+ }
+
+ fn set_fail_list_partitions(&self, fail: bool) {
+ self.fail_list_partitions.store(fail, Ordering::SeqCst);
+ }
+
+ fn take_partition_identifiers(&self) -> Vec<Identifier> {
+ std::mem::take(&mut *self.partition_identifiers.lock().unwrap())
+ }
+}
+
+#[async_trait]
+impl Catalog for PartitionCatalog {
+ async fn list_databases(&self) -> paimon::Result<Vec<String>> {
+ self.inner.list_databases().await
+ }
+
+ async fn create_database(
+ &self,
+ name: &str,
+ ignore_if_exists: bool,
+ properties: std::collections::HashMap<String, String>,
+ ) -> paimon::Result<()> {
+ self.inner
+ .create_database(name, ignore_if_exists, properties)
+ .await
+ }
+
+ async fn get_database(&self, name: &str) ->
paimon::Result<paimon::catalog::Database> {
+ self.inner.get_database(name).await
+ }
+
+ async fn drop_database(
+ &self,
+ name: &str,
+ ignore_if_not_exists: bool,
+ cascade: bool,
+ ) -> paimon::Result<()> {
+ self.inner
+ .drop_database(name, ignore_if_not_exists, cascade)
+ .await
+ }
+
+ async fn get_table(&self, identifier: &Identifier) ->
paimon::Result<paimon::table::Table> {
+ self.inner.get_table(identifier).await
+ }
+
+ async fn list_tables(&self, database_name: &str) ->
paimon::Result<Vec<String>> {
+ self.inner.list_tables(database_name).await
+ }
+
+ async fn create_table(
+ &self,
+ identifier: &Identifier,
+ creation: paimon::spec::Schema,
+ ignore_if_exists: bool,
+ ) -> paimon::Result<()> {
+ self.inner
+ .create_table(identifier, creation, ignore_if_exists)
+ .await
+ }
+
+ async fn drop_table(
+ &self,
+ identifier: &Identifier,
+ ignore_if_not_exists: bool,
+ ) -> paimon::Result<()> {
+ self.inner
+ .drop_table(identifier, ignore_if_not_exists)
+ .await
+ }
+
+ async fn rename_table(
+ &self,
+ from: &Identifier,
+ to: &Identifier,
+ ignore_if_not_exists: bool,
+ ) -> paimon::Result<()> {
+ self.inner
+ .rename_table(from, to, ignore_if_not_exists)
+ .await
+ }
+
+ async fn alter_table(
+ &self,
+ identifier: &Identifier,
+ changes: Vec<SchemaChange>,
+ ignore_if_not_exists: bool,
+ ) -> paimon::Result<()> {
+ self.inner
+ .alter_table(identifier, changes, ignore_if_not_exists)
+ .await
+ }
+
+ async fn list_partitions(
+ &self,
+ identifier: &Identifier,
+ ) -> paimon::Result<Vec<paimon::spec::Partition>> {
+ self.partition_identifiers
+ .lock()
+ .unwrap()
+ .push(identifier.clone());
+
+ let Some(branch) = identifier.branch_name()? else {
+ return self.inner.list_partitions(identifier).await;
+ };
+ if self.fail_list_partitions.load(Ordering::SeqCst) {
+ return Err(paimon::Error::Unsupported {
+ message: "injected list_partitions failure".to_string(),
+ });
+ }
+
+ let base = Identifier::new(identifier.database(),
identifier.table_name()?);
+ let table = self.inner.get_table(&base).await?;
+ let table = table.copy_with_branch(&branch).await?;
+ let mut partitions = list_partitions_from_file_system(&table).await?;
+ for partition in &mut partitions {
+ partition.created_by = Some("catalog".to_string());
+ }
+ Ok(partitions)
+ }
+}
+
+async fn collect_ids(sql_context: &SQLContext, sql: &str) -> Vec<i32> {
+ let batches = sql_context.sql(sql).await.unwrap().collect().await.unwrap();
+ let mut ids = Vec::new();
+ for batch in batches {
+ let id_array = batch
+ .column_by_name("id")
+ .and_then(|c| c.as_any().downcast_ref::<Int32Array>())
+ .expect("id column");
+ for row in 0..batch.num_rows() {
+ ids.push(id_array.value(row));
+ }
+ }
+ ids.sort_unstable();
+ ids
+}
+
+async fn collect_i64_column(sql_context: &SQLContext, sql: &str, column: &str)
-> Vec<i64> {
+ let batches = sql_context.sql(sql).await.unwrap().collect().await.unwrap();
+ let mut values = Vec::new();
+ for batch in batches {
+ let array = batch
+ .column_by_name(column)
+ .and_then(|c| c.as_any().downcast_ref::<Int64Array>())
+ .expect(column);
+ for row in 0..batch.num_rows() {
+ values.push(array.value(row));
+ }
+ }
+ values.sort_unstable();
+ values
+}
+
+async fn collect_string_column(sql_context: &SQLContext, sql: &str, column:
&str) -> Vec<String> {
+ let batches = sql_context.sql(sql).await.unwrap().collect().await.unwrap();
+ let mut values = Vec::new();
+ for batch in batches {
+ let array = batch
+ .column_by_name(column)
+ .and_then(|c| c.as_any().downcast_ref::<StringArray>())
+ .expect(column);
+ for row in 0..batch.num_rows() {
+ if !array.is_null(row) {
+ values.push(array.value(row).to_string());
+ }
+ }
+ }
+ values.sort_unstable();
+ values
+}
+
+async fn assert_sql_error_contains(sql_context: &SQLContext, sql: &str,
expected: &str) {
+ let err = match sql_context.sql(sql).await {
+ Ok(df) => df
+ .collect()
+ .await
+ .expect_err("SQL should fail but succeeded")
+ .to_string(),
+ Err(err) => err.to_string(),
+ };
+ assert!(
+ err.contains(expected),
+ "expected error containing '{expected}', got: {err}"
+ );
+}
+
#[tokio::test]
async fn test_show_tables_is_enabled() {
let (_tmp, catalog) = create_test_env();
@@ -61,6 +265,319 @@ async fn test_show_tables_is_enabled() {
.expect("SHOW TABLES should execute");
}
+#[tokio::test]
+async fn test_select_branch_table_reads_branch_snapshot() {
+ let (_tmp, catalog) = create_test_env();
+ let sql_context = create_sql_context(catalog.clone()).await;
+
+ sql_context
+ .sql("CREATE TABLE paimon.default.branch_orders (id INT, name STRING)")
+ .await
+ .unwrap()
+ .collect()
+ .await
+ .unwrap();
+ sql_context
+ .sql("INSERT INTO paimon.default.branch_orders VALUES (1, 'branch')")
+ .await
+ .unwrap()
+ .collect()
+ .await
+ .unwrap();
+
+ let identifier = Identifier::new("default", "branch_orders");
+ let table = catalog.get_table(&identifier).await.unwrap();
+ let snapshot_manager =
+ SnapshotManager::new(table.file_io().clone(),
table.location().to_string());
+ let snapshot = snapshot_manager
+ .get_latest_snapshot()
+ .await
+ .unwrap()
+ .unwrap();
+ let tag_manager = TagManager::new(table.file_io().clone(),
table.location().to_string());
+ tag_manager.create("branch_base", &snapshot).await.unwrap();
+ let branch_manager = BranchManager::new(table.file_io().clone(),
table.location().to_string());
+ branch_manager
+ .create_branch_from_tag("b1", "branch_base")
+ .await
+ .unwrap();
+
+ sql_context
+ .sql("INSERT INTO paimon.default.branch_orders VALUES (2, 'main')")
+ .await
+ .unwrap()
+ .collect()
+ .await
+ .unwrap();
+
+ assert_eq!(
+ collect_ids(&sql_context, "SELECT id FROM
paimon.default.branch_orders").await,
+ vec![1, 2]
+ );
+ assert_eq!(
+ collect_ids(
+ &sql_context,
+ "SELECT id FROM paimon.default.branch_orders$branch_b1"
+ )
+ .await,
+ vec![1]
+ );
+ assert_eq!(
+ collect_i64_column(
+ &sql_context,
+ "SELECT snapshot_id FROM paimon.default.branch_orders$snapshots",
+ "snapshot_id"
+ )
+ .await,
+ vec![1, 2]
+ );
+ assert_eq!(
+ collect_i64_column(
+ &sql_context,
+ "SELECT snapshot_id FROM
paimon.default.branch_orders$branch_b1$snapshots",
+ "snapshot_id"
+ )
+ .await,
+ vec![1]
+ );
+ assert_eq!(
+ collect_i64_column(
+ &sql_context,
+ "SELECT record_count FROM paimon.default.branch_orders$files
VERSION AS OF 'branch_base'",
+ "record_count"
+ )
+ .await,
+ vec![1]
+ );
+ assert_eq!(
+ collect_i64_column(
+ &sql_context,
+ "SELECT record_count FROM
paimon.default.branch_orders$branch_b1$files VERSION AS OF 'branch_base'",
+ "record_count"
+ )
+ .await,
+ vec![1]
+ );
+ assert!(!collect_string_column(
+ &sql_context,
+ "SELECT file_name FROM
paimon.default.branch_orders$branch_b1$manifests",
+ "file_name",
+ )
+ .await
+ .is_empty());
+
+ let branch_table = table.copy_with_branch("b1").await.unwrap();
+ let write_builder = branch_table.new_write_builder();
+ assert!(write_builder.new_write().is_err());
+ assert!(write_builder.new_update(vec!["name".to_string()]).is_err());
+ assert!(write_builder.new_delete().is_err());
+ assert!(write_builder.try_new_commit().is_err());
+
+ assert_sql_error_contains(
+ &sql_context,
+ "INSERT INTO paimon.default.branch_orders$branch_b1 VALUES (3,
'blocked')",
+ "Writing to Paimon branch 'b1' is not supported",
+ )
+ .await;
+ assert_sql_error_contains(
+ &sql_context,
+ "INSERT INTO paimon.default.branch_orders$branch_main VALUES (3,
'blocked')",
+ "Writing to Paimon branch 'main' is not supported",
+ )
+ .await;
+ assert_sql_error_contains(
+ &sql_context,
+ "UPDATE paimon.default.branch_orders$branch_b1 SET name = 'blocked'
WHERE id = 1",
+ "UPDATE on Paimon branch 'b1' is not supported",
+ )
+ .await;
+ assert_sql_error_contains(
+ &sql_context,
+ "UPDATE paimon.default.branch_orders$branch_main SET name = 'blocked'
WHERE id = 1",
+ "UPDATE on Paimon branch 'main' is not supported",
+ )
+ .await;
+ assert_sql_error_contains(
+ &sql_context,
+ "DELETE FROM paimon.default.branch_orders$branch_b1 WHERE id = 1",
+ "DELETE on Paimon branch 'b1' is not supported",
+ )
+ .await;
+ assert_sql_error_contains(
+ &sql_context,
+ "DELETE FROM paimon.default.branch_orders$branch_main WHERE id = 1",
+ "DELETE on Paimon branch 'main' is not supported",
+ )
+ .await;
+ assert_sql_error_contains(
+ &sql_context,
+ "MERGE INTO paimon.default.branch_orders$branch_main AS target \
+ USING (SELECT 1 AS id, 'blocked' AS name) AS source \
+ ON target.id = source.id \
+ WHEN MATCHED THEN UPDATE SET name = source.name",
+ "MERGE INTO on Paimon branch 'main' is not supported",
+ )
+ .await;
+ assert_sql_error_contains(
+ &sql_context,
+ "TRUNCATE TABLE paimon.default.branch_orders$branch_main",
+ "TRUNCATE TABLE on Paimon branch 'main' is not supported",
+ )
+ .await;
+ assert_sql_error_contains(
+ &sql_context,
+ "ALTER TABLE paimon.default.branch_orders$branch_main ADD COLUMN
blocked INT",
+ "ALTER TABLE on Paimon branch 'main' is not supported",
+ )
+ .await;
+ assert_sql_error_contains(
+ &sql_context,
+ "INSERT OVERWRITE paimon.default.branch_orders$branch_main \
+ PARTITION (id = 1) SELECT 'blocked'",
+ "INSERT OVERWRITE on Paimon branch 'main' is not supported",
+ )
+ .await;
+}
+
+#[tokio::test]
+async fn test_branch_partitions_system_table_reads_branch_snapshot() {
+ let (_tmp, file_catalog) = create_test_env();
+ let catalog = Arc::new(PartitionCatalog::new(file_catalog.clone()));
+ let mut sql_context = SQLContext::new();
+ sql_context
+ .register_catalog("paimon", catalog.clone())
+ .await
+ .unwrap();
+
+ sql_context
+ .sql(
+ "CREATE TABLE paimon.default.branch_partition_orders \
+ (id INT, name STRING) PARTITIONED BY (id)",
+ )
+ .await
+ .unwrap()
+ .collect()
+ .await
+ .unwrap();
+ sql_context
+ .sql("INSERT INTO paimon.default.branch_partition_orders VALUES (1,
'branch')")
+ .await
+ .unwrap()
+ .collect()
+ .await
+ .unwrap();
+
+ let identifier = Identifier::new("default", "branch_partition_orders");
+ let table = file_catalog.get_table(&identifier).await.unwrap();
+ let snapshot_manager =
+ SnapshotManager::new(table.file_io().clone(),
table.location().to_string());
+ let snapshot = snapshot_manager
+ .get_latest_snapshot()
+ .await
+ .unwrap()
+ .unwrap();
+ let tag_manager = TagManager::new(table.file_io().clone(),
table.location().to_string());
+ tag_manager
+ .create("partition_branch_base", &snapshot)
+ .await
+ .unwrap();
+ let branch_manager = BranchManager::new(table.file_io().clone(),
table.location().to_string());
+ branch_manager
+ .create_branch_from_tag("b1", "partition_branch_base")
+ .await
+ .unwrap();
+
+ sql_context
+ .sql("INSERT INTO paimon.default.branch_partition_orders VALUES (2,
'main')")
+ .await
+ .unwrap()
+ .collect()
+ .await
+ .unwrap();
+
+ assert_eq!(
+ collect_string_column(
+ &sql_context,
+ "SELECT \"partition\" FROM
paimon.default.branch_partition_orders$partitions",
+ "partition",
+ )
+ .await,
+ vec!["id=1".to_string(), "id=2".to_string()]
+ );
+ catalog.take_partition_identifiers();
+
+ assert_eq!(
+ collect_string_column(
+ &sql_context,
+ "SELECT created_by FROM
paimon.default.branch_partition_orders$branch_b1$partitions",
+ "created_by",
+ )
+ .await,
+ vec!["catalog".to_string()]
+ );
+ assert_eq!(
+ catalog.take_partition_identifiers(),
+ vec![Identifier::new(
+ "default",
+ "branch_partition_orders$branch_b1"
+ )]
+ );
+
+ assert_eq!(
+ collect_string_column(
+ &sql_context,
+ "SELECT \"partition\" FROM
paimon.default.branch_partition_orders$branch_main$partitions",
+ "partition",
+ )
+ .await,
+ vec!["id=1".to_string(), "id=2".to_string()]
+ );
+ assert_eq!(
+ catalog.take_partition_identifiers(),
+ vec![Identifier::new("default", "branch_partition_orders")]
+ );
+
+ catalog.set_fail_list_partitions(true);
+ assert_eq!(
+ collect_string_column(
+ &sql_context,
+ "SELECT \"partition\" FROM
paimon.default.branch_partition_orders$branch_b1$partitions",
+ "partition",
+ )
+ .await,
+ vec!["id=1".to_string()]
+ );
+ assert_eq!(
+ catalog.take_partition_identifiers(),
+ vec![Identifier::new(
+ "default",
+ "branch_partition_orders$branch_b1"
+ )]
+ );
+
+ assert_eq!(
+ collect_string_column(
+ &sql_context,
+ "SELECT \"partition\" FROM
paimon.default.branch_partition_orders$partitions \
+ VERSION AS OF 'partition_branch_base'",
+ "partition",
+ )
+ .await,
+ vec!["id=1".to_string()]
+ );
+ assert_eq!(
+ collect_string_column(
+ &sql_context,
+ "SELECT \"partition\" FROM
paimon.default.branch_partition_orders$branch_b1$partitions \
+ VERSION AS OF 'partition_branch_base'",
+ "partition",
+ )
+ .await,
+ vec!["id=1".to_string()]
+ );
+ assert!(catalog.take_partition_identifiers().is_empty());
+}
+
// ======================= CREATE / DROP SCHEMA =======================
#[tokio::test]
diff --git a/crates/paimon/src/catalog/mod.rs b/crates/paimon/src/catalog/mod.rs
index 166d59a..6d5db12 100644
--- a/crates/paimon/src/catalog/mod.rs
+++ b/crates/paimon/src/catalog/mod.rs
@@ -68,6 +68,35 @@ pub struct Identifier {
object: String,
}
+/// Parsed form of a Paimon object name.
+///
+/// Mirrors Java `Identifier.splitObjectName`: `table$branch_b1$snapshots`
+/// resolves to table `table`, branch `b1`, and system table `snapshots`.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct ParsedObjectName {
+ table: String,
+ branch: Option<String>,
+ system_table: Option<String>,
+}
+
+impl ParsedObjectName {
+ pub fn table(&self) -> &str {
+ &self.table
+ }
+
+ pub fn branch(&self) -> Option<&str> {
+ self.branch.as_deref()
+ }
+
+ pub fn branch_or_default(&self) -> &str {
+ self.branch.as_deref().unwrap_or(DEFAULT_MAIN_BRANCH)
+ }
+
+ pub fn system_table(&self) -> Option<&str> {
+ self.system_table.as_deref()
+ }
+}
+
impl Identifier {
/// Create an identifier from database and object name.
pub fn new(database: impl Into<String>, object: impl Into<String>) -> Self
{
@@ -112,6 +141,79 @@ impl Identifier {
format!("{}.{}", self.database, self.object)
}
}
+
+ /// Parse the object name into table, branch, and system-table components.
+ pub fn parsed_object_name(&self) -> Result<ParsedObjectName> {
+ parse_object_name(&self.object)
+ }
+
+ pub fn table_name(&self) -> Result<String> {
+ Ok(self.parsed_object_name()?.table)
+ }
+
+ pub fn branch_name(&self) -> Result<Option<String>> {
+ Ok(self.parsed_object_name()?.branch)
+ }
+
+ pub fn branch_name_or_default(&self) -> Result<String> {
+ Ok(self
+ .branch_name()?
+ .unwrap_or_else(|| DEFAULT_MAIN_BRANCH.to_string()))
+ }
+
+ pub fn system_table_name(&self) -> Result<Option<String>> {
+ Ok(self.parsed_object_name()?.system_table)
+ }
+}
+
+/// Parse a Paimon object name into table, optional branch, and optional
system table.
+pub fn parse_object_name(object: &str) -> Result<ParsedObjectName> {
+ let parts: Vec<&str> = object.split(SYSTEM_TABLE_SPLITTER).collect();
+ let invalid = || Error::IdentifierInvalid {
+ message: format!("Invalid object name: {object}"),
+ };
+ let branch_from = |part: &str| {
+ let branch = part
+ .strip_prefix(SYSTEM_BRANCH_PREFIX)
+ .ok_or_else(invalid)?;
+ if branch.trim().is_empty() {
+ return Err(Error::IdentifierInvalid {
+ message: format!("Branch name cannot be empty in object name:
{object}"),
+ });
+ }
+ validate_branch_name(branch)?;
+ Ok(branch.to_string())
+ };
+
+ match parts.as_slice() {
+ [table] => Ok(ParsedObjectName {
+ table: (*table).to_string(),
+ branch: None,
+ system_table: None,
+ }),
+ [table, second] if second.starts_with(SYSTEM_BRANCH_PREFIX) =>
Ok(ParsedObjectName {
+ table: (*table).to_string(),
+ branch: Some(branch_from(second)?),
+ system_table: None,
+ }),
+ [table, system_table] => Ok(ParsedObjectName {
+ table: (*table).to_string(),
+ branch: None,
+ system_table: Some((*system_table).to_string()),
+ }),
+ [table, branch, system_table] if
branch.starts_with(SYSTEM_BRANCH_PREFIX) => {
+ Ok(ParsedObjectName {
+ table: (*table).to_string(),
+ branch: Some(branch_from(branch)?),
+ system_table: Some((*system_table).to_string()),
+ })
+ }
+ _ => Err(invalid()),
+ }
+}
+
+pub(crate) fn validate_branch_name(name: &str) -> Result<()> {
+ validate_identifier_name("branch", name)
}
fn validate_identifier_name(kind: &str, name: &str) -> Result<()> {
@@ -300,6 +402,43 @@ pub trait Catalog: Send + Sync {
mod tests {
use super::*;
+ #[test]
+ fn parses_plain_object_name() {
+ let parsed = parse_object_name("orders").unwrap();
+ assert_eq!(parsed.table(), "orders");
+ assert_eq!(parsed.branch(), None);
+ assert_eq!(parsed.branch_or_default(), DEFAULT_MAIN_BRANCH);
+ assert_eq!(parsed.system_table(), None);
+ }
+
+ #[test]
+ fn parses_branch_object_name() {
+ let parsed = parse_object_name("orders$branch_b1").unwrap();
+ assert_eq!(parsed.table(), "orders");
+ assert_eq!(parsed.branch(), Some("b1"));
+ assert_eq!(parsed.system_table(), None);
+ }
+
+ #[test]
+ fn parses_branch_system_table_object_name() {
+ let parsed = parse_object_name("orders$branch_b1$snapshots").unwrap();
+ assert_eq!(parsed.table(), "orders");
+ assert_eq!(parsed.branch(), Some("b1"));
+ assert_eq!(parsed.system_table(), Some("snapshots"));
+ }
+
+ #[test]
+ fn rejects_invalid_three_part_object_name() {
+ assert!(parse_object_name("orders$foo$bar").is_err());
+ }
+
+ #[test]
+ fn rejects_path_unsafe_branch_name() {
+ assert!(parse_object_name("orders$branch_../../other").is_err());
+ assert!(parse_object_name("orders$branch_nested/name").is_err());
+ assert!(parse_object_name("orders$branch_..").is_err());
+ }
+
#[test]
fn test_identifier_validate_should_reject_path_control_names() {
for (database, object) in [
diff --git a/crates/paimon/src/catalog/partition_listing.rs
b/crates/paimon/src/catalog/partition_listing.rs
index 7652aba..3cb7504 100644
--- a/crates/paimon/src/catalog/partition_listing.rs
+++ b/crates/paimon/src/catalog/partition_listing.rs
@@ -33,14 +33,18 @@ use crate::Result;
/// matching the shape catalogs would otherwise return from a metastore.
pub async fn list_partitions_from_file_system(table: &Table) ->
Result<Vec<Partition>> {
let file_io = table.file_io();
- let sm = SnapshotManager::new(file_io.clone(),
table.location().to_string());
- let snapshot = match sm.get_latest_snapshot().await? {
- Some(s) => s,
- None => return Ok(Vec::new()),
+ let snapshot_sm = table.snapshot_manager();
+ let manifest_sm = SnapshotManager::new(file_io.clone(),
table.location().to_string());
+ let snapshot = match table.travel_snapshot().cloned() {
+ Some(snapshot) => snapshot,
+ None => match snapshot_sm.get_latest_snapshot().await? {
+ Some(snapshot) => snapshot,
+ None => return Ok(Vec::new()),
+ },
};
- let base_path = sm.manifest_path(snapshot.base_manifest_list());
- let delta_path = sm.manifest_path(snapshot.delta_manifest_list());
+ let base_path = manifest_sm.manifest_path(snapshot.base_manifest_list());
+ let delta_path = manifest_sm.manifest_path(snapshot.delta_manifest_list());
let (base_metas, delta_metas) = futures::try_join!(
ManifestList::read(file_io, &base_path),
ManifestList::read(file_io, &delta_path),
@@ -48,7 +52,7 @@ pub async fn list_partitions_from_file_system(table: &Table)
-> Result<Vec<Parti
let mut all_entries = Vec::new();
for meta in base_metas.into_iter().chain(delta_metas) {
- let manifest_path = sm.manifest_path(meta.file_name());
+ let manifest_path = manifest_sm.manifest_path(meta.file_name());
let entries = Manifest::read(file_io, &manifest_path).await?;
all_entries.extend(entries);
}
diff --git a/crates/paimon/src/table/btree_global_index_build_builder.rs
b/crates/paimon/src/table/btree_global_index_build_builder.rs
index 73b9e7c..cc87c70 100644
--- a/crates/paimon/src/table/btree_global_index_build_builder.rs
+++ b/crates/paimon/src/table/btree_global_index_build_builder.rs
@@ -68,6 +68,8 @@ impl<'a> BTreeGlobalIndexBuildBuilder<'a> {
}
pub async fn execute(&self) -> Result<usize> {
+ self.table.ensure_not_branch_reference_for_write()?;
+
let index_type =
normalize_sorted_global_index_type(&self.index_type).ok_or_else(|| {
Error::Unsupported {
message: format!(
diff --git a/crates/paimon/src/table/format_write_builder.rs
b/crates/paimon/src/table/format_write_builder.rs
index 3608e82..ffd12af 100644
--- a/crates/paimon/src/table/format_write_builder.rs
+++ b/crates/paimon/src/table/format_write_builder.rs
@@ -56,6 +56,11 @@ impl<'a> FormatWriteBuilder<'a> {
TableCommit::new(self.table.clone(), self.commit_user.clone())
}
+ pub(crate) fn try_new_commit(&self) -> crate::Result<TableCommit> {
+ self.table.ensure_not_branch_reference_for_write()?;
+ Ok(self.new_commit())
+ }
+
pub(crate) fn new_write(&self) -> crate::Result<TableWrite> {
Err(crate::Error::Unsupported {
message: "Writing format tables is not supported by the Rust
client yet".to_string(),
diff --git a/crates/paimon/src/table/full_text_search_builder.rs
b/crates/paimon/src/table/full_text_search_builder.rs
index 042a9e8..30e1d35 100644
--- a/crates/paimon/src/table/full_text_search_builder.rs
+++ b/crates/paimon/src/table/full_text_search_builder.rs
@@ -28,7 +28,6 @@ use crate::table::global_index_scanner::{
deleted_row_ranges_for_data_evolution_dvs, search_limit_with_deleted_rows,
unindexed_ranges_for_global_index_entries, RowRangeIndex,
};
-use crate::table::snapshot_manager::SnapshotManager;
use crate::table::{find_field_id_by_name, merge_row_ranges, RowRange, Table};
use crate::tantivy::full_text_search::{FullTextSearch, SearchResult};
use crate::tantivy::reader::TantivyFullTextReader;
@@ -120,10 +119,7 @@ impl<'a> FullTextSearchBuilder<'a> {
let search = FullTextSearch::new(query_text.to_string(), limit,
text_column.to_string())?;
- let snapshot_manager = SnapshotManager::new(
- self.table.file_io().clone(),
- self.table.location().to_string(),
- );
+ let snapshot_manager = self.table.snapshot_manager();
let snapshot = match snapshot_manager.get_latest_snapshot().await? {
Some(s) => s,
@@ -132,11 +128,7 @@ impl<'a> FullTextSearchBuilder<'a> {
let index_entries = match snapshot.index_manifest() {
Some(index_manifest_name) => {
- let manifest_path = format!(
- "{}/manifest/{}",
- self.table.location().trim_end_matches('/'),
- index_manifest_name
- );
+ let manifest_path =
snapshot_manager.manifest_path(index_manifest_name);
IndexManifest::read(self.table.file_io(),
&manifest_path).await?
}
None => Vec::new(),
diff --git a/crates/paimon/src/table/global_index_drop_builder.rs
b/crates/paimon/src/table/global_index_drop_builder.rs
index 9d6f557..e9ab86c 100644
--- a/crates/paimon/src/table/global_index_drop_builder.rs
+++ b/crates/paimon/src/table/global_index_drop_builder.rs
@@ -50,6 +50,8 @@ impl<'a> GlobalIndexDropBuilder<'a> {
}
pub async fn execute(&self) -> Result<usize> {
+ self.table.ensure_not_branch_reference_for_write()?;
+
let index_type =
normalize_global_index_type_for_drop(&self.index_type).ok_or_else(|| {
Error::Unsupported {
diff --git a/crates/paimon/src/table/lumina_index_build_builder.rs
b/crates/paimon/src/table/lumina_index_build_builder.rs
index 5ead6de..4a2980a 100644
--- a/crates/paimon/src/table/lumina_index_build_builder.rs
+++ b/crates/paimon/src/table/lumina_index_build_builder.rs
@@ -71,6 +71,8 @@ impl<'a> LuminaIndexBuildBuilder<'a> {
}
pub async fn execute(&self) -> Result<usize> {
+ self.table.ensure_not_branch_reference_for_write()?;
+
if !is_lumina_index_type(&self.index_type) {
return Err(Error::DataInvalid {
message: format!("Unsupported Lumina index type: {}",
self.index_type),
diff --git a/crates/paimon/src/table/mod.rs b/crates/paimon/src/table/mod.rs
index b0b6060..96e9e43 100644
--- a/crates/paimon/src/table/mod.rs
+++ b/crates/paimon/src/table/mod.rs
@@ -114,7 +114,7 @@ pub use vector_search_builder::{BatchVectorSearchBuilder,
VectorSearchBuilder};
pub use vindex_index_build_builder::VindexIndexBuildBuilder;
pub use write_builder::WriteBuilder;
-use crate::catalog::Identifier;
+use crate::catalog::{validate_branch_name, Identifier, DEFAULT_MAIN_BRANCH};
use crate::io::FileIO;
use crate::spec::{CoreOptions, DataField, Snapshot, TableSchema};
use std::collections::HashMap;
@@ -127,6 +127,8 @@ pub struct Table {
location: String,
schema: TableSchema,
schema_manager: SchemaManager,
+ branch: String,
+ branch_reference: bool,
rest_env: Option<RESTEnv>,
/// True when this table copy was switched to a historical schema by
/// [`Table::copy_with_time_travel`]. Such a copy is read-only.
@@ -147,12 +149,15 @@ impl Table {
rest_env: Option<RESTEnv>,
) -> Self {
let schema_manager = SchemaManager::new(file_io.clone(),
location.clone());
+ let branch = DEFAULT_MAIN_BRANCH.to_string();
Self {
file_io,
identifier,
location,
schema,
schema_manager,
+ branch,
+ branch_reference: false,
rest_env,
time_traveled: false,
travel_snapshot: None,
@@ -184,6 +189,49 @@ impl Table {
&self.schema_manager
}
+ pub fn branch(&self) -> &str {
+ &self.branch
+ }
+
+ pub fn is_main_branch(&self) -> bool {
+ self.branch == DEFAULT_MAIN_BRANCH
+ }
+
+ pub fn is_branch_reference(&self) -> bool {
+ self.branch_reference
+ }
+
+ pub(crate) fn ensure_not_branch_reference_for_write(&self) -> Result<()> {
+ if self.is_branch_reference() {
+ Err(crate::Error::Unsupported {
+ message: format!(
+ "Writing to Paimon branch '{}' is not supported",
+ self.branch()
+ ),
+ })
+ } else {
+ Ok(())
+ }
+ }
+
+ pub fn snapshot_manager(&self) -> SnapshotManager {
+ let manager = SnapshotManager::new(self.file_io.clone(),
self.location.clone());
+ if self.is_main_branch() {
+ manager
+ } else {
+ manager.with_branch(&self.branch)
+ }
+ }
+
+ pub fn tag_manager(&self) -> TagManager {
+ let manager = TagManager::new(self.file_io.clone(),
self.location.clone());
+ if self.is_main_branch() {
+ manager
+ } else {
+ manager.with_branch(&self.branch)
+ }
+ }
+
/// Get the REST environment, if this table was loaded from a REST catalog.
pub fn rest_env(&self) -> Option<&RESTEnv> {
self.rest_env.as_ref()
@@ -269,6 +317,8 @@ impl Table {
location: self.location.clone(),
schema: self.schema.copy_with_options(extra),
schema_manager: self.schema_manager.clone(),
+ branch: self.branch.clone(),
+ branch_reference: self.branch_reference,
rest_env: self.rest_env.clone(),
time_traveled: self.time_traveled,
travel_snapshot: if selector_changed {
@@ -299,9 +349,12 @@ impl Table {
CoreOptions::new(table.schema().options()).validate_scan_options()?;
// travel_to_snapshot returns Ok(None) without IO when the merged
// options contain no selector.
- if let Ok(Some(snapshot)) =
- time_travel::travel_to_snapshot(&table.file_io, &table.location,
table.schema.options())
- .await
+ if let Ok(Some(snapshot)) = time_travel::travel_to_snapshot(
+ &table.snapshot_manager(),
+ &table.tag_manager(),
+ table.schema.options(),
+ )
+ .await
{
if snapshot.schema_id() != table.schema.id() {
let snapshot_schema =
table.schema_manager.schema(snapshot.schema_id()).await?;
@@ -314,6 +367,44 @@ impl Table {
Ok(table)
}
+ pub async fn copy_with_branch(&self, branch_name: &str) -> Result<Self> {
+ let branch = if branch_name.trim().is_empty() {
+ return Err(crate::Error::DataInvalid {
+ message: "Branch name cannot be empty.".to_string(),
+ source: None,
+ });
+ } else {
+ validate_branch_name(branch_name)?;
+ branch_name.to_string()
+ };
+ let schema_manager = if branch == DEFAULT_MAIN_BRANCH {
+ SchemaManager::new(self.file_io.clone(), self.location.clone())
+ } else {
+ SchemaManager::new(self.file_io.clone(),
self.location.clone()).with_branch(&branch)
+ };
+ let schema = schema_manager
+ .latest()
+ .await?
+ .ok_or_else(|| crate::Error::DataInvalid {
+ message: format!("Branch '{branch}' does not exist."),
+ source: None,
+ })?;
+ let mut options = schema.options().clone();
+ options.insert("branch".to_string(), branch.clone());
+ Ok(Self {
+ file_io: self.file_io.clone(),
+ identifier: self.identifier.clone(),
+ location: self.location.clone(),
+ schema: schema.copy_with_replaced_options(options),
+ schema_manager,
+ branch,
+ branch_reference: true,
+ rest_env: self.rest_env.clone(),
+ time_traveled: false,
+ travel_snapshot: None,
+ })
+ }
+
/// Whether this table copy reads a historical snapshot with its
/// historical schema (see [`Table::copy_with_time_travel`]).
pub fn is_time_traveled(&self) -> bool {
@@ -330,7 +421,7 @@ impl Table {
/// The snapshot resolved by [`Table::copy_with_time_travel`] from this
/// copy's options, if any. Lets scans skip re-resolving the selector.
- pub(crate) fn travel_snapshot(&self) -> Option<&Snapshot> {
+ pub fn travel_snapshot(&self) -> Option<&Snapshot> {
self.travel_snapshot.as_ref()
}
}
diff --git a/crates/paimon/src/table/snapshot_manager.rs
b/crates/paimon/src/table/snapshot_manager.rs
index 73effe6..d7d8fbb 100644
--- a/crates/paimon/src/table/snapshot_manager.rs
+++ b/crates/paimon/src/table/snapshot_manager.rs
@@ -18,6 +18,7 @@
//! Snapshot manager for reading snapshot metadata using FileIO.
//!
//!
Reference:[org.apache.paimon.utils.SnapshotManager](https://github.com/apache/paimon/blob/release-1.3/paimon-core/src/main/java/org/apache/paimon/utils/SnapshotManager.java).
+use crate::catalog::DEFAULT_MAIN_BRANCH;
use crate::io::FileIO;
use crate::spec::Snapshot;
use futures::future::try_join_all;
@@ -35,6 +36,7 @@ const EARLIEST_HINT: &str = "EARLIEST";
pub struct SnapshotManager {
file_io: FileIO,
table_path: String,
+ branch: String,
}
impl SnapshotManager {
@@ -43,6 +45,7 @@ impl SnapshotManager {
Self {
file_io,
table_path,
+ branch: DEFAULT_MAIN_BRANCH.to_string(),
}
}
@@ -52,13 +55,26 @@ impl SnapshotManager {
/// Path to the snapshot directory (e.g. `table_path/snapshot`).
pub fn snapshot_dir(&self) -> String {
- format!("{}/{}", self.table_path, SNAPSHOT_DIR)
+ let branch_path = if self.branch == DEFAULT_MAIN_BRANCH {
+ self.table_path.clone()
+ } else {
+ format!("{}/branch/branch-{}", self.table_path, self.branch)
+ };
+ format!("{branch_path}/{SNAPSHOT_DIR}")
}
/// Create a SnapshotManager for a branch of this table.
pub fn with_branch(&self, branch_name: &str) -> Self {
- let branch_path = format!("{}/branch/branch-{}", self.table_path,
branch_name);
- Self::new(self.file_io.clone(), branch_path)
+ let branch = if branch_name.trim().is_empty() {
+ DEFAULT_MAIN_BRANCH
+ } else {
+ branch_name
+ };
+ Self {
+ file_io: self.file_io.clone(),
+ table_path: self.table_path.clone(),
+ branch: branch.to_string(),
+ }
}
/// Path to the LATEST hint file.
@@ -503,4 +519,39 @@ mod tests {
let ids: Vec<i64> = snaps.iter().map(|s| s.id()).collect();
assert_eq!(ids, vec![1, 2, 3]);
}
+
+ #[test]
+ fn test_branch_scopes_snapshot_paths_only() {
+ let sm = SnapshotManager::new(test_file_io(),
"memory:/test_branch_paths".to_string());
+ let branch_sm = sm.with_branch("b1");
+
+ assert_eq!(
+ branch_sm.snapshot_path(1),
+ "memory:/test_branch_paths/branch/branch-b1/snapshot/snapshot-1"
+ );
+ assert_eq!(
+ branch_sm.latest_hint_path(),
+ "memory:/test_branch_paths/branch/branch-b1/snapshot/LATEST"
+ );
+ assert_eq!(
+ branch_sm.manifest_path("manifest-list-1"),
+ "memory:/test_branch_paths/manifest/manifest-list-1"
+ );
+
+ let other_branch_sm = branch_sm.with_branch("b2");
+ assert_eq!(
+ other_branch_sm.snapshot_dir(),
+ "memory:/test_branch_paths/branch/branch-b2/snapshot"
+ );
+ assert_eq!(
+ other_branch_sm.manifest_dir(),
+ "memory:/test_branch_paths/manifest"
+ );
+ assert_eq!(
+ other_branch_sm
+ .with_branch(DEFAULT_MAIN_BRANCH)
+ .snapshot_dir(),
+ "memory:/test_branch_paths/snapshot"
+ );
+ }
}
diff --git a/crates/paimon/src/table/table_commit.rs
b/crates/paimon/src/table/table_commit.rs
index 76aaf50..dad551f 100644
--- a/crates/paimon/src/table/table_commit.rs
+++ b/crates/paimon/src/table/table_commit.rs
@@ -128,6 +128,8 @@ impl TableCommit {
commit_messages: Vec<CommitMessage>,
commit_identifier: i64,
) -> Result<()> {
+ self.table.ensure_not_branch_reference_for_write()?;
+
if commit_messages.is_empty() {
return Ok(());
}
@@ -168,6 +170,8 @@ impl TableCommit {
expected_snapshot_id: i64,
commit_identifier: i64,
) -> Result<()> {
+ self.table.ensure_not_branch_reference_for_write()?;
+
if commit_messages.is_empty() {
return Ok(());
}
@@ -217,6 +221,8 @@ impl TableCommit {
static_partitions: Option<HashMap<String, Option<Datum>>>,
commit_identifier: i64,
) -> Result<()> {
+ self.table.ensure_not_branch_reference_for_write()?;
+
if commit_messages.is_empty() && static_partitions.is_none() {
return Ok(());
}
@@ -450,6 +456,8 @@ impl TableCommit {
partitions: Vec<HashMap<String, Option<Datum>>>,
commit_identifier: i64,
) -> Result<()> {
+ self.table.ensure_not_branch_reference_for_write()?;
+
if partitions.is_empty() {
return Ok(());
}
@@ -489,6 +497,8 @@ impl TableCommit {
partitions: Vec<HashMap<String, Option<Datum>>>,
commit_identifier: i64,
) -> Result<()> {
+ self.table.ensure_not_branch_reference_for_write()?;
+
if partitions.is_empty() {
return Err(crate::Error::DataInvalid {
message: "Partitions list cannot be empty.".to_string(),
@@ -507,6 +517,8 @@ impl TableCommit {
/// Truncate the entire table with a caller-provided commit identifier.
pub async fn truncate_table_with_identifier(&self, commit_identifier: i64)
-> Result<()> {
+ self.table.ensure_not_branch_reference_for_write()?;
+
self.try_commit(
CommitEntriesPlan::Overwrite {
partition_filter: None,
@@ -529,6 +541,8 @@ impl TableCommit {
/// files or storage errors are ignored so abort cleanup never masks the
/// original write failure.
pub async fn abort(&self, commit_messages: &[CommitMessage]) -> Result<()>
{
+ self.table.ensure_not_branch_reference_for_write()?;
+
for message in commit_messages {
let bucket_path = self.bucket_path(&message.partition,
message.bucket)?;
for file in message
diff --git a/crates/paimon/src/table/table_scan.rs
b/crates/paimon/src/table/table_scan.rs
index ac879ba..58338ab 100644
--- a/crates/paimon/src/table/table_scan.rs
+++ b/crates/paimon/src/table/table_scan.rs
@@ -48,7 +48,6 @@ use crate::table::source::{
DataSplitBuilder, DeletionFile, PartitionBucket, Plan, RowRange,
};
use crate::table::ScanTrace;
-use crate::table::SnapshotManager;
use futures::{StreamExt, TryStreamExt};
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
@@ -868,20 +867,16 @@ impl<'a> PaimonTableScan<'a> {
});
}
- let file_io = self.table.file_io();
- let table_path = self.table.location();
-
match super::time_travel::travel_to_snapshot(
- file_io,
- table_path,
+ &self.table.snapshot_manager(),
+ &self.table.tag_manager(),
self.table.schema().options(),
)
.await?
{
Some(snapshot) => Ok(Some(snapshot)),
None => {
- let snapshot_manager =
- SnapshotManager::new(file_io.clone(),
table_path.to_string());
+ let snapshot_manager = self.table.snapshot_manager();
snapshot_manager.get_latest_snapshot().await
}
}
diff --git a/crates/paimon/src/table/time_travel.rs
b/crates/paimon/src/table/time_travel.rs
index ebb67ae..a70f6c1 100644
--- a/crates/paimon/src/table/time_travel.rs
+++ b/crates/paimon/src/table/time_travel.rs
@@ -17,7 +17,6 @@
//! Snapshot resolution for time travel, mirroring Java `TimeTravelUtil`.
-use crate::io::FileIO;
use crate::spec::{CoreOptions, Snapshot, TimeTravelSelector};
use crate::table::SnapshotManager;
use crate::table::TagManager;
@@ -31,12 +30,11 @@ use std::collections::HashMap;
/// match any snapshot — callers that need Java `tryTravelToSnapshot`'s silent
/// fallback (keep the current schema on failure) handle the `Err` themselves.
pub(crate) async fn travel_to_snapshot(
- file_io: &FileIO,
- table_path: &str,
+ snapshot_manager: &SnapshotManager,
+ tag_manager: &TagManager,
options: &HashMap<String, String>,
) -> crate::Result<Option<Snapshot>> {
let core_options = CoreOptions::new(options);
- let snapshot_manager = SnapshotManager::new(file_io.clone(),
table_path.to_string());
match core_options.try_time_travel_selector()? {
Some(TimeTravelSelector::TimestampMillis(ts)) => {
@@ -53,9 +51,8 @@ pub(crate) async fn travel_to_snapshot(
option_name,
}) => {
// `scan.version` is ambiguous by design: tag first, then snapshot
id.
- let tag_manager = TagManager::new(file_io.clone(),
table_path.to_string());
if tag_manager.tag_exists(v).await? {
- resolve_tag(&tag_manager, v).await.map(Some)
+ resolve_tag(tag_manager, v).await.map(Some)
} else if let Ok(id) = v.parse::<i64>() {
snapshot_manager.get_snapshot(id).await.map(Some)
} else {
@@ -83,9 +80,8 @@ pub(crate) async fn travel_to_snapshot(
option_name: _,
}) => {
// An explicit tag name: resolve strictly by tag, never as a
snapshot id.
- let tag_manager = TagManager::new(file_io.clone(),
table_path.to_string());
if tag_manager.tag_exists(v).await? {
- resolve_tag(&tag_manager, v).await.map(Some)
+ resolve_tag(tag_manager, v).await.map(Some)
} else {
Err(Error::DataInvalid {
message: format!("Tag '{v}' doesn't exist."),
@@ -415,7 +411,9 @@ mod tests {
async fn test_invalid_snapshot_id_error_names_original_option() {
let (file_io, table_path) = setup_evolved_table().await;
let opts = options(&[("scan.snapshot-id", "abc")]);
- let err = super::travel_to_snapshot(&file_io, &table_path, &opts)
+ let sm = SnapshotManager::new(file_io.clone(), table_path.clone());
+ let tm = TagManager::new(file_io.clone(), table_path.clone());
+ let err = super::travel_to_snapshot(&sm, &tm, &opts)
.await
.expect_err("non-numeric snapshot-id must fail");
match err {
@@ -545,13 +543,9 @@ mod tests {
// scan.snapshot-id must only accept a numeric snapshot id; it must not
// fall back to resolving a tag of the same name.
- let err = super::travel_to_snapshot(
- &file_io,
- &table_path,
- &options(&[("scan.snapshot-id", "v1-tag")]),
- )
- .await
- .expect_err("scan.snapshot-id must not resolve a tag name");
+ let err = super::travel_to_snapshot(&sm, &tm,
&options(&[("scan.snapshot-id", "v1-tag")]))
+ .await
+ .expect_err("scan.snapshot-id must not resolve a tag name");
assert!(
matches!(err, crate::Error::DataInvalid { ref message, .. }
if message.contains("scan.snapshot-id")),
@@ -563,10 +557,11 @@ mod tests {
async fn test_tag_name_selector_rejects_snapshot_id() {
let (file_io, table_path) = setup_evolved_table().await;
// No tag named "1" exists, but snapshot 1 does.
- let err =
- super::travel_to_snapshot(&file_io, &table_path,
&options(&[("scan.tag-name", "1")]))
- .await
- .expect_err("scan.tag-name must not resolve a snapshot id");
+ let sm = SnapshotManager::new(file_io.clone(), table_path.clone());
+ let tm = TagManager::new(file_io.clone(), table_path.clone());
+ let err = super::travel_to_snapshot(&sm, &tm,
&options(&[("scan.tag-name", "1")]))
+ .await
+ .expect_err("scan.tag-name must not resolve a snapshot id");
assert!(
matches!(err, crate::Error::DataInvalid { ref message, .. }
if message.contains("Tag '1'")),
diff --git a/crates/paimon/src/table/vector_search_builder.rs
b/crates/paimon/src/table/vector_search_builder.rs
index 8d75c49..c439bc6 100644
--- a/crates/paimon/src/table/vector_search_builder.rs
+++ b/crates/paimon/src/table/vector_search_builder.rs
@@ -26,7 +26,6 @@ use crate::table::global_index_scanner::{
deleted_row_ranges_for_data_evolution_dvs, search_limit_with_deleted_rows,
unindexed_ranges_for_global_index_entries, RowRangeIndex,
};
-use crate::table::snapshot_manager::SnapshotManager;
use crate::table::{find_field_id_by_name, merge_row_ranges, RowRange, Table};
use crate::vector_search::{GlobalIndexIOMeta, SearchResult, VectorSearch};
use crate::vindex::is_vindex_index_type;
@@ -219,10 +218,7 @@ impl<'a> BatchVectorSearchBuilder<'a> {
})
.collect::<crate::Result<Vec<_>>>()?;
- let snapshot_manager = SnapshotManager::new(
- self.table.file_io().clone(),
- self.table.location().to_string(),
- );
+ let snapshot_manager = self.table.snapshot_manager();
let snapshot = match snapshot_manager.get_latest_snapshot().await? {
Some(s) => s,
@@ -231,11 +227,7 @@ impl<'a> BatchVectorSearchBuilder<'a> {
let index_entries = match snapshot.index_manifest() {
Some(index_manifest_name) => {
- let manifest_path = format!(
- "{}/manifest/{}",
- self.table.location().trim_end_matches('/'),
- index_manifest_name
- );
+ let manifest_path =
snapshot_manager.manifest_path(index_manifest_name);
IndexManifest::read(self.table.file_io(),
&manifest_path).await?
}
None => Vec::new(),
diff --git a/crates/paimon/src/table/vindex_index_build_builder.rs
b/crates/paimon/src/table/vindex_index_build_builder.rs
index 7adb48a..5803af8 100644
--- a/crates/paimon/src/table/vindex_index_build_builder.rs
+++ b/crates/paimon/src/table/vindex_index_build_builder.rs
@@ -62,6 +62,8 @@ impl<'a> VindexIndexBuildBuilder<'a> {
}
pub async fn execute(&self) -> Result<usize> {
+ self.table.ensure_not_branch_reference_for_write()?;
+
if !is_vindex_index_type(&self.index_type) {
return Err(Error::DataInvalid {
message: format!("Unsupported vindex index type: {}",
self.index_type),
diff --git a/crates/paimon/src/table/write_builder.rs
b/crates/paimon/src/table/write_builder.rs
index ab86f4b..52ba577 100644
--- a/crates/paimon/src/table/write_builder.rs
+++ b/crates/paimon/src/table/write_builder.rs
@@ -83,6 +83,14 @@ impl<'a> WriteBuilder<'a> {
}
}
+ /// Try to create a new TableCommit for committing write results.
+ pub fn try_new_commit(&self) -> crate::Result<TableCommit> {
+ match &self.0 {
+ WriteBuilderKind::Paimon(builder) => builder.try_new_commit(),
+ WriteBuilderKind::Format(builder) => builder.try_new_commit(),
+ }
+ }
+
/// Create a new TableWrite for writing Arrow data.
pub fn new_write(&self) -> crate::Result<TableWrite> {
match &self.0 {
@@ -157,11 +165,21 @@ impl<'a> PaimonWriteBuilder<'a> {
TableCommit::new(self.table.clone(), self.commit_user.clone())
}
+ /// Try to create a new TableCommit for committing write results.
+ pub fn try_new_commit(&self) -> crate::Result<TableCommit> {
+ self.ensure_main_branch_write()?;
+ Ok(TableCommit::new(
+ self.table.clone(),
+ self.commit_user.clone(),
+ ))
+ }
+
/// Create a new TableWrite for writing Arrow data.
///
/// For primary-key tables, sequence numbers are lazily scanned per
partition
/// when the first writer for that partition is created.
pub fn new_write(&self) -> crate::Result<TableWrite> {
+ self.ensure_main_branch_write()?;
// A table with a time-travel selector reads a pinned snapshot (and may
// carry that snapshot's historical schema), so writing through the
// same copy would be inconsistent with what its reads observe — even
@@ -190,13 +208,19 @@ impl<'a> PaimonWriteBuilder<'a> {
/// Create a new TableUpdate for data-evolution row-id updates.
pub fn new_update(&self, update_columns: Vec<String>) ->
crate::Result<TableUpdate> {
+ self.ensure_main_branch_write()?;
TableUpdate::new(self.table, update_columns)
}
/// Create a new writer for data-evolution row-id deletes.
pub fn new_delete(&self) -> crate::Result<DataEvolutionDeleteWriter> {
+ self.ensure_main_branch_write()?;
DataEvolutionDeleteWriter::new(self.table)
}
+
+ fn ensure_main_branch_write(&self) -> crate::Result<()> {
+ self.table.ensure_not_branch_reference_for_write()
+ }
}
pub(super) fn validate_commit_user(commit_user: &str) -> crate::Result<()> {
@@ -276,6 +300,13 @@ mod tests {
)
}
+ fn as_main_branch_reference(table: Table) -> Table {
+ Table {
+ branch_reference: true,
+ ..table
+ }
+ }
+
fn input_changelog_pk_table(file_io: &FileIO, table_path: &str) -> Table {
let schema = Schema::builder()
.column("id", DataType::Int(IntType::new()))
@@ -399,6 +430,60 @@ mod tests {
assert_eq!(snapshot.commit_user(), "my-commit-user");
}
+ #[tokio::test]
+ async fn test_branch_reference_rejects_write_and_index_builders() {
+ let table = as_main_branch_reference(test_postpone_pk_table(
+ &test_file_io(),
+ "memory:/test_branch_reference_writes",
+ ));
+
+ let write_err =
table.new_write_builder().try_new_commit().err().unwrap();
+ assert!(
+ matches!(write_err, crate::Error::Unsupported { ref message }
+ if message == "Writing to Paimon branch 'main' is not
supported"),
+ "Expected branch write rejection, got: {write_err:?}"
+ );
+
+ let commit_err = table
+ .new_write_builder()
+ .new_commit()
+ .commit(Vec::new())
+ .await
+ .err()
+ .unwrap();
+ assert!(
+ matches!(commit_err, crate::Error::Unsupported { ref message }
+ if message == "Writing to Paimon branch 'main' is not
supported"),
+ "Expected branch commit rejection, got: {commit_err:?}"
+ );
+
+ let index_err = table
+ .new_btree_global_index_build_builder()
+ .with_index_column("value")
+ .execute()
+ .await
+ .err()
+ .unwrap();
+ assert!(
+ matches!(index_err, crate::Error::Unsupported { ref message }
+ if message == "Writing to Paimon branch 'main' is not
supported"),
+ "Expected branch index-build rejection, got: {index_err:?}"
+ );
+
+ let index_drop_err = table
+ .new_global_index_drop_builder()
+ .with_index_column("value")
+ .execute()
+ .await
+ .err()
+ .unwrap();
+ assert!(
+ matches!(index_drop_err, crate::Error::Unsupported { ref message }
+ if message == "Writing to Paimon branch 'main' is not
supported"),
+ "Expected branch index-drop rejection, got: {index_drop_err:?}"
+ );
+ }
+
#[tokio::test]
async fn test_with_overwrite_marks_new_write_as_overwrite_aware() {
let file_io = test_file_io();
diff --git a/docs/src/sql.md b/docs/src/sql.md
index 827e045..6651344 100644
--- a/docs/src/sql.md
+++ b/docs/src/sql.md
@@ -1636,12 +1636,23 @@ WHERE r.source = 'total';
### Branch References
-System tables support branch syntax:
+Read a table branch with Java-compatible `$branch_<name>` syntax:
```sql
-SELECT * FROM paimon.default.my_table$branch_main$options;
+SELECT * FROM paimon.default.my_table$branch_b1;
```
+System tables support the same branch syntax:
+
+```sql
+SELECT * FROM paimon.default.my_table$branch_b1$options;
+SELECT * FROM paimon.default.my_table$branch_b1$snapshots;
+```
+
+Branch references are read-only in DataFusion. `INSERT`, `UPDATE`, `DELETE`,
+`MERGE INTO`, `TRUNCATE TABLE`, and `ALTER TABLE` against a branch reference
are
+rejected.
+
## Table Options
Set via `WITH ('key' = 'value')` at table creation time, or dynamically via
`SET`.