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 cd5d36c4 feat(datafusion): add SQL options to SQLContext (#996)
cd5d36c4 is described below

commit cd5d36c4f7a7d35aa5054adfc84a6b0368c36648
Author: shyjsarah <[email protected]>
AuthorDate: Fri Oct 2 10:06:43 2026 +0800

    feat(datafusion): add SQL options to SQLContext (#996)
---
 crates/integrations/datafusion/src/sql_context.rs  | 77 +++++++++++++++-------
 .../datafusion/tests/sql_context_tests.rs          | 54 +++++++++++++++
 2 files changed, 108 insertions(+), 23 deletions(-)

diff --git a/crates/integrations/datafusion/src/sql_context.rs 
b/crates/integrations/datafusion/src/sql_context.rs
index d13bb93b..0fb1213f 100644
--- a/crates/integrations/datafusion/src/sql_context.rs
+++ b/crates/integrations/datafusion/src/sql_context.rs
@@ -58,6 +58,7 @@ use datafusion::common::tree_node::{TreeNode, 
TreeNodeRecursion};
 use datafusion::common::TableReference;
 use datafusion::datasource::{MemTable, TableProvider};
 use datafusion::error::{DataFusionError, Result as DFResult};
+use datafusion::execution::context::SQLOptions;
 use datafusion::execution::runtime_env::RuntimeEnv;
 use datafusion::execution::SessionStateBuilder;
 use datafusion::logical_expr::{Expr as LogicalExpr, LogicalPlan, Volatility};
@@ -447,6 +448,11 @@ impl SQLContext {
     /// Execute a SQL statement. Paimon database and table extensions are 
handled
     /// directly; everything else is delegated to DataFusion.
     pub async fn sql(&self, sql: &str) -> DFResult<DataFrame> {
+        self.sql_with_options(sql, SQLOptions::new()).await
+    }
+
+    /// Execute a SQL statement with options for statements delegated to 
DataFusion.
+    pub async fn sql_with_options(&self, sql: &str, options: SQLOptions) -> 
DFResult<DataFrame> {
         let is_create_table = looks_like_create_table(sql);
         let enable_ident_normalization = self.ctx.enable_ident_normalization();
         let (rewritten_sql, partition_keys) = if is_create_table {
@@ -456,7 +462,7 @@ impl SQLContext {
         };
         if contains_time_travel_keyword(&rewritten_sql) {
             // Time-travel queries are not DDL; skip our own parsing and 
handle directly.
-            return self.handle_time_travel_query(&rewritten_sql).await;
+            return self.handle_time_travel_query(&rewritten_sql, 
options).await;
         }
         if let Some(show_partitions) =
             crate::format_partition_ddl::parse_show_partitions(&rewritten_sql)?
@@ -522,7 +528,7 @@ impl SQLContext {
             Statement::Use(Use::Object(name)) => 
self.handle_use_database(name).await,
             Statement::CreateTable(create_table) => {
                 if create_table.temporary {
-                    self.handle_create_temp_table(create_table).await
+                    self.handle_create_temp_table(create_table, options).await
                 } else {
                     let (catalog, _catalog_name, _) =
                         self.resolve_catalog_and_table(&create_table.name)?;
@@ -538,7 +544,7 @@ impl SQLContext {
             Statement::ShowCreate {
                 obj_type: ShowCreateObject::Table,
                 obj_name,
-            } => self.handle_show_create_table(sql, obj_name).await,
+            } => self.handle_show_create_table(sql, obj_name, options).await,
             Statement::AlterTable(alter_table) => {
                 if alter_table.location.is_some()
                     && alter_table.operations.iter().any(|operation| {
@@ -572,7 +578,7 @@ impl SQLContext {
                 if insert.overwrite
                     && insert.partitioned.as_ref().is_some_and(|p| 
!p.is_empty()) =>
             {
-                self.handle_insert_overwrite_partition(insert, 
enable_ident_normalization)
+                self.handle_insert_overwrite_partition(insert, 
enable_ident_normalization, options)
                     .await
             }
             Statement::Set(Set::SingleAssignment {
@@ -596,7 +602,7 @@ impl SQLContext {
                         .insert(paimon_key.to_string(), value);
                     return ok_result(&self.ctx);
                 }
-                self.ctx.sql(sql).await
+                self.ctx.sql_with_options(sql, options).await
             }
             Statement::Reset(ResetStatement {
                 reset: Reset::ConfigurationParameter(name),
@@ -607,7 +613,7 @@ impl SQLContext {
                     self.dynamic_options.write().unwrap().remove(paimon_key);
                     return ok_result(&self.ctx);
                 }
-                self.ctx.sql(sql).await
+                self.ctx.sql_with_options(sql, options).await
             }
             Statement::Truncate(truncate) => {
                 self.handle_truncate_table(truncate, 
enable_ident_normalization)
@@ -625,15 +631,15 @@ impl SQLContext {
             Statement::CreateView(create_view) => {
                 if create_view.temporary {
                     // Temporary views are always handled by us (Paimon 
catalog temp storage)
-                    self.handle_create_view(create_view).await
+                    self.handle_create_view(create_view, options).await
                 } else {
                     // Non-temporary views: only intercept if the target 
catalog is Paimon
                     let view_name = create_view.name.to_string();
                     let table_ref: TableReference = view_name.as_str().into();
                     if self.is_paimon_catalog_ref(&table_ref) {
-                        self.handle_create_view(create_view).await
+                        self.handle_create_view(create_view, options).await
                     } else {
-                        self.ctx.sql(sql).await
+                        self.ctx.sql_with_options(sql, options).await
                     }
                 }
             }
@@ -641,7 +647,7 @@ impl SQLContext {
                 if self.is_paimon_function_name(&create_function.name) {
                     self.handle_create_function(create_function).await
                 } else {
-                    self.ctx.sql(sql).await
+                    self.ctx.sql_with_options(sql, options).await
                 }
             }
             Statement::Drop {
@@ -686,7 +692,7 @@ impl SQLContext {
                             self.resolve_catalog_and_table(&names[0])?;
                         self.handle_drop_table(&catalog, names, 
*if_exists).await
                     } else {
-                        self.ctx.sql(sql).await
+                        self.ctx.sql_with_options(sql, options).await
                     }
                 } else {
                     let targets_paimon_catalog = names.iter().any(|name| {
@@ -694,7 +700,7 @@ impl SQLContext {
                         self.is_paimon_catalog_ref(&table_ref)
                     });
                     if !targets_paimon_catalog {
-                        return self.ctx.sql(sql).await;
+                        return self.ctx.sql_with_options(sql, options).await;
                     }
                     let [name] = names.as_slice() else {
                         return Err(DataFusionError::Plan(
@@ -752,9 +758,11 @@ impl SQLContext {
                     &current_database,
                 )
                 .await?;
-                self.ctx.sql(&expanded.to_string()).await
+                self.ctx
+                    .sql_with_options(&expanded.to_string(), options)
+                    .await
             }
-            _ => self.ctx.sql(sql).await,
+            _ => self.ctx.sql_with_options(sql, options).await,
         }
     }
 
@@ -766,7 +774,11 @@ impl SQLContext {
     /// 3. For each table, create a `PaimonTableProvider` with the appropriate 
scan options
     ///    (merged with session-scoped dynamic options)
     /// 4. Register them as UUID-named temp tables, execute the rewritten SQL, 
then deregister
-    async fn handle_time_travel_query(&self, sql: &str) -> DFResult<DataFrame> 
{
+    async fn handle_time_travel_query(
+        &self,
+        sql: &str,
+        sql_options: SQLOptions,
+    ) -> DFResult<DataFrame> {
         use crate::table::PaimonTableProvider;
         use paimon::spec::{SCAN_TIMESTAMP_MILLIS_OPTION, SCAN_VERSION_OPTION};
 
@@ -897,7 +909,7 @@ impl SQLContext {
             &current_database,
         )
         .await?;
-        self.ctx.sql(&expanded).await
+        self.ctx.sql_with_options(&expanded, sql_options).await
     }
 
     /// Parse a timestamp string to milliseconds since epoch (using local 
timezone).
@@ -1043,7 +1055,11 @@ impl SQLContext {
         ok_result(&self.ctx)
     }
 
-    async fn handle_create_temp_table(&self, ct: &CreateTable) -> 
DFResult<DataFrame> {
+    async fn handle_create_temp_table(
+        &self,
+        ct: &CreateTable,
+        sql_options: SQLOptions,
+    ) -> DFResult<DataFrame> {
         let table_ref: TableReference = ct.name.to_string().as_str().into();
 
         if ct.if_not_exists && self.temp_table_exist(table_ref.clone())? {
@@ -1075,7 +1091,7 @@ impl SQLContext {
         if let Some(query) = &ct.query {
             // CREATE TEMPORARY TABLE ... AS SELECT ...
             let query_sql = query.to_string();
-            let df = self.ctx.sql(&query_sql).await?;
+            let df = self.ctx.sql_with_options(&query_sql, sql_options).await?;
             let schema = df.schema().inner().clone();
             let batches = df.collect().await?;
 
@@ -1200,11 +1216,18 @@ impl SQLContext {
         ok_result(&self.ctx)
     }
 
-    async fn handle_show_create_table(&self, sql: &str, name: &ObjectName) -> 
DFResult<DataFrame> {
+    async fn handle_show_create_table(
+        &self,
+        sql: &str,
+        name: &ObjectName,
+        sql_options: SQLOptions,
+    ) -> DFResult<DataFrame> {
         let (catalog, catalog_name, identifier) = 
self.resolve_catalog_and_table(name)?;
         let table = match catalog.get_table(&identifier).await {
             Ok(table) => table,
-            Err(paimon::Error::TableNotExist { .. }) => return 
self.ctx.sql(sql).await,
+            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)?;
@@ -1569,6 +1592,7 @@ impl SQLContext {
         &self,
         insert: &Insert,
         enable_ident_normalization: bool,
+        sql_options: SQLOptions,
     ) -> DFResult<DataFrame> {
         self.ensure_no_time_travel_for_write("INSERT OVERWRITE")?;
         let table_name = match &insert.table {
@@ -1600,7 +1624,10 @@ impl SQLContext {
         let source = insert.source.as_ref().ok_or_else(|| {
             DataFusionError::Plan("INSERT OVERWRITE requires a source 
query".into())
         })?;
-        let df = self.ctx.sql(&source.to_string()).await?;
+        let df = self
+            .ctx
+            .sql_with_options(&source.to_string(), sql_options)
+            .await?;
 
         let all_fields = table.schema().fields();
         let non_static_fields: Vec<&PaimonDataField> = all_fields
@@ -1779,7 +1806,11 @@ impl SQLContext {
         ok_result(&self.ctx)
     }
 
-    async fn handle_create_view(&self, create_view: &CreateView) -> 
DFResult<DataFrame> {
+    async fn handle_create_view(
+        &self,
+        create_view: &CreateView,
+        sql_options: SQLOptions,
+    ) -> DFResult<DataFrame> {
         if create_view.materialized {
             return Err(DataFusionError::Plan(
                 "CREATE MATERIALIZED VIEW is not supported".to_string(),
@@ -1792,7 +1823,7 @@ impl SQLContext {
             let view_name = create_view.name.to_string();
             let table_ref: TableReference = view_name.as_str().into();
             let (catalog, database, name) = 
self.resolve_temp_table_name(table_ref)?;
-            let df = self.ctx.sql(&query_sql).await?;
+            let df = self.ctx.sql_with_options(&query_sql, sql_options).await?;
             let logical_plan = df.logical_plan().clone();
             if create_view.if_not_exists
                 && 
self.temp_table_exist(format!("{catalog}.{database}.{name}"))?
diff --git a/crates/integrations/datafusion/tests/sql_context_tests.rs 
b/crates/integrations/datafusion/tests/sql_context_tests.rs
index d33342ad..16a66f29 100644
--- a/crates/integrations/datafusion/tests/sql_context_tests.rs
+++ b/crates/integrations/datafusion/tests/sql_context_tests.rs
@@ -24,6 +24,7 @@ use async_trait::async_trait;
 use datafusion::arrow::array::{Array, Int64Array};
 use datafusion::catalog::CatalogProvider;
 use datafusion::datasource::MemTable;
+use datafusion::execution::context::SQLOptions;
 use paimon::catalog::{list_partitions_from_file_system, Identifier};
 use paimon::spec::{
     ArrayType, BinaryType, BlobType, CharType, DataType, FloatType, IntType,
@@ -1523,6 +1524,59 @@ async fn test_ddl_context_delegates_select() {
     assert_eq!(total_rows, 0, "Empty table should return 0 rows");
 }
 
+#[tokio::test]
+async fn test_sql_with_options_propagates_to_datafusion() {
+    let ctx = SQLContext::new();
+    let options = SQLOptions::new().with_allow_statements(false);
+
+    for sql in [
+        "SET datafusion.execution.batch_size = 1024",
+        "RESET datafusion.execution.batch_size",
+    ] {
+        let error = ctx.sql_with_options(sql, options).await.unwrap_err();
+        assert!(error.to_string().contains("Statement not supported"));
+    }
+}
+
+#[tokio::test]
+async fn test_sql_with_options_propagates_to_copy() {
+    let temp_dir = TempDir::new().unwrap();
+    let destination = temp_dir.path().join("copy.parquet");
+    let ctx = SQLContext::new();
+    let options = SQLOptions::new().with_allow_dml(false);
+    let sql = format!("COPY (VALUES (1)) TO '{}'", destination.display());
+
+    let error = ctx.sql_with_options(&sql, options).await.unwrap_err();
+
+    assert!(
+        error.to_string().contains("DML not supported: COPY"),
+        "unexpected error: {error}"
+    );
+    assert!(!destination.exists());
+}
+
+#[tokio::test]
+async fn test_sql_with_options_propagates_to_explain() {
+    let temp_dir = TempDir::new().unwrap();
+    let ctx = SQLContext::new();
+    let options = SQLOptions::new().with_allow_dml(false);
+
+    for (explain, file_name) in [
+        ("EXPLAIN", "explain-copy.parquet"),
+        ("EXPLAIN ANALYZE", "explain-analyze-copy.parquet"),
+    ] {
+        let destination = temp_dir.path().join(file_name);
+        let sql = format!("{explain} COPY (VALUES (1)) TO '{}'", 
destination.display());
+        let error = ctx.sql_with_options(&sql, options).await.unwrap_err();
+
+        assert!(
+            error.to_string().contains("DML not supported: COPY"),
+            "unexpected error: {error}"
+        );
+        assert!(!destination.exists());
+    }
+}
+
 // ======================= MULTI-CATALOG =======================
 
 #[tokio::test]

Reply via email to