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 009039f feat(datafusion): support REST Catalog CREATE FUNCTION (#499)
009039f is described below
commit 009039f11c3668cbd2994e18758d4344cc62e6e4
Author: Jingsong Lee <[email protected]>
AuthorDate: Sat Jul 11 16:59:45 2026 +0800
feat(datafusion): support REST Catalog CREATE FUNCTION (#499)
---
crates/integrations/datafusion/README.md | 23 +-
crates/integrations/datafusion/src/sql_context.rs | 972 ++++++++++++++++++++-
crates/integrations/datafusion/src/sql_function.rs | 75 +-
crates/integrations/datafusion/src/table/mod.rs | 2 +-
crates/paimon/src/api/api_request.rs | 39 +-
crates/paimon/src/api/mod.rs | 4 +-
crates/paimon/src/api/rest_api.rs | 19 +-
crates/paimon/src/catalog/mod.rs | 15 +
crates/paimon/src/catalog/rest/rest_catalog.rs | 32 +
crates/paimon/src/error.rs | 2 +
crates/paimon/tests/mock_server.rs | 68 +-
crates/paimon/tests/rest_api_test.rs | 35 +
crates/paimon/tests/rest_catalog_test.rs | 135 +++
crates/paimon/tests/rest_object_models_test.rs | 69 +-
docs/src/sql.md | 71 +-
15 files changed, 1512 insertions(+), 49 deletions(-)
diff --git a/crates/integrations/datafusion/README.md
b/crates/integrations/datafusion/README.md
index 0f6a664..c4fa4d2 100644
--- a/crates/integrations/datafusion/README.md
+++ b/crates/integrations/datafusion/README.md
@@ -26,11 +26,16 @@ 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 in a Paimon
REST Catalog. It can
-also create persistent REST Catalog views:
+`SQLContext` can read, execute, and create persistent views and SQL scalar
functions in a Paimon
+REST Catalog:
```sql
CREATE VIEW [IF NOT EXISTS] view_name [(column_name, ...)] AS query;
+
+CREATE FUNCTION [IF NOT EXISTS] function_name([parameter_name data_type, ...])
+RETURNS data_type
+[LANGUAGE SQL]
+RETURN scalar_expression;
```
- A persistent view is resolved lazily like a table. The `datafusion` dialect
is preferred and the
@@ -44,10 +49,15 @@ CREATE VIEW [IF NOT EXISTS] view_name [(column_name, ...)]
AS query;
`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.
+- `CREATE FUNCTION` requires named parameters, one return type, and a scalar
`RETURN` expression.
+ `LANGUAGE SQL` is optional and SQL is the default, matching Databricks
syntax. Determinism is
+ 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`, 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.
+ `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.
Use `SQLContext::sql` for function expansion:
@@ -56,8 +66,9 @@ 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?;
+ctx.sql("CREATE FUNCTION plus_one(x BIGINT) RETURNS BIGINT RETURN x +
1").await?;
let view = ctx.sql("SELECT * FROM analytics_view").await?;
-let function = ctx.sql("SELECT normalize_score(score) FROM scores").await?;
+let function = ctx.sql("SELECT plus_one(score) FROM scores").await?;
```
See the [documentation](https://paimon.apache.org/docs/rust/datafusion/) for
getting started guide and more details.
diff --git a/crates/integrations/datafusion/src/sql_context.rs
b/crates/integrations/datafusion/src/sql_context.rs
index bc00552..ec72013 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`
+//! - `CREATE FUNCTION name(args) RETURNS type [LANGUAGE SQL] RETURN
expression`
//! - `TRUNCATE TABLE db.t`
//! - `TRUNCATE TABLE db.t PARTITION (col = val, ...)`
@@ -44,20 +45,25 @@ use datafusion::arrow::array::{
use datafusion::arrow::compute::cast;
use datafusion::arrow::datatypes::{DataType as ArrowDataType, Field, Schema};
use datafusion::arrow::record_batch::RecordBatch;
+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::SessionStateBuilder;
+use datafusion::logical_expr::{Expr as LogicalExpr, LogicalPlan, Volatility};
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,
- ObjectType, RenameTableNameKind, Reset, ResetStatement, Set,
ShowCreateObject, SqlOption,
- Statement, TableFactor, TableObject, Truncate, Update, Value as SqlValue,
+ AlterTableOperation, BinaryLength, CharacterLength, ColumnDef,
ColumnOption, CreateFunction,
+ CreateFunctionBody, CreateTable, CreateTableOptions, CreateView, Delete,
Expr as SqlExpr,
+ FromTable, FunctionBehavior, FunctionReturnType, Insert, Merge,
ObjectName, ObjectType,
+ RenameTableNameKind, Reset, ResetStatement, Set, ShowCreateObject,
SqlOption, Statement,
+ TableFactor, TableObject, Truncate, Update, Value as SqlValue,
};
use datafusion::sql::sqlparser::dialect::GenericDialect;
+use datafusion::sql::sqlparser::keywords::Keyword;
use datafusion::sql::sqlparser::parser::Parser;
+use datafusion::sql::sqlparser::tokenizer::{Token, Tokenizer};
use futures::StreamExt;
use paimon::catalog::{parse_object_name, Catalog, Identifier};
use paimon::spec::{
@@ -356,8 +362,7 @@ impl SQLContext {
return self.handle_time_travel_query(&rewritten_sql).await;
}
- let statements = Parser::parse_sql(&GenericDialect {}, &rewritten_sql)
- .map_err(|e| DataFusionError::Plan(format!("SQL parse error:
{e}")))?;
+ let statements = parse_sql_statements(&rewritten_sql)?;
if statements.len() != 1 {
return Err(DataFusionError::Plan(
@@ -450,6 +455,13 @@ impl SQLContext {
}
}
}
+ Statement::CreateFunction(create_function) => {
+ if self.is_paimon_function_name(&create_function.name) {
+ self.handle_create_function(create_function).await
+ } else {
+ self.ctx.sql(sql).await
+ }
+ }
Statement::Drop {
object_type,
if_exists,
@@ -1430,6 +1442,157 @@ impl SQLContext {
}
}
+ async fn handle_create_function(
+ &self,
+ create_function: &CreateFunction,
+ ) -> DFResult<DataFrame> {
+ validate_persistent_create_function(create_function)?;
+ if create_function
+ .language
+ .as_ref()
+ .is_some_and(|language|
!language.value.eq_ignore_ascii_case("sql"))
+ {
+ return Err(DataFusionError::Plan(
+ "CREATE FUNCTION only supports LANGUAGE SQL".to_string(),
+ ));
+ }
+ if matches!(
+ create_function.behavior,
+ Some(FunctionBehavior::Stable | FunctionBehavior::Volatile)
+ ) {
+ return Err(DataFusionError::Plan(
+ "CREATE FUNCTION only supports deterministic SQL
functions".to_string(),
+ ));
+ }
+ let FunctionReturnType::DataType(return_type) = create_function
+ .return_type
+ .as_ref()
+ .ok_or_else(|| DataFusionError::Plan("CREATE FUNCTION requires
RETURNS".to_string()))?
+ else {
+ return Err(DataFusionError::Plan(
+ "CREATE FUNCTION SETOF return types are not
supported".to_string(),
+ ));
+ };
+ let CreateFunctionBody::Return(body) = create_function
+ .function_body
+ .as_ref()
+ .ok_or_else(|| DataFusionError::Plan("CREATE FUNCTION requires
RETURN".to_string()))?
+ else {
+ return Err(DataFusionError::Plan(
+ "CREATE FUNCTION only supports a RETURN
expression".to_string(),
+ ));
+ };
+
+ let mut parameter_names = HashSet::new();
+ let input_params = create_function
+ .args
+ .as_deref()
+ .unwrap_or_default()
+ .iter()
+ .enumerate()
+ .map(|(id, argument)| {
+ if argument.mode.is_some() || argument.default_expr.is_some() {
+ return Err(DataFusionError::Plan(
+ "CREATE FUNCTION argument modes and defaults are not
supported".to_string(),
+ ));
+ }
+ let name = argument
+ .name
+ .clone()
+ .map(normalize_create_function_argument_name)
+ .ok_or_else(|| {
+ DataFusionError::Plan(
+ "CREATE FUNCTION arguments must have
names".to_string(),
+ )
+ })?;
+ if !parameter_names.insert(name.clone()) {
+ return Err(DataFusionError::Plan(format!(
+ "duplicate function argument name '{name}'"
+ )));
+ }
+ Ok(PaimonDataField::new(
+ id as i32,
+ name,
+ sql_data_type_to_paimon_type(&argument.data_type, true)?,
+ ))
+ })
+ .collect::<DFResult<Vec<_>>>()?;
+ let return_params = vec![PaimonDataField::new(
+ 0,
+ "result".to_string(),
+ sql_data_type_to_paimon_type(return_type, true)?,
+ )];
+ let (catalog, catalog_name, identifier) =
+ self.resolve_catalog_and_function(&create_function.name)?;
+ let function = paimon::catalog::Function::new(
+ identifier,
+ Some(input_params),
+ Some(return_params),
+ true,
+ HashMap::from([(
+ "datafusion".to_string(),
+ paimon::catalog::FunctionDefinition::Sql {
+ definition: body.to_string(),
+ },
+ )]),
+ None,
+ HashMap::new(),
+ );
+ self.validate_create_function(&function, &catalog_name)
+ .await?;
+ catalog
+ .create_function(&function, create_function.if_not_exists)
+ .await
+ .map_err(to_datafusion_error)?;
+ ok_result(&self.ctx)
+ }
+
+ async fn validate_create_function(
+ &self,
+ function: &paimon::catalog::Function,
+ catalog_name: &str,
+ ) -> DFResult<()> {
+ let arguments = function
+ .input_params()
+ .unwrap_or_default()
+ .iter()
+ .map(|field| {
+ let sql_type =
+
crate::table::data_type_to_sql(field.data_type()).map_err(|error| {
+ DataFusionError::Plan(format!(
+ "Invalid CREATE FUNCTION argument type '{:?}':
{error}",
+ field.data_type()
+ ))
+ })?;
+ Ok(format!("CAST(NULL AS {sql_type})"))
+ })
+ .collect::<DFResult<Vec<_>>>()?
+ .join(", ");
+ let quote = |identifier: &str| format!("\"{}\"",
identifier.replace('"', "\"\""));
+ let validation_sql = format!(
+ "SELECT {}.{}.{}({arguments})",
+ quote(catalog_name),
+ quote(function.identifier().database()),
+ quote(function.name())
+ );
+ let expanded = crate::sql_function::expand_sql_with_candidate(
+ &validation_sql,
+ &self.catalogs,
+ catalog_name,
+ function.identifier().database(),
+ function,
+ )
+ .await?;
+ let mut state = self.ctx.state();
+ state.config_mut().options_mut().catalog.default_catalog =
catalog_name.to_string();
+ state.config_mut().options_mut().catalog.default_schema =
+ function.identifier().database().to_string();
+ let logical_plan = state.create_logical_plan(&expanded).await?;
+ validate_immutable_scalar_plan(&logical_plan)?;
+ state.create_physical_plan(&logical_plan).await?;
+ Ok(())
+ }
+
async fn handle_drop_partitions(
&self,
catalog: &Arc<dyn Catalog>,
@@ -1496,6 +1659,77 @@ impl SQLContext {
self.catalogs.contains_key(&catalog_name)
}
+ fn is_paimon_function_name(&self, name: &ObjectName) -> bool {
+ let Some(parts) = name
+ .0
+ .iter()
+ .map(|part| {
+ part.as_ident()
+ .map(|identifier|
IdentNormalizer::default().normalize(identifier.clone()))
+ })
+ .collect::<Option<Vec<_>>>()
+ else {
+ return false;
+ };
+ let catalog_name = match parts.as_slice() {
+ [catalog, _, _] => catalog.clone(),
+ [_] | [_, _] => self.current_catalog_name(),
+ _ => return false,
+ };
+ self.catalogs.contains_key(&catalog_name)
+ }
+
+ fn resolve_catalog_and_function(
+ &self,
+ name: &ObjectName,
+ ) -> DFResult<(Arc<dyn Catalog>, String, Identifier)> {
+ let parts = name
+ .0
+ .iter()
+ .map(|part| {
+ part.as_ident()
+ .cloned()
+ .map(|identifier|
IdentNormalizer::default().normalize(identifier))
+ .ok_or_else(|| {
+ DataFusionError::Plan(format!("Invalid function
reference: {name}"))
+ })
+ })
+ .collect::<DFResult<Vec<_>>>()?;
+ match parts.as_slice() {
+ [catalog_name, database, function] => {
+ let catalog = self.catalogs.get(catalog_name).ok_or_else(|| {
+ DataFusionError::Plan(format!("Unknown catalog
'{catalog_name}'"))
+ })?;
+ Ok((
+ Arc::clone(catalog),
+ catalog_name.clone(),
+ Identifier::new(database, function),
+ ))
+ }
+ [database, function] => Ok((
+ self.current_catalog()?,
+ self.current_catalog_name(),
+ Identifier::new(database, function),
+ )),
+ [function] => Ok((
+ self.current_catalog()?,
+ self.current_catalog_name(),
+ Identifier::new(
+ self.ctx
+ .state()
+ .config_options()
+ .catalog
+ .default_schema
+ .clone(),
+ function,
+ ),
+ )),
+ _ => Err(DataFusionError::Plan(format!(
+ "Invalid function reference: {name}"
+ ))),
+ }
+ }
+
/// Resolve an ObjectName like `catalog.db.table` or `db.table` to a
catalog and Identifier.
fn resolve_catalog_and_table(
&self,
@@ -1569,6 +1803,182 @@ impl SQLContext {
}
}
+fn validate_immutable_scalar_plan(plan: &LogicalPlan) -> DFResult<()> {
+ let mut violation = None;
+ plan.apply(|node| {
+ node.apply_expressions(|expression| {
+ expression.apply(|expression| {
+ match expression {
+ LogicalExpr::ScalarFunction(function)
+ if function.func.signature().volatility !=
Volatility::Immutable =>
+ {
+ violation = Some(format!(
+ "CREATE FUNCTION body uses non-immutable function
'{}'",
+ function.func.name()
+ ));
+ }
+ LogicalExpr::HigherOrderFunction(function)
+ if function.func.signature().volatility !=
Volatility::Immutable =>
+ {
+ violation = Some(format!(
+ "CREATE FUNCTION body uses non-immutable function
'{}'",
+ function.func.name()
+ ));
+ }
+ LogicalExpr::AggregateFunction(_) |
LogicalExpr::WindowFunction(_) => {
+ violation = Some(
+ "CREATE FUNCTION body must be a scalar expression;
aggregate and window functions are not supported"
+ .to_string(),
+ );
+ }
+ LogicalExpr::Exists(_)
+ | LogicalExpr::InSubquery(_)
+ | LogicalExpr::SetComparison(_)
+ | LogicalExpr::ScalarSubquery(_) => {
+ violation = Some(
+ "CREATE FUNCTION body must be a scalar expression;
subqueries are not supported"
+ .to_string(),
+ );
+ }
+ LogicalExpr::Unnest(_) => {
+ violation = Some(
+ "CREATE FUNCTION body must be a scalar expression;
UNNEST is not supported"
+ .to_string(),
+ );
+ }
+ _ => {}
+ }
+ Ok(TreeNodeRecursion::Continue)
+ })?;
+ Ok(TreeNodeRecursion::Continue)
+ })?;
+ Ok(TreeNodeRecursion::Continue)
+ })?;
+ if let Some(message) = violation {
+ return Err(DataFusionError::Plan(message));
+ }
+ Ok(())
+}
+
+fn normalize_create_function_argument_name(
+ identifier: datafusion::sql::sqlparser::ast::Ident,
+) -> String {
+ if identifier.quote_style.is_none() {
+ if let Some(value) = identifier
+ .value
+ .strip_prefix('"')
+ .and_then(|value| value.strip_suffix('"'))
+ {
+ return value.replace("\"\"", "\"");
+ }
+ }
+ IdentNormalizer::default().normalize(identifier)
+}
+
+fn validate_persistent_create_function(create_function: &CreateFunction) ->
DFResult<()> {
+ let unsupported = if create_function.or_alter {
+ Some("CREATE OR ALTER FUNCTION is not supported")
+ } else if create_function.or_replace {
+ Some("CREATE OR REPLACE FUNCTION is not supported")
+ } else if create_function.temporary {
+ Some("CREATE TEMPORARY FUNCTION is not supported")
+ } else if create_function.args.is_none() {
+ Some("CREATE FUNCTION requires a parenthesized argument list")
+ } else if create_function.called_on_null.is_some() {
+ Some("CREATE FUNCTION NULL INPUT clauses are not supported")
+ } else if create_function.parallel.is_some() {
+ Some("CREATE FUNCTION PARALLEL clauses are not supported")
+ } else if create_function.security.is_some() {
+ Some("CREATE FUNCTION SECURITY clauses are not supported")
+ } else if !create_function.set_params.is_empty() {
+ Some("CREATE FUNCTION SET clauses are not supported")
+ } else if create_function.using.is_some() {
+ Some("CREATE FUNCTION USING clauses are not supported")
+ } else if create_function.determinism_specifier.is_some() {
+ Some("CREATE FUNCTION determinism specifiers are not supported")
+ } else if create_function.options.is_some() {
+ Some("CREATE FUNCTION OPTIONS clauses are not supported")
+ } else if create_function.remote_connection.is_some() {
+ Some("CREATE FUNCTION REMOTE clauses are not supported")
+ } else {
+ None
+ };
+ if let Some(message) = unsupported {
+ return Err(DataFusionError::Plan(message.to_string()));
+ }
+ Ok(())
+}
+
+fn parse_sql_statements(sql: &str) -> DFResult<Vec<Statement>> {
+ let dialect = GenericDialect {};
+ let mut tokens = Tokenizer::new(&dialect, sql)
+ .tokenize_with_location()
+ .map_err(|error| DataFusionError::Plan(format!("SQL parse error:
{error}")))?;
+ let significant = tokens
+ .iter()
+ .enumerate()
+ .filter_map(|(index, token)| match &token.token {
+ Token::Whitespace(_) => None,
+ Token::Word(word) => Some((index, word.keyword)),
+ _ => Some((index, Keyword::NoKeyword)),
+ })
+ .take(5)
+ .collect::<Vec<_>>();
+ let create_function_if_not_exists = matches!(
+ significant.as_slice(),
+ [
+ (_, Keyword::CREATE),
+ (_, Keyword::FUNCTION),
+ (_, Keyword::IF),
+ (_, Keyword::NOT),
+ (_, Keyword::EXISTS)
+ ]
+ );
+ let create_function_or_alter = matches!(
+ significant.as_slice(),
+ [
+ (_, Keyword::CREATE),
+ (_, Keyword::OR),
+ (_, Keyword::ALTER),
+ (_, Keyword::FUNCTION),
+ ..
+ ]
+ );
+ if create_function_if_not_exists {
+ let removed = significant[2..=4]
+ .iter()
+ .map(|(index, _)| *index)
+ .collect::<HashSet<_>>();
+ tokens = tokens
+ .into_iter()
+ .enumerate()
+ .filter(|(index, _)| !removed.contains(index))
+ .map(|(_, token)| token)
+ .collect();
+ }
+ let mut statements = Parser::new(&dialect)
+ .with_tokens_with_locations(tokens)
+ .parse_statements()
+ .map_err(|error| DataFusionError::Plan(format!("SQL parse error:
{error}")))?;
+ if create_function_if_not_exists {
+ let Some(Statement::CreateFunction(create_function)) =
statements.first_mut() else {
+ return Err(DataFusionError::Plan(
+ "SQL parse error: invalid CREATE FUNCTION IF NOT EXISTS
statement".to_string(),
+ ));
+ };
+ create_function.if_not_exists = true;
+ }
+ if create_function_or_alter {
+ let Some(Statement::CreateFunction(create_function)) =
statements.first_mut() else {
+ return Err(DataFusionError::Plan(
+ "SQL parse error: invalid CREATE OR ALTER FUNCTION
statement".to_string(),
+ ));
+ };
+ create_function.or_alter = true;
+ }
+ Ok(statements)
+}
+
fn validate_persistent_create_view(create_view: &CreateView) -> DFResult<()> {
let unsupported = if create_view.or_alter {
Some("CREATE OR ALTER VIEW is not supported")
@@ -2970,6 +3380,24 @@ mod tests {
.collect())
}
+ async fn create_function(
+ &self,
+ function: &paimon::catalog::Function,
+ ignore_if_exists: bool,
+ ) -> paimon::Result<()> {
+ let mut functions = self.functions.lock().unwrap();
+ if functions.contains_key(function.identifier()) {
+ if ignore_if_exists {
+ return Ok(());
+ }
+ return Err(paimon::Error::FunctionAlreadyExist {
+ full_name: function.full_name(),
+ });
+ }
+ functions.insert(function.identifier().clone(), function.clone());
+ Ok(())
+ }
+
async fn get_function(
&self,
identifier: &Identifier,
@@ -3143,6 +3571,538 @@ mod tests {
assert_eq!(answers.value(0), 42);
}
+ #[tokio::test]
+ async fn persistent_rest_catalog_function_can_be_created_and_called() {
+ let catalog = Arc::new(MockCatalog::new());
+ let ctx = make_sql_context(Arc::clone(&catalog)).await;
+
+ ctx.sql(
+ "CREATE FUNCTION plus_one(x BIGINT) RETURNS BIGINT \
+ LANGUAGE SQL IMMUTABLE RETURN x + 1",
+ )
+ .await
+ .unwrap();
+
+ let stored = catalog
+ .get_function(&Identifier::new("default", "plus_one"))
+ .await
+ .unwrap();
+ assert_eq!(stored.input_params().unwrap()[0].id(), 0);
+ assert_eq!(stored.input_params().unwrap()[0].name(), "x");
+ assert!(stored.input_params().unwrap()[0].data_type().is_nullable());
+ assert_eq!(stored.return_params().unwrap()[0].id(), 0);
+ assert_eq!(stored.return_params().unwrap()[0].name(), "result");
+ assert!(stored.return_params().unwrap()[0].data_type().is_nullable());
+
+ let batches = ctx
+ .sql("SELECT plus_one(41) AS answer")
+ .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_function_uses_databricks_default_sql_syntax() {
+ let catalog = Arc::new(MockCatalog::new());
+ let ctx = make_sql_context(Arc::clone(&catalog)).await;
+
+ ctx.sql("CREATE FUNCTION plus_one(x BIGINT) RETURNS BIGINT RETURN x +
1")
+ .await
+ .unwrap();
+
+ let stored = catalog
+ .get_function(&Identifier::new("default", "plus_one"))
+ .await
+ .unwrap();
+ assert!(stored.is_deterministic());
+
+ let batches = ctx
+ .sql("SELECT plus_one(41) AS answer")
+ .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_function_supports_array_argument() {
+ let catalog = Arc::new(MockCatalog::new());
+ let ctx = make_sql_context(Arc::clone(&catalog)).await;
+
+ ctx.sql(
+ "CREATE FUNCTION array_answer(x ARRAY<BIGINT>) \
+ RETURNS BIGINT RETURN 42",
+ )
+ .await
+ .unwrap();
+
+ assert!(catalog
+ .get_function(&Identifier::new("default", "array_answer"))
+ .await
+ .is_ok());
+ }
+
+ #[tokio::test]
+ async fn persistent_rest_catalog_function_supports_array_return_type() {
+ let catalog = Arc::new(MockCatalog::new());
+ let ctx = make_sql_context(catalog).await;
+
+ ctx.sql(
+ "CREATE FUNCTION singleton(x BIGINT) \
+ RETURNS ARRAY<BIGINT> RETURN make_array(x)",
+ )
+ .await
+ .unwrap();
+
+ let batches = ctx
+ .sql("SELECT singleton(42) AS answer")
+ .await
+ .unwrap()
+ .collect()
+ .await
+ .unwrap();
+ assert_eq!(batches[0].num_rows(), 1);
+ assert!(matches!(
+ batches[0]
+ .schema()
+ .field_with_name("answer")
+ .unwrap()
+ .data_type(),
+ ArrowDataType::List(_)
+ ));
+ }
+
+ #[tokio::test]
+ async fn persistent_rest_catalog_function_normalizes_unquoted_bare_name() {
+ let catalog = Arc::new(MockCatalog::new());
+ let ctx = make_sql_context(Arc::clone(&catalog)).await;
+
+ ctx.sql(
+ "CREATE FUNCTION PlusOne(X BIGINT) RETURNS BIGINT \
+ LANGUAGE SQL IMMUTABLE RETURN X + 1",
+ )
+ .await
+ .unwrap();
+
+ let stored = catalog
+ .get_function(&Identifier::new("default", "plusone"))
+ .await
+ .unwrap();
+ assert_eq!(stored.input_params().unwrap()[0].name(), "x");
+ let batches = ctx
+ .sql("SELECT plusone(41) AS answer")
+ .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_function_preserves_quoted_names() {
+ let catalog = Arc::new(MockCatalog::new());
+ let ctx = make_sql_context(Arc::clone(&catalog)).await;
+
+ ctx.sql(
+ "CREATE FUNCTION \"PlusOne\"(\"Input\" BIGINT) RETURNS BIGINT \
+ LANGUAGE SQL IMMUTABLE RETURN \"Input\" + 1",
+ )
+ .await
+ .unwrap();
+
+ let stored = catalog
+ .get_function(&Identifier::new("default", "PlusOne"))
+ .await
+ .unwrap();
+ assert_eq!(stored.input_params().unwrap()[0].name(), "Input");
+ let batches = ctx
+ .sql("SELECT \"PlusOne\"(41) AS answer")
+ .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_function_if_not_exists_preserves_existing_function() {
+ let catalog = Arc::new(MockCatalog::new());
+ let ctx = make_sql_context(Arc::clone(&catalog)).await;
+ ctx.sql(
+ "CREATE FUNCTION answer() RETURNS BIGINT \
+ LANGUAGE SQL IMMUTABLE RETURN 1",
+ )
+ .await
+ .unwrap();
+
+ ctx.sql(
+ "CrEaTe /* keep comments */ FuNcTiOn IF /* gap */ NOT EXISTS
answer() \
+ RETURNS BIGINT LANGUAGE SQL IMMUTABLE RETURN 2",
+ )
+ .await
+ .unwrap();
+
+ let stored = catalog
+ .get_function(&Identifier::new("default", "answer"))
+ .await
+ .unwrap();
+ assert_eq!(
+ stored
+ .definition("datafusion")
+ .and_then(paimon::catalog::FunctionDefinition::sql),
+ Some("1")
+ );
+ }
+
+ #[tokio::test]
+ async fn
persistent_rest_catalog_function_if_not_exists_validates_proposed_body() {
+ let catalog = Arc::new(MockCatalog::new());
+ let ctx = make_sql_context(Arc::clone(&catalog)).await;
+ ctx.sql(
+ "CREATE FUNCTION answer() RETURNS BIGINT \
+ LANGUAGE SQL IMMUTABLE RETURN 1",
+ )
+ .await
+ .unwrap();
+
+ let error = ctx
+ .sql(
+ "CREATE FUNCTION IF NOT EXISTS answer() RETURNS BIGINT \
+ LANGUAGE SQL IMMUTABLE RETURN undeclared + 1",
+ )
+ .await
+ .unwrap_err();
+
+ assert!(error.to_string().contains("undeclared identifier"));
+ let stored = catalog
+ .get_function(&Identifier::new("default", "answer"))
+ .await
+ .unwrap();
+ assert_eq!(
+ stored
+ .definition("datafusion")
+ .and_then(paimon::catalog::FunctionDefinition::sql),
+ Some("1")
+ );
+ }
+
+ #[tokio::test]
+ async fn
persistent_rest_catalog_function_uses_owning_database_for_dependencies() {
+ 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 FUNCTION paimon.default.wrapper(x BIGINT) RETURNS BIGINT \
+ LANGUAGE SQL IMMUTABLE RETURN plus_one(x)",
+ )
+ .await
+ .unwrap();
+
+ let batches = ctx
+ .sql("SELECT paimon.default.wrapper(41) AS answer")
+ .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_function_rejects_nondeterministic_dependency() {
+ let catalog = Arc::new(MockCatalog::new());
+ add_unary_sql_function(&catalog, "unstable", "x + 1", false);
+ let ctx = make_sql_context(Arc::clone(&catalog)).await;
+
+ let error = ctx
+ .sql(
+ "CREATE FUNCTION wrapper(x BIGINT) RETURNS BIGINT \
+ LANGUAGE SQL IMMUTABLE RETURN unstable(x)",
+ )
+ .await
+ .unwrap_err();
+
+ assert!(error.to_string().contains("non-deterministic"));
+ assert!(matches!(
+ catalog
+ .get_function(&Identifier::new("default", "wrapper"))
+ .await,
+ Err(paimon::Error::FunctionNotExist { .. })
+ ));
+ }
+
+ #[tokio::test]
+ async fn
persistent_rest_catalog_function_rejects_direct_recursion_before_create() {
+ let catalog = Arc::new(MockCatalog::new());
+ let ctx = make_sql_context(Arc::clone(&catalog)).await;
+
+ let error = ctx
+ .sql(
+ "CREATE FUNCTION abs(x BIGINT) RETURNS BIGINT \
+ LANGUAGE SQL IMMUTABLE RETURN abs(x)",
+ )
+ .await
+ .unwrap_err();
+
+ assert!(
+ error.to_string().contains("recursive REST SQL function"),
+ "unexpected error: {error}"
+ );
+ assert!(matches!(
+ catalog
+ .get_function(&Identifier::new("default", "abs"))
+ .await,
+ Err(paimon::Error::FunctionNotExist { .. })
+ ));
+ }
+
+ #[tokio::test]
+ async fn
persistent_rest_catalog_function_rejects_indirect_recursion_before_create() {
+ let catalog = Arc::new(MockCatalog::new());
+ add_unary_sql_function(&catalog, "existing", "candidate(x)", true);
+ let ctx = make_sql_context(Arc::clone(&catalog)).await;
+
+ let error = ctx
+ .sql(
+ "CREATE FUNCTION candidate(x BIGINT) RETURNS BIGINT \
+ LANGUAGE SQL IMMUTABLE RETURN existing(x)",
+ )
+ .await
+ .unwrap_err();
+
+ assert!(
+ error.to_string().contains("recursive REST SQL function"),
+ "unexpected error: {error}"
+ );
+ assert!(matches!(
+ catalog
+ .get_function(&Identifier::new("default", "candidate"))
+ .await,
+ Err(paimon::Error::FunctionNotExist { .. })
+ ));
+ }
+
+ #[tokio::test]
+ async fn
persistent_rest_catalog_function_rejects_volatile_datafusion_function() {
+ let catalog = Arc::new(MockCatalog::new());
+ let ctx = make_sql_context(Arc::clone(&catalog)).await;
+
+ let error = ctx
+ .sql(
+ "CREATE FUNCTION random_value() RETURNS DOUBLE \
+ LANGUAGE SQL IMMUTABLE RETURN random()",
+ )
+ .await
+ .unwrap_err();
+
+ assert!(
+ error
+ .to_string()
+ .contains("non-immutable function 'random'"),
+ "unexpected error: {error}"
+ );
+ assert!(matches!(
+ catalog
+ .get_function(&Identifier::new("default", "random_value"))
+ .await,
+ Err(paimon::Error::FunctionNotExist { .. })
+ ));
+ }
+
+ #[tokio::test]
+ async fn persistent_rest_catalog_function_rejects_unsupported_clauses() {
+ let cases = [
+ (
+ "CREATE OR REPLACE FUNCTION invalid() RETURNS BIGINT LANGUAGE
SQL IMMUTABLE RETURN 1",
+ "OR REPLACE",
+ ),
+ (
+ "CREATE OR ALTER FUNCTION invalid() RETURNS BIGINT LANGUAGE
SQL IMMUTABLE RETURN 1",
+ "OR ALTER",
+ ),
+ (
+ "CREATE TEMPORARY FUNCTION invalid() RETURNS BIGINT LANGUAGE
SQL IMMUTABLE RETURN 1",
+ "TEMPORARY",
+ ),
+ (
+ "CREATE FUNCTION invalid() RETURNS BIGINT LANGUAGE SQL
IMMUTABLE CALLED ON NULL INPUT RETURN 1",
+ "NULL INPUT",
+ ),
+ (
+ "CREATE FUNCTION invalid() RETURNS BIGINT LANGUAGE SQL
IMMUTABLE PARALLEL SAFE RETURN 1",
+ "PARALLEL",
+ ),
+ (
+ "CREATE FUNCTION invalid() RETURNS BIGINT LANGUAGE SQL
IMMUTABLE SECURITY INVOKER RETURN 1",
+ "SECURITY",
+ ),
+ (
+ "CREATE FUNCTION invalid() RETURNS BIGINT LANGUAGE SQL
IMMUTABLE SET search_path TO public RETURN 1",
+ "SET",
+ ),
+ ];
+
+ for (sql, clause) in cases {
+ let catalog = Arc::new(MockCatalog::new());
+ let ctx = make_sql_context(catalog).await;
+ let error = match ctx.sql(sql).await {
+ Ok(_) => panic!("expected error for {clause}: {sql}"),
+ Err(error) => error,
+ };
+ assert!(
+ error.to_string().contains(clause),
+ "expected error for {clause}, got: {error}"
+ );
+ }
+ }
+
+ #[tokio::test]
+ async fn persistent_rest_catalog_function_rejects_non_scalar_bodies() {
+ let cases = [
+ (
+ "CREATE FUNCTION invalid() RETURNS BIGINT LANGUAGE SQL
IMMUTABLE RETURN count(1)",
+ "aggregate and window",
+ ),
+ (
+ "CREATE FUNCTION invalid() RETURNS BIGINT LANGUAGE SQL
IMMUTABLE RETURN row_number() OVER ()",
+ "aggregate and window",
+ ),
+ (
+ "CREATE FUNCTION invalid() RETURNS BIGINT LANGUAGE SQL
IMMUTABLE RETURN (SELECT 1)",
+ "subqueries",
+ ),
+ ];
+
+ for (sql, expected) in cases {
+ let catalog = Arc::new(MockCatalog::new());
+ let ctx = make_sql_context(catalog).await;
+ let error = match ctx.sql(sql).await {
+ Ok(_) => panic!("expected error containing {expected}: {sql}"),
+ Err(error) => error,
+ };
+ assert!(
+ error.to_string().contains(expected),
+ "expected {expected}, got: {error}"
+ );
+ }
+ }
+
+ #[tokio::test]
+ async fn
persistent_rest_catalog_function_rejects_invalid_signature_and_body_forms() {
+ let cases = [
+ (
+ "CREATE FUNCTION invalid() RETURNS BIGINT LANGUAGE PYTHON
RETURN 1",
+ "LANGUAGE SQL",
+ ),
+ (
+ "CREATE FUNCTION invalid() RETURNS BIGINT LANGUAGE SQL STABLE
RETURN 1",
+ "deterministic SQL",
+ ),
+ (
+ "CREATE FUNCTION invalid() RETURNS BIGINT LANGUAGE SQL
IMMUTABLE AS '1'",
+ "RETURN expression",
+ ),
+ (
+ "CREATE FUNCTION invalid() RETURNS SETOF BIGINT LANGUAGE SQL
IMMUTABLE RETURN 1",
+ "SETOF",
+ ),
+ (
+ "CREATE FUNCTION invalid(BIGINT) RETURNS BIGINT LANGUAGE SQL
IMMUTABLE RETURN 1",
+ "must have names",
+ ),
+ (
+ "CREATE FUNCTION invalid(IN x BIGINT) RETURNS BIGINT LANGUAGE
SQL IMMUTABLE RETURN x",
+ "modes and defaults",
+ ),
+ (
+ "CREATE FUNCTION invalid(x BIGINT = 1) RETURNS BIGINT LANGUAGE
SQL IMMUTABLE RETURN x",
+ "modes and defaults",
+ ),
+ (
+ "CREATE FUNCTION invalid(X BIGINT, x BIGINT) RETURNS BIGINT
LANGUAGE SQL IMMUTABLE RETURN x",
+ "duplicate function argument",
+ ),
+ ];
+
+ for (sql, expected) in cases {
+ let catalog = Arc::new(MockCatalog::new());
+ let ctx = make_sql_context(catalog).await;
+ let error = match ctx.sql(sql).await {
+ Ok(_) => panic!("expected error containing {expected}: {sql}"),
+ Err(error) => error,
+ };
+ assert!(
+ error.to_string().contains(expected),
+ "expected {expected}, got: {error}"
+ );
+ }
+ }
+
+ #[tokio::test]
+ async fn
persistent_rest_catalog_function_rejects_incompatible_return_type() {
+ let catalog = Arc::new(MockCatalog::new());
+ let ctx = make_sql_context(Arc::clone(&catalog)).await;
+
+ let error = ctx
+ .sql(
+ "CREATE FUNCTION invalid() RETURNS BIGINT \
+ LANGUAGE SQL IMMUTABLE RETURN named_struct('value', 1)",
+ )
+ .await
+ .unwrap_err();
+
+ assert!(
+ error.to_string().to_ascii_lowercase().contains("cast"),
+ "unexpected error: {error}"
+ );
+ assert!(matches!(
+ catalog
+ .get_function(&Identifier::new("default", "invalid"))
+ .await,
+ Err(paimon::Error::FunctionNotExist { .. })
+ ));
+ }
+
#[tokio::test]
async fn persistent_rest_catalog_view_infers_type_and_nullability() {
let catalog = Arc::new(MockCatalog::new());
diff --git a/crates/integrations/datafusion/src/sql_function.rs
b/crates/integrations/datafusion/src/sql_function.rs
index 88d52be..4d6af48 100644
--- a/crates/integrations/datafusion/src/sql_function.rs
+++ b/crates/integrations/datafusion/src/sql_function.rs
@@ -39,6 +39,16 @@ pub(crate) async fn expand_sql(
catalogs: &HashMap<String, Arc<dyn Catalog>>,
current_catalog: &str,
current_database: &str,
+) -> DFResult<String> {
+ expand_sql_internal(sql, catalogs, current_catalog, current_database,
None).await
+}
+
+async fn expand_sql_internal(
+ sql: &str,
+ catalogs: &HashMap<String, Arc<dyn Catalog>>,
+ current_catalog: &str,
+ current_database: &str,
+ candidate: Option<&Function>,
) -> DFResult<String> {
let mut statements = Parser::parse_sql(&GenericDialect {}, sql)
.map_err(|error| DataFusionError::Plan(format!("Invalid REST SQL:
{error}")))?;
@@ -48,16 +58,35 @@ pub(crate) async fn expand_sql(
statements.len()
)));
}
- let statement = expand_statement(
+ let statement = expand_statement_internal(
statements.remove(0),
catalogs,
current_catalog,
current_database,
+ MAX_EXPANDED_CALLS,
+ candidate,
)
.await?;
Ok(statement.to_string())
}
+pub(crate) async fn expand_sql_with_candidate(
+ sql: &str,
+ catalogs: &HashMap<String, Arc<dyn Catalog>>,
+ current_catalog: &str,
+ current_database: &str,
+ candidate: &Function,
+) -> DFResult<String> {
+ expand_sql_internal(
+ sql,
+ catalogs,
+ current_catalog,
+ current_database,
+ Some(candidate),
+ )
+ .await
+}
+
pub(crate) async fn expand_statement(
statement: Statement,
catalogs: &HashMap<String, Arc<dyn Catalog>>,
@@ -75,12 +104,33 @@ pub(crate) async fn expand_statement(
}
pub(crate) async fn expand_statement_with_budget(
+ statement: Statement,
+ catalogs: &HashMap<String, Arc<dyn Catalog>>,
+ current_catalog: &str,
+ current_database: &str,
+ max_expanded_calls: usize,
+) -> DFResult<Statement> {
+ expand_statement_internal(
+ statement,
+ catalogs,
+ current_catalog,
+ current_database,
+ max_expanded_calls,
+ None,
+ )
+ .await
+}
+
+async fn expand_statement_internal(
mut statement: Statement,
catalogs: &HashMap<String, Arc<dyn Catalog>>,
current_catalog: &str,
current_database: &str,
max_expanded_calls: usize,
+ candidate: Option<&Function>,
) -> DFResult<Statement> {
+ let candidate_reference =
+ candidate.map(|function| (current_catalog.to_string(),
function.identifier().clone()));
let mut functions = HashMap::new();
let mut dependencies = HashMap::new();
let mut total_expanded_calls = 0;
@@ -111,11 +161,15 @@ pub(crate) async fn expand_statement_with_budget(
functions.insert(reference, None);
continue;
};
- let function = match catalog.get_function(&reference.1).await {
- Ok(function) => Some(function),
- Err(paimon::Error::FunctionNotExist { .. })
- | Err(paimon::Error::Unsupported { .. }) => None,
- Err(error) => return Err(crate::to_datafusion_error(error)),
+ let function = if candidate_reference.as_ref() == Some(&reference)
{
+ candidate.cloned()
+ } else {
+ match catalog.get_function(&reference.1).await {
+ Ok(function) => Some(function),
+ Err(paimon::Error::FunctionNotExist { .. })
+ | Err(paimon::Error::Unsupported { .. }) => None,
+ Err(error) => return
Err(crate::to_datafusion_error(error)),
+ }
};
if let Some(function) = &function {
let nested = function_dependencies(function, &reference.0,
reference.1.database());
@@ -403,19 +457,12 @@ fn expand_call(
});
let return_type = function.return_params().expect("validated
above")[0].data_type();
- let serialized_type = serde_json::to_value(return_type).map_err(|error| {
+ let sql_type = crate::table::data_type_to_sql(return_type).map_err(|error|
{
DataFusionError::Plan(format!(
"Invalid return type for REST SQL function '{}': {error}",
function.full_name()
))
})?;
- let sql_type = serialized_type.as_str().ok_or_else(|| {
- DataFusionError::Plan(format!(
- "REST SQL function '{}' has a return type that cannot be
represented in SQL",
- function.full_name()
- ))
- })?;
- let sql_type = sql_type.strip_suffix(" NOT NULL").unwrap_or(sql_type);
Parser::new(&GenericDialect {})
.try_with_sql(&format!("CAST(({body}) AS {sql_type})"))
.map_err(|error| {
diff --git a/crates/integrations/datafusion/src/table/mod.rs
b/crates/integrations/datafusion/src/table/mod.rs
index 9b698f4..9e333ac 100644
--- a/crates/integrations/datafusion/src/table/mod.rs
+++ b/crates/integrations/datafusion/src/table/mod.rs
@@ -209,7 +209,7 @@ fn quote_string_literal(text: &str) -> String {
/// 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> {
+pub(crate) 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()),
diff --git a/crates/paimon/src/api/api_request.rs
b/crates/paimon/src/api/api_request.rs
index fabcf4a..018673c 100644
--- a/crates/paimon/src/api/api_request.rs
+++ b/crates/paimon/src/api/api_request.rs
@@ -23,8 +23,8 @@ use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use crate::{
- catalog::{Identifier, ViewSchema},
- spec::{Schema, SchemaChange},
+ catalog::{Function, FunctionDefinition, Identifier, ViewSchema},
+ spec::{DataField, Schema, SchemaChange},
};
/// Request to create a new database.
@@ -115,6 +115,41 @@ impl CreateViewRequest {
}
}
+/// Request to create a persistent function.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct CreateFunctionRequest {
+ /// Unqualified function name.
+ pub name: String,
+ /// Declared input parameters.
+ pub input_params: Option<Vec<DataField>>,
+ /// Declared return parameters.
+ pub return_params: Option<Vec<DataField>>,
+ /// Whether the function is deterministic.
+ pub deterministic: bool,
+ /// Engine-specific function definitions.
+ pub definitions: HashMap<String, FunctionDefinition>,
+ /// Optional function comment.
+ pub comment: Option<String>,
+ /// Function options.
+ pub options: HashMap<String, String>,
+}
+
+impl CreateFunctionRequest {
+ /// Create a request from a catalog function.
+ pub fn from_function(function: &Function) -> Self {
+ Self {
+ name: function.name().to_string(),
+ input_params: function.input_params().map(<[_]>::to_vec),
+ return_params: function.return_params().map(<[_]>::to_vec),
+ deterministic: function.is_deterministic(),
+ definitions: function.definitions().clone(),
+ comment: function.comment().map(str::to_string),
+ options: function.options().clone(),
+ }
+ }
+}
+
/// 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 643b1ec..fb2da9f 100644
--- a/crates/paimon/src/api/mod.rs
+++ b/crates/paimon/src/api/mod.rs
@@ -31,8 +31,8 @@ mod api_response;
// Re-export request types
pub use api_request::{
- AlterDatabaseRequest, AlterTableRequest, CreateDatabaseRequest,
CreateTableRequest,
- CreateViewRequest, RenameTableRequest,
+ AlterDatabaseRequest, AlterTableRequest, CreateDatabaseRequest,
CreateFunctionRequest,
+ CreateTableRequest, 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 37f89fd..5d60b33 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, ViewSchema};
+use crate::catalog::{Function, 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,
- CreateViewRequest, RenameTableRequest,
+ AlterDatabaseRequest, AlterTableRequest, CreateDatabaseRequest,
CreateFunctionRequest,
+ CreateTableRequest, CreateViewRequest, RenameTableRequest,
};
use super::api_response::{
ConfigResponse, GetDatabaseResponse, GetFunctionResponse,
GetTableResponse, GetViewResponse,
@@ -469,6 +469,19 @@ impl RESTApi {
// ==================== Function Operations ====================
+ /// Create a persistent function.
+ pub async fn create_function(&self, function: &Function) -> Result<()> {
+ let database = function.identifier().database();
+ validate_non_empty_multi(&[
+ (database, "database name"),
+ (function.name(), "function name"),
+ ])?;
+ let path = self.resource_paths.functions(database);
+ let request = CreateFunctionRequest::from_function(function);
+ let _resp: serde_json::Value = self.client.post(&path,
&request).await?;
+ Ok(())
+ }
+
/// List persistent functions in a database.
pub async fn list_functions(&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 8d17300..516b830 100644
--- a/crates/paimon/src/catalog/mod.rs
+++ b/crates/paimon/src/catalog/mod.rs
@@ -405,6 +405,21 @@ pub trait Catalog: Send + Sync {
// ======================= function methods ===============================
+ /// Create a persistent function.
+ ///
+ /// * `ignore_if_exists` - if true, do nothing when the function already
exists;
+ /// if false, return [`crate::Error::FunctionAlreadyExist`].
+ ///
+ /// # Errors
+ /// * [`crate::Error::DatabaseNotExist`] - database in the function
identifier does not exist.
+ /// * [`crate::Error::FunctionAlreadyExist`] - function already exists when
+ /// `ignore_if_exists` is false.
+ async fn create_function(&self, _function: &Function, _ignore_if_exists:
bool) -> Result<()> {
+ Err(Error::Unsupported {
+ message: "Catalog does not support functions".to_string(),
+ })
+ }
+
/// List persistent function names in a database.
async fn list_functions(&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 58eb63b..9fbb388 100644
--- a/crates/paimon/src/catalog/rest/rest_catalog.rs
+++ b/crates/paimon/src/catalog/rest/rest_catalog.rs
@@ -320,6 +320,21 @@ impl Catalog for RESTCatalog {
.map_err(|e| map_unsupported_endpoint(e, "function"))
}
+ async fn create_function(
+ &self,
+ function: &crate::catalog::Function,
+ ignore_if_exists: bool,
+ ) -> Result<()> {
+ let result = self
+ .api
+ .create_function(function)
+ .await
+ .map_err(|error| map_rest_error_for_create_function(error,
function.identifier()));
+ ignore_error_if(result, |error| {
+ ignore_if_exists && matches!(error, Error::FunctionAlreadyExist {
.. })
+ })
+ }
+
async fn get_function(&self, identifier: &Identifier) ->
Result<crate::catalog::Function> {
let response = self
.api
@@ -460,6 +475,23 @@ fn map_rest_error_for_function(err: Error, identifier:
&Identifier) -> Error {
}
}
+/// Map a REST API error from creating a persistent function.
+fn map_rest_error_for_create_function(err: Error, identifier: &Identifier) ->
Error {
+ match err {
+ Error::RestApi {
+ source: RestError::NoSuchResource { .. },
+ } => Error::DatabaseNotExist {
+ database: identifier.database().to_string(),
+ },
+ Error::RestApi {
+ source: RestError::AlreadyExists { .. },
+ } => Error::FunctionAlreadyExist {
+ full_name: identifier.full_name(),
+ },
+ other => map_unsupported_endpoint(other, "function"),
+ }
+}
+
fn map_unsupported_endpoint(err: Error, object_type: &str) -> Error {
match err {
Error::RestApi {
diff --git a/crates/paimon/src/error.rs b/crates/paimon/src/error.rs
index 507ee3a..39969ee 100644
--- a/crates/paimon/src/error.rs
+++ b/crates/paimon/src/error.rs
@@ -108,6 +108,8 @@ pub enum Error {
ViewNotExist { full_name: String },
#[snafu(display("Function {} does not exist.", full_name))]
FunctionNotExist { full_name: String },
+ #[snafu(display("Function {} already exists.", full_name))]
+ FunctionAlreadyExist { full_name: String },
#[snafu(display("Column {} already exists in table {}.", column,
full_name))]
ColumnAlreadyExist { full_name: String, column: String },
#[snafu(display("Column {} does not exist in table {}.", column,
full_name))]
diff --git a/crates/paimon/tests/mock_server.rs
b/crates/paimon/tests/mock_server.rs
index 35a8a3f..21e9520 100644
--- a/crates/paimon/tests/mock_server.rs
+++ b/crates/paimon/tests/mock_server.rs
@@ -34,12 +34,13 @@ use std::sync::{Arc, Mutex};
use tokio::task::JoinHandle;
use paimon::api::{
- AlterDatabaseRequest, AlterTableRequest, AuditRESTResponse,
ConfigResponse, CreateViewRequest,
- ErrorResponse, GetDatabaseResponse, GetFunctionResponse, GetTableResponse,
GetViewResponse,
- ListDatabasesResponse, ListFunctionsResponse, ListTablesResponse,
ListViewsResponse,
- RenameTableRequest, ResourcePaths,
+ AlterDatabaseRequest, AlterTableRequest, AuditRESTResponse, ConfigResponse,
+ CreateFunctionRequest, CreateViewRequest, ErrorResponse,
GetDatabaseResponse,
+ GetFunctionResponse, GetTableResponse, GetViewResponse,
ListDatabasesResponse,
+ ListFunctionsResponse, ListTablesResponse, ListViewsResponse,
RenameTableRequest,
+ ResourcePaths,
};
-use paimon::catalog::Function;
+use paimon::catalog::{Function, Identifier};
#[derive(Clone, Debug, Default)]
struct MockState {
@@ -505,6 +506,61 @@ impl RESTServer {
.into_response()
}
+ /// Handle POST /databases/:db/functions - create a persistent function.
+ pub async fn create_function(
+ Path(db): Path<String>,
+ Extension(state): Extension<Arc<RESTServer>>,
+ Json(request): Json<CreateFunctionRequest>,
+ ) -> impl IntoResponse {
+ let mut s = state.inner.lock().unwrap();
+ let function_name = request.name.clone();
+ if s.view_function_endpoints_unsupported {
+ let err = ErrorResponse::new(
+ Some("function".to_string()),
+ Some(function_name),
+ 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),
+ Some("Not Found".to_string()),
+ Some(404),
+ );
+ return (StatusCode::NOT_FOUND, Json(err)).into_response();
+ }
+ let key = format!("{db}.{function_name}");
+ if s.functions.contains_key(&key) {
+ let err = ErrorResponse::new(
+ Some("function".to_string()),
+ Some(function_name),
+ Some("Already Exists".to_string()),
+ Some(409),
+ );
+ return (StatusCode::CONFLICT, Json(err)).into_response();
+ }
+ let function = Function::new(
+ Identifier::new(&db, &request.name),
+ request.input_params,
+ request.return_params,
+ request.deterministic,
+ request.definitions,
+ request.comment,
+ request.options,
+ );
+ s.functions.insert(
+ key,
+ GetFunctionResponse::from_function(
+ &function,
+ AuditRESTResponse::new(None, None, None, None, None),
+ ),
+ );
+ (StatusCode::OK, Json(json!({"function":
function_name}))).into_response()
+ }
+
/// Handle POST /databases/:db/tables - create a new table.
pub async fn create_table(
Path(db): Path<String>,
@@ -999,7 +1055,7 @@ pub async fn start_mock_server(
)
.route(
&format!("{prefix}/databases/:db/functions"),
- get(RESTServer::list_functions),
+ get(RESTServer::list_functions).post(RESTServer::create_function),
)
.route(
&format!("{prefix}/databases/:db/functions/:function"),
diff --git a/crates/paimon/tests/rest_api_test.rs
b/crates/paimon/tests/rest_api_test.rs
index d90e633..e020b6c 100644
--- a/crates/paimon/tests/rest_api_test.rs
+++ b/crates/paimon/tests/rest_api_test.rs
@@ -230,6 +230,41 @@ async fn test_list_functions() {
);
}
+#[tokio::test]
+async fn test_create_function() {
+ let ctx = setup_test_server(vec!["default"]).await;
+ let function = Function::new(
+ Identifier::new("default", "plus_one"),
+ Some(
+ serde_json::from_value(json!([
+ {"id": 0, "name": "x", "type": "BIGINT"}
+ ]))
+ .unwrap(),
+ ),
+ Some(
+ serde_json::from_value(json!([
+ {"id": 0, "name": "result", "type": "BIGINT"}
+ ]))
+ .unwrap(),
+ ),
+ true,
+ HashMap::from([(
+ "datafusion".to_string(),
+ FunctionDefinition::Sql {
+ definition: "x + 1".to_string(),
+ },
+ )]),
+ None,
+ HashMap::new(),
+ );
+
+ ctx.api.create_function(&function).await.unwrap();
+
+ let response = ctx.api.get_function(function.identifier()).await.unwrap();
+ assert_eq!(response.name.as_deref(), Some("plus_one"));
+ assert_eq!(response.input_params.unwrap()[0].name(), "x");
+}
+
#[tokio::test]
async fn test_create_database() {
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 722a7b2..849b699 100644
--- a/crates/paimon/tests/rest_catalog_test.rs
+++ b/crates/paimon/tests/rest_catalog_test.rs
@@ -1187,6 +1187,123 @@ async fn test_catalog_get_function() {
);
}
+#[tokio::test]
+async fn test_catalog_create_function() {
+ let ctx = setup_catalog(vec!["default"]).await;
+ let function = Function::new(
+ Identifier::new("default", "plus_one"),
+ Some(
+ serde_json::from_value(serde_json::json!([
+ {"id": 0, "name": "x", "type": "BIGINT"}
+ ]))
+ .unwrap(),
+ ),
+ Some(
+ serde_json::from_value(serde_json::json!([
+ {"id": 0, "name": "result", "type": "BIGINT"}
+ ]))
+ .unwrap(),
+ ),
+ true,
+ HashMap::from([(
+ "datafusion".to_string(),
+ FunctionDefinition::Sql {
+ definition: "x + 1".to_string(),
+ },
+ )]),
+ None,
+ HashMap::new(),
+ );
+
+ ctx.catalog.create_function(&function, false).await.unwrap();
+
+ let stored = ctx
+ .catalog
+ .get_function(function.identifier())
+ .await
+ .unwrap();
+ assert_eq!(stored, function);
+}
+
+#[tokio::test]
+async fn test_catalog_create_function_already_exists() {
+ let ctx = setup_catalog(vec!["default"]).await;
+ let function = Function::new(
+ Identifier::new("default", "answer"),
+ Some(Vec::new()),
+ Some(
+ serde_json::from_value(serde_json::json!([
+ {"id": 0, "name": "result", "type": "INT"}
+ ]))
+ .unwrap(),
+ ),
+ true,
+ HashMap::from([(
+ "datafusion".to_string(),
+ FunctionDefinition::Sql {
+ definition: "42".to_string(),
+ },
+ )]),
+ None,
+ HashMap::new(),
+ );
+ ctx.catalog.create_function(&function, false).await.unwrap();
+
+ let error = ctx
+ .catalog
+ .create_function(&function, false)
+ .await
+ .unwrap_err();
+
+ assert!(matches!(
+ error,
+ paimon::Error::FunctionAlreadyExist { full_name }
+ if full_name == "default.answer"
+ ));
+}
+
+#[tokio::test]
+async fn test_catalog_create_function_ignore_if_exists() {
+ let ctx = setup_catalog(vec!["default"]).await;
+ let function = Function::new(
+ Identifier::new("default", "answer"),
+ Some(Vec::new()),
+ Some(Vec::new()),
+ true,
+ HashMap::new(),
+ None,
+ HashMap::new(),
+ );
+ ctx.catalog.create_function(&function, false).await.unwrap();
+
+ ctx.catalog.create_function(&function, true).await.unwrap();
+}
+
+#[tokio::test]
+async fn test_catalog_create_function_missing_database() {
+ let ctx = setup_catalog(vec!["default"]).await;
+ let function = Function::new(
+ Identifier::new("missing_db", "answer"),
+ Some(Vec::new()),
+ Some(Vec::new()),
+ true,
+ HashMap::new(),
+ None,
+ HashMap::new(),
+ );
+
+ let error = ctx
+ .catalog
+ .create_function(&function, false)
+ .await
+ .unwrap_err();
+
+ assert!(matches!(
+ error,
+ paimon::Error::DatabaseNotExist { database } if database ==
"missing_db"
+ ));
+}
+
#[tokio::test]
async fn test_catalog_list_functions() {
let ctx = setup_catalog(vec!["default"]).await;
@@ -1286,6 +1403,24 @@ async fn
test_catalog_maps_unsupported_view_and_function_endpoints() {
ctx.catalog.list_functions("default").await.unwrap_err(),
paimon::Error::Unsupported { .. }
));
+ assert!(matches!(
+ ctx.catalog
+ .create_function(
+ &Function::new(
+ Identifier::new("default", "function"),
+ Some(Vec::new()),
+ Some(Vec::new()),
+ true,
+ HashMap::new(),
+ None,
+ HashMap::new(),
+ ),
+ false,
+ )
+ .await
+ .unwrap_err(),
+ paimon::Error::Unsupported { .. }
+ ));
assert!(matches!(
ctx.catalog
.get_function(&Identifier::new("default", "function"))
diff --git a/crates/paimon/tests/rest_object_models_test.rs
b/crates/paimon/tests/rest_object_models_test.rs
index 6e2806a..f4e3af1 100644
--- a/crates/paimon/tests/rest_object_models_test.rs
+++ b/crates/paimon/tests/rest_object_models_test.rs
@@ -17,7 +17,7 @@
use std::collections::HashMap;
-use paimon::api::CreateViewRequest;
+use paimon::api::{CreateFunctionRequest, CreateViewRequest};
use paimon::catalog::{Function, FunctionDefinition, Identifier, View,
ViewSchema};
use paimon::spec::DataField;
use serde_json::json;
@@ -209,3 +209,70 @@ fn function_binds_identifier_signature_and_definition() {
Some("length * width")
);
}
+
+#[test]
+fn create_function_request_serialization_contract() {
+ let input_params: Vec<DataField> = serde_json::from_value(json!([
+ {"id": 0, "name": "x", "type": "BIGINT"}
+ ]))
+ .unwrap();
+ let return_params: Vec<DataField> = serde_json::from_value(json!([
+ {"id": 0, "name": "result", "type": "BIGINT"}
+ ]))
+ .unwrap();
+ let function = Function::new(
+ Identifier::new("analytics", "plus_one"),
+ Some(input_params),
+ Some(return_params),
+ true,
+ HashMap::from([(
+ "datafusion".to_string(),
+ FunctionDefinition::Sql {
+ definition: "x + 1".to_string(),
+ },
+ )]),
+ None,
+ HashMap::new(),
+ );
+
+ assert_eq!(
+
serde_json::to_value(CreateFunctionRequest::from_function(&function)).unwrap(),
+ json!({
+ "name": "plus_one",
+ "inputParams": [{"id": 0, "name": "x", "type": "BIGINT"}],
+ "returnParams": [{"id": 0, "name": "result", "type": "BIGINT"}],
+ "deterministic": true,
+ "definitions": {
+ "datafusion": {"type": "sql", "definition": "x + 1"}
+ },
+ "comment": null,
+ "options": {}
+ })
+ );
+}
+
+#[test]
+fn create_function_request_serializes_zero_arguments_as_empty_array() {
+ let function = Function::new(
+ Identifier::new("analytics", "answer"),
+ Some(Vec::new()),
+ Some(
+ serde_json::from_value(json!([
+ {"id": 0, "name": "result", "type": "BIGINT"}
+ ]))
+ .unwrap(),
+ ),
+ true,
+ HashMap::from([(
+ "datafusion".to_string(),
+ FunctionDefinition::Sql {
+ definition: "42".to_string(),
+ },
+ )]),
+ None,
+ HashMap::new(),
+ );
+
+ let json =
serde_json::to_value(CreateFunctionRequest::from_function(&function)).unwrap();
+ assert_eq!(json["inputParams"], json!([]));
+}
diff --git a/docs/src/sql.md b/docs/src/sql.md
index 0a6073f..50623e4 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`, 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.*'`.
+- 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.*'`.
-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.
+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.
For the exact delegated SQL grammar, see the [DataFusion SQL
Reference](https://datafusion.apache.org/user-guide/sql/index.html).
@@ -78,9 +78,10 @@ internally for `SET`/`RESET` support.
### 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 in the catalog. It can also
-create persistent views with this syntax:
+When the registered catalog is a Paimon REST Catalog, `SQLContext` can read,
+execute, and create persistent views and SQL scalar functions.
+
+Create a persistent view with this syntax:
```sql
CREATE VIEW [IF NOT EXISTS] view_name [(column_name, ...)] AS query;
@@ -109,8 +110,7 @@ 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.
+and persistent `ALTER VIEW` / `DROP VIEW` are not supported.
Persistent views resolve through the normal DataFusion catalog path, so they
can be queried wherever a table can be used:
@@ -136,6 +136,57 @@ SELECT normalize_score(score) FROM scores;
SELECT paimon.reporting.normalize_score(score) FROM scores;
```
+Create a persistent REST SQL scalar function with this syntax:
+
+```sql
+CREATE FUNCTION [IF NOT EXISTS] function_name(
+ [parameter_name data_type, ...]
+)
+RETURNS data_type
+[LANGUAGE SQL]
+RETURN scalar_expression;
+```
+
+For example:
+
+```sql
+CREATE FUNCTION paimon.reporting.add_tax(amount DECIMAL(12, 2))
+RETURNS DECIMAL(12, 2)
+RETURN amount * DECIMAL '1.10';
+
+SELECT add_tax(total) FROM orders;
+```
+
+Bare, two-part (`database.function`), and three-part
+(`catalog.database.function`) names are accepted as creation targets. Unquoted
+names are normalized and quoted names are preserved. Calls remain limited to
+bare or three-part names; two-part function calls are not supported.
+
+Parameters must be named and cannot have modes or defaults. Zero parameters
+are allowed. Inputs and the single return value are stored as nullable fields;
+parameter IDs start at zero and the return field has ID `0` and name `result`.
+The canonical, unexpanded `RETURN` expression is stored in
+`definitions.datafusion` with `type: "sql"`.
+
+`LANGUAGE SQL` is optional and SQL is the default, matching Databricks SQL
+function syntax. `IMMUTABLE` is not required; when omitted, determinism is
+inferred from the planned expression. An explicit `IMMUTABLE` clause remains
+accepted for compatibility.
+
+Before the REST create request is sent, `SQLContext` expands dependencies using
+the new function as a candidate, validates argument substitution and the
+declared return cast, and builds both logical and physical DataFusion plans in
+the function's owning catalog/database. This rejects undeclared identifiers,
+recursive dependencies (including indirect recursion), non-deterministic REST
+dependencies, subqueries/table access, aggregate or window functions,
+Stable/Volatile DataFusion functions, and incompatible return types. The
+function is stored as deterministic only after the planned expression passes
+these checks.
+
+`IF NOT EXISTS` still validates the proposed definition first. The REST server
+then handles the create atomically; only an already-existing function error is
+ignored.
+
A function is executable only when all of the following are true:
- `definitions.datafusion` exists and has `type: "sql"`;
@@ -157,7 +208,11 @@ Function expansion is implemented by `SQLContext::sql`.
Queries executed
directly through a raw DataFusion `SessionContext` do not expand REST SQL
functions. Two-part function names such as `database.function(...)`, lambda or
file definitions, aggregate/table/multi-return functions, and non-deterministic
-functions are not supported by this read-only integration.
+functions are not supported. `CREATE OR REPLACE/ALTER/TEMPORARY FUNCTION`,
+non-SQL bodies, `STABLE`/`VOLATILE`, null-input/parallel/security/SET clauses,
+options/remote functions, and persistent `ALTER FUNCTION` / `DROP FUNCTION`
+are also not supported. Catalog implementations other than REST Catalog may
+return `Unsupported` for persistent function creation.
## Data Types