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 4e67c89 feat(datafusion): support REST Catalog CREATE VIEW (#497)
4e67c89 is described below
commit 4e67c892dce06784430735e78e077c1e0e8c1e9c
Author: Jingsong Lee <[email protected]>
AuthorDate: Sat Jul 11 08:56:32 2026 +0800
feat(datafusion): support REST Catalog CREATE VIEW (#497)
---
crates/integrations/datafusion/README.md | 20 +-
crates/integrations/datafusion/src/sql_context.rs | 482 +++++++++++++++++++++-
crates/paimon/src/api/api_request.rs | 19 +-
crates/paimon/src/api/mod.rs | 2 +-
crates/paimon/src/api/rest_api.rs | 15 +-
crates/paimon/src/catalog/mod.rs | 12 +
crates/paimon/src/catalog/rest/rest_catalog.rs | 33 ++
crates/paimon/src/catalog/view.rs | 17 +
crates/paimon/src/error.rs | 2 +
crates/paimon/tests/mock_server.rs | 52 ++-
crates/paimon/tests/rest_api_test.rs | 24 ++
crates/paimon/tests/rest_catalog_test.rs | 118 ++++++
crates/paimon/tests/rest_object_models_test.rs | 57 +++
docs/src/sql.md | 41 +-
14 files changed, 864 insertions(+), 30 deletions(-)
diff --git a/crates/integrations/datafusion/README.md
b/crates/integrations/datafusion/README.md
index cc7a3a1..0f6a664 100644
--- a/crates/integrations/datafusion/README.md
+++ b/crates/integrations/datafusion/README.md
@@ -26,19 +26,28 @@ This crate contains the integration of [Apache
DataFusion](https://datafusion.ap
## REST Catalog views and SQL functions
-`SQLContext` can read persistent views and SQL scalar functions that already
exist in a Paimon
-REST Catalog. No view or function DDL is added.
+`SQLContext` can read persistent views and SQL scalar functions in a Paimon
REST Catalog. It can
+also create persistent REST Catalog views:
+
+```sql
+CREATE VIEW [IF NOT EXISTS] view_name [(column_name, ...)] AS query;
+```
- A persistent view is resolved lazily like a table. The `datafusion` dialect
is preferred and the
default view query is used when that dialect is absent. Unqualified
relations inside the view
resolve in the view's owning catalog and database.
+- `CREATE VIEW` infers stored field types and nullability from the defining
query. An optional
+ 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.
- 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
return parameter.
-- Only reads and execution are supported. Lambda/file functions, named
arguments, multiple return
- values, non-deterministic functions, and calls made directly through a raw
DataFusion
- `SessionContext` are not supported.
+- `CREATE OR REPLACE VIEW`, materialized/secure views, comments/options,
persistent `ALTER VIEW` /
+ `DROP VIEW`, and persistent function DDL are not supported. Lambda/file
functions, named
+ arguments, multiple return values, non-deterministic functions, and calls
made directly through
+ a raw DataFusion `SessionContext` are also not supported.
Use `SQLContext::sql` for function expansion:
@@ -46,6 +55,7 @@ Use `SQLContext::sql` for function expansion:
let mut ctx = paimon_datafusion::SQLContext::new();
ctx.register_catalog("paimon", rest_catalog).await?;
+ctx.sql("CREATE VIEW daily_scores AS SELECT normalize_score(score) AS score
FROM scores").await?;
let view = ctx.sql("SELECT * FROM analytics_view").await?;
let function = ctx.sql("SELECT normalize_score(score) FROM scores").await?;
```
diff --git a/crates/integrations/datafusion/src/sql_context.rs
b/crates/integrations/datafusion/src/sql_context.rs
index d136492..bc00552 100644
--- a/crates/integrations/datafusion/src/sql_context.rs
+++ b/crates/integrations/datafusion/src/sql_context.rs
@@ -30,10 +30,11 @@
//! - `ALTER TABLE db.t RENAME COLUMN old TO new`
//! - `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`
//! - `TRUNCATE TABLE db.t`
//! - `TRUNCATE TABLE db.t PARTITION (col = val, ...)`
-use std::collections::HashMap;
+use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use datafusion::arrow::array::{
@@ -48,6 +49,7 @@ use datafusion::datasource::{MemTable, TableProvider};
use datafusion::error::{DataFusionError, Result as DFResult};
use datafusion::execution::SessionStateBuilder;
use datafusion::prelude::{DataFrame, SessionContext};
+use datafusion::sql::planner::IdentNormalizer;
use datafusion::sql::sqlparser::ast::{
AlterTableOperation, BinaryLength, CharacterLength, ColumnDef,
ColumnOption, CreateTable,
CreateTableOptions, CreateView, Delete, Expr as SqlExpr, FromTable,
Insert, Merge, ObjectName,
@@ -1348,16 +1350,14 @@ 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)?;
-
- // Use DataFusion's SQL planner to convert the sqlparser Query into a
LogicalPlan
let query_sql = create_view.query.to_string();
- let df = self.ctx.sql(&query_sql).await?;
- let logical_plan = df.logical_plan().clone();
if create_view.temporary {
+ 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 logical_plan = df.logical_plan().clone();
if create_view.if_not_exists
&&
self.temp_table_exist(format!("{catalog}.{database}.{name}"))?
{
@@ -1368,10 +1368,65 @@ impl SQLContext {
self.register_temp_table(format!("{catalog}.{database}.{name}"),
Arc::new(view_table))?;
ok_result(&self.ctx)
} else {
- Err(DataFusionError::Plan(
- "CREATE VIEW (non-temporary) is not supported. Use CREATE
TEMPORARY VIEW instead."
- .to_string(),
- ))
+ validate_persistent_create_view(create_view)?;
+ let (catalog, catalog_name, identifier) =
+ self.resolve_catalog_and_table(&create_view.name)?;
+ let mut state = self.ctx.state();
+ state.config_mut().options_mut().catalog.default_catalog =
catalog_name.clone();
+ state.config_mut().options_mut().catalog.default_schema =
+ identifier.database().to_string();
+ let expanded_query = crate::sql_function::expand_sql(
+ &query_sql,
+ &self.catalogs,
+ &catalog_name,
+ identifier.database(),
+ )
+ .await?;
+ let logical_plan =
state.create_logical_plan(&expanded_query).await?;
+ let mut arrow_fields = logical_plan
+ .schema()
+ .as_arrow()
+ .fields()
+ .iter()
+ .map(|field| field.as_ref().clone())
+ .collect::<Vec<_>>();
+ if !create_view.columns.is_empty() && create_view.columns.len() !=
arrow_fields.len() {
+ return Err(DataFusionError::Plan(format!(
+ "view column list has {} columns but query produces {}
columns",
+ create_view.columns.len(),
+ arrow_fields.len()
+ )));
+ }
+ let column_names = create_view
+ .columns
+ .iter()
+ .map(|column|
IdentNormalizer::default().normalize(column.name.clone()))
+ .collect::<Vec<_>>();
+ let mut unique_names = HashSet::with_capacity(column_names.len());
+ for name in &column_names {
+ if !unique_names.insert(name.clone()) {
+ return Err(DataFusionError::Plan(format!(
+ "duplicate view column name '{name}'"
+ )));
+ }
+ }
+ for (field, name) in arrow_fields.iter_mut().zip(column_names) {
+ *field = field.clone().with_name(name);
+ }
+ let fields = paimon::arrow::arrow_fields_to_paimon(&arrow_fields)
+ .map_err(to_datafusion_error)?;
+ let schema = paimon::catalog::ViewSchema::new(
+ fields,
+ query_sql.clone(),
+ HashMap::from([("datafusion".to_string(), query_sql)]),
+ None,
+ HashMap::new(),
+ );
+ catalog
+ .create_view(&identifier, schema, create_view.if_not_exists)
+ .await
+ .map_err(to_datafusion_error)?;
+ ok_result(&self.ctx)
}
}
@@ -1514,6 +1569,74 @@ impl SQLContext {
}
}
+fn validate_persistent_create_view(create_view: &CreateView) -> DFResult<()> {
+ let unsupported = if create_view.or_alter {
+ Some("CREATE OR ALTER VIEW is not supported")
+ } else if create_view.or_replace {
+ Some("CREATE OR REPLACE VIEW is not supported")
+ } else if create_view.secure {
+ Some("CREATE SECURE VIEW is not supported")
+ } else if create_view.copy_grants {
+ Some("CREATE VIEW COPY GRANTS is not supported")
+ } else if create_view.name_before_not_exists {
+ Some("CREATE VIEW with the name before IF NOT EXISTS is not supported")
+ } else {
+ match &create_view.options {
+ CreateTableOptions::None => None,
+ CreateTableOptions::With(_) => Some("CREATE VIEW WITH options are
not supported"),
+ CreateTableOptions::Options(_) => Some("CREATE VIEW OPTIONS are
not supported"),
+ _ => Some("CREATE VIEW options are not supported"),
+ }
+ };
+ if let Some(message) = unsupported {
+ return Err(DataFusionError::Plan(message.to_string()));
+ }
+ if create_view.comment.is_some() {
+ return Err(DataFusionError::Plan(
+ "CREATE VIEW COMMENT is not supported".to_string(),
+ ));
+ }
+ if !create_view.cluster_by.is_empty() {
+ return Err(DataFusionError::Plan(
+ "CREATE VIEW CLUSTER BY is not supported".to_string(),
+ ));
+ }
+ if create_view.to.is_some() {
+ return Err(DataFusionError::Plan(
+ "CREATE VIEW TO is not supported".to_string(),
+ ));
+ }
+ if create_view.with_no_schema_binding {
+ return Err(DataFusionError::Plan(
+ "CREATE VIEW WITH NO SCHEMA BINDING is not supported".to_string(),
+ ));
+ }
+ if create_view.params.is_some() {
+ return Err(DataFusionError::Plan(
+ "CREATE VIEW view parameters are not supported".to_string(),
+ ));
+ }
+ if create_view
+ .columns
+ .iter()
+ .any(|column| column.data_type.is_some())
+ {
+ return Err(DataFusionError::Plan(
+ "CREATE VIEW column data types are not supported".to_string(),
+ ));
+ }
+ if create_view
+ .columns
+ .iter()
+ .any(|column| column.options.is_some())
+ {
+ return Err(DataFusionError::Plan(
+ "CREATE VIEW column options are not supported".to_string(),
+ ));
+ }
+ Ok(())
+}
+
/// Quick check whether the SQL looks like a CREATE TABLE statement.
/// Skips leading whitespace, `--` line comments, and `/* */` block comments.
fn looks_like_create_table(sql: &str) -> bool {
@@ -2882,6 +3005,28 @@ mod tests {
full_name: identifier.full_name(),
})
}
+
+ async fn create_view(
+ &self,
+ identifier: &Identifier,
+ schema: paimon::catalog::ViewSchema,
+ ignore_if_exists: bool,
+ ) -> paimon::Result<()> {
+ let mut views = self.views.lock().unwrap();
+ if views.contains_key(identifier) {
+ if ignore_if_exists {
+ return Ok(());
+ }
+ return Err(paimon::Error::ViewAlreadyExist {
+ full_name: identifier.full_name(),
+ });
+ }
+ views.insert(
+ identifier.clone(),
+ paimon::catalog::View::new(identifier.clone(), schema),
+ );
+ Ok(())
+ }
}
async fn make_sql_context(catalog: Arc<MockCatalog>) -> SQLContext {
@@ -2970,6 +3115,319 @@ mod tests {
));
}
+ #[tokio::test]
+ async fn persistent_rest_catalog_view_can_be_created_and_read() {
+ let catalog = Arc::new(MockCatalog::new());
+ let ctx = make_sql_context(catalog).await;
+
+ ctx.sql(
+ "CREATE VIEW answer_view AS \
+ SELECT CAST(42 AS BIGINT) AS answer",
+ )
+ .await
+ .unwrap();
+
+ let batches = ctx
+ .sql("SELECT * FROM answer_view")
+ .await
+ .unwrap()
+ .collect()
+ .await
+ .unwrap();
+ let answers = batches[0]
+ .column_by_name("answer")
+ .unwrap()
+ .as_any()
+ .downcast_ref::<Int64Array>()
+ .unwrap();
+ assert_eq!(answers.value(0), 42);
+ }
+
+ #[tokio::test]
+ async fn persistent_rest_catalog_view_infers_type_and_nullability() {
+ let catalog = Arc::new(MockCatalog::new());
+ let ctx = make_sql_context(Arc::clone(&catalog)).await;
+
+ ctx.sql(
+ "CREATE VIEW paimon.default.inferred_view AS \
+ SELECT CAST(1 AS BIGINT) AS required, CAST(NULL AS BIGINT) AS
optional",
+ )
+ .await
+ .unwrap();
+
+ let view = catalog
+ .get_view(&Identifier::new("default", "inferred_view"))
+ .await
+ .unwrap();
+ let fields = view.schema().fields();
+ assert!(matches!(fields[0].data_type(), PaimonDataType::BigInt(_)));
+ assert!(!fields[0].data_type().is_nullable());
+ assert!(matches!(fields[1].data_type(), PaimonDataType::BigInt(_)));
+ assert!(fields[1].data_type().is_nullable());
+ }
+
+ #[tokio::test]
+ async fn
persistent_rest_catalog_view_expands_function_in_owning_database() {
+ let catalog = Arc::new(MockCatalog::new());
+ add_unary_sql_function_in_database(&catalog, "default", "plus_one", "x
+ 1", true);
+ add_unary_sql_function_in_database(&catalog, "other", "plus_one", "x +
100", true);
+ let ctx = make_sql_context(Arc::clone(&catalog)).await;
+ ctx.set_current_database("other").await.unwrap();
+
+ ctx.sql(
+ "CREATE VIEW paimon.default.function_view AS \
+ SELECT plus_one(41) AS answer",
+ )
+ .await
+ .unwrap();
+
+ let view = catalog
+ .get_view(&Identifier::new("default", "function_view"))
+ .await
+ .unwrap();
+ assert!(view.query_for("datafusion").contains("plus_one(41)"));
+ let batches = ctx
+ .sql("SELECT * FROM paimon.default.function_view")
+ .await
+ .unwrap()
+ .collect()
+ .await
+ .unwrap();
+ let answers = batches[0]
+ .column_by_name("answer")
+ .unwrap()
+ .as_any()
+ .downcast_ref::<Int64Array>()
+ .unwrap();
+ assert_eq!(answers.value(0), 42);
+ }
+
+ #[tokio::test]
+ async fn persistent_rest_catalog_view_binds_to_owning_database() {
+ let catalog = Arc::new(MockCatalog::new());
+ add_bigint_view(
+ &catalog,
+ "default",
+ "base_view",
+ "SELECT CAST(42 AS BIGINT) AS answer",
+ );
+ let other_schema = serde_json::from_value(serde_json::json!({
+ "fields": [
+ {"id": 0, "name": "answer", "type": "BIGINT"},
+ {"id": 1, "name": "extra", "type": "BIGINT"}
+ ],
+ "query": "SELECT CAST(7 AS BIGINT) AS answer, CAST(8 AS BIGINT) AS
extra",
+ "dialects": {},
+ "comment": null,
+ "options": {}
+ }))
+ .unwrap();
+ catalog.add_view(paimon::catalog::View::new(
+ Identifier::new("other", "base_view"),
+ other_schema,
+ ));
+ let ctx = make_sql_context(Arc::clone(&catalog)).await;
+ ctx.set_current_database("other").await.unwrap();
+
+ ctx.sql("CREATE VIEW paimon.default.created_view AS SELECT * FROM
base_view")
+ .await
+ .unwrap();
+
+ let view = catalog
+ .get_view(&Identifier::new("default", "created_view"))
+ .await
+ .unwrap();
+ assert_eq!(view.schema().fields().len(), 1);
+ assert_eq!(view.schema().fields()[0].name(), "answer");
+ }
+
+ #[tokio::test]
+ async fn persistent_rest_catalog_view_columns_override_names() {
+ let catalog = Arc::new(MockCatalog::new());
+ let ctx = make_sql_context(Arc::clone(&catalog)).await;
+
+ ctx.sql(
+ "CREATE VIEW paimon.default.named_view (renamed) \
+ AS SELECT CAST(42 AS BIGINT) AS answer",
+ )
+ .await
+ .unwrap();
+
+ let view = catalog
+ .get_view(&Identifier::new("default", "named_view"))
+ .await
+ .unwrap();
+ assert_eq!(view.schema().fields()[0].name(), "renamed");
+ }
+
+ #[tokio::test]
+ async fn persistent_rest_catalog_view_rejects_column_count_mismatch() {
+ let catalog = Arc::new(MockCatalog::new());
+ let ctx = make_sql_context(catalog).await;
+
+ let error = ctx
+ .sql(
+ "CREATE VIEW paimon.default.invalid_view (first, second) \
+ AS SELECT CAST(42 AS BIGINT) AS answer",
+ )
+ .await
+ .unwrap_err();
+
+ assert!(error
+ .to_string()
+ .contains("view column list has 2 columns but query produces 1
columns"));
+ }
+
+ #[tokio::test]
+ async fn persistent_rest_catalog_view_rejects_duplicate_column_names() {
+ let catalog = Arc::new(MockCatalog::new());
+ let ctx = make_sql_context(catalog).await;
+
+ let error = ctx
+ .sql(
+ "CREATE VIEW paimon.default.invalid_view (duplicate,
duplicate) \
+ AS SELECT CAST(42 AS BIGINT), CAST(7 AS BIGINT)",
+ )
+ .await
+ .unwrap_err();
+
+ assert!(
+ error
+ .to_string()
+ .contains("duplicate view column name 'duplicate'"),
+ "unexpected error: {error}"
+ );
+ }
+
+ #[tokio::test]
+ async fn persistent_rest_catalog_view_rejects_duplicate_inferred_names() {
+ let catalog = Arc::new(MockCatalog::new());
+ let ctx = make_sql_context(catalog).await;
+
+ let error = ctx
+ .sql(
+ "CREATE VIEW paimon.default.invalid_view AS \
+ SELECT CAST(42 AS BIGINT) AS duplicate, \
+ CAST(7 AS BIGINT) AS duplicate",
+ )
+ .await
+ .unwrap_err();
+
+ assert!(
+ error
+ .to_string()
+ .contains("Projections require unique expression names"),
+ "unexpected error: {error}"
+ );
+ }
+
+ #[tokio::test]
+ async fn
persistent_rest_catalog_view_if_not_exists_preserves_existing_view() {
+ let catalog = Arc::new(MockCatalog::new());
+ let ctx = make_sql_context(Arc::clone(&catalog)).await;
+
+ ctx.sql(
+ "CREATE VIEW paimon.default.existing_view \
+ AS SELECT CAST(1 AS BIGINT) AS answer",
+ )
+ .await
+ .unwrap();
+ ctx.sql(
+ "CREATE VIEW IF NOT EXISTS paimon.default.existing_view \
+ AS SELECT CAST(2 AS BIGINT) AS answer",
+ )
+ .await
+ .unwrap();
+
+ let view = catalog
+ .get_view(&Identifier::new("default", "existing_view"))
+ .await
+ .unwrap();
+ assert!(view.query_for("datafusion").contains("CAST(1 AS BIGINT)"));
+ }
+
+ #[tokio::test]
+ async fn persistent_rest_catalog_view_rejects_or_replace() {
+ let catalog = Arc::new(MockCatalog::new());
+ let ctx = make_sql_context(catalog).await;
+
+ let error = ctx
+ .sql(
+ "CREATE OR REPLACE VIEW paimon.default.invalid_view \
+ AS SELECT CAST(1 AS BIGINT) AS answer",
+ )
+ .await
+ .unwrap_err();
+
+ assert!(error
+ .to_string()
+ .contains("CREATE OR REPLACE VIEW is not supported"));
+ }
+
+ #[tokio::test]
+ async fn persistent_rest_catalog_view_rejects_unsupported_clauses() {
+ let cases = [
+ (
+ "CREATE OR ALTER VIEW paimon.default.invalid_view AS SELECT 1",
+ "OR ALTER",
+ ),
+ (
+ "CREATE SECURE VIEW paimon.default.invalid_view AS SELECT 1",
+ "SECURE",
+ ),
+ (
+ "CREATE VIEW paimon.default.invalid_view COPY GRANTS AS SELECT
1",
+ "COPY GRANTS",
+ ),
+ (
+ "CREATE VIEW paimon.default.invalid_view IF NOT EXISTS AS
SELECT 1",
+ "name before IF NOT EXISTS",
+ ),
+ (
+ "CREATE VIEW paimon.default.invalid_view WITH ('key' =
'value') AS SELECT 1",
+ "WITH options",
+ ),
+ (
+ "CREATE VIEW paimon.default.invalid_view OPTIONS(key =
'value') AS SELECT 1",
+ "OPTIONS",
+ ),
+ (
+ "CREATE VIEW paimon.default.invalid_view COMMENT = 'comment'
AS SELECT 1",
+ "COMMENT",
+ ),
+ (
+ "CREATE VIEW paimon.default.invalid_view CLUSTER BY (answer)
AS SELECT 1 AS answer",
+ "CLUSTER BY",
+ ),
+ (
+ "CREATE VIEW paimon.default.invalid_view TO default.sink AS
SELECT 1",
+ "TO",
+ ),
+ (
+ "CREATE VIEW paimon.default.invalid_view AS SELECT 1 WITH NO
SCHEMA BINDING",
+ "WITH NO SCHEMA BINDING",
+ ),
+ (
+ "CREATE ALGORITHM = MERGE VIEW paimon.default.invalid_view AS
SELECT 1",
+ "view parameters",
+ ),
+ (
+ "CREATE VIEW paimon.default.invalid_view (answer COMMENT
'comment') AS SELECT 1",
+ "column options",
+ ),
+ ];
+
+ for (sql, clause) 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(clause),
+ "expected error for {clause}, got: {error}"
+ );
+ }
+ }
+
#[tokio::test]
async fn rest_catalog_view_is_planned_lazily() {
let catalog = Arc::new(MockCatalog::new());
diff --git a/crates/paimon/src/api/api_request.rs
b/crates/paimon/src/api/api_request.rs
index 28e4bac..fabcf4a 100644
--- a/crates/paimon/src/api/api_request.rs
+++ b/crates/paimon/src/api/api_request.rs
@@ -23,7 +23,7 @@ use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use crate::{
- catalog::Identifier,
+ catalog::{Identifier, ViewSchema},
spec::{Schema, SchemaChange},
};
@@ -98,6 +98,23 @@ impl CreateTableRequest {
}
}
+/// Request to create a persistent view.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct CreateViewRequest {
+ /// The identifier for the view to create.
+ pub identifier: Identifier,
+ /// The schema and SQL definitions for the view.
+ pub schema: ViewSchema,
+}
+
+impl CreateViewRequest {
+ /// Create a new create-view request.
+ pub fn new(identifier: Identifier, schema: ViewSchema) -> Self {
+ Self { identifier, schema }
+ }
+}
+
/// Request to alter a table's schema.
///
/// Wire-compatible with Java Paimon's `AlterTableRequest` (`{"changes":
[...]}`).
diff --git a/crates/paimon/src/api/mod.rs b/crates/paimon/src/api/mod.rs
index a81067b..643b1ec 100644
--- a/crates/paimon/src/api/mod.rs
+++ b/crates/paimon/src/api/mod.rs
@@ -32,7 +32,7 @@ mod api_response;
// Re-export request types
pub use api_request::{
AlterDatabaseRequest, AlterTableRequest, CreateDatabaseRequest,
CreateTableRequest,
- RenameTableRequest,
+ CreateViewRequest, RenameTableRequest,
};
// Re-export response types
diff --git a/crates/paimon/src/api/rest_api.rs
b/crates/paimon/src/api/rest_api.rs
index 421a23d..37f89fd 100644
--- a/crates/paimon/src/api/rest_api.rs
+++ b/crates/paimon/src/api/rest_api.rs
@@ -23,14 +23,14 @@
use std::collections::HashMap;
use crate::api::rest_client::HttpClient;
-use crate::catalog::Identifier;
+use crate::catalog::{Identifier, ViewSchema};
use crate::common::{CatalogOptions, Options};
use crate::spec::{Partition, PartitionStatistics, Schema, SchemaChange,
Snapshot};
use crate::Result;
use super::api_request::{
AlterDatabaseRequest, AlterTableRequest, CreateDatabaseRequest,
CreateTableRequest,
- RenameTableRequest,
+ CreateViewRequest, RenameTableRequest,
};
use super::api_response::{
ConfigResponse, GetDatabaseResponse, GetFunctionResponse,
GetTableResponse, GetViewResponse,
@@ -397,6 +397,17 @@ impl RESTApi {
// ==================== View Operations ====================
+ /// Create a persistent view.
+ pub async fn create_view(&self, identifier: &Identifier, schema:
ViewSchema) -> Result<()> {
+ let database = identifier.database();
+ let view = identifier.object();
+ validate_non_empty_multi(&[(database, "database name"), (view, "view
name")])?;
+ let path = self.resource_paths.views(database);
+ let request = CreateViewRequest::new(identifier.clone(), schema);
+ let _resp: serde_json::Value = self.client.post(&path,
&request).await?;
+ Ok(())
+ }
+
/// List persistent views in a database.
pub async fn list_views(&self, database: &str) -> Result<Vec<String>> {
validate_non_empty(database, "database name")?;
diff --git a/crates/paimon/src/catalog/mod.rs b/crates/paimon/src/catalog/mod.rs
index fa653a1..8d17300 100644
--- a/crates/paimon/src/catalog/mod.rs
+++ b/crates/paimon/src/catalog/mod.rs
@@ -377,6 +377,18 @@ pub trait Catalog: Send + Sync {
// ======================= view methods ===============================
+ /// Create a persistent view.
+ async fn create_view(
+ &self,
+ _identifier: &Identifier,
+ _schema: ViewSchema,
+ _ignore_if_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 6901312..58eb63b 100644
--- a/crates/paimon/src/catalog/rest/rest_catalog.rs
+++ b/crates/paimon/src/catalog/rest/rest_catalog.rs
@@ -278,6 +278,22 @@ impl Catalog for RESTCatalog {
})
}
+ async fn create_view(
+ &self,
+ identifier: &Identifier,
+ schema: crate::catalog::ViewSchema,
+ ignore_if_exists: bool,
+ ) -> Result<()> {
+ let result = self
+ .api
+ .create_view(identifier, schema)
+ .await
+ .map_err(|error| map_rest_error_for_create_view(error,
identifier));
+ ignore_error_if(result, |error| {
+ ignore_if_exists && matches!(error, Error::ViewAlreadyExist { .. })
+ })
+ }
+
async fn list_views(&self, database_name: &str) -> Result<Vec<String>> {
self.api
.list_views(database_name)
@@ -403,9 +419,26 @@ fn map_rest_error_for_table(err: Error, identifier:
&Identifier) -> Error {
}
}
+/// Map a REST API error from creating a persistent view.
+fn map_rest_error_for_create_view(err: Error, identifier: &Identifier) ->
Error {
+ match err {
+ Error::RestApi {
+ source: RestError::NoSuchResource { .. },
+ } => Error::DatabaseNotExist {
+ database: identifier.database().to_string(),
+ },
+ other => map_rest_error_for_view(other, identifier),
+ }
+}
+
/// Map a REST API error to a catalog-level view error.
fn map_rest_error_for_view(err: Error, identifier: &Identifier) -> Error {
match err {
+ Error::RestApi {
+ source: RestError::AlreadyExists { .. },
+ } => Error::ViewAlreadyExist {
+ full_name: identifier.full_name(),
+ },
Error::RestApi {
source: RestError::NoSuchResource { .. },
} => Error::ViewNotExist {
diff --git a/crates/paimon/src/catalog/view.rs
b/crates/paimon/src/catalog/view.rs
index d99f819..9921cf0 100644
--- a/crates/paimon/src/catalog/view.rs
+++ b/crates/paimon/src/catalog/view.rs
@@ -76,6 +76,23 @@ pub struct ViewSchema {
}
impl ViewSchema {
+ /// Create a persistent view schema.
+ pub fn new(
+ fields: Vec<DataField>,
+ query: String,
+ dialects: HashMap<String, String>,
+ comment: Option<String>,
+ options: HashMap<String, String>,
+ ) -> Self {
+ Self {
+ fields,
+ query,
+ dialects,
+ comment,
+ options,
+ }
+ }
+
/// Declared output fields of the view.
pub fn fields(&self) -> &[DataField] {
&self.fields
diff --git a/crates/paimon/src/error.rs b/crates/paimon/src/error.rs
index ddac874..507ee3a 100644
--- a/crates/paimon/src/error.rs
+++ b/crates/paimon/src/error.rs
@@ -102,6 +102,8 @@ pub enum Error {
TableAlreadyExist { full_name: String },
#[snafu(display("Table {} does not exist.", full_name))]
TableNotExist { full_name: String },
+ #[snafu(display("View {} already exists.", full_name))]
+ ViewAlreadyExist { full_name: String },
#[snafu(display("View {} does not exist.", full_name))]
ViewNotExist { full_name: String },
#[snafu(display("Function {} does not exist.", full_name))]
diff --git a/crates/paimon/tests/mock_server.rs
b/crates/paimon/tests/mock_server.rs
index 99e11e6..35a8a3f 100644
--- a/crates/paimon/tests/mock_server.rs
+++ b/crates/paimon/tests/mock_server.rs
@@ -34,8 +34,8 @@ use std::sync::{Arc, Mutex};
use tokio::task::JoinHandle;
use paimon::api::{
- AlterDatabaseRequest, AlterTableRequest, AuditRESTResponse,
ConfigResponse, ErrorResponse,
- GetDatabaseResponse, GetFunctionResponse, GetTableResponse,
GetViewResponse,
+ AlterDatabaseRequest, AlterTableRequest, AuditRESTResponse,
ConfigResponse, CreateViewRequest,
+ ErrorResponse, GetDatabaseResponse, GetFunctionResponse, GetTableResponse,
GetViewResponse,
ListDatabasesResponse, ListFunctionsResponse, ListTablesResponse,
ListViewsResponse,
RenameTableRequest, ResourcePaths,
};
@@ -399,6 +399,52 @@ impl RESTServer {
.into_response()
}
+ /// Handle POST /databases/:db/views - create a persistent view.
+ pub async fn create_view(
+ Path(db): Path<String>,
+ Extension(state): Extension<Arc<RESTServer>>,
+ Json(request): Json<CreateViewRequest>,
+ ) -> impl IntoResponse {
+ let mut s = state.inner.lock().unwrap();
+ let view = request.identifier.object().to_string();
+ 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 !s.databases.contains_key(&db) {
+ let err = ErrorResponse::new(
+ Some("database".to_string()),
+ Some(db.clone()),
+ Some("Not Found".to_string()),
+ Some(404),
+ );
+ return (StatusCode::NOT_FOUND, Json(err)).into_response();
+ }
+ let key = format!("{db}.{view}");
+ if s.views.contains_key(&key) {
+ let err = ErrorResponse::new(
+ Some("view".to_string()),
+ Some(view),
+ Some("Already Exists".to_string()),
+ Some(409),
+ );
+ return (StatusCode::CONFLICT, Json(err)).into_response();
+ }
+ let response = GetViewResponse::new(
+ Some(view.clone()),
+ Some(view),
+ request.schema,
+ AuditRESTResponse::new(None, None, None, None, None),
+ );
+ s.views.insert(key, response);
+ (StatusCode::OK, Json(serde_json::json!(""))).into_response()
+ }
+
/// Handle GET /databases/:db/functions/:function - get a persistent
function.
pub async fn get_function(
Path((db, function)): Path<(String, String)>,
@@ -945,7 +991,7 @@ pub async fn start_mock_server(
)
.route(
&format!("{prefix}/databases/:db/views"),
- get(RESTServer::list_views),
+ get(RESTServer::list_views).post(RESTServer::create_view),
)
.route(
&format!("{prefix}/databases/:db/views/:view"),
diff --git a/crates/paimon/tests/rest_api_test.rs
b/crates/paimon/tests/rest_api_test.rs
index 07593cd..d90e633 100644
--- a/crates/paimon/tests/rest_api_test.rs
+++ b/crates/paimon/tests/rest_api_test.rs
@@ -110,6 +110,30 @@ async fn test_get_view() {
);
}
+#[tokio::test]
+async fn test_create_view() {
+ let ctx = setup_test_server(vec!["default"]).await;
+ let schema = ViewSchema::new(
+ serde_json::from_value(json!([
+ {"id": 0, "name": "id", "type": "INT"}
+ ]))
+ .unwrap(),
+ "SELECT id FROM source".to_string(),
+ HashMap::from([(
+ "datafusion".to_string(),
+ "SELECT id FROM source".to_string(),
+ )]),
+ None,
+ HashMap::new(),
+ );
+ let identifier = Identifier::new("default", "active_ids");
+
+ ctx.api.create_view(&identifier, schema).await.unwrap();
+
+ let response = ctx.api.get_view(&identifier).await.unwrap();
+ assert_eq!(response.schema.query(), "SELECT id FROM source");
+}
+
#[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 778337c..722a7b2 100644
--- a/crates/paimon/tests/rest_catalog_test.rs
+++ b/crates/paimon/tests/rest_catalog_test.rs
@@ -1025,6 +1025,107 @@ async fn test_catalog_get_view() {
);
}
+#[tokio::test]
+async fn test_catalog_create_view() {
+ let ctx = setup_catalog(vec!["default"]).await;
+ let schema = ViewSchema::new(
+ serde_json::from_value(serde_json::json!([
+ {"id": 0, "name": "id", "type": "INT"}
+ ]))
+ .unwrap(),
+ "SELECT id FROM source".to_string(),
+ HashMap::from([(
+ "datafusion".to_string(),
+ "SELECT id FROM source".to_string(),
+ )]),
+ None,
+ HashMap::new(),
+ );
+ let identifier = Identifier::new("default", "active_ids");
+
+ ctx.catalog
+ .create_view(&identifier, schema, false)
+ .await
+ .unwrap();
+
+ let view = ctx.catalog.get_view(&identifier).await.unwrap();
+ assert_eq!(view.query_for("datafusion"), "SELECT id FROM source");
+}
+
+#[tokio::test]
+async fn test_catalog_create_view_missing_database() {
+ let ctx = setup_catalog(vec!["default"]).await;
+ let identifier = Identifier::new("missing_db", "active_ids");
+ let schema = ViewSchema::new(
+ Vec::new(),
+ "SELECT 1".to_string(),
+ HashMap::new(),
+ None,
+ HashMap::new(),
+ );
+
+ let error = ctx
+ .catalog
+ .create_view(&identifier, schema, false)
+ .await
+ .unwrap_err();
+
+ assert!(matches!(
+ error,
+ paimon::Error::DatabaseNotExist { database } if database ==
"missing_db"
+ ));
+}
+
+#[tokio::test]
+async fn test_catalog_create_view_already_exists() {
+ let ctx = setup_catalog(vec!["default"]).await;
+ let identifier = Identifier::new("default", "active_ids");
+ let schema = ViewSchema::new(
+ Vec::new(),
+ "SELECT 1".to_string(),
+ HashMap::new(),
+ None,
+ HashMap::new(),
+ );
+ ctx.catalog
+ .create_view(&identifier, schema.clone(), false)
+ .await
+ .unwrap();
+
+ let error = ctx
+ .catalog
+ .create_view(&identifier, schema, false)
+ .await
+ .unwrap_err();
+
+ assert!(matches!(
+ error,
+ paimon::Error::ViewAlreadyExist { full_name } if full_name ==
"default.active_ids"
+ ));
+}
+
+#[tokio::test]
+async fn test_catalog_create_view_ignore_if_exists() {
+ let ctx = setup_catalog(vec!["default"]).await;
+ let identifier = Identifier::new("default", "active_ids");
+ let schema = ViewSchema::new(
+ Vec::new(),
+ "SELECT 1".to_string(),
+ HashMap::new(),
+ None,
+ HashMap::new(),
+ );
+ ctx.catalog
+ .create_view(&identifier, schema.clone(), false)
+ .await
+ .unwrap();
+
+ ctx.catalog
+ .create_view(&identifier, schema, true)
+ .await
+ .unwrap();
+}
+
#[tokio::test]
async fn test_catalog_list_views() {
let ctx = setup_catalog(vec!["default"]).await;
@@ -1153,6 +1254,23 @@ async fn
test_catalog_maps_unsupported_view_and_function_endpoints() {
let ctx = setup_catalog(vec!["default"]).await;
ctx.server.set_view_function_endpoints_unsupported();
+ assert!(matches!(
+ ctx.catalog
+ .create_view(
+ &Identifier::new("default", "view"),
+ ViewSchema::new(
+ Vec::new(),
+ "SELECT 1".to_string(),
+ HashMap::new(),
+ None,
+ HashMap::new(),
+ ),
+ false,
+ )
+ .await
+ .unwrap_err(),
+ paimon::Error::Unsupported { .. }
+ ));
assert!(matches!(
ctx.catalog.list_views("default").await.unwrap_err(),
paimon::Error::Unsupported { .. }
diff --git a/crates/paimon/tests/rest_object_models_test.rs
b/crates/paimon/tests/rest_object_models_test.rs
index 3c16bbd..6e2806a 100644
--- a/crates/paimon/tests/rest_object_models_test.rs
+++ b/crates/paimon/tests/rest_object_models_test.rs
@@ -17,10 +17,67 @@
use std::collections::HashMap;
+use paimon::api::CreateViewRequest;
use paimon::catalog::{Function, FunctionDefinition, Identifier, View,
ViewSchema};
use paimon::spec::DataField;
use serde_json::json;
+#[test]
+fn construct_view_schema_contract() {
+ let fields: Vec<DataField> = serde_json::from_value(json!([
+ {"id": 0, "name": "id", "type": "INT"}
+ ]))
+ .unwrap();
+ let schema = ViewSchema::new(
+ fields,
+ "SELECT id FROM source".to_string(),
+ HashMap::from([(
+ "datafusion".to_string(),
+ "SELECT id FROM source".to_string(),
+ )]),
+ None,
+ HashMap::new(),
+ );
+
+ assert_eq!(schema.fields()[0].name(), "id");
+ assert_eq!(schema.query_for("datafusion"), "SELECT id FROM source");
+}
+
+#[test]
+fn create_view_request_serialization_contract() {
+ let fields: Vec<DataField> = serde_json::from_value(json!([
+ {"id": 0, "name": "id", "type": "INT"}
+ ]))
+ .unwrap();
+ let request = CreateViewRequest::new(
+ Identifier::new("analytics", "active_ids"),
+ ViewSchema::new(
+ fields,
+ "SELECT id FROM source".to_string(),
+ HashMap::from([(
+ "datafusion".to_string(),
+ "SELECT id FROM source".to_string(),
+ )]),
+ None,
+ HashMap::new(),
+ ),
+ );
+
+ assert_eq!(
+ serde_json::to_value(request).unwrap(),
+ json!({
+ "identifier": {"database": "analytics", "object": "active_ids"},
+ "schema": {
+ "fields": [{"id": 0, "name": "id", "type": "INT"}],
+ "query": "SELECT id FROM source",
+ "dialects": {"datafusion": "SELECT id FROM source"},
+ "comment": null,
+ "options": {}
+ }
+ })
+ );
+}
+
#[test]
fn deserialize_view_schema_contract() {
let schema: ViewSchema = serde_json::from_value(json!({
diff --git a/docs/src/sql.md b/docs/src/sql.md
index 2d58ba6..0a6073f 100644
--- a/docs/src/sql.md
+++ b/docs/src/sql.md
@@ -40,9 +40,9 @@ 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`, `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 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`, persistent `CREATE VIEW`,
`CREATE MATERIALIZED VIEW`, and persistent `CREATE TABLE AS SELECT` are
rejected or not implemented. DataFusion `COPY` can export query results to
files; it does not create or commit Paimon table files.
+Not every DataFusion DDL/DML statement maps to a Paimon table operation. For
Paimon catalogs, `CREATE EXTERNAL TABLE`, `LOCATION`, `CREATE MATERIALIZED
VIEW`, `CREATE FUNCTION`, and persistent `CREATE TABLE AS SELECT` are rejected
or not implemented. DataFusion `COPY` can export query results to files; it
does not create or commit Paimon table files.
For the exact delegated SQL grammar, see the [DataFusion SQL
Reference](https://datafusion.apache.org/user-guide/sql/index.html).
@@ -76,12 +76,41 @@ catalogs; registering a catalog also registers the built-in
scalar function
feature is enabled) against it. It also manages session-scoped dynamic options
internally for `SET`/`RESET` support.
-### Existing REST Catalog Views and SQL Functions
+### REST Catalog Views and SQL Functions
When the registered catalog is a Paimon REST Catalog, `SQLContext` can read and
-execute persistent views and SQL scalar functions that already exist in the
-catalog. This integration is read-only: it does not add `CREATE`, `ALTER`, or
-`DROP` support for persistent views or functions.
+execute persistent views and SQL scalar functions in the catalog. It can also
+create persistent views with this syntax:
+
+```sql
+CREATE VIEW [IF NOT EXISTS] view_name [(column_name, ...)] AS query;
+```
+
+For example:
+
+```sql
+CREATE VIEW paimon.reporting.daily_orders (order_date, order_count) AS
+SELECT order_date, COUNT(*)
+FROM orders
+GROUP BY order_date;
+```
+
+The defining query is planned before the view is created. Its output types and
+nullability become the stored REST view schema, with field IDs assigned from
+zero. An optional column list changes only the output names and must contain
+exactly one unique name per query column. `IF NOT EXISTS` is passed to the
+catalog so the REST server handles concurrent creates atomically.
+
+Unqualified relations and REST SQL functions in the defining query resolve in
+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 FUNCTION`, `ALTER FUNCTION`, and `DROP FUNCTION` are also not
supported.
Persistent views resolve through the normal DataFusion catalog path, so they
can be queried wherever a table can be used: