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 d6890564 fix(datafusion): keep metadata queries available without
table engines (#747)
d6890564 is described below
commit d6890564fd5ec35da7672210e45f91b3d45a94bb
Author: shyjsarah <[email protected]>
AuthorDate: Sun Aug 30 21:02:33 2026 +0800
fix(datafusion): keep metadata queries available without table engines
(#747)
---
crates/integrations/datafusion/src/catalog.rs | 82 ++++++++++++--
crates/integrations/datafusion/src/table/mod.rs | 2 +-
.../datafusion/tests/table_type_routing.rs | 126 +++++++++++++++++++--
crates/paimon/src/catalog/filesystem.rs | 3 +-
crates/paimon/src/catalog/mod.rs | 39 ++++++-
crates/paimon/src/catalog/rest/rest_catalog.rs | 3 +-
6 files changed, 231 insertions(+), 24 deletions(-)
diff --git a/crates/integrations/datafusion/src/catalog.rs
b/crates/integrations/datafusion/src/catalog.rs
index d8893243..340cf07c 100644
--- a/crates/integrations/datafusion/src/catalog.rs
+++ b/crates/integrations/datafusion/src/catalog.rs
@@ -154,6 +154,58 @@ impl TableProvider for ReadOnlyTableProvider {
}
}
+/// Metadata-only provider for an external table whose engine is not
registered.
+///
+/// DataFusion's information schema asks every catalog table for its schema.
+/// Returning this provider keeps those metadata queries available without
+/// weakening the fail-closed behavior for actual table reads or writes.
+#[derive(Debug)]
+struct UnavailableEngineTableProvider {
+ schema: datafusion::arrow::datatypes::SchemaRef,
+ declared: PaimonTableType,
+ table_name: String,
+}
+
+impl UnavailableEngineTableProvider {
+ fn unavailable_error(&self) -> datafusion::error::DataFusionError {
+ plan_datafusion_err!(
+ "no table engine is registered for '{}' tables ('{}')",
+ self.declared,
+ self.table_name
+ )
+ }
+}
+
+#[async_trait]
+impl TableProvider for UnavailableEngineTableProvider {
+ fn schema(&self) -> datafusion::arrow::datatypes::SchemaRef {
+ Arc::clone(&self.schema)
+ }
+
+ fn table_type(&self) -> TableType {
+ TableType::Base
+ }
+
+ async fn scan(
+ &self,
+ _state: &dyn datafusion::catalog::Session,
+ _projection: Option<&Vec<usize>>,
+ _filters: &[Expr],
+ _limit: Option<usize>,
+ ) -> DFResult<Arc<dyn datafusion::physical_plan::ExecutionPlan>> {
+ Err(self.unavailable_error())
+ }
+
+ async fn insert_into(
+ &self,
+ _state: &dyn datafusion::catalog::Session,
+ _input: Arc<dyn datafusion::physical_plan::ExecutionPlan>,
+ _insert_op: datafusion::logical_expr::dml::InsertOp,
+ ) -> DFResult<Arc<dyn datafusion::physical_plan::ExecutionPlan>> {
+ Err(self.unavailable_error())
+ }
+}
+
/// Register `resolver` as the engine for `table_type` on the Paimon catalog
/// named `catalog_name`.
///
@@ -693,8 +745,24 @@ impl SchemaProvider for PaimonSchemaProvider {
identifier.full_name()
));
}
+ let Some(resolver) = table_engines.get(&declared) else {
+ let schema = match external.fields() {
+ Some(fields) =>
crate::table::datafusion_arrow_schema(
+ fields,
+ schema_force_view_types,
+ )?,
+ None =>
Arc::new(datafusion::arrow::datatypes::Schema::empty()),
+ };
+ return Ok(Some(Arc::new(UnavailableEngineTableProvider
{
+ schema,
+ declared,
+ 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.
+ // 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.
let session_options = dynamic_options
.read()
.unwrap_or_else(|e| e.into_inner())
@@ -702,13 +770,6 @@ impl SchemaProvider for PaimonSchemaProvider {
paimon::spec::CoreOptions::new(&session_options)
.ensure_engine_can_serve(&identifier.full_name())
.map_err(to_datafusion_error)?;
- let resolver = table_engines.get(&declared).ok_or_else(|| {
- plan_datafusion_err!(
- "no table engine is registered for '{}' tables
('{}')",
- declared,
- identifier.full_name()
- )
- })?;
let resolved = resolver
.resolve_table(&EngineTableRequest::new(
identifier.database().to_string(),
@@ -907,7 +968,10 @@ impl SchemaProvider for PaimonSchemaProvider {
true
}
},
- None => false,
+ // `table()` returns a metadata-only provider in
this
+ // case, so the SchemaProvider existence contract
+ // requires the same answer here.
+ None => true,
}
}
Ok(paimon::catalog::LoadedTable::Paimon(table)) => {
diff --git a/crates/integrations/datafusion/src/table/mod.rs
b/crates/integrations/datafusion/src/table/mod.rs
index 262c3217..9683bf58 100644
--- a/crates/integrations/datafusion/src/table/mod.rs
+++ b/crates/integrations/datafusion/src/table/mod.rs
@@ -63,7 +63,7 @@ pub(crate) fn datafusion_read_fields(table: &Table) ->
Vec<DataField> {
fields
}
-fn datafusion_arrow_schema(
+pub(crate) fn datafusion_arrow_schema(
fields: &[DataField],
schema_force_view_types: bool,
) -> DFResult<ArrowSchemaRef> {
diff --git a/crates/integrations/datafusion/tests/table_type_routing.rs
b/crates/integrations/datafusion/tests/table_type_routing.rs
index 3777ea97..5620d6bc 100644
--- a/crates/integrations/datafusion/tests/table_type_routing.rs
+++ b/crates/integrations/datafusion/tests/table_type_routing.rs
@@ -34,6 +34,23 @@ use tempfile::TempDir;
const CATALOG: &str = "cat";
const DB: &str = "shared_db";
+fn string_column_values(batches: &[RecordBatch], column: &str) -> Vec<String> {
+ batches
+ .iter()
+ .flat_map(|batch| {
+ let values = batch
+ .column_by_name(column)
+ .unwrap()
+ .as_any()
+ .downcast_ref::<StringArray>()
+ .unwrap();
+ (0..values.len())
+ .map(|row| values.value(row).to_string())
+ .collect::<Vec<_>>()
+ })
+ .collect()
+}
+
#[derive(Debug)]
struct TypedTestCatalog {
inner: Arc<FileSystemCatalog>,
@@ -88,8 +105,14 @@ impl Catalog for TypedTestCatalog {
if let Some(declared) = self.declared_types.get(identifier.object()) {
if declared.requires_table_engine() {
let options = HashMap::new();
- return LoadedTable::external(
+ let fields = vec![paimon::spec::DataField::new(
+ 0,
+ "external_id".to_string(),
+ paimon::spec::DataType::Int(paimon::spec::IntType::new()),
+ )];
+ return LoadedTable::external_with_fields(
*declared,
+ fields,
&paimon::spec::CoreOptions::new(&options),
&identifier.full_name(),
);
@@ -867,14 +890,97 @@ async fn an_external_type_without_an_engine_says_so() {
.await
.unwrap();
- let Err(err) = ctx.sql(&format!("SELECT * FROM {CATALOG}.{DB}.it")).await
else {
- panic!("an external table without an engine must not resolve");
- };
+ let err = ctx
+ .sql(&format!("SELECT * FROM {CATALOG}.{DB}.it"))
+ .await
+ .expect("metadata-only planning should succeed")
+ .collect()
+ .await
+ .expect_err("an external table without an engine must not be
readable");
let msg = err.to_string();
assert!(msg.contains("no table engine is registered"), "{msg}");
assert!(msg.contains("iceberg-table"), "{msg}");
}
+#[tokio::test]
+async fn
unregistered_external_table_does_not_break_information_schema_columns() {
+ 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,
+ declared_types: HashMap::from([("external".to_string(),
TableType::IcebergTable)]),
+ });
+ let mut ctx = SQLContext::new();
+ ctx.register_catalog(CATALOG, typed_catalog).await.unwrap();
+ ctx.sql(&format!("CREATE SCHEMA {CATALOG}.{DB}"))
+ .await
+ .unwrap();
+ ctx.sql(&format!(
+ "CREATE TABLE {CATALOG}.{DB}.pt (id INT NOT NULL, name STRING)"
+ ))
+ .await
+ .unwrap();
+ ctx.sql("SET 'paimon.scan.version' = '1'").await.unwrap();
+
+ let provider = ctx.ctx().catalog(CATALOG).unwrap();
+ let schema = provider.schema(DB).unwrap();
+ assert!(
+ schema.table("external").await.unwrap().is_some(),
+ "metadata-only table loading should succeed without a registered
engine"
+ );
+ assert!(
+ schema.table_exist("external"),
+ "table_exist must agree with the metadata-only table provider"
+ );
+
+ let show_tables = ctx
+ .sql("SHOW TABLES")
+ .await
+ .expect("SHOW TABLES must not load an external table engine")
+ .collect()
+ .await
+ .expect("SHOW TABLES must remain queryable");
+ let shown_names = string_column_values(&show_tables, "table_name");
+ assert!(
+ shown_names.contains(&"external".to_string()),
+ "{shown_names:?}"
+ );
+
+ let batches = ctx
+ .sql(&format!(
+ "SELECT column_name FROM information_schema.columns \
+ WHERE table_catalog = '{CATALOG}' \
+ AND table_schema = '{DB}' \
+ AND table_name = 'pt' \
+ ORDER BY ordinal_position"
+ ))
+ .await
+ .expect("an unrelated unregistered engine table must not break
planning")
+ .collect()
+ .await
+ .expect("information_schema.columns must remain queryable");
+ let names = string_column_values(&batches, "column_name");
+ assert_eq!(names, vec!["id", "name"]);
+
+ let external_columns = ctx
+ .sql(&format!(
+ "SELECT column_name FROM information_schema.columns \
+ WHERE table_catalog = '{CATALOG}' \
+ AND table_schema = '{DB}' \
+ AND table_name = 'external'"
+ ))
+ .await
+ .expect("the external table schema should be available from catalog
metadata")
+ .collect()
+ .await
+ .expect("the external table schema should not require an engine");
+ let external_names = string_column_values(&external_columns,
"column_name");
+ assert_eq!(external_names, vec!["external_id"]);
+}
+
async fn legacy_catalog_with_iceberg_table() -> (TempDir,
Arc<LegacyTestCatalog>) {
let paimon_dir = TempDir::new().unwrap();
let warehouse = format!("file://{}", paimon_dir.path().display());
@@ -928,9 +1034,13 @@ async fn
a_legacy_catalog_cannot_serve_an_external_table_as_paimon() {
let mut ctx = SQLContext::new();
ctx.register_catalog(CATALOG, catalog).await.unwrap();
- let Err(err) = ctx.sql(&format!("SELECT * FROM {CATALOG}.{DB}.it")).await
else {
- panic!("a legacy catalog must not serve an iceberg table as Paimon");
- };
+ let err = ctx
+ .sql(&format!("SELECT * FROM {CATALOG}.{DB}.it"))
+ .await
+ .expect("catalog metadata should be sufficient for planning")
+ .collect()
+ .await
+ .expect_err("a legacy catalog must not serve an iceberg table as
Paimon");
let msg = err.to_string();
assert!(msg.contains("no table engine is registered"), "{msg}");
assert!(msg.contains("iceberg-table"), "{msg}");
@@ -961,7 +1071,7 @@ async fn
a_legacy_catalog_refuses_every_destructive_statement() {
let (_dir, ctx) = legacy_sql_context().await;
for sql in [
- format!("INSERT INTO {CATALOG}.{DB}.it VALUES (1)"),
+ format!("INSERT INTO {CATALOG}.{DB}.it VALUES (1, 1)"),
format!("INSERT OVERWRITE {CATALOG}.{DB}.it PARTITION (pt = 1) VALUES
(1)"),
format!("UPDATE {CATALOG}.{DB}.it SET id = 2"),
format!("DELETE FROM {CATALOG}.{DB}.it"),
diff --git a/crates/paimon/src/catalog/filesystem.rs
b/crates/paimon/src/catalog/filesystem.rs
index 45628ad6..6a57cd86 100644
--- a/crates/paimon/src/catalog/filesystem.rs
+++ b/crates/paimon/src/catalog/filesystem.rs
@@ -389,8 +389,9 @@ impl Catalog for FileSystemCatalog {
.map(crate::catalog::LoadedTable::Object);
}
if declared.requires_table_engine() {
- return crate::catalog::LoadedTable::external(
+ return crate::catalog::LoadedTable::external_with_fields(
declared,
+ schema.fields().to_vec(),
&options,
&identifier.full_name(),
);
diff --git a/crates/paimon/src/catalog/mod.rs b/crates/paimon/src/catalog/mod.rs
index cd3a8901..f16fd25a 100644
--- a/crates/paimon/src/catalog/mod.rs
+++ b/crates/paimon/src/catalog/mod.rs
@@ -278,11 +278,12 @@ pub enum LoadedTable {
}
/// What a caller needs to pick an engine for a table Paimon cannot construct.
-/// Only [`LoadedTable::external`] can build one, so the stored-metadata checks
-/// always run before a caller sees it.
+/// Only [`LoadedTable::external`] and [`LoadedTable::external_with_fields`]
can
+/// build one, so the stored-metadata checks always run before a caller sees
it.
#[derive(Debug)]
pub struct ExternalTableMetadata {
declared: TableType,
+ fields: Option<Vec<crate::spec::DataField>>,
}
impl ExternalTableMetadata {
@@ -290,6 +291,11 @@ impl ExternalTableMetadata {
pub fn declared(&self) -> TableType {
self.declared
}
+
+ /// The table fields returned by the catalog, when available.
+ pub fn fields(&self) -> Option<&[crate::spec::DataField]> {
+ self.fields.as_deref()
+ }
}
impl LoadedTable {
@@ -303,6 +309,26 @@ impl LoadedTable {
declared: TableType,
options: &crate::spec::CoreOptions<'_>,
full_name: &str,
+ ) -> Result<Self> {
+ Self::external_impl(declared, None, options, full_name)
+ }
+
+ /// Like [`Self::external`], preserving catalog metadata for schema-only
+ /// consumers such as DataFusion's information schema.
+ pub fn external_with_fields(
+ declared: TableType,
+ fields: Vec<crate::spec::DataField>,
+ options: &crate::spec::CoreOptions<'_>,
+ full_name: &str,
+ ) -> Result<Self> {
+ Self::external_impl(declared, Some(fields), options, full_name)
+ }
+
+ fn external_impl(
+ declared: TableType,
+ fields: Option<Vec<crate::spec::DataField>>,
+ options: &crate::spec::CoreOptions<'_>,
+ full_name: &str,
) -> Result<Self> {
if !declared.requires_table_engine() {
return Err(Error::Unsupported {
@@ -312,7 +338,7 @@ impl LoadedTable {
});
}
options.ensure_engine_can_serve(full_name)?;
- Ok(Self::External(ExternalTableMetadata { declared }))
+ Ok(Self::External(ExternalTableMetadata { declared, fields }))
}
}
@@ -402,7 +428,12 @@ pub trait Catalog: Send + Sync {
.map(LoadedTable::Object);
}
if declared.requires_table_engine() {
- return LoadedTable::external(declared, &options,
&identifier.full_name());
+ return LoadedTable::external_with_fields(
+ declared,
+ table.schema().fields().to_vec(),
+ &options,
+ &identifier.full_name(),
+ );
}
Ok(LoadedTable::Paimon(Box::new(table)))
}
diff --git a/crates/paimon/src/catalog/rest/rest_catalog.rs
b/crates/paimon/src/catalog/rest/rest_catalog.rs
index 7559a369..db167c3d 100644
--- a/crates/paimon/src/catalog/rest/rest_catalog.rs
+++ b/crates/paimon/src/catalog/rest/rest_catalog.rs
@@ -233,8 +233,9 @@ impl Catalog for RESTCatalog {
.map(crate::catalog::LoadedTable::Object);
}
if declared.requires_table_engine() {
- return crate::catalog::LoadedTable::external(
+ return crate::catalog::LoadedTable::external_with_fields(
declared,
+ schema.fields().to_vec(),
&options,
&identifier.full_name(),
);