1use std::collections::BTreeSet;
16
17use crate::ast::{FunctionBinding, FunctionVolatility};
18use crate::plan::{QueryBlockPlan, QueryPlan, RelationalPlan, SourcePlan, UnifiedPlan};
19use crate::SQLError;
20use crate::ScalarExpr;
21
22pub trait VolatilityCatalog {
24 fn host_function_volatility(&self, name: &str) -> Option<FunctionVolatility>;
25 fn routine_volatilities(
26 &self,
27 name: &str,
28 binding: Option<&FunctionBinding>,
29 ) -> Option<Vec<FunctionVolatility>>;
30 fn view_query(&self, name: &str) -> Result<Option<QueryPlan>, SQLError>;
31}
32
33use super::builtin_function_dispatch_name;
34
35pub fn function_volatility(
43 catalog: &dyn VolatilityCatalog,
44 name: &str,
45 argument_count: usize,
46) -> FunctionVolatility {
47 function_volatility_with_binding(catalog, name, None, argument_count)
48}
49
50pub fn function_binding_is_volatile(
51 catalog: &dyn VolatilityCatalog,
52 name: &str,
53 binding: Option<&FunctionBinding>,
54 argument_count: usize,
55) -> bool {
56 function_volatility_with_binding(catalog, name, binding, argument_count)
57 == FunctionVolatility::Volatile
58}
59
60pub fn function_volatility_with_binding(
61 catalog: &dyn VolatilityCatalog,
62 name: &str,
63 binding: Option<&FunctionBinding>,
64 argument_count: usize,
65) -> FunctionVolatility {
66 let builtin = builtin_function_volatility(name, binding, argument_count);
67 if matches!(
68 binding.and_then(|binding| binding.dispatch),
69 Some(crate::ast::FunctionDispatch::NumericOperator(_))
70 ) || builtin == FunctionVolatility::Volatile
71 {
72 return builtin;
73 }
74 let identity = name.to_ascii_lowercase();
75
76 if let Some(volatility) = catalog.host_function_volatility(&identity) {
80 return volatility;
81 }
82
83 if let Some(volatility) = sql_routine_volatility(catalog, &identity, binding) {
84 return volatility;
85 }
86 builtin
87}
88
89pub fn builtin_function_volatility(
91 name: &str,
92 binding: Option<&FunctionBinding>,
93 argument_count: usize,
94) -> FunctionVolatility {
95 if matches!(
96 binding.and_then(|binding| binding.dispatch),
97 Some(crate::ast::FunctionDispatch::NumericOperator(_))
98 ) {
99 return FunctionVolatility::Immutable;
100 }
101 let lower = builtin_function_dispatch_name(&name.to_ascii_lowercase());
102 if builtin_is_volatile(&lower) {
103 return FunctionVolatility::Volatile;
104 }
105
106 if crate::registry::is_registered(&lower)
110 || matches!(
111 lower.as_str(),
112 "current_schema"
113 | "concat"
114 | "concat_ws"
115 | "format"
116 | "pg_typeof"
117 | "typeof"
118 | "row_to_json"
119 | "to_json"
120 | "to_jsonb"
121 | "json_build_object"
122 | "jsonb_build_object"
123 | "json_build_array"
124 | "jsonb_build_array"
125 | "to_char"
126 | "to_date"
127 | "to_number"
128 | "now"
129 | "current_date"
130 | "current_time"
131 | "current_timestamp"
132 | "localtime"
133 | "localtimestamp"
134 | "statement_timestamp"
135 | "transaction_timestamp"
136 | "current_schemas"
137 | "current_setting"
138 | "pg_backend_pid"
139 | "pg_my_temp_schema"
140 | "pg_is_other_temp_schema"
141 | "pg_trigger_depth"
142 | "version"
143 | "pg_listening_channels"
144 | "to_regclass"
145 | "to_regnamespace"
146 | "to_regproc"
147 | "to_regprocedure"
148 | "to_regrole"
149 | "to_regtype"
150 | "current_database"
151 | "current_catalog"
152 | "current_user"
153 | "session_user"
154 | "list_analyzers"
155 | "fts_index_stats"
156 | "pg_get_expr"
157 | "pg_get_partkeydef"
158 | "pg_get_serial_sequence"
159 | "pg_sequence_parameters"
160 | "pg_get_triggerdef"
161 | "pg_get_ruledef"
162 | "pg_get_viewdef"
163 | "pg_get_indexdef"
164 | "pg_get_constraintdef"
165 | "pg_get_function_arguments"
166 | "pg_get_function_identity_arguments"
167 | "pg_get_function_result"
168 | "pg_get_functiondef"
169 | "pg_get_function_sqlbody"
170 | "format_type"
171 | "pg_describe_object"
172 | "pg_has_role"
173 | "pg_get_userbyid"
174 | "has_database_privilege"
175 | "has_table_privilege"
176 | "has_column_privilege"
177 | "has_schema_privilege"
178 | "has_sequence_privilege"
179 | "has_function_privilege"
180 | "has_type_privilege"
181 | "enum_first"
183 | "enum_last"
184 | "enum_range"
185 )
186 || builtin_overload_is_stable(&lower, binding, argument_count)
187 {
188 FunctionVolatility::Stable
189 } else {
190 FunctionVolatility::Immutable
191 }
192}
193
194fn builtin_overload_is_stable(
195 name: &str,
196 binding: Option<&FunctionBinding>,
197 argument_count: usize,
198) -> bool {
199 (name == "age" && argument_count == 1)
200 || (name == "to_timestamp" && argument_count == 2)
201 || (matches!(name, "quote_literal" | "quote_nullable")
202 && binding.is_none_or(|binding| binding.argument_types.as_slice() != ["text"]))
203 || (matches!(name, "date_part" | "extract" | "date_trunc")
204 && argument_count == 2
205 && binding.is_none_or(|binding| {
206 binding
207 .argument_types
208 .iter()
209 .any(|ty| matches!(ty.as_str(), "timestamptz" | "timestamp with time zone"))
210 }))
211}
212
213fn builtin_is_volatile(name: &str) -> bool {
215 matches!(
216 name,
217 "random"
218 | "setseed"
219 | "set_config"
220 | "pg_sleep"
221 | "pg_sleep_for"
222 | "pg_sleep_until"
223 | "pg_notify"
224 | "pg_notification_queue_usage"
225 | "array_sample"
226 | "nextval"
227 | "currval"
228 | "lastval"
229 | "setval"
230 | "pg_get_sequence_data"
231 | "pg_sequence_last_value"
232 | "clock_timestamp"
233 | "timeofday"
234 | "gen_random_uuid"
235 | "uuidv4"
236 | "uuidv7"
237 | "create_analyzer"
238 | "drop_analyzer"
239 | "set_table_analyzer"
240 | "graph_create"
241 | "graph_drop"
242 | "create_graph"
243 | "drop_graph"
244 | "graph_exists"
245 | "create_vlabel"
246 | "create_elabel"
247 | "drop_label"
248 | "alter_graph"
249 | "cypher"
250 | "deep_learn"
251 | "bayesian_match"
254 | "bayesian_match_with_prior"
255 | "fts_match"
256 | "multi_field_match"
257 )
258}
259
260fn sql_routine_volatility(
261 catalog: &dyn VolatilityCatalog,
262 identity: &str,
263 binding: Option<&FunctionBinding>,
264) -> Option<FunctionVolatility> {
265 let overloads = catalog.routine_volatilities(identity, binding)?;
266 if overloads.contains(&FunctionVolatility::Volatile) {
267 return Some(FunctionVolatility::Volatile);
268 }
269 if overloads.contains(&FunctionVolatility::Stable) {
270 return Some(FunctionVolatility::Stable);
271 }
272 Some(FunctionVolatility::Immutable)
273}
274
275pub fn expr_contains_volatile_function(catalog: &dyn VolatilityCatalog, expr: &ScalarExpr) -> bool {
276 expr_contains_volatile_function_with(catalog, expr, true)
277}
278
279fn expr_contains_volatile_function_with(
281 catalog: &dyn VolatilityCatalog,
282 expr: &ScalarExpr,
283 conservative_subqueries: bool,
284) -> bool {
285 let mut volatile = false;
286 expr.visit(&mut |part| {
287 if volatile {
288 return;
289 }
290 match part {
291 ScalarExpr::Func {
292 name,
293 binding,
294 args,
295 ..
296 } => {
297 volatile =
298 function_volatility_with_binding(catalog, name, binding.as_ref(), args.len())
299 == FunctionVolatility::Volatile;
300 }
301 ScalarExpr::WindowCall { name, args, .. } => {
302 volatile =
303 function_volatility(catalog, name, args.len()) == FunctionVolatility::Volatile;
304 }
305 ScalarExpr::ScalarSubquery(_)
307 | ScalarExpr::Exists { .. }
308 | ScalarExpr::InSubquery { .. } => volatile = conservative_subqueries,
309 _ => {}
310 }
311 });
312 volatile
313}
314
315pub fn select_contains_volatile_function(
317 catalog: &dyn VolatilityCatalog,
318 block: &QueryBlockPlan,
319) -> bool {
320 block
321 .projections
322 .iter()
323 .any(|projection| expr_contains_volatile_function_with(catalog, &projection.expr, false))
324 || block
325 .r#where
326 .as_ref()
327 .is_some_and(|expr| expr_contains_volatile_function_with(catalog, expr, false))
328 || block
329 .group_by
330 .iter()
331 .any(|expr| expr_contains_volatile_function_with(catalog, expr, false))
332 || block.grouping_sets.iter().any(|set| {
333 set.iter()
334 .any(|expr| expr_contains_volatile_function_with(catalog, expr, false))
335 })
336 || block
337 .having
338 .as_ref()
339 .is_some_and(|expr| expr_contains_volatile_function_with(catalog, expr, false))
340 || block
341 .order_by
342 .iter()
343 .any(|order| expr_contains_volatile_function_with(catalog, &order.expr, false))
344 || block
345 .limit
346 .as_ref()
347 .is_some_and(|expr| expr_contains_volatile_function_with(catalog, expr, false))
348 || block
349 .offset
350 .as_ref()
351 .is_some_and(|expr| expr_contains_volatile_function_with(catalog, expr, false))
352 || block
353 .distinct_on
354 .iter()
355 .any(|expr| expr_contains_volatile_function_with(catalog, expr, false))
356}
357
358pub fn query_contains_volatile_function(
360 catalog: &dyn VolatilityCatalog,
361 plan: &QueryPlan,
362) -> Result<bool, SQLError> {
363 query_contains_volatile_function_inner(catalog, plan, &mut BTreeSet::new())
364}
365
366fn query_contains_volatile_function_inner(
367 catalog: &dyn VolatilityCatalog,
368 plan: &QueryPlan,
369 visiting_views: &mut BTreeSet<String>,
370) -> Result<bool, SQLError> {
371 for cte in &plan.ctes {
372 if match &cte.body {
373 crate::plan::CtePlanBody::Query(query) => {
374 query_contains_volatile_function_inner(catalog, query, visiting_views)?
375 }
376 crate::plan::CtePlanBody::Command(_) => true,
377 } {
378 return Ok(true);
379 }
380 }
381 match &plan.root {
382 RelationalPlan::QueryBlock(block) => {
383 if select_contains_volatile_function(catalog, block) {
384 return Ok(true);
385 }
386 for subquery in &block.subqueries {
387 if query_contains_volatile_function_inner(catalog, subquery, visiting_views)? {
388 return Ok(true);
389 }
390 }
391 if let Some(source) = &block.from {
392 source_contains_volatile_function(catalog, source, visiting_views)
393 } else {
394 Ok(false)
395 }
396 }
397 RelationalPlan::SetOp {
398 left,
399 right,
400 order_by,
401 limit,
402 offset,
403 subqueries,
404 ..
405 } => {
406 if query_contains_volatile_function_inner(catalog, left, visiting_views)?
407 || query_contains_volatile_function_inner(catalog, right, visiting_views)?
408 || order_by
409 .iter()
410 .any(|order| expr_contains_volatile_function(catalog, &order.expr))
411 || limit
412 .as_ref()
413 .is_some_and(|expr| expr_contains_volatile_function(catalog, expr))
414 || offset
415 .as_ref()
416 .is_some_and(|expr| expr_contains_volatile_function(catalog, expr))
417 {
418 return Ok(true);
419 }
420 for subquery in subqueries {
421 if query_contains_volatile_function_inner(catalog, subquery, visiting_views)? {
422 return Ok(true);
423 }
424 }
425 Ok(false)
426 }
427 RelationalPlan::Values { rows, subqueries } => {
428 if rows
429 .iter()
430 .flatten()
431 .any(|expr| expr_contains_volatile_function(catalog, expr))
432 {
433 return Ok(true);
434 }
435 for subquery in subqueries {
436 if query_contains_volatile_function_inner(catalog, subquery, visiting_views)? {
437 return Ok(true);
438 }
439 }
440 Ok(false)
441 }
442 }
443}
444
445fn source_contains_volatile_function(
446 catalog: &dyn VolatilityCatalog,
447 source: &SourcePlan,
448 visiting_views: &mut BTreeSet<String>,
449) -> Result<bool, SQLError> {
450 match source {
451 SourcePlan::Table { name, .. } => {
452 let key = name.to_ascii_lowercase();
453 if !visiting_views.insert(key.clone()) {
454 return Ok(false);
455 }
456 let result = match catalog.view_query(name)? {
457 Some(view) => {
458 query_contains_volatile_function_inner(catalog, &view, visiting_views)
459 }
460 None => Ok(false),
461 };
462 visiting_views.remove(&key);
463 result
464 }
465 SourcePlan::Join {
466 left, right, on, ..
467 } => {
468 if on
469 .as_ref()
470 .is_some_and(|expr| expr_contains_volatile_function(catalog, expr))
471 {
472 return Ok(true);
473 }
474 Ok(
475 source_contains_volatile_function(catalog, left, visiting_views)?
476 || source_contains_volatile_function(catalog, right, visiting_views)?,
477 )
478 }
479 SourcePlan::Values { rows, .. } => Ok(rows
480 .iter()
481 .flatten()
482 .any(|expr| expr_contains_volatile_function(catalog, expr))),
483 SourcePlan::Function {
484 name,
485 binding,
486 args,
487 ..
488 } => Ok(
489 function_volatility_with_binding(catalog, name, binding.as_ref(), args.len())
490 == FunctionVolatility::Volatile
491 || args
492 .iter()
493 .any(|expr| expr_contains_volatile_function(catalog, expr)),
494 ),
495 SourcePlan::FunctionGroup { functions, .. } => Ok(functions.iter().any(|function| {
496 function_volatility_with_binding(
497 catalog,
498 &function.name,
499 function.binding.as_ref(),
500 function.args.len(),
501 ) == FunctionVolatility::Volatile
502 || function
503 .args
504 .iter()
505 .any(|expr| expr_contains_volatile_function(catalog, expr))
506 })),
507 SourcePlan::Subquery { body, .. } => {
508 query_contains_volatile_function_inner(catalog, body, visiting_views)
509 }
510 }
511}
512
513pub fn unified_plan_contains_volatile_function(
517 catalog: &dyn VolatilityCatalog,
518 plan: &UnifiedPlan,
519) -> bool {
520 let mut inspected = plan.clone();
521 let mut volatile = false;
522 inspected.rewrite_scalar_expressions(&mut |expr| {
523 if volatile {
524 return;
525 }
526 match expr {
527 ScalarExpr::Func {
528 name,
529 binding,
530 args,
531 ..
532 } => {
533 volatile =
534 function_volatility_with_binding(catalog, name, binding.as_ref(), args.len())
535 == FunctionVolatility::Volatile;
536 }
537 ScalarExpr::WindowCall { name, args, .. } => {
538 volatile =
539 function_volatility(catalog, name, args.len()) == FunctionVolatility::Volatile;
540 }
541 _ => {}
542 }
543 });
544 volatile
545}
546
547#[cfg(test)]
548mod tests {
549 use super::{
550 expr_contains_volatile_function, FunctionBinding, FunctionVolatility, QueryPlan, SQLError,
551 ScalarExpr, VolatilityCatalog,
552 };
553
554 struct EmptyCatalog;
555 impl VolatilityCatalog for EmptyCatalog {
556 fn host_function_volatility(&self, _: &str) -> Option<FunctionVolatility> {
557 None
558 }
559 fn routine_volatilities(
560 &self,
561 _: &str,
562 _: Option<&FunctionBinding>,
563 ) -> Option<Vec<FunctionVolatility>> {
564 None
565 }
566 fn view_query(&self, _: &str) -> Result<Option<QueryPlan>, SQLError> {
567 Ok(None)
568 }
569 }
570 use crate::ast::{FrameExclusion, FrameMode};
571 use crate::{ScalarFrameBound, ScalarWindowFrame, ScalarWindowSpec};
572
573 #[test]
574 fn builtin_constants_preserve_postgresql_statement_and_session_inputs() {
575 for name in [
577 "concat",
578 "concat_ws",
579 "format",
580 "pg_typeof",
581 "row_to_json",
582 "to_json",
583 "to_jsonb",
584 "json_build_object",
585 "jsonb_build_array",
586 "to_char",
587 "to_date",
588 "to_number",
589 "has_table_privilege",
590 "has_column_privilege",
591 ] {
592 assert_eq!(
593 super::builtin_function_volatility(name, None, 2),
594 FunctionVolatility::Stable,
595 "{name}"
596 );
597 }
598 for name in ["repeat", "upper"] {
599 assert_eq!(
600 super::builtin_function_volatility(name, None, 2),
601 FunctionVolatility::Immutable,
602 "{name}"
603 );
604 }
605 assert_eq!(
606 super::builtin_function_volatility("nextval", None, 1),
607 FunctionVolatility::Volatile
608 );
609 }
610
611 #[test]
612 fn sequence_introspection_volatility_matches_postgresql() {
613 for (name, expected) in [
614 ("pg_get_sequence_data", FunctionVolatility::Volatile),
615 ("pg_sequence_last_value", FunctionVolatility::Volatile),
616 ("pg_sequence_parameters", FunctionVolatility::Stable),
617 ] {
618 for qualified in [name.to_string(), format!("pg_catalog.{name}")] {
619 assert_eq!(
620 super::function_volatility(&EmptyCatalog, &qualified, 1),
621 expected,
622 "{qualified}"
623 );
624 }
625 }
626 }
627
628 #[test]
629 fn volatility_inspection_includes_window_frame_expressions() {
630 let expression = ScalarExpr::WindowCall {
631 name: "sum".into(),
632 args: vec![ScalarExpr::Column("amount".into())],
633 spec: ScalarWindowSpec {
634 definition: None,
635 partition_by: Vec::new(),
636 order_by: Vec::new(),
637 frame: Some(ScalarWindowFrame {
638 mode: FrameMode::Rows,
639 start: ScalarFrameBound::Preceding(Box::new(ScalarExpr::Func {
640 order_syntax: crate::ast::FunctionOrderSyntax::Ordinary,
641 name: "random".into(),
642 binding: None,
643 args: Vec::new(),
644 distinct: false,
645 order_by: Vec::new(),
646 filter: None,
647 })),
648 end: ScalarFrameBound::CurrentRow,
649 between: true,
650 exclusion: FrameExclusion::NoOthers,
651 }),
652 },
653 filter: None,
654 modifiers: crate::ast::WindowCallModifiers::default(),
655 };
656 assert!(expr_contains_volatile_function(&EmptyCatalog, &expression));
657 }
658}