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 28102b09 feat(datafusion): route metadata tables to registered table
engines (#1100)
28102b09 is described below
commit 28102b093c4c3904534454bdf981d2e549c03815
Author: Jiajia Li <[email protected]>
AuthorDate: Mon Oct 12 13:48:39 2026 +0800
feat(datafusion): route metadata tables to registered table engines (#1100)
---
crates/integrations/datafusion/src/catalog.rs | 191 ++++++---
.../datafusion/src/system_tables/mod.rs | 53 +--
.../datafusion/tests/table_type_routing.rs | 437 ++++++++++++++++++++-
3 files changed, 574 insertions(+), 107 deletions(-)
diff --git a/crates/integrations/datafusion/src/catalog.rs
b/crates/integrations/datafusion/src/catalog.rs
index 06f34c3c..9ba17d95 100644
--- a/crates/integrations/datafusion/src/catalog.rs
+++ b/crates/integrations/datafusion/src/catalog.rs
@@ -82,6 +82,17 @@ pub trait TableEngineResolver: Debug + Send + Sync {
&self,
request: &EngineTableRequest,
) -> DFResult<Option<Arc<dyn TableProvider>>>;
+
+ /// Resolves `<table>$<metadata_table>`; returns `Ok(None)` if missing or
unsupported.
+ /// The request names the base table; `metadata_table` is ASCII-lowercase.
+ /// Session scan options are checked before this call.
+ async fn resolve_metadata_table(
+ &self,
+ _request: &EngineTableRequest,
+ _metadata_table: &str,
+ ) -> DFResult<Option<Arc<dyn TableProvider>>> {
+ Ok(None)
+ }
}
/// Read-only wrapper around an engine-resolved provider: reads delegate,
@@ -91,6 +102,7 @@ pub(crate) struct ReadOnlyTableProvider {
inner: Arc<dyn TableProvider>,
pub(crate) declared: PaimonTableType,
pub(crate) table_name: String,
+ metadata_table: bool,
}
#[async_trait]
@@ -104,7 +116,11 @@ impl TableProvider for ReadOnlyTableProvider {
}
fn table_type(&self) -> TableType {
- self.inner.table_type()
+ if self.metadata_table {
+ TableType::View
+ } else {
+ self.inner.table_type()
+ }
}
fn get_table_definition(&self) -> Option<&str> {
@@ -692,21 +708,7 @@ impl SchemaProvider for PaimonSchemaProvider {
}
let object = system_tables::parse_object_name_for_datafusion(name)?;
- if let Some(system_name) = object.system_table().map(str::to_string) {
- let dynamic_options = self
- .dynamic_options
- .read()
- .unwrap_or_else(|e| e.into_inner())
- .clone();
- return await_with_runtime(system_tables::load(
- Arc::clone(&self.catalog),
- self.database.clone(),
- object,
- system_name,
- dynamic_options,
- ))
- .await;
- }
+ let system_name = object.system_table().map(str::to_string);
let catalog = Arc::clone(&self.catalog);
let dynamic_options = Arc::clone(&self.dynamic_options);
@@ -724,6 +726,15 @@ impl SchemaProvider for PaimonSchemaProvider {
await_with_runtime(async move {
match catalog.load_table(&identifier).await {
Ok(paimon::catalog::LoadedTable::Object(table)) => {
+ if let Some(system_name) = &system_name {
+ if !system_tables::is_registered(system_name) {
+ return Ok(None);
+ }
+ return Err(plan_datafusion_err!(
+ "table '{}' is declared 'object-table' and cannot
be read as a Paimon table",
+ identifier.full_name()
+ ));
+ }
if branch.is_some() {
return Err(plan_datafusion_err!(
"branches are not supported for 'object-table'
tables ('{}')",
@@ -751,7 +762,16 @@ impl SchemaProvider for PaimonSchemaProvider {
identifier.full_name()
));
}
+ // Keep the metadata suffix in errors.
+ let table_name = format!("{}.{}", identifier.database(),
name);
let Some(resolver) = table_engines.get(&declared) else {
+ if system_name.is_some() {
+ return Err(plan_datafusion_err!(
+ "no table engine is registered for '{}' tables
('{}')",
+ declared,
+ table_name
+ ));
+ }
let schema = match external.fields() {
Some(fields) =>
crate::table::datafusion_arrow_schema(
fields,
@@ -765,33 +785,52 @@ impl SchemaProvider for PaimonSchemaProvider {
table_name: identifier.full_name(),
}) as Arc<dyn TableProvider>));
};
- // The Paimon arm below applies these; an engine would
- // ignore them and answer from current data. A missing
- // engine only exposes catalog metadata, so read-specific
- // session options do not apply to that fallback.
+ // Engines cannot apply Paimon scan options or query
authorization.
let session_options = dynamic_options
.read()
.unwrap_or_else(|e| e.into_inner())
.clone();
paimon::spec::CoreOptions::new(&session_options)
- .ensure_engine_can_serve(&identifier.full_name())
+ .ensure_engine_can_serve(&table_name)
.map_err(to_datafusion_error)?;
- let resolved = resolver
- .resolve_table(&EngineTableRequest::new(
- identifier.database().to_string(),
- identifier.object().to_string(),
- declared,
- ))
- .await?;
+ let request = EngineTableRequest::new(
+ identifier.database().to_string(),
+ identifier.object().to_string(),
+ declared,
+ );
+ let metadata_table = system_name.is_some();
+ let resolved = match system_name {
+ Some(system_name) => {
+ let system_name = system_name.to_ascii_lowercase();
+ resolver.resolve_metadata_table(&request,
&system_name).await?
+ }
+ None => resolver.resolve_table(&request).await?,
+ };
Ok(resolved.map(|inner| {
Arc::new(ReadOnlyTableProvider {
inner,
declared,
- table_name: identifier.full_name(),
+ table_name,
+ metadata_table,
}) as Arc<dyn TableProvider>
}))
}
Ok(paimon::catalog::LoadedTable::Paimon(table)) => {
+ if let Some(system_name) = system_name {
+ let opts = dynamic_options
+ .read()
+ .unwrap_or_else(|e| e.into_inner())
+ .clone();
+ return system_tables::load(
+ catalog,
+ identifier,
+ *table,
+ object,
+ system_name,
+ opts,
+ )
+ .await;
+ }
let mut table = *table;
if let Some(branch) = branch.as_deref() {
table = table
@@ -824,6 +863,16 @@ impl SchemaProvider for PaimonSchemaProvider {
Ok(Some(Arc::new(provider) as Arc<dyn TableProvider>))
}
Err(paimon::Error::TableNotExist { .. }) => {
+ if let Some(system_name) = system_name {
+ return if system_tables::is_registered(&system_name) {
+ Err(plan_datafusion_err!(
+ "Cannot read system table `${system_name}`:
base table `{}` does not exist",
+ object.table()
+ ))
+ } else {
+ Ok(None)
+ };
+ }
if branch.is_some() {
return Ok(None);
}
@@ -924,19 +973,16 @@ impl SchemaProvider for PaimonSchemaProvider {
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 dynamic_options = Arc::clone(&self.dynamic_options);
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"));
- let has_system_suffix = object.system_table().is_some();
+ let system_name = object.system_table().map(str::to_string);
+ let has_system_suffix = system_name.is_some();
+ let table_name = format!("{}.{}", self.database, name);
let engines: HashMap<PaimonTableType, Arc<dyn TableEngineResolver>> =
self
.table_engines
.read()
@@ -950,37 +996,57 @@ impl SchemaProvider for PaimonSchemaProvider {
}
Ok(paimon::catalog::LoadedTable::External(external)) => {
let declared = external.declared();
- // Paimon-only; `table()` rejects them here too.
- if branch.is_some() || has_system_suffix {
+ if branch.is_some() {
return false;
}
match engines.get(&declared) {
- Some(resolver) => match resolver
- .resolve_table(&EngineTableRequest::new(
+ Some(resolver) => {
+ let request = EngineTableRequest::new(
identifier.database().to_string(),
identifier.object().to_string(),
declared,
- ))
- .await
- {
- Ok(table) => table.is_some(),
- // Report failures as existing so `table()`
- // surfaces the real error.
- Err(err) => {
- log::warn!(
- "failed to probe engine table
existence for '{}': {err}",
- identifier.full_name()
- );
- true
+ );
+ let resolved = async {
+ if let Some(system_name) = system_name {
+ let opts = dynamic_options
+ .read()
+ .unwrap_or_else(|e| e.into_inner())
+ .clone();
+ paimon::spec::CoreOptions::new(&opts)
+
.ensure_engine_can_serve(&table_name)
+ .map_err(to_datafusion_error)?;
+ let system_name =
system_name.to_ascii_lowercase();
+ resolver
+ .resolve_metadata_table(&request,
&system_name)
+ .await
+ } else {
+ resolver.resolve_table(&request).await
+ }
}
- },
- // `table()` returns a metadata-only provider in
this
- // case, so the SchemaProvider existence contract
- // requires the same answer here.
- None => true,
+ .await;
+ match resolved {
+ Ok(table) => table.is_some(),
+ // Let `table()` report the engine error.
+ Err(err) => {
+ log::warn!(
+ "failed to probe engine table
existence for '{}': {err}",
+ table_name
+ );
+ true
+ }
+ }
+ }
+ // Only base tables have a metadata-only fallback.
+ None => !has_system_suffix,
}
}
Ok(paimon::catalog::LoadedTable::Paimon(table)) => {
+ if system_name
+ .as_deref()
+ .is_some_and(|name|
!system_tables::is_registered(name))
+ {
+ return false;
+ }
if let Some(branch) = branch.as_deref() {
if is_branches_table {
return true;
@@ -991,7 +1057,7 @@ impl SchemaProvider for PaimonSchemaProvider {
}
}
Err(paimon::Error::TableNotExist { .. }) => {
- if branch.is_some() {
+ if branch.is_some() || has_system_suffix {
return false;
}
match catalog.get_view(&identifier).await {
@@ -1032,6 +1098,15 @@ impl SchemaProvider for PaimonSchemaProvider {
}
fn deregister_table(&self, name: &str) -> DFResult<Option<Arc<dyn
TableProvider>>> {
+ // View DDL can reach this callback for metadata tables.
+ let object = system_tables::parse_object_name_for_datafusion(name)?;
+ if object.system_table().is_some() {
+ return Err(plan_datafusion_err!(
+ "Metadata table '{}.{}' is read-only",
+ self.database,
+ name
+ ));
+ }
let catalog = Arc::clone(&self.catalog);
let identifier = Identifier::new(self.database.clone(), name);
block_on_with_runtime(
diff --git a/crates/integrations/datafusion/src/system_tables/mod.rs
b/crates/integrations/datafusion/src/system_tables/mod.rs
index 7cbafb44..0a206fdf 100644
--- a/crates/integrations/datafusion/src/system_tables/mod.rs
+++ b/crates/integrations/datafusion/src/system_tables/mod.rs
@@ -158,15 +158,11 @@ pub(crate) fn provider_for_table(
.map(Some)
}
-/// Loads `<base>$<system_name>` from the catalog and wraps it as a system
-/// table provider.
-///
-/// - Unknown `system_name` → `Ok(None)` (DataFusion reports "table not found")
-/// - Base table missing → `Err(Plan)` so users can distinguish it from an
-/// unknown system name
+/// Builds a system-table provider from a loaded Paimon table.
pub(crate) async fn load(
catalog: Arc<dyn Catalog>,
- database: String,
+ identifier: Identifier,
+ mut table: Table,
object: ParsedObjectName,
system_name: String,
dynamic_options: HashMap<String, String>,
@@ -185,36 +181,25 @@ pub(crate) async fn load(
paimon::spec::CoreOptions::new(&dynamic_options)
.ensure_read_authorized()
.map_err(to_datafusion_error)?;
- let identifier = Identifier::new(database, object.table().to_string());
- match catalog.get_table(&identifier).await {
- Ok(mut table) => {
- crate::table_loader::ensure_paimon_served(&table, &identifier)?;
- paimon::spec::CoreOptions::new(table.schema().options())
- .ensure_read_authorized()
+ crate::table_loader::ensure_paimon_served(&table, &identifier)?;
+ paimon::spec::CoreOptions::new(table.schema().options())
+ .ensure_read_authorized()
+ .map_err(to_datafusion_error)?;
+ 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)?;
- 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)?;
- }
- }
- if system_name.eq_ignore_ascii_case("audit_log") &&
!dynamic_options.is_empty() {
- table = table
- .copy_with_time_travel(dynamic_options)
- .await
- .map_err(to_datafusion_error)?;
- }
- provider_for_table(catalog, identifier, table, &system_name)
}
- Err(paimon::Error::TableNotExist { .. }) =>
Err(DataFusionError::Plan(format!(
- "Cannot read system table `${system_name}`: \
- base table `{}` does not exist",
- object.table()
- ))),
- Err(e) => Err(to_datafusion_error(e)),
}
+ if system_name.eq_ignore_ascii_case("audit_log") &&
!dynamic_options.is_empty() {
+ table = table
+ .copy_with_time_travel(dynamic_options)
+ .await
+ .map_err(to_datafusion_error)?;
+ }
+ provider_for_table(catalog, identifier, table, &system_name)
}
#[cfg(test)]
diff --git a/crates/integrations/datafusion/tests/table_type_routing.rs
b/crates/integrations/datafusion/tests/table_type_routing.rs
index 3e979637..d29a3938 100644
--- a/crates/integrations/datafusion/tests/table_type_routing.rs
+++ b/crates/integrations/datafusion/tests/table_type_routing.rs
@@ -16,14 +16,17 @@
// under the License.
use std::collections::HashMap;
+use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use async_trait::async_trait;
use datafusion::arrow::array::{Array, Int32Array, StringArray};
use datafusion::arrow::datatypes::{DataType, Field, Schema as ArrowSchema};
use datafusion::arrow::record_batch::RecordBatch;
+use datafusion::assert_batches_eq;
use datafusion::datasource::{MemTable, TableProvider};
use datafusion::error::{DataFusionError, Result as DFResult};
+use futures::TryStreamExt;
use paimon::catalog::{Catalog, Database, Identifier, LoadedTable};
use paimon::spec::{Schema as PaimonSchema, SchemaChange, TableType};
use paimon::table::Table;
@@ -298,19 +301,78 @@ impl TableEngineResolver for FakeEngineResolver {
}
}
+#[derive(Debug, Default)]
+struct MetadataEngineResolver {
+ calls: AtomicUsize,
+}
+
+#[async_trait]
+impl TableEngineResolver for MetadataEngineResolver {
+ async fn resolve_table(
+ &self,
+ request: &EngineTableRequest,
+ ) -> DFResult<Option<Arc<dyn TableProvider>>> {
+ FakeEngineResolver.resolve_table(request).await
+ }
+
+ async fn resolve_metadata_table(
+ &self,
+ request: &EngineTableRequest,
+ metadata_table: &str,
+ ) -> DFResult<Option<Arc<dyn TableProvider>>> {
+ self.calls.fetch_add(1, Ordering::Relaxed);
+ assert_eq!(request.database, DB);
+ assert_eq!(request.declared, TableType::IcebergTable);
+ if request.table != "it" {
+ return Ok(None);
+ }
+ match metadata_table {
+ "snapshots" | "manifests" | "history" => {}
+ "broken" => {
+ return Err(DataFusionError::Execution(
+ "metadata backend exploded".to_string(),
+ ));
+ }
+ _ => return Ok(None),
+ }
+ let schema = Arc::new(ArrowSchema::new(vec![
+ Field::new("snapshot_id", DataType::Int32, false),
+ Field::new("operation", DataType::Utf8, false),
+ Field::new("metadata_table", DataType::Utf8, false),
+ ]));
+ let batch = RecordBatch::try_new(
+ Arc::clone(&schema),
+ vec![
+ Arc::new(Int32Array::from(vec![2, 4])),
+ Arc::new(StringArray::from(vec!["append", "overwrite"])),
+ Arc::new(StringArray::from(vec![metadata_table; 2])),
+ ],
+ )?;
+ Ok(Some(Arc::new(MemTable::try_new(
+ schema,
+ vec![vec![batch]],
+ )?)))
+ }
+}
+
struct TestEnv {
_paimon_dir: TempDir,
ctx: SQLContext,
+ catalog: Arc<FileSystemCatalog>,
}
async fn setup() -> TestEnv {
+ setup_with_engine(Some(Arc::new(FakeEngineResolver))).await
+}
+
+async fn setup_with_engine(resolver: Option<Arc<dyn TableEngineResolver>>) ->
TestEnv {
let paimon_dir = TempDir::new().unwrap();
let warehouse = format!("file://{}", paimon_dir.path().display());
let mut options = Options::new();
options.set(CatalogOptions::WAREHOUSE, warehouse);
let fs_catalog = Arc::new(FileSystemCatalog::new(options).unwrap());
let typed_catalog = Arc::new(TypedTestCatalog {
- inner: fs_catalog,
+ inner: Arc::clone(&fs_catalog),
declared_types: HashMap::from([
("it".to_string(), TableType::IcebergTable),
("ghost".to_string(), TableType::IcebergTable),
@@ -334,16 +396,15 @@ async fn setup() -> TestEnv {
ctx.sql(&stmt).await.unwrap().collect().await.unwrap();
}
- ctx.register_catalog_table_engine(
- CATALOG,
- TableType::IcebergTable,
- Arc::new(FakeEngineResolver),
- )
- .unwrap();
+ if let Some(resolver) = resolver {
+ ctx.register_catalog_table_engine(CATALOG, TableType::IcebergTable,
resolver)
+ .unwrap();
+ }
TestEnv {
_paimon_dir: paimon_dir,
ctx,
+ catalog: fs_catalog,
}
}
@@ -557,17 +618,363 @@ async fn writes_to_routed_tables_fail_closed() {
}
#[tokio::test]
-async fn system_tables_on_routed_tables_error() {
+async fn metadata_tables_without_engine_support_are_unavailable() {
let env = setup().await;
- let Err(err) = env
+ let schema = env.ctx.ctx().catalog(CATALOG).unwrap().schema(DB).unwrap();
+ for name in ["it$snapshots", "it$history"] {
+ assert!(schema.table(name).await.unwrap().is_none(), "{name}");
+ assert!(!schema.table_exist(name), "{name}");
+ let err = env
+ .ctx
+ .sql(&format!("SELECT * FROM {CATALOG}.{DB}.\"{name}\""))
+ .await
+ .unwrap_err();
+ assert!(err.to_string().contains("not found"), "{err}");
+ }
+}
+
+#[tokio::test]
+async fn
metadata_tables_without_a_registered_engine_report_the_declared_type() {
+ let env = setup_with_engine(None).await;
+ let schema = env.ctx.ctx().catalog(CATALOG).unwrap().schema(DB).unwrap();
+ for name in ["it$snapshots", "it$history"] {
+ assert!(!schema.table_exist(name), "{name}");
+ let err = schema.table(name).await.unwrap_err();
+ assert!(
+ err.to_string()
+ .contains("no table engine is registered for 'iceberg-table'"),
+ "{err}"
+ );
+ }
+}
+
+#[tokio::test]
+async fn engine_metadata_tables_support_projection_filters_and_aggregation() {
+ let env =
setup_with_engine(Some(Arc::new(MetadataEngineResolver::default()))).await;
+ for suffix in ["snapshots", "manifests", "History"] {
+ let batches = env
+ .ctx
+ .sql(&format!(
+ "SELECT operation, snapshot_id FROM
{CATALOG}.{DB}.\"it${suffix}\" \
+ WHERE snapshot_id > 2"
+ ))
+ .await
+ .unwrap()
+ .collect()
+ .await
+ .unwrap();
+ assert_batches_eq!(
+ [
+ "+-----------+-------------+",
+ "| operation | snapshot_id |",
+ "+-----------+-------------+",
+ "| overwrite | 4 |",
+ "+-----------+-------------+",
+ ],
+ &batches
+ );
+ let batches = env
+ .ctx
+ .sql(&format!(
+ "SELECT metadata_table FROM {CATALOG}.{DB}.\"it${suffix}\" \
+ WHERE snapshot_id > 2"
+ ))
+ .await
+ .unwrap()
+ .collect()
+ .await
+ .unwrap();
+ assert_eq!(
+ string_column_values(&batches, "metadata_table"),
+ vec![suffix.to_ascii_lowercase()]
+ );
+ let batches = env
+ .ctx
+ .sql(&format!(
+ "SELECT count(*) AS n FROM {CATALOG}.{DB}.\"it${suffix}\""
+ ))
+ .await
+ .unwrap()
+ .collect()
+ .await
+ .unwrap();
+ assert_batches_eq!(["+---+", "| n |", "+---+", "| 2 |", "+---+"],
&batches);
+ }
+}
+
+#[tokio::test]
+async fn metadata_table_existence_matches_the_resolver() {
+ let env =
setup_with_engine(Some(Arc::new(MetadataEngineResolver::default()))).await;
+ let schema = env.ctx.ctx().catalog(CATALOG).unwrap().schema(DB).unwrap();
+ for (name, exists) in [
+ ("it$snapshots", true),
+ ("it$history", true),
+ ("it$SNAPSHOTS", true),
+ ("it$History", true),
+ ("it$unknown", false),
+ ("ghost$history", false),
+ ("missing$history", false),
+ ("pt$history", false),
+ ] {
+ assert_eq!(
+ schema.table(name).await.unwrap().is_some(),
+ exists,
+ "{name}"
+ );
+ assert_eq!(schema.table_exist(name), exists, "{name}");
+ }
+}
+
+#[tokio::test]
+async fn metadata_engine_errors_are_surfaced() {
+ let env =
setup_with_engine(Some(Arc::new(MetadataEngineResolver::default()))).await;
+ let schema = env.ctx.ctx().catalog(CATALOG).unwrap().schema(DB).unwrap();
+ assert!(schema.table_exist("it$broken"));
+ let err = env
.ctx
- .sql(&format!("SELECT * FROM {CATALOG}.{DB}.\"it$snapshots\""))
+ .sql(&format!("SELECT * FROM {CATALOG}.{DB}.\"it$broken\""))
.await
- else {
- panic!("system table on a routed table must fail");
- };
- let msg = err.to_string();
- assert!(msg.contains("cannot be read as a Paimon table"), "{msg}");
+ .unwrap_err();
+ assert!(
+ err.to_string().contains("metadata backend exploded"),
+ "{err}"
+ );
+}
+
+#[tokio::test]
+async fn paimon_system_tables_keep_the_paimon_provider() {
+ let env =
setup_with_engine(Some(Arc::new(MetadataEngineResolver::default()))).await;
+ let schema = env.ctx.ctx().catalog(CATALOG).unwrap().schema(DB).unwrap();
+ assert!(schema.table_exist("pt$snapshots"));
+ let batches = env
+ .ctx
+ .sql(&format!(
+ "SELECT snapshot_id FROM {CATALOG}.{DB}.\"pt$snapshots\" ORDER BY
snapshot_id"
+ ))
+ .await
+ .unwrap()
+ .collect()
+ .await
+ .unwrap();
+ assert_batches_eq!(
+ [
+ "+-------------+",
+ "| snapshot_id |",
+ "+-------------+",
+ "| 1 |",
+ "| 2 |",
+ "+-------------+",
+ ],
+ &batches
+ );
+}
+
+#[tokio::test]
+async fn routed_metadata_tables_are_read_only() {
+ let env =
setup_with_engine(Some(Arc::new(MetadataEngineResolver::default()))).await;
+ let err = async {
+ env.ctx
+ .sql(&format!(
+ "INSERT INTO {CATALOG}.{DB}.\"it$history\" VALUES (5,
'append', 'history')"
+ ))
+ .await?
+ .collect()
+ .await
+ }
+ .await
+ .unwrap_err();
+ assert!(err.to_string().contains("write is not supported"), "{err}");
+}
+
+#[tokio::test]
+async fn routed_metadata_tables_are_views() {
+ use datafusion::datasource::TableType as DFTableType;
+
+ let env =
setup_with_engine(Some(Arc::new(MetadataEngineResolver::default()))).await;
+ let schema = env.ctx.ctx().catalog(CATALOG).unwrap().schema(DB).unwrap();
+ let metadata = schema.table("it$history").await.unwrap().unwrap();
+ assert_eq!(metadata.table_type(), DFTableType::View);
+ let base = schema.table("it").await.unwrap().unwrap();
+ assert_eq!(base.table_type(), DFTableType::Base);
+
+ // Native DROP TABLE must reject metadata views.
+ let result = async {
+ env.ctx
+ .ctx()
+ .sql(&format!("DROP TABLE {CATALOG}.{DB}.\"it$history\""))
+ .await?
+ .collect()
+ .await
+ }
+ .await;
+ assert!(result.is_err(), "{result:?}");
+ assert!(schema.table_exist("it$history"));
+}
+
+#[tokio::test]
+async fn metadata_drop_view_preserves_physical_name_collisions() {
+ for name in ["it$history", "it$History", "pt$snapshots"] {
+ assert_metadata_ddl_preserves_physical_table(
+ name,
+ &format!("DROP VIEW {CATALOG}.{DB}.\"{name}\""),
+ )
+ .await;
+ }
+}
+
+#[tokio::test]
+async fn metadata_replace_view_preserves_physical_name_collisions() {
+ for name in ["it$history", "it$History", "pt$snapshots"] {
+ assert_metadata_ddl_preserves_physical_table(
+ name,
+ &format!("CREATE OR REPLACE VIEW {CATALOG}.{DB}.\"{name}\" AS
SELECT 42 AS id"),
+ )
+ .await;
+ }
+}
+
+async fn assert_metadata_ddl_preserves_physical_table(name: &str, ddl: &str) {
+ let env =
setup_with_engine(Some(Arc::new(MetadataEngineResolver::default()))).await;
+ env.ctx
+ .sql(&format!(
+ "CREATE TABLE {CATALOG}.{DB}.\"{name}\" (id INT NOT NULL)"
+ ))
+ .await
+ .unwrap();
+ let identifier = Identifier::new(DB, name);
+ let table = env.catalog.get_table(&identifier).await.unwrap();
+ let batch = RecordBatch::try_new(
+ Arc::new(ArrowSchema::new(vec![Field::new(
+ "id",
+ DataType::Int32,
+ false,
+ )])),
+ vec![Arc::new(Int32Array::from(vec![7, 9]))],
+ )
+ .unwrap();
+ let builder = table.new_write_builder();
+ let mut write = builder.new_write().unwrap();
+ write.write_arrow_batch(&batch).await.unwrap();
+ let messages = write.prepare_commit().await.unwrap();
+ builder.new_commit().commit(messages).await.unwrap();
+
+ // Use native DDL to bypass SQLContext guards.
+ let result = async { env.ctx.ctx().sql(ddl).await?.collect().await }.await;
+ assert!(
+ table.file_io().exists_dir(table.location()).await.unwrap(),
+ "{ddl} deleted the physical table directory"
+ );
+ let remaining = env.catalog.get_table(&identifier).await.unwrap();
+ let read = remaining.new_read_builder();
+ let plan = read.new_scan().plan().await.unwrap();
+ let batches = read
+ .new_read()
+ .unwrap()
+ .to_arrow(plan.splits())
+ .unwrap()
+ .try_collect::<Vec<_>>()
+ .await
+ .unwrap();
+ assert_eq!(column_i32(&batches), vec![7, 9], "{ddl}");
+ let error = result.unwrap_err().to_string();
+ assert!(error.contains(name), "{ddl}: {error}");
+}
+
+#[tokio::test]
+async fn metadata_deregistration_reports_read_only() {
+ let env =
setup_with_engine(Some(Arc::new(MetadataEngineResolver::default()))).await;
+ let schema = env.ctx.ctx().catalog(CATALOG).unwrap().schema(DB).unwrap();
+ for name in ["it$history", "it$History", "it$unknown", "pt$snapshots"] {
+ let error = schema.deregister_table(name).unwrap_err().to_string();
+ assert!(error.contains("read-only"), "{name}: {error}");
+ assert!(error.contains(&format!("{DB}.{name}")), "{name}: {error}");
+ }
+}
+
+#[tokio::test]
+async fn native_drop_table_still_removes_a_regular_table() {
+ let env = setup().await;
+ let identifier = Identifier::new(DB, "pt");
+ let table = env.catalog.get_table(&identifier).await.unwrap();
+ env.ctx
+ .ctx()
+ .sql(&format!("DROP TABLE {CATALOG}.{DB}.pt"))
+ .await
+ .unwrap()
+ .collect()
+ .await
+ .unwrap();
+ assert!(!table.file_io().exists_dir(table.location()).await.unwrap());
+ assert!(matches!(
+ env.catalog.get_table(&identifier).await,
+ Err(paimon::Error::TableNotExist { .. })
+ ));
+}
+
+#[tokio::test]
+async fn routed_metadata_tables_reject_branches_and_time_travel() {
+ let env =
setup_with_engine(Some(Arc::new(MetadataEngineResolver::default()))).await;
+ let schema = env.ctx.ctx().catalog(CATALOG).unwrap().schema(DB).unwrap();
+ let err = schema.table("it$branch_b1$history").await.unwrap_err();
+ assert!(
+ err.to_string().contains("branches are not supported"),
+ "{err}"
+ );
+ assert!(!schema.table_exist("it$branch_b1$history"));
+
+ env.ctx
+ .ctx()
+ .sql("SET datafusion.sql_parser.dialect = 'databricks'")
+ .await
+ .unwrap()
+ .collect()
+ .await
+ .unwrap();
+ for clause in [
+ "VERSION AS OF 1",
+ "TIMESTAMP AS OF '2020-01-01 00:00:00'",
+ "FOR SYSTEM_TIME AS OF '2020-01-01 00:00:00'",
+ ] {
+ let err = env
+ .ctx
+ .ctx()
+ .sql(&format!(
+ "SELECT * FROM {CATALOG}.{DB}.\"it$history\" {clause}"
+ ))
+ .await
+ .unwrap_err();
+ assert!(
+ err.to_string().contains("time travel is not supported"),
+ "{err}"
+ );
+ }
+}
+
+#[tokio::test]
+async fn
metadata_resolution_enforces_session_options_before_calling_the_engine() {
+ for (option, value, error) in [
+ ("query-auth.enabled", "true", "query-auth"),
+ ("scan.version", "1", "time travel"),
+ ("incremental-between", "1,5", "incremental-between"),
+ ] {
+ let resolver = Arc::new(MetadataEngineResolver::default());
+ let env = setup_with_engine(Some(resolver.clone())).await;
+ env.ctx
+ .sql(&format!("SET 'paimon.{option}' = '{value}'"))
+ .await
+ .unwrap()
+ .collect()
+ .await
+ .unwrap();
+ let schema =
env.ctx.ctx().catalog(CATALOG).unwrap().schema(DB).unwrap();
+ let err = schema.table("it$history").await.unwrap_err();
+ assert!(err.to_string().contains(error), "{err}");
+ if option == "scan.version" {
+ assert!(err.to_string().contains("it$history"), "{err}");
+ }
+ assert!(schema.table_exist("it$history"));
+ assert_eq!(resolver.calls.load(Ordering::Relaxed), 0);
+ }
}
#[tokio::test]