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 c3c3423e feat(datafusion): support SHOW CREATE TABLE for external
engines (#1131)
c3c3423e is described below
commit c3c3423ef385ba88999f94e09dd31db2328e3335
Author: Jiajia Li <[email protected]>
AuthorDate: Fri Oct 16 12:11:08 2026 +0800
feat(datafusion): support SHOW CREATE TABLE for external engines (#1131)
---
crates/integrations/datafusion/README.md | 6 +
crates/integrations/datafusion/src/sql_context.rs | 56 ++++-
.../datafusion/tests/table_type_routing.rs | 251 +++++++++++++++++++--
3 files changed, 294 insertions(+), 19 deletions(-)
diff --git a/crates/integrations/datafusion/README.md
b/crates/integrations/datafusion/README.md
index 5c7904b9..b12615c4 100644
--- a/crates/integrations/datafusion/README.md
+++ b/crates/integrations/datafusion/README.md
@@ -39,6 +39,12 @@ current provider. Raw `SessionContext` needs
`register_catalog_table_engine` and
dialect supporting these clauses; it preloads the current provider and
propagates
loading errors.
+## External engine DDL
+
+`SQLContext` answers `SHOW CREATE TABLE` on an external data table with the
resolved
+provider's `get_table_definition()`, unchanged. A missing or blank definition
is
+reported as unsupported.
+
## Reader memory
Paimon Parquet scans use the memory pool from the executing DataFusion
`TaskContext`.
diff --git a/crates/integrations/datafusion/src/sql_context.rs
b/crates/integrations/datafusion/src/sql_context.rs
index 3c8d34dd..bad1203f 100644
--- a/crates/integrations/datafusion/src/sql_context.rs
+++ b/crates/integrations/datafusion/src/sql_context.rs
@@ -1274,15 +1274,35 @@ impl SQLContext {
);
}
let (catalog, catalog_name, identifier) =
self.resolve_catalog_and_table(name)?;
- let table = match catalog.get_table(&identifier).await {
- Ok(table) => table,
+ let definition = match catalog.load_table(&identifier).await {
+ Ok(paimon::catalog::LoadedTable::External(external)) => {
+ let catalog_provider = self
+ .ctx
+ .catalog(&catalog_name)
+ .ok_or_else(|| plan_datafusion_err!("Unknown catalog
'{catalog_name}'"))?;
+ engine_table_definition(catalog_provider, &identifier,
external.declared()).await?
+ }
+ Ok(paimon::catalog::LoadedTable::Paimon(table)) => {
+ crate::table_loader::ensure_paimon_served(&table,
&identifier)?;
+ crate::table::build_table_definition(&table)?
+ }
+ Ok(paimon::catalog::LoadedTable::Object(_)) => {
+ return Err(plan_datafusion_err!(
+ "table '{}' is declared 'object-table' and cannot be read
as a Paimon table",
+ identifier.full_name()
+ ));
+ }
+ Ok(_) => {
+ return Err(plan_datafusion_err!(
+ "table '{}' cannot be read as a Paimon table",
+ identifier.full_name()
+ ));
+ }
Err(paimon::Error::TableNotExist { .. }) => {
return self.ctx.sql_with_options(sql, sql_options).await;
}
Err(e) => return Err(to_datafusion_error(e)),
};
- crate::table_loader::ensure_paimon_served(&table, &identifier)?;
- let definition = crate::table::build_table_definition(&table)?;
self.show_create_result(
catalog_name,
identifier.database().to_string(),
@@ -2456,6 +2476,34 @@ impl SQLContext {
}
}
+/// DDL from the engine provider of an external table.
+async fn engine_table_definition(
+ catalog_provider: Arc<dyn datafusion::catalog::CatalogProvider>,
+ identifier: &Identifier,
+ declared: paimon::spec::TableType,
+) -> DFResult<String> {
+ let table_name = identifier.full_name();
+ let schema = catalog_provider
+ .schema(identifier.database())
+ .ok_or_else(|| plan_datafusion_err!("database '{}' not found",
identifier.database()))?;
+ let provider = schema
+ .table(identifier.object())
+ .await?
+ .ok_or_else(|| plan_datafusion_err!("table '{table_name}' not
found"))?;
+ if let Some(unavailable) =
+
provider.downcast_ref::<crate::catalog::UnavailableEngineTableProvider>()
+ {
+ return Err(unavailable.unavailable_error());
+ }
+ match provider.get_table_definition() {
+ Some(definition) if !definition.trim().is_empty() =>
Ok(definition.to_string()),
+ _ => Err(plan_datafusion_err!(
+ "SHOW CREATE TABLE is not supported for '{declared}' table
'{table_name}': \
+ the table engine provides no definition"
+ )),
+ }
+}
+
fn validate_immutable_scalar_plan(plan: &LogicalPlan) -> DFResult<()> {
let mut violation = None;
plan.apply(|node| {
diff --git a/crates/integrations/datafusion/tests/table_type_routing.rs
b/crates/integrations/datafusion/tests/table_type_routing.rs
index 9e291430..45e3c27f 100644
--- a/crates/integrations/datafusion/tests/table_type_routing.rs
+++ b/crates/integrations/datafusion/tests/table_type_routing.rs
@@ -477,6 +477,13 @@ async fn setup() -> TestEnv {
}
async fn setup_with_engine(resolver: Option<Arc<dyn TableEngineResolver>>) ->
TestEnv {
+ setup_with_external_tables(resolver, &["it", "ghost", "ft"]).await
+}
+
+async fn setup_with_external_tables(
+ resolver: Option<Arc<dyn TableEngineResolver>>,
+ external_tables: &[&str],
+) -> TestEnv {
let paimon_dir = TempDir::new().unwrap();
let warehouse = format!("file://{}", paimon_dir.path().display());
let mut options = Options::new();
@@ -484,11 +491,10 @@ async fn setup_with_engine(resolver: Option<Arc<dyn
TableEngineResolver>>) -> Te
let fs_catalog = Arc::new(FileSystemCatalog::new(options).unwrap());
let typed_catalog = Arc::new(TypedTestCatalog {
inner: Arc::clone(&fs_catalog),
- declared_types: HashMap::from([
- ("it".to_string(), TableType::IcebergTable),
- ("ghost".to_string(), TableType::IcebergTable),
- ("ft".to_string(), TableType::IcebergTable),
- ]),
+ declared_types: external_tables
+ .iter()
+ .map(|name| (name.to_string(), TableType::IcebergTable))
+ .collect(),
});
let mut ctx = SQLContext::new();
ctx.register_catalog(CATALOG, typed_catalog).await.unwrap();
@@ -2021,19 +2027,234 @@ async fn
every_version_clause_on_routed_tables_is_rejected() {
}
}
+#[derive(Debug)]
+struct DefinedTable {
+ inner: MemTable,
+ definition: Option<String>,
+}
+
+#[async_trait]
+impl TableProvider for DefinedTable {
+ fn schema(&self) -> datafusion::arrow::datatypes::SchemaRef {
+ self.inner.schema()
+ }
+
+ fn table_type(&self) -> datafusion::logical_expr::TableType {
+ datafusion::logical_expr::TableType::Base
+ }
+
+ fn get_table_definition(&self) -> Option<&str> {
+ self.definition.as_deref()
+ }
+
+ async fn scan(
+ &self,
+ state: &dyn datafusion::catalog::Session,
+ projection: Option<&Vec<usize>>,
+ filters: &[datafusion::logical_expr::Expr],
+ limit: Option<usize>,
+ ) -> DFResult<Arc<dyn datafusion::physical_plan::ExecutionPlan>> {
+ self.inner.scan(state, projection, filters, limit).await
+ }
+}
+
+const QUOTED_TABLE: &str = "Lake Events";
+
+fn engine_ddl(database: &str, table: &str) -> String {
+ format!("CREATE TABLE lake.{database}.`{table}` (id INT) USING iceberg\n")
+}
+
+#[derive(Debug, Default)]
+struct DdlEngineResolver {
+ calls: AtomicUsize,
+}
+
+#[async_trait]
+impl TableEngineResolver for DdlEngineResolver {
+ async fn resolve_table(
+ &self,
+ request: &EngineTableRequest,
+ ) -> DFResult<Option<Arc<dyn TableProvider>>> {
+ self.calls.fetch_add(1, Ordering::Relaxed);
+ let definition = match request.table.as_str() {
+ "it" | QUOTED_TABLE => Some(engine_ddl(&request.database,
&request.table)),
+ "no_ddl" => None,
+ "blank_ddl" => Some(" \n\t".to_string()),
+ "broken" => {
+ return Err(DataFusionError::Execution(
+ "ddl backend exploded".to_string(),
+ ))
+ }
+ _ => return Ok(None),
+ };
+ let schema = Arc::new(ArrowSchema::new(vec![Field::new(
+ "id",
+ DataType::Int32,
+ false,
+ )]));
+ Ok(Some(Arc::new(DefinedTable {
+ inner: MemTable::try_new(schema, vec![vec![]])?,
+ definition,
+ })))
+ }
+}
+
+async fn ddl_env(external_tables: &[&str]) -> (TestEnv,
Arc<DdlEngineResolver>) {
+ let resolver = Arc::new(DdlEngineResolver::default());
+ let env = setup_with_external_tables(Some(resolver.clone()),
external_tables).await;
+ (env, resolver)
+}
+
+async fn show_create(env: &TestEnv, table: &str) -> DFResult<Vec<RecordBatch>>
{
+ show_create_in(env, DB, table).await
+}
+
+async fn show_create_in(env: &TestEnv, db: &str, table: &str) ->
DFResult<Vec<RecordBatch>> {
+ env.ctx
+ .sql(&format!("SHOW CREATE TABLE {CATALOG}.{db}.{table}"))
+ .await?
+ .collect()
+ .await
+}
+
+fn show_create_definition(batches: &[RecordBatch]) -> Option<String> {
+ let rows: Vec<_> = batches.iter().filter(|b| b.num_rows() > 0).collect();
+ assert_eq!(rows.len(), 1);
+ assert_eq!(rows[0].num_rows(), 1);
+ let column = rows[0]
+ .column_by_name("definition")
+ .unwrap()
+ .as_any()
+ .downcast_ref::<StringArray>()
+ .unwrap();
+ column.is_valid(0).then(|| column.value(0).to_string())
+}
+
#[tokio::test]
-async fn show_create_on_routed_tables_reports_the_declared_type() {
- let env = setup().await;
- let Err(err) = env
+async fn show_create_returns_the_engine_definition() {
+ let (env, resolver) = ddl_env(&["it", QUOTED_TABLE]).await;
+ env.ctx
+ .sql(&format!("CREATE SCHEMA {CATALOG}.other_db"))
+ .await
+ .unwrap();
+ for (db, name, table) in [
+ (DB, "it", "it"),
+ (DB, "\"Lake Events\"", QUOTED_TABLE),
+ ("other_db", "it", "it"),
+ ] {
+ let batches = show_create_in(&env, db, name).await.unwrap();
+ assert_eq!(string_column_values(&batches, "table_catalog"), [CATALOG]);
+ assert_eq!(string_column_values(&batches, "table_schema"), [db]);
+ assert_eq!(string_column_values(&batches, "table_name"), [table]);
+ assert_eq!(
+ show_create_definition(&batches),
+ Some(engine_ddl(db, table))
+ );
+ }
+ assert_eq!(resolver.calls.load(Ordering::Relaxed), 3);
+}
+
+#[tokio::test]
+async fn show_create_reports_external_tables_it_cannot_describe() {
+ let (env, _) = ddl_env(&["no_ddl", "blank_ddl", "broken", "ghost"]).await;
+ for (table, message) in [
+ (
+ "no_ddl",
+ "SHOW CREATE TABLE is not supported for 'iceberg-table' table
'shared_db.no_ddl'",
+ ),
+ (
+ "blank_ddl",
+ "SHOW CREATE TABLE is not supported for 'iceberg-table' table
'shared_db.blank_ddl'",
+ ),
+ ("broken", "ddl backend exploded"),
+ ("ghost", "table 'shared_db.ghost' not found"),
+ ("missing", "not found"),
+ ] {
+ let err = show_create(&env, table).await.unwrap_err();
+ assert!(err.to_string().contains(message), "{table}: {err}");
+ }
+
+ let err = show_create(&setup_with_engine(None).await, "it")
+ .await
+ .unwrap_err();
+ assert!(
+ err.to_string()
+ .contains("no table engine is registered for 'iceberg-table'
tables"),
+ "{err}"
+ );
+ let err = show_create(&setup().await, "it").await.unwrap_err();
+ assert!(
+ err.to_string()
+ .contains("SHOW CREATE TABLE is not supported for
'iceberg-table'"),
+ "{err}"
+ );
+}
+
+#[tokio::test]
+async fn show_create_resolves_only_the_target_table() {
+ let (env, resolver) = ddl_env(&["it", "broken"]).await;
+ let err = env
.ctx
- .sql(&format!("SHOW CREATE TABLE {CATALOG}.{DB}.it"))
+ .sql("SELECT * FROM information_schema.views")
.await
- else {
- panic!("Paimon DDL would misrepresent an engine-served table");
- };
- let msg = err.to_string();
- assert!(msg.contains("iceberg-table"), "{msg}");
- assert!(msg.contains("cannot be read as a Paimon table"), "{msg}");
+ .unwrap()
+ .collect()
+ .await
+ .unwrap_err();
+ assert!(err.to_string().contains("ddl backend exploded"), "{err}");
+
+ let calls = resolver.calls.load(Ordering::Relaxed);
+ let batches = show_create(&env, "it").await.unwrap();
+ assert_eq!(show_create_definition(&batches), Some(engine_ddl(DB, "it")));
+ assert_eq!(resolver.calls.load(Ordering::Relaxed), calls + 1);
+}
+
+#[tokio::test]
+async fn show_create_checks_session_options_before_calling_the_engine() {
+ let (env, resolver) = ddl_env(&["it"]).await;
+ for (key, value, message) in [
+ ("query-auth.enabled", "true", "query-auth"),
+ ("scan.version", "20", "time travel"),
+ ("incremental-between", "1,5", "incremental-between"),
+ ] {
+ env.ctx
+ .sql(&format!("SET 'paimon.{key}' = '{value}'"))
+ .await
+ .unwrap();
+ let err = show_create(&env, "it").await.unwrap_err();
+ assert!(err.to_string().contains(message), "{key}: {err}");
+ env.ctx.sql(&format!("RESET 'paimon.{key}'")).await.unwrap();
+ }
+ assert_eq!(resolver.calls.load(Ordering::Relaxed), 0);
+}
+
+#[tokio::test]
+async fn show_create_follows_temporary_shadowing_and_keeps_paimon_ddl() {
+ // `broken` would fail a catalog scan.
+ let (env, resolver) = ddl_env(&["it", "broken"]).await;
+ let paimon_ddl = show_create_definition(&show_create(&env,
"pt").await.unwrap()).unwrap();
+ assert!(
+ paimon_ddl.contains("CREATE TABLE \"shared_db\".\"pt\""),
+ "{paimon_ddl}"
+ );
+ assert!(paimon_ddl.contains("\"id\" INT NOT NULL"), "{paimon_ddl}");
+
+ let schema = Arc::new(ArrowSchema::new(vec![Field::new(
+ "id",
+ DataType::Int32,
+ false,
+ )]));
+ env.ctx
+ .register_temp_table(
+ format!("{CATALOG}.{DB}.it"),
+ Arc::new(MemTable::try_new(schema, vec![vec![]]).unwrap()),
+ )
+ .unwrap();
+
+ let batches = show_create(&env, "it").await.unwrap();
+ assert_eq!(string_column_values(&batches, "table_name"), ["it"]);
+ assert_eq!(show_create_definition(&batches), None);
+ assert_eq!(resolver.calls.load(Ordering::Relaxed), 0);
}
#[tokio::test]