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 ffa4d0c feat(datafusion): support SHOW CREATE TABLE via
get_table_definition (#444)
ffa4d0c is described below
commit ffa4d0c61c799a132f3cdfc229a6591ebcdf98ce
Author: shyjsarah <[email protected]>
AuthorDate: Mon Jul 6 19:44:13 2026 +0800
feat(datafusion): support SHOW CREATE TABLE via get_table_definition (#444)
DataFusion 53 rewrites `SHOW CREATE TABLE` into a query against
`information_schema.views`, whose `definition` column is populated
by `TableProvider::get_table_definition()`. PaimonTableProvider did
not override this method, so the column came back empty for Paimon
tables.
Add a cached DDL string built from the table's identifier, schema
(fields, primary keys, partition keys, options), and a recursive
`data_type_to_sql` renderer covering all DataType variants. Override
`get_table_definition()` to return it.
---
crates/integrations/datafusion/src/sql_context.rs | 215 ++++++++--
crates/integrations/datafusion/src/table/mod.rs | 159 +++++++-
.../datafusion/tests/sql_context_tests.rs | 454 ++++++++++++++++++++-
3 files changed, 791 insertions(+), 37 deletions(-)
diff --git a/crates/integrations/datafusion/src/sql_context.rs
b/crates/integrations/datafusion/src/sql_context.rs
index c550eff..4b6adc5 100644
--- a/crates/integrations/datafusion/src/sql_context.rs
+++ b/crates/integrations/datafusion/src/sql_context.rs
@@ -49,20 +49,21 @@ use datafusion::error::{DataFusionError, Result as
DFResult};
use datafusion::execution::SessionStateBuilder;
use datafusion::prelude::{DataFrame, SessionContext};
use datafusion::sql::sqlparser::ast::{
- AlterTableOperation, ColumnDef, CreateTable, CreateTableOptions,
CreateView, Delete,
- Expr as SqlExpr, FromTable, Insert, Merge, ObjectName, ObjectType,
RenameTableNameKind, Reset,
- ResetStatement, Set, SqlOption, Statement, TableFactor, TableObject,
Truncate, Update,
- Value as SqlValue,
+ AlterTableOperation, BinaryLength, CharacterLength, ColumnDef,
CreateTable, CreateTableOptions,
+ CreateView, Delete, Expr as SqlExpr, FromTable, Insert, Merge, ObjectName,
ObjectType,
+ RenameTableNameKind, Reset, ResetStatement, Set, SqlOption, Statement,
TableFactor,
+ TableObject, Truncate, Update, Value as SqlValue,
};
use datafusion::sql::sqlparser::dialect::GenericDialect;
use datafusion::sql::sqlparser::parser::Parser;
use futures::StreamExt;
use paimon::catalog::{Catalog, Identifier};
use paimon::spec::{
- ArrayType as PaimonArrayType, BigIntType, BlobType, BooleanType, DataField
as PaimonDataField,
- DataType as PaimonDataType, DateType, Datum, DecimalType, DoubleType,
FloatType, IntType,
- LocalZonedTimestampType, MapType as PaimonMapType, RowType as
PaimonRowType, SchemaChange,
- SmallIntType, TimestampType, TinyIntType, VarBinaryType, VarCharType,
VariantType,
+ ArrayType as PaimonArrayType, BigIntType, BinaryType, BlobType,
BooleanType, CharType,
+ DataField as PaimonDataField, DataType as PaimonDataType, DateType, Datum,
DecimalType,
+ DoubleType, FloatType, IntType, LocalZonedTimestampType, MapType as
PaimonMapType,
+ RowType as PaimonRowType, SchemaChange, SmallIntType, TimestampType,
TinyIntType,
+ VarBinaryType, VarCharType, VariantType,
};
use crate::error::to_datafusion_error;
@@ -682,7 +683,7 @@ impl SQLContext {
let pk_cols: Vec<String> = pk
.columns
.iter()
- .map(|c| c.column.expr.to_string())
+ .map(|c| primary_key_column_name(&c.column.expr))
.collect();
builder = builder.primary_key(pk_cols);
}
@@ -1532,11 +1533,25 @@ fn parse_partition_column(token: &str) ->
DFResult<String> {
let first = trimmed.as_bytes()[0];
if first == b'"' || first == b'`' {
- let close = if first == b'"' { b'"' } else { b'`' };
- if let Some(end) = trimmed[1..].find(close as char) {
- let after_quote = trimmed[1 + end + 1..].trim();
- if after_quote.is_empty() {
- return Ok(trimmed[1..1 + end].to_string());
+ let mut value = String::new();
+ let mut end = None;
+ let mut chars = trimmed[1..].char_indices().peekable();
+ while let Some((idx, ch)) = chars.next() {
+ if ch == first as char {
+ if chars.peek().is_some_and(|(_, next)| *next == first as
char) {
+ value.push(ch);
+ chars.next();
+ } else {
+ end = Some(1 + idx + ch.len_utf8());
+ break;
+ }
+ } else {
+ value.push(ch);
+ }
+ }
+ if let Some(end) = end {
+ if trimmed[end..].trim().is_empty() {
+ return Ok(value);
}
}
return Err(DataFusionError::Plan(format!(
@@ -1555,6 +1570,38 @@ fn parse_partition_column(token: &str) ->
DFResult<String> {
}
}
+fn split_partition_columns(inner: &str) -> DFResult<Vec<&str>> {
+ let mut columns = Vec::new();
+ let mut start = 0;
+ let mut quote = None;
+ let mut chars = inner.char_indices().peekable();
+ while let Some((idx, ch)) = chars.next() {
+ match quote {
+ Some(q) if ch == q => {
+ if chars.peek().is_some_and(|(_, next)| *next == q) {
+ chars.next();
+ } else {
+ quote = None;
+ }
+ }
+ Some(_) => {}
+ None if ch == '"' || ch == '`' => quote = Some(ch),
+ None if ch == ',' => {
+ columns.push(&inner[start..idx]);
+ start = idx + ch.len_utf8();
+ }
+ None => {}
+ }
+ }
+ if quote.is_some() {
+ return Err(DataFusionError::Plan(
+ "Unterminated quoted identifier in PARTITIONED BY".to_string(),
+ ));
+ }
+ columns.push(&inner[start..]);
+ Ok(columns)
+}
+
/// Extract `PARTITIONED BY (col1, col2, ...)` from SQL before parsing.
///
/// Paimon only allows column references (no types) in PARTITIONED BY.
@@ -1578,17 +1625,28 @@ fn extract_partition_by(sql: &str) -> DFResult<(String,
Vec<String>)> {
let inner_start = paren_start + 1;
let mut depth = 1;
let mut paren_end = None;
- for (i, ch) in sql[inner_start..].char_indices() {
- match ch {
- '(' => depth += 1,
- ')' => {
+ let mut quote = None;
+ let mut chars = sql[inner_start..].char_indices().peekable();
+ while let Some((i, ch)) = chars.next() {
+ match quote {
+ Some(q) if ch == q => {
+ if chars.peek().is_some_and(|(_, next)| *next == q) {
+ chars.next();
+ } else {
+ quote = None;
+ }
+ }
+ Some(_) => {}
+ None if ch == '"' || ch == '`' => quote = Some(ch),
+ None if ch == '(' => depth += 1,
+ None if ch == ')' => {
depth -= 1;
if depth == 0 {
paren_end = Some(inner_start + i);
break;
}
}
- _ => {}
+ None => {}
}
}
let paren_end = paren_end.ok_or_else(|| {
@@ -1603,7 +1661,7 @@ fn extract_partition_by(sql: &str) -> DFResult<(String,
Vec<String>)> {
}
let mut partition_keys = Vec::new();
- for token in inner.split(',') {
+ for token in split_partition_columns(inner)? {
partition_keys.push(parse_partition_column(token)?);
}
@@ -1627,6 +1685,45 @@ fn column_def_to_paimon_type(col: &ColumnDef) ->
DFResult<PaimonDataType> {
sql_data_type_to_paimon_type(&col.data_type, column_def_nullable(col))
}
+fn primary_key_column_name(expr: &SqlExpr) -> String {
+ match expr {
+ SqlExpr::Identifier(ident) => ident.value.clone(),
+ _ => expr.to_string(),
+ }
+}
+
+fn character_length_or_default(
+ length: &Option<CharacterLength>,
+ default_length: u32,
+) -> DFResult<u32> {
+ match length {
+ Some(CharacterLength::IntegerLength { length, .. }) =>
(*length).try_into().map_err(|_| {
+ DataFusionError::Plan(format!("Character length {length} exceeds
supported range"))
+ }),
+ Some(CharacterLength::Max) => Ok(VarCharType::MAX_LENGTH),
+ None => Ok(default_length),
+ }
+}
+
+fn u64_length_or_default(length: Option<u64>, default_length: usize) ->
DFResult<usize> {
+ match length {
+ Some(length) => length.try_into().map_err(|_| {
+ DataFusionError::Plan(format!("Binary length {length} exceeds
supported range"))
+ }),
+ None => Ok(default_length),
+ }
+}
+
+fn binary_length_or_default(length: &Option<BinaryLength>, default_length:
u32) -> DFResult<u32> {
+ match length {
+ Some(BinaryLength::IntegerLength { length }) =>
(*length).try_into().map_err(|_| {
+ DataFusionError::Plan(format!("Binary length {length} exceeds
supported range"))
+ }),
+ Some(BinaryLength::Max) => Ok(VarBinaryType::MAX_LENGTH),
+ None => Ok(default_length),
+ }
+}
+
fn column_def_nullable(col: &ColumnDef) -> bool {
!col.options.iter().any(|opt| {
matches!(
@@ -1668,21 +1765,39 @@ fn sql_data_type_to_paimon_type(
SqlType::Double(_) | SqlType::DoublePrecision => {
Ok(PaimonDataType::Double(DoubleType::with_nullable(nullable)))
}
- SqlType::Varchar(_)
- | SqlType::CharVarying(_)
- | SqlType::Text
- | SqlType::String(_)
- | SqlType::Char(_)
- | SqlType::Character(_) => Ok(PaimonDataType::VarChar(
+ SqlType::Char(length) | SqlType::Character(length) =>
Ok(PaimonDataType::Char(
+ CharType::with_nullable(nullable,
character_length_or_default(length, 1)? as usize)
+ .map_err(to_datafusion_error)?,
+ )),
+ SqlType::Varchar(length)
+ | SqlType::Nvarchar(length)
+ | SqlType::CharVarying(length)
+ | SqlType::CharacterVarying(length) => Ok(PaimonDataType::VarChar(
+ VarCharType::with_nullable(
+ nullable,
+ character_length_or_default(length, VarCharType::MAX_LENGTH)?,
+ )
+ .map_err(to_datafusion_error)?,
+ )),
+ SqlType::Text | SqlType::String(_) => Ok(PaimonDataType::VarChar(
VarCharType::with_nullable(nullable, VarCharType::MAX_LENGTH)
.map_err(to_datafusion_error)?,
)),
- SqlType::Binary(_) | SqlType::Varbinary(_) | SqlType::Bytea => {
- Ok(PaimonDataType::VarBinary(
- VarBinaryType::try_new(nullable, VarBinaryType::MAX_LENGTH)
- .map_err(to_datafusion_error)?,
- ))
- }
+ SqlType::Binary(length) => Ok(PaimonDataType::Binary(
+ BinaryType::with_nullable(nullable, u64_length_or_default(*length,
1)? as usize)
+ .map_err(to_datafusion_error)?,
+ )),
+ SqlType::Varbinary(length) => Ok(PaimonDataType::VarBinary(
+ VarBinaryType::try_new(
+ nullable,
+ binary_length_or_default(length, VarBinaryType::MAX_LENGTH)?,
+ )
+ .map_err(to_datafusion_error)?,
+ )),
+ SqlType::Bytea => Ok(PaimonDataType::VarBinary(
+ VarBinaryType::try_new(nullable, VarBinaryType::MAX_LENGTH)
+ .map_err(to_datafusion_error)?,
+ )),
SqlType::Blob(_) =>
Ok(PaimonDataType::Blob(BlobType::with_nullable(nullable))),
SqlType::Custom(name, modifiers)
if name.to_string().eq_ignore_ascii_case("VARIANT") &&
modifiers.is_empty() =>
@@ -2822,7 +2937,7 @@ mod tests {
#[test]
fn test_sql_type_string_variants() {
- use datafusion::sql::sqlparser::ast::DataType as SqlType;
+ use datafusion::sql::sqlparser::ast::{CharacterLength, DataType as
SqlType};
for sql_type in [SqlType::Varchar(None), SqlType::Text,
SqlType::String(None)] {
assert_sql_type_to_paimon(
sql_type.clone(),
@@ -2831,17 +2946,39 @@ mod tests {
),
);
}
+ assert_sql_type_to_paimon(
+ SqlType::Char(Some(CharacterLength::IntegerLength {
+ length: 7,
+ unit: None,
+ })),
+ PaimonDataType::Char(CharType::with_nullable(true, 7).unwrap()),
+ );
+ assert_sql_type_to_paimon(
+ SqlType::Varchar(Some(CharacterLength::IntegerLength {
+ length: 42,
+ unit: None,
+ })),
+ PaimonDataType::VarChar(VarCharType::with_nullable(true,
42).unwrap()),
+ );
}
#[test]
fn test_sql_type_binary() {
- use datafusion::sql::sqlparser::ast::DataType as SqlType;
+ use datafusion::sql::sqlparser::ast::{BinaryLength, DataType as
SqlType};
assert_sql_type_to_paimon(
SqlType::Bytea,
PaimonDataType::VarBinary(
VarBinaryType::try_new(true,
VarBinaryType::MAX_LENGTH).unwrap(),
),
);
+ assert_sql_type_to_paimon(
+ SqlType::Binary(Some(8)),
+ PaimonDataType::Binary(BinaryType::with_nullable(true,
8).unwrap()),
+ );
+ assert_sql_type_to_paimon(
+ SqlType::Varbinary(Some(BinaryLength::IntegerLength { length: 32
})),
+ PaimonDataType::VarBinary(VarBinaryType::try_new(true,
32).unwrap()),
+ );
}
#[test]
@@ -3558,6 +3695,16 @@ mod tests {
assert_eq!(keys, vec!["order"]);
}
+ #[test]
+ fn
test_extract_partition_by_double_quoted_identifier_with_escaped_quote_and_comma()
{
+ let (_, keys) = extract_partition_by(
+ "CREATE TABLE t (\"a\"\"b,c\" INT, `d``e,f` INT) \
+ PARTITIONED BY (\"a\"\"b,c\", `d``e,f`)",
+ )
+ .unwrap();
+ assert_eq!(keys, vec!["a\"b,c", "d`e,f"]);
+ }
+
#[test]
fn test_extract_partition_by_backtick_quoted_identifier() {
let (_, keys) =
diff --git a/crates/integrations/datafusion/src/table/mod.rs
b/crates/integrations/datafusion/src/table/mod.rs
index c0a9457..311adf5 100644
--- a/crates/integrations/datafusion/src/table/mod.rs
+++ b/crates/integrations/datafusion/src/table/mod.rs
@@ -17,6 +17,7 @@
//! Paimon table provider for DataFusion.
+use std::fmt::Write as _;
use std::sync::Arc;
use async_trait::async_trait;
@@ -24,7 +25,7 @@ use datafusion::arrow::datatypes::{Field, Schema, SchemaRef
as ArrowSchemaRef};
use datafusion::catalog::Session;
use datafusion::datasource::sink::DataSinkExec;
use datafusion::datasource::{TableProvider, TableType};
-use datafusion::error::Result as DFResult;
+use datafusion::error::{DataFusionError, Result as DFResult};
use datafusion::logical_expr::dml::InsertOp;
use datafusion::logical_expr::{Expr, TableProviderFilterPushDown};
use datafusion::physical_plan::ExecutionPlan;
@@ -68,6 +69,7 @@ pub(crate) fn datafusion_read_fields(table: &Table) ->
Vec<DataField> {
pub struct PaimonTableProvider {
table: Table,
schema: ArrowSchemaRef,
+ table_definition: String,
}
impl PaimonTableProvider {
@@ -78,7 +80,12 @@ impl PaimonTableProvider {
let fields = datafusion_read_fields(&table);
let schema =
paimon::arrow::build_target_arrow_schema(&fields).map_err(to_datafusion_error)?;
- Ok(Self { table, schema })
+ let table_definition = build_table_definition(&table)?;
+ Ok(Self {
+ table,
+ schema,
+ table_definition,
+ })
}
pub fn try_new_with_blob_reader_registry(
@@ -95,6 +102,150 @@ impl PaimonTableProvider {
}
}
+/// Build a `CREATE TABLE` DDL string for a Paimon table.
+///
+/// Mirrors the syntax accepted by `SQLContext::handle_create_table`:
+/// `CREATE TABLE <db>.<table> (<col> <type>, ..., PRIMARY KEY (...))
[PARTITIONED BY (...)] [WITH ('k'='v', ...)]`.
+fn build_table_definition(table: &Table) -> DFResult<String> {
+ let identifier = table.identifier();
+ let schema = table.schema();
+ let mut ddl = String::new();
+ let _ = write!(
+ ddl,
+ "CREATE TABLE {}.{} (",
+ quote_identifier(identifier.database()),
+ quote_identifier(identifier.object())
+ );
+
+ for (i, field) in schema.fields().iter().enumerate() {
+ if i > 0 {
+ ddl.push_str(", ");
+ }
+ // `NOT NULL` is a column constraint; render it here at the column
+ // level rather than inside nested type arguments (see
`data_type_to_sql`).
+ let ty = data_type_to_sql(field.data_type())?;
+ if field.data_type().is_nullable() {
+ let _ = write!(ddl, "{} {}", quote_identifier(field.name()), ty);
+ } else {
+ let _ = write!(ddl, "{} {} NOT NULL",
quote_identifier(field.name()), ty);
+ }
+ }
+
+ let pks = schema.primary_keys();
+ if !pks.is_empty() {
+ ddl.push_str(", PRIMARY KEY (");
+ for (i, pk) in pks.iter().enumerate() {
+ if i > 0 {
+ ddl.push_str(", ");
+ }
+ let _ = write!(ddl, "{}", quote_identifier(pk));
+ }
+ ddl.push(')');
+ }
+ ddl.push(')');
+
+ let partition_keys = schema.partition_keys();
+ if !partition_keys.is_empty() {
+ ddl.push_str(" PARTITIONED BY (");
+ for (i, pk) in partition_keys.iter().enumerate() {
+ if i > 0 {
+ ddl.push_str(", ");
+ }
+ let _ = write!(ddl, "{}", quote_identifier(pk));
+ }
+ ddl.push(')');
+ }
+
+ let mut options: Vec<_> = schema.options().iter().collect();
+ options.sort_by_key(|(left, _)| *left);
+ if !options.is_empty() {
+ ddl.push_str(" WITH (");
+ for (i, (k, v)) in options.iter().enumerate() {
+ if i > 0 {
+ ddl.push_str(", ");
+ }
+ let _ = write!(
+ ddl,
+ "{} = {}",
+ quote_string_literal(k),
+ quote_string_literal(v)
+ );
+ }
+ ddl.push(')');
+ }
+
+ Ok(ddl)
+}
+
+fn quote_identifier(identifier: &str) -> String {
+ format!("\"{}\"", identifier.replace('"', "\"\""))
+}
+
+fn quote_string_literal(text: &str) -> String {
+ format!("'{}'", text.replace('\'', "''"))
+}
+
+/// Render a Paimon [`DataType`] as a SQL type string matching the syntax
+/// accepted by paimon-rust's `CREATE TABLE` parser.
+///
+/// `NOT NULL` is a column constraint, not a type modifier — it is only valid
+/// at the top of a column definition, not nested inside `MAP`, `ARRAY`, or
+/// `STRUCT` arguments. Callers that render a column should append `NOT NULL`
+/// themselves when the field is non-nullable; recursive calls below must not.
+fn data_type_to_sql(data_type: &DataType) -> DFResult<String> {
+ match data_type {
+ DataType::Boolean(_) => Ok("BOOLEAN".to_string()),
+ DataType::TinyInt(_) => Ok("TINYINT".to_string()),
+ DataType::SmallInt(_) => Ok("SMALLINT".to_string()),
+ DataType::Int(_) => Ok("INT".to_string()),
+ DataType::BigInt(_) => Ok("BIGINT".to_string()),
+ DataType::Decimal(t) => Ok(format!("DECIMAL({}, {})", t.precision(),
t.scale())),
+ DataType::Double(_) => Ok("DOUBLE".to_string()),
+ DataType::Float(_) => Ok("FLOAT".to_string()),
+ DataType::Binary(t) => Ok(format!("BINARY({})", t.length())),
+ DataType::VarBinary(t) => Ok(format!("VARBINARY({})", t.length())),
+ DataType::Blob(_) => Ok("BLOB".to_string()),
+ DataType::Char(t) => Ok(format!("CHAR({})", t.length())),
+ DataType::VarChar(t) => Ok(format!("VARCHAR({})", t.length())),
+ DataType::Date(_) => Ok("DATE".to_string()),
+ DataType::Time(_) => Err(unsupported_show_create_table_type("TIME")),
+ DataType::Timestamp(t) => Ok(format!("TIMESTAMP({})", t.precision())),
+ DataType::Variant(_) => Ok("VARIANT".to_string()),
+ DataType::LocalZonedTimestamp(t) => {
+ Ok(format!("TIMESTAMP({}) WITH TIME ZONE", t.precision()))
+ }
+ DataType::Array(t) => Ok(format!("ARRAY<{}>",
data_type_to_sql(t.element_type())?)),
+ DataType::Map(t) => Ok(format!(
+ "MAP({}, {})",
+ data_type_to_sql(t.key_type())?,
+ data_type_to_sql(t.value_type())?
+ )),
+ DataType::Multiset(_) =>
Err(unsupported_show_create_table_type("MULTISET")),
+ DataType::Row(t) => {
+ let inner: Vec<String> = t
+ .fields()
+ .iter()
+ .map(|f| {
+ let ty = data_type_to_sql(f.data_type())?;
+ if f.name().is_empty() {
+ Ok(ty)
+ } else {
+ Ok(format!("{} {}", quote_identifier(f.name()), ty))
+ }
+ })
+ .collect::<DFResult<_>>()?;
+ Ok(format!("STRUCT<{}>", inner.join(", ")))
+ }
+ DataType::Vector(_) =>
Err(unsupported_show_create_table_type("VECTOR")),
+ }
+}
+
+fn unsupported_show_create_table_type(type_name: &str) -> DataFusionError {
+ DataFusionError::NotImplemented(format!(
+ "SHOW CREATE TABLE does not support {type_name} columns because
paimon-rust cannot round-trip this type in CREATE TABLE"
+ ))
+}
+
/// Distribute `items` into `num_buckets` groups using round-robin assignment.
pub(crate) fn bucket_round_robin<T>(items: Vec<T>, num_buckets: usize) ->
Vec<Vec<T>> {
let mut buckets: Vec<Vec<T>> = (0..num_buckets).map(|_|
Vec::new()).collect();
@@ -170,6 +321,10 @@ impl TableProvider for PaimonTableProvider {
TableType::Base
}
+ fn get_table_definition(&self) -> Option<&str> {
+ Some(&self.table_definition)
+ }
+
async fn scan(
&self,
state: &dyn Session,
diff --git a/crates/integrations/datafusion/tests/sql_context_tests.rs
b/crates/integrations/datafusion/tests/sql_context_tests.rs
index d4f0c1d..14e6ae4 100644
--- a/crates/integrations/datafusion/tests/sql_context_tests.rs
+++ b/crates/integrations/datafusion/tests/sql_context_tests.rs
@@ -22,7 +22,11 @@ use std::sync::Arc;
use datafusion::catalog::CatalogProvider;
use datafusion::datasource::MemTable;
use paimon::catalog::Identifier;
-use paimon::spec::{ArrayType, BlobType, DataType, IntType, MapType,
VarCharType};
+use paimon::spec::{
+ ArrayType, BinaryType, BlobType, CharType, DataType, FloatType, IntType,
+ LocalZonedTimestampType, MapType, MultisetType, TimeType, VarBinaryType,
VarCharType,
+ VectorType,
+};
use paimon::{Catalog, CatalogOptions, FileSystemCatalog, Options};
use paimon_datafusion::{PaimonCatalogProvider, SQLContext};
use tempfile::TempDir;
@@ -1262,3 +1266,451 @@ async fn
test_multiple_temporary_views_in_same_database() {
.sum::<usize>();
assert_eq!(rows2, 3);
}
+
+// ======================= SHOW CREATE TABLE =======================
+
+/// Collect the `definition` column from `SHOW CREATE TABLE` output as a
String.
+async fn collect_definition(sql_context: &SQLContext, table_ref: &str) ->
String {
+ let rows = sql_context
+ .sql(&format!("SHOW CREATE TABLE {}", table_ref))
+ .await
+ .expect("SHOW CREATE TABLE should plan")
+ .collect()
+ .await
+ .expect("SHOW CREATE TABLE should execute");
+ assert_eq!(
+ rows.len(),
+ 1,
+ "SHOW CREATE TABLE should return exactly one row"
+ );
+ let row = &rows[0];
+ assert_eq!(
+ row.num_rows(),
+ 1,
+ "SHOW CREATE TABLE should return exactly one row"
+ );
+ let val = row.column(3); // definition is the 4th column
+ let def = val
+ .as_any()
+ .downcast_ref::<datafusion::arrow::array::StringArray>()
+ .expect("definition column should be a StringArray")
+ .value(0);
+ def.to_string()
+}
+
+#[tokio::test]
+async fn test_show_create_table_simple() {
+ let (_tmp, catalog) = create_test_env();
+ let sql_context = create_sql_context(catalog).await;
+
+ sql_context
+ .sql("CREATE SCHEMA paimon.test_db")
+ .await
+ .expect("CREATE SCHEMA should succeed");
+ sql_context
+ .sql("CREATE TABLE paimon.test_db.t (id INT, name VARCHAR(100))")
+ .await
+ .expect("CREATE TABLE should succeed");
+
+ let definition = collect_definition(&sql_context,
"paimon.test_db.t").await;
+ assert!(
+ definition.contains("CREATE TABLE \"test_db\".\"t\""),
+ "definition should start with CREATE TABLE \"test_db\".\"t\", got:
{definition}"
+ );
+ assert!(
+ definition.contains("\"id\" INT"),
+ "definition should contain `\"id\" INT`, got: {definition}"
+ );
+ assert!(
+ definition.contains("\"name\" VARCHAR("),
+ "definition should contain `\"name\" VARCHAR(...)`, got: {definition}"
+ );
+}
+
+#[tokio::test]
+async fn test_show_create_table_with_primary_key() {
+ let (_tmp, catalog) = create_test_env();
+ let sql_context = create_sql_context(catalog).await;
+
+ sql_context
+ .sql("CREATE SCHEMA paimon.test_db")
+ .await
+ .expect("CREATE SCHEMA should succeed");
+ sql_context
+ .sql("CREATE TABLE paimon.test_db.t (id INT NOT NULL, name VARCHAR,
PRIMARY KEY (id))")
+ .await
+ .expect("CREATE TABLE should succeed");
+
+ let definition = collect_definition(&sql_context,
"paimon.test_db.t").await;
+ assert!(
+ definition.contains("PRIMARY KEY (\"id\")"),
+ "definition should contain PRIMARY KEY (\"id\"), got: {definition}"
+ );
+}
+
+#[tokio::test]
+async fn test_show_create_table_with_partition_and_options() {
+ let (_tmp, catalog) = create_test_env();
+ let sql_context = create_sql_context(catalog).await;
+
+ sql_context
+ .sql("CREATE SCHEMA paimon.test_db")
+ .await
+ .expect("CREATE SCHEMA should succeed");
+ sql_context
+ .sql(
+ "CREATE TABLE paimon.test_db.t (id INT, name VARCHAR, pt INT) \
+ PARTITIONED BY (pt) WITH ('bucket' = '4', 'file.format' =
'parquet')",
+ )
+ .await
+ .expect("CREATE TABLE should succeed");
+
+ let definition = collect_definition(&sql_context,
"paimon.test_db.t").await;
+ assert!(
+ definition.contains("PARTITIONED BY (\"pt\")"),
+ "definition should contain PARTITIONED BY (\"pt\"), got: {definition}"
+ );
+ assert!(
+ definition.contains("'bucket' = '4'"),
+ "definition should contain bucket option, got: {definition}"
+ );
+ assert!(
+ definition.contains("'file.format' = 'parquet'"),
+ "definition should contain file.format option, got: {definition}"
+ );
+}
+
+#[tokio::test]
+async fn test_show_create_table_various_types() {
+ let (_tmp, catalog) = create_test_env();
+ let sql_context = create_sql_context(catalog).await;
+
+ sql_context
+ .sql("CREATE SCHEMA paimon.test_db")
+ .await
+ .expect("CREATE SCHEMA should succeed");
+ sql_context
+ .sql(
+ "CREATE TABLE paimon.test_db.t (\
+ a BOOLEAN, \
+ b TINYINT, \
+ c SMALLINT, \
+ d BIGINT, \
+ e DECIMAL(10, 2), \
+ f DOUBLE, \
+ g FLOAT, \
+ h DATE, \
+ i TIMESTAMP(3), \
+ j BLOB) \
+ WITH ('data-evolution.enabled' = 'true')",
+ )
+ .await
+ .expect("CREATE TABLE should succeed");
+
+ let definition = collect_definition(&sql_context,
"paimon.test_db.t").await;
+ for needle in [
+ "\"a\" BOOLEAN",
+ "\"b\" TINYINT",
+ "\"c\" SMALLINT",
+ "\"d\" BIGINT",
+ "\"e\" DECIMAL(10, 2)",
+ "\"f\" DOUBLE",
+ "\"g\" FLOAT",
+ "\"h\" DATE",
+ "\"i\" TIMESTAMP(3)",
+ "\"j\" BLOB",
+ ] {
+ assert!(
+ definition.contains(needle),
+ "definition should contain `{needle}`, got: {definition}"
+ );
+ }
+}
+
+/// Assert that two `TableSchema`s are equivalent for round-trip purposes:
+/// same fields (id, name, type), same primary keys, same partition keys.
+///
+/// We do not compare `options` because the CREATE TABLE path may inject
+/// catalog defaults (e.g. `bucket`) that the user did not specify; the
+/// schema fields and key columns are what the DDL must preserve.
+fn assert_schema_equivalent(left: &paimon::spec::TableSchema, right:
&paimon::spec::TableSchema) {
+ assert_eq!(
+ left.fields().len(),
+ right.fields().len(),
+ "field count mismatch\nleft (original): {:?}\nright (recreated):
{:?}",
+ left.fields(),
+ right.fields()
+ );
+ for (lf, rf) in left.fields().iter().zip(right.fields().iter()) {
+ assert_eq!(
+ lf.id(),
+ rf.id(),
+ "field id mismatch for `{}`: {} vs {}",
+ lf.name(),
+ lf.id(),
+ rf.id()
+ );
+ assert_eq!(
+ lf.name(),
+ rf.name(),
+ "field name mismatch: `{}` vs `{}`",
+ lf.name(),
+ rf.name()
+ );
+ assert_eq!(
+ lf.data_type(),
+ rf.data_type(),
+ "field type mismatch for `{}`: {:?} vs {:?}",
+ lf.name(),
+ lf.data_type(),
+ rf.data_type()
+ );
+ }
+ assert_eq!(
+ left.primary_keys(),
+ right.primary_keys(),
+ "primary keys mismatch: {:?} vs {:?}",
+ left.primary_keys(),
+ right.primary_keys()
+ );
+ assert_eq!(
+ left.partition_keys(),
+ right.partition_keys(),
+ "partition keys mismatch: {:?} vs {:?}",
+ left.partition_keys(),
+ right.partition_keys()
+ );
+}
+
+/// Round-trip test: the DDL returned by `SHOW CREATE TABLE` must be executable
+/// by paimon-rust's own `CREATE TABLE` parser and reproduce an equivalent
+/// schema (fields, primary keys, partition keys).
+///
+/// This guards against regressions where the rendered DDL drifts away from
+/// what the parser accepts (e.g. `ROW<name: type>` vs `STRUCT<name type>`,
+/// `MAP<k: v>` vs `MAP(k, v)`, or dropped `NOT NULL`).
+#[tokio::test]
+async fn test_show_create_table_round_trip() {
+ let (_tmp, catalog) = create_test_env();
+ let sql_context = create_sql_context(catalog.clone()).await;
+
+ sql_context
+ .sql("CREATE SCHEMA paimon.test_db")
+ .await
+ .expect("CREATE SCHEMA should succeed");
+ sql_context
+ .sql(
+ "CREATE TABLE paimon.test_db.t1 (\
+ id INT NOT NULL, \
+ name VARCHAR NOT NULL, \
+ tags ARRAY<STRING>, \
+ props MAP(INT, VARCHAR), \
+ addr STRUCT<city VARCHAR, zip VARCHAR>, \
+ meta STRUCT<kv MAP(STRING, STRING), tags ARRAY<INT>>, \
+ PRIMARY KEY (id)) \
+ PARTITIONED BY (name) \
+ WITH ('bucket' = '2', 'file.format' = 'parquet')",
+ )
+ .await
+ .expect("CREATE TABLE should succeed");
+
+ let identifier = Identifier::new("test_db", "t1");
+ let original = catalog.get_table(&identifier).await.expect("table exists");
+ let original_schema = original.schema().clone();
+
+ let definition = collect_definition(&sql_context,
"paimon.test_db.t1").await;
+ // The DDL is rendered as `CREATE TABLE test_db.t1 (...)` without the
+ // catalog prefix; paimon is the default catalog so this resolves back
+ // to the same catalog/database.
+ assert!(
+ definition.starts_with("CREATE TABLE \"test_db\".\"t1\""),
+ "definition should start with `CREATE TABLE \"test_db\".\"t1\"`, got:
{definition}"
+ );
+
+ catalog
+ .drop_table(&identifier, false)
+ .await
+ .expect("drop should succeed");
+
+ sql_context
+ .sql(&definition)
+ .await
+ .expect("DDL should re-execute")
+ .collect()
+ .await
+ .expect("DDL should execute");
+
+ let recreated = catalog
+ .get_table(&identifier)
+ .await
+ .expect("recreated table exists");
+ assert_schema_equivalent(&original_schema, recreated.schema());
+}
+
+#[tokio::test]
+async fn
test_show_create_table_round_trip_with_quoted_identifiers_and_options() {
+ let (_tmp, catalog) = create_test_env();
+ let sql_context = create_sql_context(catalog.clone()).await;
+
+ sql_context
+ .sql("CREATE SCHEMA paimon.test_db")
+ .await
+ .expect("CREATE SCHEMA should succeed");
+
+ let identifier = Identifier::new("test_db", "select");
+ let schema = paimon::spec::Schema::builder()
+ .column("group", DataType::Int(IntType::with_nullable(false)))
+ .column("order", DataType::Int(IntType::with_nullable(false)))
+ .column("a\"b,c", DataType::Int(IntType::new()))
+ .column(
+ "nested",
+ DataType::Row(paimon::spec::RowType::new(vec![
+ paimon::spec::DataField::new(
+ 0,
+ "from".to_string(),
+
DataType::VarChar(VarCharType::new(VarCharType::MAX_LENGTH).unwrap()),
+ ),
+ ])),
+ )
+ .column(
+ "ts_ltz",
+
DataType::LocalZonedTimestamp(LocalZonedTimestampType::new(3).unwrap()),
+ )
+ .column("fixed_char", DataType::Char(CharType::new(7).unwrap()))
+ .column(
+ "bounded_varchar",
+ DataType::VarChar(VarCharType::new(42).unwrap()),
+ )
+ .column(
+ "fixed_binary",
+ DataType::Binary(BinaryType::new(8).unwrap()),
+ )
+ .column(
+ "bounded_varbinary",
+ DataType::VarBinary(VarBinaryType::try_new(true, 32).unwrap()),
+ )
+ .primary_key(vec!["group", "order"])
+ .partition_keys(vec!["a\"b,c"])
+ .option("comment", "Bob's table")
+ .build()
+ .expect("schema should build");
+ catalog
+ .create_table(&identifier, schema, false)
+ .await
+ .expect("table should be created");
+ let original = catalog.get_table(&identifier).await.expect("table exists");
+ let original_schema = original.schema().clone();
+
+ let definition = collect_definition(&sql_context,
"paimon.test_db.\"select\"").await;
+ assert!(
+ definition.starts_with("CREATE TABLE \"test_db\".\"select\""),
+ "definition should quote table identifiers, got: {definition}"
+ );
+ assert!(
+ definition.contains("\"order\" INT NOT NULL"),
+ "definition should quote column identifiers, got: {definition}"
+ );
+ assert!(
+ definition.contains("PRIMARY KEY (\"group\", \"order\")"),
+ "definition should quote primary key identifiers, got: {definition}"
+ );
+ assert!(
+ definition.contains("\"a\"\"b,c\" INT"),
+ "definition should escape quoted column identifiers, got: {definition}"
+ );
+ assert!(
+ definition.contains("PARTITIONED BY (\"a\"\"b,c\")"),
+ "definition should escape quoted partition identifiers, got:
{definition}"
+ );
+ assert!(
+ definition.contains("STRUCT<\"from\" VARCHAR"),
+ "definition should quote nested struct field identifiers, got:
{definition}"
+ );
+ assert!(
+ definition.contains("'comment' = 'Bob''s table'"),
+ "definition should escape string literals, got: {definition}"
+ );
+ assert!(
+ definition.contains("\"ts_ltz\" TIMESTAMP(3) WITH TIME ZONE"),
+ "definition should render TIMESTAMP WITH TIME ZONE for LTZ, got:
{definition}"
+ );
+ assert!(
+ definition.contains("\"fixed_char\" CHAR(7)"),
+ "definition should preserve CHAR length, got: {definition}"
+ );
+ assert!(
+ definition.contains("\"bounded_varchar\" VARCHAR(42)"),
+ "definition should preserve VARCHAR length, got: {definition}"
+ );
+ assert!(
+ definition.contains("\"fixed_binary\" BINARY(8)"),
+ "definition should preserve BINARY length, got: {definition}"
+ );
+ assert!(
+ definition.contains("\"bounded_varbinary\" VARBINARY(32)"),
+ "definition should preserve VARBINARY length, got: {definition}"
+ );
+
+ catalog
+ .drop_table(&identifier, false)
+ .await
+ .expect("drop should succeed");
+
+ sql_context
+ .sql(&definition)
+ .await
+ .expect("DDL should re-execute")
+ .collect()
+ .await
+ .expect("DDL should execute");
+
+ let recreated = catalog
+ .get_table(&identifier)
+ .await
+ .expect("recreated table exists");
+ assert_schema_equivalent(&original_schema, recreated.schema());
+}
+
+#[tokio::test]
+async fn test_show_create_table_rejects_non_round_trippable_types() {
+ let (_tmp, catalog) = create_test_env();
+ let sql_context = create_sql_context(catalog.clone()).await;
+
+ sql_context
+ .sql("CREATE SCHEMA paimon.test_db")
+ .await
+ .expect("CREATE SCHEMA should succeed");
+
+ for (table_name, data_type, type_name) in [
+ ("time_t", DataType::Time(TimeType::new(3).unwrap()), "TIME"),
+ (
+ "multiset_t",
+
DataType::Multiset(MultisetType::new(DataType::Int(IntType::new()))),
+ "MULTISET",
+ ),
+ (
+ "vector_t",
+ DataType::Vector(VectorType::new(4,
DataType::Float(FloatType::new())).unwrap()),
+ "VECTOR",
+ ),
+ ] {
+ let identifier = Identifier::new("test_db", table_name);
+ let schema = paimon::spec::Schema::builder()
+ .column("unsupported_col", data_type)
+ .build()
+ .expect("schema should build");
+ catalog
+ .create_table(&identifier, schema, false)
+ .await
+ .expect("table should be created");
+
+ let err = sql_context
+ .sql(&format!("SHOW CREATE TABLE paimon.test_db.{table_name}"))
+ .await
+ .expect_err("SHOW CREATE TABLE should reject unsupported type");
+ assert!(
+ err.to_string().contains(type_name),
+ "error should mention {type_name}, got: {err}"
+ );
+ }
+}