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 5c80b52  feat(datafusion): implement DROP VIEW functionality in REST 
Catalog (#501)
5c80b52 is described below

commit 5c80b52dfe2edc1d2f7d389929ca6ec08cb1a419
Author: QuakeWang <[email protected]>
AuthorDate: Mon Jul 13 13:10:28 2026 +0800

    feat(datafusion): implement DROP VIEW functionality in REST Catalog (#501)
---
 crates/integrations/datafusion/README.md          |  16 +-
 crates/integrations/datafusion/src/sql_context.rs | 226 +++++++++++++++++++++-
 crates/paimon/src/api/rest_api.rs                 |  10 +
 crates/paimon/src/catalog/mod.rs                  |  11 ++
 crates/paimon/src/catalog/rest/rest_catalog.rs    |  11 ++
 crates/paimon/tests/mock_server.rs                |  46 ++++-
 crates/paimon/tests/rest_api_test.rs              |  31 +++
 crates/paimon/tests/rest_catalog_test.rs          |  72 +++++++
 docs/src/sql.md                                   |  35 +++-
 9 files changed, 442 insertions(+), 16 deletions(-)

diff --git a/crates/integrations/datafusion/README.md 
b/crates/integrations/datafusion/README.md
index c4fa4d2..9451572 100644
--- a/crates/integrations/datafusion/README.md
+++ b/crates/integrations/datafusion/README.md
@@ -26,11 +26,11 @@ This crate contains the integration of [Apache 
DataFusion](https://datafusion.ap
 
 ## REST Catalog views and SQL functions
 
-`SQLContext` can read, execute, and create persistent views and SQL scalar 
functions in a Paimon
-REST Catalog:
+`SQLContext` can read, execute, create, and drop persistent views and can 
create SQL scalar functions in a Paimon REST Catalog:
 
 ```sql
 CREATE VIEW [IF NOT EXISTS] view_name [(column_name, ...)] AS query;
+DROP VIEW [IF EXISTS] view_name;
 
 CREATE FUNCTION [IF NOT EXISTS] function_name([parameter_name data_type, ...])
 RETURNS data_type
@@ -45,6 +45,10 @@ RETURN scalar_expression;
   column list overrides names only and must match the query output. 
Unqualified relations and REST
   SQL functions are planned in the new view's owning catalog and database. `IF 
NOT EXISTS` is
   handled atomically by the catalog.
+- `DROP VIEW` accepts bare, two-part, and three-part names and sends one 
direct REST delete request.
+  `IF EXISTS` ignores only a missing view. Multiple targets and `CASCADE`, 
`RESTRICT`, `PURGE`, or
+  other drop modifiers are not supported. Catalogs without persistent view 
support may return
+  `Unsupported`.
 - A SQL function can be called as `function(args...)` in the current 
catalog/database or as
   `catalog.database.function(args...)`. Its `definitions.datafusion` value 
must be a scalar SQL
   expression, it must be deterministic, and it must declare its input 
parameters and exactly one
@@ -54,10 +58,10 @@ RETURN scalar_expression;
   inferred and validated from the planned expression before sending the REST 
create request. Bare,
   two-part, and three-part creation targets are supported; calls remain 
limited to bare and
   three-part names.
-- `CREATE OR REPLACE VIEW`, materialized/secure views, comments/options, 
persistent `ALTER VIEW` /
-  `DROP VIEW`, `CREATE OR REPLACE/ALTER/TEMPORARY FUNCTION`, and persistent 
`ALTER FUNCTION` /
-  `DROP FUNCTION` are not supported. Lambda/file, 
aggregate/table/multi-return, non-deterministic,
-  Stable/Volatile, and non-SQL functions are also not supported.
+- `CREATE OR REPLACE VIEW`, materialized/secure views, comments/options, 
persistent `ALTER VIEW`,
+  `CREATE OR REPLACE/ALTER/TEMPORARY FUNCTION`, and persistent `ALTER 
FUNCTION` / `DROP FUNCTION`
+  are not supported. Lambda/file, aggregate/table/multi-return, 
non-deterministic, Stable/Volatile,
+  and non-SQL functions are also not supported.
 
 Use `SQLContext::sql` for function expansion:
 
diff --git a/crates/integrations/datafusion/src/sql_context.rs 
b/crates/integrations/datafusion/src/sql_context.rs
index ec72013..b1e8729 100644
--- a/crates/integrations/datafusion/src/sql_context.rs
+++ b/crates/integrations/datafusion/src/sql_context.rs
@@ -31,6 +31,7 @@
 //! - `ALTER TABLE db.t RENAME TO new_name`
 //! - `ALTER TABLE db.t DROP PARTITION (col = val, ...)`
 //! - `CREATE VIEW [IF NOT EXISTS] view [(col, ...)] AS query`
+//! - `DROP VIEW [IF EXISTS] view`
 //! - `CREATE FUNCTION name(args) RETURNS type [LANGUAGE SQL] RETURN 
expression`
 //! - `TRUNCATE TABLE db.t`
 //! - `TRUNCATE TABLE db.t PARTITION (col = val, ...)`
@@ -466,8 +467,11 @@ impl SQLContext {
                 object_type,
                 if_exists,
                 names,
+                cascade,
+                restrict,
+                purge,
                 temporary,
-                ..
+                table,
             } if matches!(*object_type, ObjectType::Table | ObjectType::View) 
=> {
                 if *temporary {
                     self.handle_drop_temp_table(names, *if_exists)
@@ -482,7 +486,42 @@ impl SQLContext {
                         self.ctx.sql(sql).await
                     }
                 } else {
-                    self.ctx.sql(sql).await
+                    let targets_paimon_catalog = names.iter().any(|name| {
+                        let table_ref: TableReference = 
name.to_string().as_str().into();
+                        self.is_paimon_catalog_ref(&table_ref)
+                    });
+                    if !targets_paimon_catalog {
+                        return self.ctx.sql(sql).await;
+                    }
+                    let [name] = names.as_slice() else {
+                        return Err(DataFusionError::Plan(
+                            "Persistent DROP VIEW does not support multiple 
views".to_string(),
+                        ));
+                    };
+                    if *cascade {
+                        return Err(DataFusionError::Plan(
+                            "DROP VIEW CASCADE is not supported".to_string(),
+                        ));
+                    }
+                    if *restrict {
+                        return Err(DataFusionError::Plan(
+                            "DROP VIEW RESTRICT is not supported".to_string(),
+                        ));
+                    }
+                    if *purge {
+                        return Err(DataFusionError::Plan(
+                            "DROP VIEW PURGE is not supported".to_string(),
+                        ));
+                    }
+                    if table.is_some() {
+                        return Err(DataFusionError::Plan(
+                            "DROP VIEW ON clauses are not 
supported".to_string(),
+                        ));
+                    }
+                    let (catalog, _catalog_name, identifier) =
+                        self.resolve_catalog_and_table(name)?;
+                    self.handle_drop_view(&catalog, &identifier, *if_exists)
+                        .await
                 }
             }
             Statement::Call(func) => {
@@ -940,6 +979,19 @@ impl SQLContext {
         ok_result(&self.ctx)
     }
 
+    async fn handle_drop_view(
+        &self,
+        catalog: &Arc<dyn Catalog>,
+        identifier: &Identifier,
+        if_exists: bool,
+    ) -> DFResult<DataFrame> {
+        catalog
+            .drop_view(identifier, if_exists)
+            .await
+            .map_err(to_datafusion_error)?;
+        ok_result(&self.ctx)
+    }
+
     async fn handle_show_create_table(&self, sql: &str, name: &ObjectName) -> 
DFResult<DataFrame> {
         let (catalog, catalog_name, identifier) = 
self.resolve_catalog_and_table(name)?;
         let table = match catalog.get_table(&identifier).await {
@@ -3256,6 +3308,7 @@ mod tests {
         existing_table: Mutex<Option<Table>>,
         functions: Mutex<HashMap<Identifier, paimon::catalog::Function>>,
         views: Mutex<HashMap<Identifier, paimon::catalog::View>>,
+        drop_view_supported: bool,
     }
 
     impl MockCatalog {
@@ -3265,6 +3318,14 @@ mod tests {
                 existing_table: Mutex::new(None),
                 functions: Mutex::new(HashMap::new()),
                 views: Mutex::new(HashMap::new()),
+                drop_view_supported: true,
+            }
+        }
+
+        fn without_drop_view_support() -> Self {
+            Self {
+                drop_view_supported: false,
+                ..Self::new()
             }
         }
 
@@ -3455,6 +3516,25 @@ mod tests {
             );
             Ok(())
         }
+
+        async fn drop_view(
+            &self,
+            identifier: &Identifier,
+            ignore_if_not_exists: bool,
+        ) -> paimon::Result<()> {
+            if !self.drop_view_supported {
+                return Err(paimon::Error::Unsupported {
+                    message: "Catalog does not support views".to_string(),
+                });
+            }
+            if self.views.lock().unwrap().remove(identifier).is_some() || 
ignore_if_not_exists {
+                Ok(())
+            } else {
+                Err(paimon::Error::ViewNotExist {
+                    full_name: identifier.full_name(),
+                })
+            }
+        }
     }
 
     async fn make_sql_context(catalog: Arc<MockCatalog>) -> SQLContext {
@@ -3571,6 +3651,148 @@ mod tests {
         assert_eq!(answers.value(0), 42);
     }
 
+    #[tokio::test]
+    async fn persistent_rest_catalog_view_can_be_dropped() {
+        let catalog = Arc::new(MockCatalog::new());
+        let ctx = make_sql_context(Arc::clone(&catalog)).await;
+        ctx.sql(
+            "CREATE VIEW answer_view AS \
+             SELECT CAST(42 AS BIGINT) AS answer",
+        )
+        .await
+        .unwrap();
+
+        ctx.sql("DROP VIEW answer_view").await.unwrap();
+
+        assert!(matches!(
+            catalog
+                .get_view(&Identifier::new("default", "answer_view"))
+                .await,
+            Err(paimon::Error::ViewNotExist { .. })
+        ));
+        assert!(ctx.sql("SELECT * FROM answer_view").await.is_err());
+    }
+
+    #[tokio::test]
+    async fn persistent_rest_catalog_view_drop_honors_if_exists() {
+        let catalog = Arc::new(MockCatalog::new());
+        let ctx = make_sql_context(catalog).await;
+
+        let error = ctx.sql("DROP VIEW missing_view").await.unwrap_err();
+        assert!(
+            error
+                .to_string()
+                .contains("View default.missing_view does not exist"),
+            "unexpected error: {error}"
+        );
+        ctx.sql("DROP VIEW IF EXISTS missing_view").await.unwrap();
+    }
+
+    #[tokio::test]
+    async fn persistent_rest_catalog_view_drop_resolves_supported_name_forms() 
{
+        let catalog = Arc::new(MockCatalog::new());
+        add_bigint_view(&catalog, "default", "bare_view", "SELECT 1 AS 
answer");
+        add_bigint_view(&catalog, "other", "two_part", "SELECT 1 AS answer");
+        add_bigint_view(&catalog, "other", "three_part", "SELECT 1 AS answer");
+        add_bigint_view(&catalog, "default", "Quoted View", "SELECT 1 AS 
answer");
+        let ctx = make_sql_context(Arc::clone(&catalog)).await;
+
+        ctx.sql("DROP VIEW bare_view").await.unwrap();
+        ctx.sql("DROP VIEW IF EXISTS other.two_part").await.unwrap();
+        ctx.sql("DROP VIEW IF EXISTS paimon.other.three_part")
+            .await
+            .unwrap();
+        ctx.sql("DROP VIEW \"Quoted View\"").await.unwrap();
+
+        for identifier in [
+            Identifier::new("default", "bare_view"),
+            Identifier::new("other", "two_part"),
+            Identifier::new("other", "three_part"),
+            Identifier::new("default", "Quoted View"),
+        ] {
+            assert!(matches!(
+                catalog.get_view(&identifier).await,
+                Err(paimon::Error::ViewNotExist { .. })
+            ));
+        }
+    }
+
+    #[tokio::test]
+    async fn persistent_rest_catalog_view_drop_rejects_unsupported_modifiers() 
{
+        let cases = [
+            ("DROP VIEW paimon.default.invalid_view CASCADE", "CASCADE"),
+            ("DROP VIEW paimon.default.invalid_view RESTRICT", "RESTRICT"),
+            ("DROP VIEW paimon.default.invalid_view PURGE", "PURGE"),
+            (
+                "DROP VIEW paimon.default.invalid_view ON default.target",
+                "ON clauses",
+            ),
+        ];
+
+        for (sql, modifier) in cases {
+            let catalog = Arc::new(MockCatalog::new());
+            let ctx = make_sql_context(catalog).await;
+            let error = ctx.sql(sql).await.unwrap_err();
+            assert!(
+                error.to_string().contains(modifier),
+                "expected {modifier} error, got: {error}"
+            );
+        }
+    }
+
+    #[tokio::test]
+    async fn 
persistent_rest_catalog_view_drop_rejects_multiple_targets_before_deleting() {
+        let catalog = Arc::new(MockCatalog::new());
+        add_bigint_view(&catalog, "default", "first", "SELECT 1 AS answer");
+        let ctx = make_sql_context(Arc::clone(&catalog)).await;
+
+        let error = ctx
+            .sql("DROP VIEW paimon.default.first, datafusion.public.second")
+            .await
+            .unwrap_err();
+
+        assert!(error
+            .to_string()
+            .contains("Persistent DROP VIEW does not support multiple views"));
+        assert!(catalog
+            .get_view(&Identifier::new("default", "first"))
+            .await
+            .is_ok());
+    }
+
+    #[tokio::test]
+    async fn 
persistent_rest_catalog_view_drop_propagates_unsupported_catalog() {
+        let catalog = Arc::new(MockCatalog::without_drop_view_support());
+        add_bigint_view(&catalog, "default", "answer_view", "SELECT 1 AS 
answer");
+        let ctx = make_sql_context(Arc::clone(&catalog)).await;
+
+        let error = ctx.sql("DROP VIEW answer_view").await.unwrap_err();
+
+        assert!(error.to_string().contains("Catalog does not support views"));
+        assert!(catalog
+            .get_view(&Identifier::new("default", "answer_view"))
+            .await
+            .is_ok());
+    }
+
+    #[tokio::test]
+    async fn persistent_rest_catalog_view_drop_delegates_non_paimon_catalog() {
+        let catalog = Arc::new(MockCatalog::new());
+        let ctx = make_sql_context(catalog).await;
+        ctx.sql("CREATE VIEW datafusion.public.delegated_view AS SELECT 1 AS 
answer")
+            .await
+            .unwrap();
+
+        ctx.sql("DROP VIEW datafusion.public.delegated_view")
+            .await
+            .unwrap();
+
+        assert!(ctx
+            .sql("SELECT * FROM datafusion.public.delegated_view")
+            .await
+            .is_err());
+    }
+
     #[tokio::test]
     async fn persistent_rest_catalog_function_can_be_created_and_called() {
         let catalog = Arc::new(MockCatalog::new());
diff --git a/crates/paimon/src/api/rest_api.rs 
b/crates/paimon/src/api/rest_api.rs
index 5d60b33..5110133 100644
--- a/crates/paimon/src/api/rest_api.rs
+++ b/crates/paimon/src/api/rest_api.rs
@@ -467,6 +467,16 @@ impl RESTApi {
         self.client.get(&path, None::<&[(&str, &str)]>).await
     }
 
+    /// Drop a persistent view.
+    pub async fn drop_view(&self, identifier: &Identifier) -> Result<()> {
+        let database = identifier.database();
+        let view = identifier.object();
+        validate_non_empty_multi(&[(database, "database name"), (view, "view 
name")])?;
+        let path = self.resource_paths.view(database, view);
+        let _resp: serde_json::Value = self.client.delete(&path, 
None::<&[(&str, &str)]>).await?;
+        Ok(())
+    }
+
     // ==================== Function Operations ====================
 
     /// Create a persistent function.
diff --git a/crates/paimon/src/catalog/mod.rs b/crates/paimon/src/catalog/mod.rs
index 516b830..38a414b 100644
--- a/crates/paimon/src/catalog/mod.rs
+++ b/crates/paimon/src/catalog/mod.rs
@@ -389,6 +389,17 @@ pub trait Catalog: Send + Sync {
         })
     }
 
+    /// Drop a persistent view.
+    ///
+    /// # Errors
+    /// * [`crate::Error::ViewNotExist`] - view does not exist when
+    ///   `ignore_if_not_exists` is false.
+    async fn drop_view(&self, _identifier: &Identifier, _ignore_if_not_exists: 
bool) -> Result<()> {
+        Err(Error::Unsupported {
+            message: "Catalog does not support views".to_string(),
+        })
+    }
+
     /// List persistent view names in a database.
     async fn list_views(&self, _database_name: &str) -> Result<Vec<String>> {
         Err(Error::Unsupported {
diff --git a/crates/paimon/src/catalog/rest/rest_catalog.rs 
b/crates/paimon/src/catalog/rest/rest_catalog.rs
index 9fbb388..2057d3f 100644
--- a/crates/paimon/src/catalog/rest/rest_catalog.rs
+++ b/crates/paimon/src/catalog/rest/rest_catalog.rs
@@ -313,6 +313,17 @@ impl Catalog for RESTCatalog {
         ))
     }
 
+    async fn drop_view(&self, identifier: &Identifier, ignore_if_not_exists: 
bool) -> Result<()> {
+        let result = self
+            .api
+            .drop_view(identifier)
+            .await
+            .map_err(|error| map_rest_error_for_view(error, identifier));
+        ignore_error_if(result, |error| {
+            ignore_if_not_exists && matches!(error, Error::ViewNotExist { .. })
+        })
+    }
+
     async fn list_functions(&self, database_name: &str) -> Result<Vec<String>> 
{
         self.api
             .list_functions(database_name)
diff --git a/crates/paimon/tests/mock_server.rs 
b/crates/paimon/tests/mock_server.rs
index 21e9520..6c8a004 100644
--- a/crates/paimon/tests/mock_server.rs
+++ b/crates/paimon/tests/mock_server.rs
@@ -49,6 +49,7 @@ struct MockState {
     views: HashMap<String, GetViewResponse>,
     functions: HashMap<String, GetFunctionResponse>,
     view_function_endpoints_unsupported: bool,
+    drop_view_error_status: Option<StatusCode>,
     list_page_size: Option<usize>,
     no_permission_databases: HashSet<String>,
     no_permission_tables: HashSet<String>,
@@ -369,6 +370,44 @@ impl RESTServer {
         }
     }
 
+    /// Handle DELETE /databases/:db/views/:view - drop a persistent view.
+    pub async fn drop_view(
+        Path((db, view)): Path<(String, String)>,
+        Extension(state): Extension<Arc<RESTServer>>,
+    ) -> impl IntoResponse {
+        let mut s = state.inner.lock().unwrap();
+        if s.view_function_endpoints_unsupported {
+            let err = ErrorResponse::new(
+                Some("view".to_string()),
+                Some(view),
+                Some("Not Implemented".to_string()),
+                Some(501),
+            );
+            return (StatusCode::NOT_IMPLEMENTED, Json(err)).into_response();
+        }
+        if let Some(status) = s.drop_view_error_status {
+            let err = ErrorResponse::new(
+                Some("view".to_string()),
+                Some(view),
+                status.canonical_reason().map(ToString::to_string),
+                Some(status.as_u16() as i32),
+            );
+            return (status, Json(err)).into_response();
+        }
+        let key = format!("{db}.{view}");
+        if s.views.remove(&key).is_some() {
+            (StatusCode::OK, Json(serde_json::json!(""))).into_response()
+        } else {
+            let err = ErrorResponse::new(
+                Some("view".to_string()),
+                Some(view),
+                Some("Not Found".to_string()),
+                Some(404),
+            );
+            (StatusCode::NOT_FOUND, Json(err)).into_response()
+        }
+    }
+
     /// Handle GET /databases/:db/views - list persistent views.
     pub async fn list_views(
         Path(db): Path<String>,
@@ -886,6 +925,11 @@ impl RESTServer {
             .view_function_endpoints_unsupported = true;
     }
 
+    /// Make the drop-view endpoint return the given status.
+    pub fn set_drop_view_error_status(&self, status: Option<StatusCode>) {
+        self.inner.lock().unwrap().drop_view_error_status = status;
+    }
+
     /// Add a table with schema and path to the server state.
     ///
     /// This is needed for `RESTCatalog::get_table` which requires
@@ -1051,7 +1095,7 @@ pub async fn start_mock_server(
         )
         .route(
             &format!("{prefix}/databases/:db/views/:view"),
-            get(RESTServer::get_view),
+            get(RESTServer::get_view).delete(RESTServer::drop_view),
         )
         .route(
             &format!("{prefix}/databases/:db/functions"),
diff --git a/crates/paimon/tests/rest_api_test.rs 
b/crates/paimon/tests/rest_api_test.rs
index e020b6c..99b952c 100644
--- a/crates/paimon/tests/rest_api_test.rs
+++ b/crates/paimon/tests/rest_api_test.rs
@@ -134,6 +134,37 @@ async fn test_create_view() {
     assert_eq!(response.schema.query(), "SELECT id FROM source");
 }
 
+#[tokio::test]
+async fn test_drop_view_uses_encoded_individual_view_path() {
+    let database = "db+%";
+    let view = "active+?#%";
+    let ctx = setup_test_server(vec![database]).await;
+    let schema = ViewSchema::new(
+        Vec::new(),
+        "SELECT 1".to_string(),
+        HashMap::new(),
+        None,
+        HashMap::new(),
+    );
+    let identifier = Identifier::new(database, view);
+    ctx.api.create_view(&identifier, schema).await.unwrap();
+
+    ctx.api.drop_view(&identifier).await.unwrap();
+
+    assert!(matches!(
+        ctx.api.get_view(&identifier).await.unwrap_err(),
+        paimon::Error::RestApi {
+            source: paimon::api::RestError::NoSuchResource { .. }
+        }
+    ));
+    assert!(matches!(
+        ctx.api.drop_view(&identifier).await.unwrap_err(),
+        paimon::Error::RestApi {
+            source: paimon::api::RestError::NoSuchResource { .. }
+        }
+    ));
+}
+
 #[tokio::test]
 async fn test_list_views() {
     let ctx = setup_test_server(vec!["default"]).await;
diff --git a/crates/paimon/tests/rest_catalog_test.rs 
b/crates/paimon/tests/rest_catalog_test.rs
index 849b699..2000f9e 100644
--- a/crates/paimon/tests/rest_catalog_test.rs
+++ b/crates/paimon/tests/rest_catalog_test.rs
@@ -25,6 +25,7 @@ use std::sync::Arc;
 
 use arrow_array::{Array, BinaryArray, Int32Array, Int64Array, RecordBatch, 
StringArray};
 use arrow_schema::{DataType as ArrowDataType, Field as ArrowField, Schema as 
ArrowSchema};
+use axum::http::StatusCode;
 use futures::TryStreamExt;
 use paimon::api::ConfigResponse;
 use paimon::catalog::{Catalog, Function, FunctionDefinition, Identifier, 
RESTCatalog, ViewSchema};
@@ -1052,6 +1053,70 @@ async fn test_catalog_create_view() {
     assert_eq!(view.query_for("datafusion"), "SELECT id FROM source");
 }
 
+#[tokio::test]
+async fn test_catalog_drop_view() {
+    let ctx = setup_catalog(vec!["default"]).await;
+    let identifier = Identifier::new("default", "active_ids");
+    ctx.catalog
+        .create_view(
+            &identifier,
+            ViewSchema::new(
+                Vec::new(),
+                "SELECT 1".to_string(),
+                HashMap::new(),
+                None,
+                HashMap::new(),
+            ),
+            false,
+        )
+        .await
+        .unwrap();
+
+    ctx.catalog.drop_view(&identifier, false).await.unwrap();
+
+    assert!(ctx.catalog.list_views("default").await.unwrap().is_empty());
+    assert!(matches!(
+        ctx.catalog.get_view(&identifier).await.unwrap_err(),
+        paimon::Error::ViewNotExist { full_name } if full_name == 
"default.active_ids"
+    ));
+}
+
+#[tokio::test]
+async fn test_catalog_drop_missing_view_honors_ignore_if_not_exists() {
+    let ctx = setup_catalog(vec!["default"]).await;
+    let identifier = Identifier::new("default", "missing");
+
+    assert!(matches!(
+        ctx.catalog.drop_view(&identifier, false).await.unwrap_err(),
+        paimon::Error::ViewNotExist { full_name } if full_name == 
"default.missing"
+    ));
+    ctx.catalog.drop_view(&identifier, true).await.unwrap();
+}
+
+#[tokio::test]
+async fn test_catalog_drop_view_if_exists_does_not_hide_other_errors() {
+    let ctx = setup_catalog(vec!["default"]).await;
+    let identifier = Identifier::new("default", "active_ids");
+
+    ctx.server
+        .set_drop_view_error_status(Some(StatusCode::FORBIDDEN));
+    assert!(matches!(
+        ctx.catalog.drop_view(&identifier, true).await.unwrap_err(),
+        paimon::Error::RestApi {
+            source: paimon::api::RestError::Forbidden { .. }
+        }
+    ));
+
+    ctx.server
+        .set_drop_view_error_status(Some(StatusCode::INTERNAL_SERVER_ERROR));
+    assert!(matches!(
+        ctx.catalog.drop_view(&identifier, true).await.unwrap_err(),
+        paimon::Error::RestApi {
+            source: paimon::api::RestError::ServiceFailure { .. }
+        }
+    ));
+}
+
 #[tokio::test]
 async fn test_catalog_create_view_missing_database() {
     let ctx = setup_catalog(vec!["default"]).await;
@@ -1399,6 +1464,13 @@ async fn 
test_catalog_maps_unsupported_view_and_function_endpoints() {
             .unwrap_err(),
         paimon::Error::Unsupported { .. }
     ));
+    assert!(matches!(
+        ctx.catalog
+            .drop_view(&Identifier::new("default", "view"), true)
+            .await
+            .unwrap_err(),
+        paimon::Error::Unsupported { .. }
+    ));
     assert!(matches!(
         ctx.catalog.list_functions("default").await.unwrap_err(),
         paimon::Error::Unsupported { .. }
diff --git a/docs/src/sql.md b/docs/src/sql.md
index 50623e4..587603e 100644
--- a/docs/src/sql.md
+++ b/docs/src/sql.md
@@ -40,7 +40,7 @@ Mosaic support is always available and currently read-only. 
SQL queries can read
 SQL support has two layers:
 
 - DataFusion provides the parser, query planner, optimizer, execution engine, 
expressions, scalar functions, aggregate functions, and window functions. SQL 
statements that `SQLContext` does not intercept are delegated to DataFusion. 
This includes the DataFusion SQL surface for `SELECT` queries, CTEs (including 
recursive CTEs), subqueries, joins including `LATERAL` joins, SQL lambda 
functions, grouping, `HAVING`, window clauses, `QUALIFY`, set operations, 
`ORDER BY`, `LIMIT`/`OFFSET`, `EX [...]
-- Paimon-specific table management and row-level writes are implemented by 
`SQLContext`. This includes Paimon `CREATE TABLE`, `ALTER TABLE`, `DROP TABLE`, 
`CREATE TEMPORARY TABLE`, `CREATE TEMPORARY VIEW`, REST Catalog persistent 
`CREATE VIEW` and `CREATE FUNCTION`, `DROP TEMPORARY TABLE` / `VIEW`, `INSERT 
OVERWRITE ... PARTITION`, `UPDATE`, `DELETE`, `MERGE INTO`, `TRUNCATE TABLE`, 
`ALTER TABLE ... DROP PARTITION`, `CALL sys.*`, Paimon time travel, and `SET` / 
`RESET 'paimon.*'`.
+- Paimon-specific table management and row-level writes are implemented by 
`SQLContext`. This includes Paimon `CREATE TABLE`, `ALTER TABLE`, `DROP TABLE`, 
`CREATE TEMPORARY TABLE`, `CREATE TEMPORARY VIEW`, REST Catalog persistent 
`CREATE VIEW`, `DROP VIEW`, and `CREATE FUNCTION`, `DROP TEMPORARY TABLE` / 
`VIEW`, `INSERT OVERWRITE ... PARTITION`, `UPDATE`, `DELETE`, `MERGE INTO`, 
`TRUNCATE TABLE`, `ALTER TABLE ... DROP PARTITION`, `CALL sys.*`, Paimon time 
travel, and `SET` / `RESET 'paimon.*'`.
 
 Not every DataFusion DDL/DML statement maps to a Paimon table operation. For 
Paimon catalogs, `CREATE EXTERNAL TABLE`, `LOCATION`, `CREATE MATERIALIZED 
VIEW`, and persistent `CREATE TABLE AS SELECT` are rejected or not implemented. 
Persistent `CREATE FUNCTION` is supported only for the REST Catalog SQL scalar 
form documented below. DataFusion `COPY` can export query results to files; it 
does not create or commit Paimon table files.
 
@@ -78,8 +78,7 @@ internally for `SET`/`RESET` support.
 
 ### REST Catalog Views and SQL Functions
 
-When the registered catalog is a Paimon REST Catalog, `SQLContext` can read,
-execute, and create persistent views and SQL scalar functions.
+When the registered catalog is a Paimon REST Catalog, `SQLContext` can read, 
execute, create, and drop persistent views and can create SQL scalar functions.
 
 Create a persistent view with this syntax:
 
@@ -87,6 +86,12 @@ Create a persistent view with this syntax:
 CREATE VIEW [IF NOT EXISTS] view_name [(column_name, ...)] AS query;
 ```
 
+Drop a persistent view with this syntax:
+
+```sql
+DROP VIEW [IF EXISTS] view_name;
+```
+
 For example:
 
 ```sql
@@ -107,10 +112,14 @@ the new view's owning catalog and database, not the 
session's current database.
 The canonical query is stored as both the default query and the `datafusion`
 dialect definition.
 
-Persistent `CREATE VIEW` is currently implemented by REST Catalog. Other
-catalog implementations may return `Unsupported`. `CREATE OR REPLACE VIEW`,
-materialized/secure views, view comments or options, vendor-specific modifiers,
-and persistent `ALTER VIEW` / `DROP VIEW` are not supported.
+Persistent `CREATE VIEW` and `DROP VIEW` are currently implemented by REST
+Catalog. `DROP VIEW` sends a direct delete request; `IF EXISTS` ignores only a
+missing-view response. Bare, two-part, and three-part names are supported, but
+only one target may be dropped per statement. Other catalog implementations may
+return `Unsupported`. `CREATE OR REPLACE VIEW`, materialized/secure views, view
+comments or options, vendor-specific create modifiers, persistent `ALTER VIEW`,
+and `DROP VIEW` modifiers such as `CASCADE`, `RESTRICT`, or `PURGE` are not
+supported.
 
 Persistent views resolve through the normal DataFusion catalog path, so they
 can be queried wherever a table can be used:
@@ -588,6 +597,18 @@ DROP TABLE paimon.my_db.users;
 DROP TABLE IF EXISTS paimon.my_db.users;
 ```
 
+### DROP VIEW
+
+Drop one persistent view from a REST Catalog:
+
+```sql
+DROP VIEW active_users;
+DROP VIEW IF EXISTS my_db.active_users;
+DROP VIEW IF EXISTS paimon.my_db.active_users;
+```
+
+`IF EXISTS` ignores only a missing view; authorization, server, and network 
errors are still returned. Only one persistent view target is supported per 
statement. `CASCADE`, `RESTRICT`, `PURGE`, and other drop modifiers are 
rejected. Catalog implementations without persistent view support return 
`Unsupported`.
+
 ### CREATE TEMPORARY TABLE
 
 Create an in-memory temporary table from a query result. Temporary tables 
exist only for the lifetime of the `SQLContext` instance and are automatically 
cleaned up when the context is dropped.

Reply via email to