Skip to content

Commit 14aa185

Browse files
quwincodebuff-team
andauthored
fix: reject invalid placeholders in CREATE FUNCTION bodies at definition time (#25039)
### Which issue does this PR close? - Closes #25038. ### Rationale for this change `CREATE FUNCTION` accepted placeholders that did not match a declared argument, such as `$3` in a function with two arguments. The error only appeared when the function was called. This PR rejects these invalid definitions when the function is created. ### What changes are included? - Adds placeholder validation to `CreateFunction::try_new`, keeping the semantic check in the logical-expression layer rather than the SQL planner. - Validates placeholders in function bodies, argument defaults, and nested subqueries. - Keeps valid placeholders and `PREPARE` parameter handling unchanged. - Adds end-to-end SQL logic tests and focused unit coverage for nested subqueries. ### User-facing change Invalid placeholders now produce a planning error during `CREATE FUNCTION` instead of failing later when the function is called. --------- Signed-off-by: Quwin <[email protected]> Co-authored-by: Codebuff <[email protected]>
1 parent 22eb8ff commit 14aa185

5 files changed

Lines changed: 219 additions & 34 deletions

File tree

‎datafusion/core/tests/user_defined/user_defined_scalar_functions.rs‎

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1112,23 +1112,6 @@ async fn create_scalar_function_from_sql_statement() -> Result<()> {
11121112
"#;
11131113
assert!(ctx.sql(bad_definition_sql).await.is_err());
11141114

1115-
// FIXME: Definitions with invalid placeholders are allowed, fail at runtime
1116-
let bad_expression_sql = r#"
1117-
CREATE FUNCTION better_add(DOUBLE, DOUBLE)
1118-
RETURNS DOUBLE
1119-
RETURN $1 + $3
1120-
"#;
1121-
assert!(ctx.sql(bad_expression_sql).await.is_ok());
1122-
1123-
let err = ctx
1124-
.sql("select better_add(2.0, 2.0)")
1125-
.await?
1126-
.collect()
1127-
.await
1128-
.expect_err("unknown placeholder");
1129-
let expected = "Optimizer rule 'simplify_expressions' failed\ncaused by\nExecution error: Invalid placeholder, out of range: $3";
1130-
assert!(expected.starts_with(&err.strip_backtrace()));
1131-
11321115
Ok(())
11331116
}
11341117

‎datafusion/expr/src/logical_plan/ddl.rs‎

Lines changed: 154 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,15 @@ use std::{
1111
hash::{Hash, Hasher},
1212
};
1313

14-
use crate::expr::Sort;
14+
use crate::expr::{Exists, InSubquery, SetComparison, Sort};
1515
#[cfg(not(feature = "sql"))]
1616
use crate::sql::Ident;
1717
use arrow::datatypes::DataType;
18-
use datafusion_common::tree_node::{Transformed, TreeNodeContainer, TreeNodeRecursion};
18+
use datafusion_common::tree_node::{
19+
Transformed, TreeNode, TreeNodeContainer, TreeNodeRecursion,
20+
};
1921
use datafusion_common::{
20-
Constraints, DFSchema, DFSchemaRef, Result, SchemaReference, TableReference,
22+
Constraints, DFSchema, DFSchemaRef, Result, SchemaReference, TableReference, plan_err,
2123
};
2224
#[cfg(feature = "sql")]
2325
use sqlparser::ast::Ident;
@@ -904,6 +906,95 @@ pub struct CreateFunction {
904906
pub schema: DFSchemaRef,
905907
}
906908

909+
impl CreateFunction {
910+
/// Creates a function definition, validating placeholders in its body and
911+
/// argument defaults, including expressions inside subqueries.
912+
///
913+
/// Placeholders must be positional (`$1`, `$2`, ...) and reference a
914+
/// declared argument. SQL argument names must be resolved before calling
915+
/// this constructor.
916+
pub fn try_new(
917+
or_replace: bool,
918+
temporary: bool,
919+
name: String,
920+
args: Option<Vec<OperateFunctionArg>>,
921+
return_type: Option<DataType>,
922+
params: CreateFunctionBody,
923+
) -> Result<Self> {
924+
let arg_count = args.as_ref().map_or(0, |declared| declared.len());
925+
for default_expr in args
926+
.iter()
927+
.flatten()
928+
.filter_map(|arg| arg.default_expr.as_ref())
929+
{
930+
validate_function_body_placeholders(default_expr, arg_count)?;
931+
}
932+
if let Some(body) = &params.function_body {
933+
validate_function_body_placeholders(body, arg_count)?;
934+
}
935+
Ok(Self {
936+
or_replace,
937+
temporary,
938+
name,
939+
args,
940+
return_type,
941+
params,
942+
schema: DFSchemaRef::new(DFSchema::empty()),
943+
})
944+
}
945+
}
946+
947+
/// Rejects placeholders in a `CREATE FUNCTION` body or argument default
948+
/// expression that do not reference a declared argument (e.g. `$3` for a
949+
/// two-argument function) at definition time, instead of deferring the error
950+
/// to function invocation.
951+
fn validate_function_body_placeholders(expr: &Expr, arg_count: usize) -> Result<()> {
952+
expr.apply(|expr| {
953+
if let Expr::Placeholder(placeholder) = expr {
954+
match placeholder
955+
.id
956+
.strip_prefix('$')
957+
.and_then(|id| id.parse::<usize>().ok())
958+
{
959+
// In range, e.g. `$2` with two declared arguments
960+
Some(idx) if (1..=arg_count).contains(&idx) => {}
961+
// Out of range, e.g. `$3` with two declared arguments
962+
Some(_) => {
963+
return plan_err!(
964+
"Invalid placeholder, out of range: {}",
965+
placeholder.id
966+
);
967+
}
968+
// Named or malformed placeholder
969+
None => {
970+
return plan_err!("Unknown placeholder: {}", placeholder.id);
971+
}
972+
}
973+
}
974+
// `Expr::apply` does not descend into subqueries, so walk their
975+
// plans explicitly to validate placeholders inside them
976+
if let Some(subquery) = match expr {
977+
Expr::ScalarSubquery(subquery) => Some(&subquery.subquery),
978+
Expr::Exists(Exists { subquery, .. }) => Some(&subquery.subquery),
979+
Expr::InSubquery(InSubquery { subquery, .. }) => Some(&subquery.subquery),
980+
Expr::SetComparison(SetComparison { subquery, .. }) => {
981+
Some(&subquery.subquery)
982+
}
983+
_ => None,
984+
} {
985+
// Nested subqueries are handled by this expression visitor
986+
subquery.apply(|plan| {
987+
plan.apply_expressions(|e| {
988+
validate_function_body_placeholders(e, arg_count)?;
989+
Ok(TreeNodeRecursion::Continue)
990+
})
991+
})?;
992+
}
993+
Ok(TreeNodeRecursion::Continue)
994+
})?;
995+
Ok(())
996+
}
997+
907998
// Manual implementation needed because of `schema` field. Comparison excludes this field.
908999
impl PartialOrd for CreateFunction {
9091000
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
@@ -1074,13 +1165,69 @@ impl PartialOrd for CreateIndex {
10741165

10751166
#[cfg(test)]
10761167
mod test {
1168+
use super::{CreateFunction, CreateFunctionBody, OperateFunctionArg};
1169+
use crate::expr_fn::placeholder;
10771170
use crate::{
1078-
CreateCatalog, CreateExternalCatalog, DdlStatement, DropCatalog, DropView,
1171+
CreateCatalog, CreateExternalCatalog, DdlStatement, DropCatalog, DropView, Expr,
1172+
LogicalPlanBuilder, Subquery,
10791173
};
1174+
use arrow::datatypes::DataType;
10801175
use datafusion_common::{DFSchema, DFSchemaRef, TableReference};
1081-
use std::cmp::Ordering;
1082-
use std::collections::HashMap;
1083-
use std::sync::Arc;
1176+
use std::{cmp::Ordering, collections::HashMap, sync::Arc};
1177+
1178+
#[test]
1179+
fn create_function_try_new_validates_placeholders() {
1180+
let create = |body, default_expr| {
1181+
CreateFunction::try_new(
1182+
false,
1183+
false,
1184+
"f".to_string(),
1185+
Some(vec![OperateFunctionArg {
1186+
name: None,
1187+
data_type: DataType::Int64,
1188+
default_expr,
1189+
}]),
1190+
Some(DataType::Int64),
1191+
CreateFunctionBody {
1192+
language: None,
1193+
behavior: None,
1194+
function_body: body,
1195+
},
1196+
)
1197+
};
1198+
1199+
assert!(create(Some(placeholder("$1")), Some(placeholder("$1"))).is_ok());
1200+
assert!(create(None, None).is_ok());
1201+
for (body, default_expr) in [
1202+
(Some(placeholder("$2")), None),
1203+
(None, Some(placeholder("$2"))),
1204+
] {
1205+
let err = create(body, default_expr).unwrap_err();
1206+
assert_eq!(
1207+
err.strip_backtrace(),
1208+
"Error during planning: Invalid placeholder, out of range: $2"
1209+
);
1210+
}
1211+
1212+
let mut nested_body = placeholder("$2");
1213+
for _ in 0..16 {
1214+
let plan = LogicalPlanBuilder::empty(true)
1215+
.project(vec![nested_body.alias("v")])
1216+
.unwrap()
1217+
.build()
1218+
.unwrap();
1219+
nested_body = Expr::ScalarSubquery(Subquery {
1220+
subquery: Arc::new(plan),
1221+
outer_ref_columns: vec![],
1222+
spans: Default::default(),
1223+
});
1224+
}
1225+
let err = create(Some(nested_body), None).unwrap_err();
1226+
assert_eq!(
1227+
err.strip_backtrace(),
1228+
"Error during planning: Invalid placeholder, out of range: $2"
1229+
);
1230+
}
10841231

10851232
#[test]
10861233
fn test_partial_ord() {

‎datafusion/sql/src/expr/value.rs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,8 @@ impl<S: ContextProvider> SqlToRel<'_, S> {
142142
}
143143
};
144144
// Check if the placeholder is in the parameter list
145-
// FIXME: In the CREATE FUNCTION branch, param_type = None should raise an error
145+
// CreateFunction::try_new validates function placeholders; types stay
146+
// permissive so PREPARE can defer inference to bind time.
146147
let param_type = param_data_types.get(idx).and_then(|v| v.clone());
147148
// Data type of the parameter
148149
debug!("type of param {param} param_data_types[idx]: {param_type:?}");

‎datafusion/sql/src/statement.rs‎

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1498,15 +1498,15 @@ impl<S: ContextProvider> SqlToRel<'_, S> {
14981498
function_body,
14991499
};
15001500

1501-
let statement = DdlStatement::CreateFunction(Box::new(CreateFunction {
1502-
or_replace,
1503-
temporary,
1504-
name,
1505-
return_type: return_type.map(|f| f.data_type().clone()),
1506-
args,
1507-
params,
1508-
schema: DFSchemaRef::new(DFSchema::empty()),
1509-
}));
1501+
let statement =
1502+
DdlStatement::CreateFunction(Box::new(CreateFunction::try_new(
1503+
or_replace,
1504+
temporary,
1505+
name,
1506+
args,
1507+
return_type.map(|f| f.data_type().clone()),
1508+
params,
1509+
)?));
15101510

15111511
Ok(LogicalPlan::Ddl(statement))
15121512
}

‎datafusion/sqllogictest/test_files/create_function.slt‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,62 @@
99

1010
# Create function will fail unless a user supplied function factory is supplied
1111
statement error DataFusion error: Invalid or Unsupported Configuration: Function factory has not been configured
12+
CREATE FUNCTION foo (DOUBLE) RETURNS DOUBLE RETURN $1;
13+
14+
# Invalid placeholders are rejected during planning, before a function
15+
# factory is consulted (see #25038)
16+
statement error DataFusion error: Error during planning: Invalid placeholder, out of range: \$2
1217
CREATE FUNCTION foo (DOUBLE) RETURNS DOUBLE RETURN $1 + $2;
1318

19+
# a zero-argument function cannot reference arguments either
20+
statement error DataFusion error: Error during planning: Invalid placeholder, out of range: \$1
21+
CREATE FUNCTION foo_zero () RETURNS DOUBLE RETURN $1;
22+
23+
# placeholders inside subqueries are validated too
24+
statement error DataFusion error: Error during planning: Invalid placeholder, out of range: \$9
25+
CREATE FUNCTION foo_subquery (DOUBLE) RETURNS DOUBLE RETURN $1 + (SELECT $9);
26+
27+
# placeholders in argument default expressions are validated too
28+
statement error DataFusion error: Error during planning: Invalid placeholder, out of range: \$9
29+
CREATE FUNCTION foo_default (a DOUBLE, b DOUBLE DEFAULT $9) RETURNS DOUBLE RETURN $1 + $2;
30+
31+
# out of range placeholder in a subquery's LIMIT clause
32+
statement error DataFusion error: Error during planning: Invalid placeholder, out of range: \$9
33+
CREATE FUNCTION foo_limit (DOUBLE) RETURNS BOOLEAN RETURN $1 IN (SELECT v FROM (VALUES (1.0), (2.0)) AS t(v) ORDER BY v LIMIT $9);
34+
35+
# Named arguments are resolved before placeholder validation
36+
statement error DataFusion error: Error during planning: Invalid placeholder, out of range: \$3
37+
CREATE FUNCTION foo_named (a DOUBLE, b DOUBLE) RETURNS DOUBLE RETURN $a + $3;
38+
39+
statement error DataFusion error: Error during planning: Unknown placeholder: \$a
40+
CREATE FUNCTION foo_unknown () RETURNS DOUBLE RETURN $a;
41+
42+
# Defaults use positional placeholders
43+
statement error DataFusion error: Error during planning: Unknown placeholder: \$a
44+
CREATE FUNCTION foo_named_default (a DOUBLE, b DOUBLE DEFAULT $a) RETURNS DOUBLE RETURN $1 + $2;
45+
46+
statement error DataFusion error: Error during planning: Invalid placeholder, out of range: \$9
47+
CREATE FUNCTION foo_exists (DOUBLE) RETURNS BOOLEAN RETURN EXISTS (SELECT $9);
48+
49+
statement error DataFusion error: Error during planning: Invalid placeholder, out of range: \$9
50+
CREATE FUNCTION foo_in (DOUBLE) RETURNS BOOLEAN RETURN $1 IN (SELECT $9);
51+
52+
statement error DataFusion error: Error during planning: Invalid placeholder, out of range: \$9
53+
CREATE FUNCTION foo_any (DOUBLE) RETURNS BOOLEAN RETURN $1 = ANY (SELECT $9);
54+
55+
statement error DataFusion error: Error during planning: Invalid placeholder, out of range: \$9
56+
CREATE FUNCTION foo_nested (DOUBLE) RETURNS BOOLEAN RETURN EXISTS (SELECT 1 WHERE (SELECT $9) > 0.0);
57+
58+
# Valid definitions reach the function factory
59+
statement error DataFusion error: Invalid or Unsupported Configuration: Function factory has not been configured
60+
CREATE FUNCTION foo_valid (DOUBLE, DOUBLE) RETURNS DOUBLE RETURN $1 + $2;
61+
62+
statement error DataFusion error: Invalid or Unsupported Configuration: Function factory has not been configured
63+
CREATE FUNCTION foo_valid_default (a DOUBLE, b DOUBLE DEFAULT $1) RETURNS DOUBLE RETURN $1 + $2;
64+
65+
statement error DataFusion error: Invalid or Unsupported Configuration: Function factory has not been configured
66+
CREATE FUNCTION foo_valid_subquery (DOUBLE) RETURNS BOOLEAN RETURN $1 IN (SELECT v FROM (VALUES (1.0)) AS t(v) WHERE v > $1);
67+
1468
# multi-part identifiers are not supported
1569
statement error DataFusion error: This feature is not implemented: Qualified functions are not supported
1670
CREATE FUNCTION foo.bar (DOUBLE) RETURNS DOUBLE RETURN $1 + $2;

0 commit comments

Comments
 (0)