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
60fn is_intrinsic_scalar_syntax(binding: Option<&FunctionBinding>) -> bool {
61 binding
62 .and_then(|binding| binding.dispatch)
63 .is_some_and(crate::ast::FunctionDispatch::is_concatenation)
64 || matches!(
65 binding.and_then(|binding| binding.dispatch),
66 Some(
67 crate::ast::FunctionDispatch::NumericOperator(_)
68 | crate::ast::FunctionDispatch::Pattern { .. }
69 | crate::ast::FunctionDispatch::PatternEscape { .. }
70 | crate::ast::FunctionDispatch::Collate { .. }
71 | crate::ast::FunctionDispatch::BooleanTest(_)
72 | crate::ast::FunctionDispatch::ArraySubscripts
73 | crate::ast::FunctionDispatch::ArraySlices
74 | crate::ast::FunctionDispatch::ArraySlicesWithBounds(_)
75 )
76 )
77}
78
79pub fn function_volatility_with_binding(
80 catalog: &dyn VolatilityCatalog,
81 name: &str,
82 binding: Option<&FunctionBinding>,
83 argument_count: usize,
84) -> FunctionVolatility {
85 let builtin = builtin_function_volatility(name, binding, argument_count);
86 if is_intrinsic_scalar_syntax(binding) || builtin == FunctionVolatility::Volatile {
87 return builtin;
88 }
89 let identity = name.to_ascii_lowercase();
90
91 if let Some(volatility) = catalog.host_function_volatility(&identity) {
95 return volatility;
96 }
97
98 if let Some(volatility) = sql_routine_volatility(catalog, &identity, binding) {
99 return volatility;
100 }
101 builtin
102}
103
104pub fn builtin_function_volatility(
106 name: &str,
107 binding: Option<&FunctionBinding>,
108 argument_count: usize,
109) -> FunctionVolatility {
110 if let Some(crate::ast::FunctionDispatch::Concat { oid }) =
111 binding.and_then(|binding| binding.dispatch)
112 {
113 return oid
114 .and_then(|oid| crate::type_resolution::binary_operator_by_oid(i64::from(oid)))
115 .filter(|operator| operator.name == "||")
116 .map_or(FunctionVolatility::Stable, |operator| operator.volatility());
117 }
118 if is_intrinsic_scalar_syntax(binding) {
119 return FunctionVolatility::Immutable;
120 }
121 let lower = builtin_function_dispatch_name(&name.to_ascii_lowercase());
122 if builtin_is_volatile(&lower) {
123 return FunctionVolatility::Volatile;
124 }
125
126 if crate::registry::is_registered(&lower)
130 || matches!(
131 lower.as_str(),
132 "current_schema"
133 | "concat"
134 | "concat_ws"
135 | "format"
136 | "pg_typeof"
137 | "pg_collation_for"
138 | "typeof"
139 | "row_to_json"
140 | "to_json"
141 | "to_jsonb"
142 | "json_build_object"
143 | "jsonb_build_object"
144 | "json_build_array"
145 | "jsonb_build_array"
146 | "to_char"
147 | "to_date"
148 | "to_number"
149 | "now"
150 | "current_date"
151 | "current_time"
152 | "current_timestamp"
153 | "localtime"
154 | "localtimestamp"
155 | "statement_timestamp"
156 | "transaction_timestamp"
157 | "current_schemas"
158 | "current_setting"
159 | "pg_backend_pid"
160 | "pg_my_temp_schema"
161 | "pg_is_other_temp_schema"
162 | "pg_trigger_depth"
163 | "version"
164 | "pg_listening_channels"
165 | "to_regclass"
166 | "to_regcollation"
167 | "to_regnamespace"
168 | "to_regproc"
169 | "to_regprocedure"
170 | "to_regrole"
171 | "to_regtype"
172 | "current_database"
173 | "current_catalog"
174 | "current_user"
175 | "session_user"
176 | "list_analyzers"
177 | "fts_index_stats"
178 | "pg_get_expr"
179 | "pg_get_partkeydef"
180 | "pg_get_serial_sequence"
181 | "pg_sequence_parameters"
182 | "pg_get_triggerdef"
183 | "pg_get_ruledef"
184 | "pg_get_viewdef"
185 | "pg_get_indexdef"
186 | "pg_get_constraintdef"
187 | "pg_get_function_arguments"
188 | "pg_get_function_identity_arguments"
189 | "pg_get_function_result"
190 | "pg_get_functiondef"
191 | "pg_get_function_sqlbody"
192 | "format_type"
193 | "pg_describe_object"
194 | "pg_has_role"
195 | "pg_get_userbyid"
196 | "has_database_privilege"
197 | "has_table_privilege"
198 | "has_column_privilege"
199 | "has_schema_privilege"
200 | "has_sequence_privilege"
201 | "has_function_privilege"
202 | "has_type_privilege"
203 | "enum_first"
205 | "enum_last"
206 | "enum_range"
207 )
208 || builtin_overload_is_stable(&lower, binding, argument_count)
209 {
210 FunctionVolatility::Stable
211 } else {
212 FunctionVolatility::Immutable
213 }
214}
215
216fn builtin_overload_is_stable(
217 name: &str,
218 binding: Option<&FunctionBinding>,
219 argument_count: usize,
220) -> bool {
221 (matches!(name, "age" | "timezone") && argument_count == 1)
222 || (name == "to_timestamp" && argument_count == 2)
223 || (matches!(name, "quote_literal" | "quote_nullable")
224 && binding.is_none_or(|binding| binding.argument_types.as_slice() != ["text"]))
225 || (matches!(name, "date_part" | "extract" | "date_trunc")
226 && argument_count == 2
227 && binding.is_none_or(|binding| {
228 binding
229 .argument_types
230 .iter()
231 .any(|ty| matches!(ty.as_str(), "timestamptz" | "timestamp with time zone"))
232 }))
233}
234
235fn builtin_is_volatile(name: &str) -> bool {
237 matches!(
238 name,
239 "random"
240 | "setseed"
241 | "set_config"
242 | "pg_sleep"
243 | "pg_sleep_for"
244 | "pg_sleep_until"
245 | "pg_notify"
246 | "pg_notification_queue_usage"
247 | "array_sample"
248 | "nextval"
249 | "currval"
250 | "lastval"
251 | "setval"
252 | "pg_get_sequence_data"
253 | "pg_sequence_last_value"
254 | "clock_timestamp"
255 | "timeofday"
256 | "gen_random_uuid"
257 | "uuidv4"
258 | "uuidv7"
259 | "create_analyzer"
260 | "drop_analyzer"
261 | "set_table_analyzer"
262 | "graph_create"
263 | "graph_drop"
264 | "create_graph"
265 | "drop_graph"
266 | "graph_exists"
267 | "create_vlabel"
268 | "create_elabel"
269 | "drop_label"
270 | "alter_graph"
271 | "cypher"
272 | "deep_learn"
273 | "bayesian_match"
276 | "bayesian_match_with_prior"
277 | "fts_match"
278 | "multi_field_match"
279 )
280}
281
282fn sql_routine_volatility(
283 catalog: &dyn VolatilityCatalog,
284 identity: &str,
285 binding: Option<&FunctionBinding>,
286) -> Option<FunctionVolatility> {
287 let overloads = catalog.routine_volatilities(identity, binding)?;
288 if overloads.contains(&FunctionVolatility::Volatile) {
289 return Some(FunctionVolatility::Volatile);
290 }
291 if overloads.contains(&FunctionVolatility::Stable) {
292 return Some(FunctionVolatility::Stable);
293 }
294 Some(FunctionVolatility::Immutable)
295}
296
297pub fn expr_contains_volatile_function(catalog: &dyn VolatilityCatalog, expr: &ScalarExpr) -> bool {
298 expr_contains_volatile_function_with(catalog, expr, true)
299}
300
301fn expr_contains_volatile_function_with(
303 catalog: &dyn VolatilityCatalog,
304 expr: &ScalarExpr,
305 conservative_subqueries: bool,
306) -> bool {
307 let mut volatile = false;
308 expr.visit(&mut |part| {
309 if volatile {
310 return;
311 }
312 match part {
313 ScalarExpr::Func {
314 name,
315 binding,
316 args,
317 ..
318 } => {
319 volatile =
320 function_volatility_with_binding(catalog, name, binding.as_ref(), args.len())
321 == FunctionVolatility::Volatile;
322 }
323 ScalarExpr::WindowCall { name, args, .. } => {
324 volatile =
325 function_volatility(catalog, name, args.len()) == FunctionVolatility::Volatile;
326 }
327 ScalarExpr::ScalarSubquery(_)
329 | ScalarExpr::Exists { .. }
330 | ScalarExpr::InSubquery { .. } => volatile = conservative_subqueries,
331 _ => {}
332 }
333 });
334 volatile
335}
336
337pub fn expression_plan_contains_volatile_function(
339 catalog: &dyn VolatilityCatalog,
340 plan: &crate::plan::ExpressionPlan,
341) -> Result<bool, SQLError> {
342 if expr_contains_volatile_function_with(catalog, &plan.scalar, false) {
343 return Ok(true);
344 }
345 for query in &plan.subqueries {
346 if query_contains_volatile_function(catalog, query)? {
347 return Ok(true);
348 }
349 }
350 Ok(false)
351}
352
353pub fn select_contains_volatile_function(
355 catalog: &dyn VolatilityCatalog,
356 block: &QueryBlockPlan,
357) -> bool {
358 block
359 .projections
360 .iter()
361 .any(|projection| expr_contains_volatile_function_with(catalog, &projection.expr, false))
362 || block
363 .r#where
364 .as_ref()
365 .is_some_and(|expr| expr_contains_volatile_function_with(catalog, expr, false))
366 || block
367 .group_by
368 .iter()
369 .any(|expr| expr_contains_volatile_function_with(catalog, expr, false))
370 || block.grouping_sets.iter().any(|set| {
371 set.iter()
372 .any(|expr| expr_contains_volatile_function_with(catalog, expr, false))
373 })
374 || block
375 .having
376 .as_ref()
377 .is_some_and(|expr| expr_contains_volatile_function_with(catalog, expr, false))
378 || block
379 .order_by
380 .iter()
381 .any(|order| expr_contains_volatile_function_with(catalog, &order.expr, false))
382 || block
383 .limit
384 .as_ref()
385 .is_some_and(|expr| expr_contains_volatile_function_with(catalog, expr, false))
386 || block
387 .offset
388 .as_ref()
389 .is_some_and(|expr| expr_contains_volatile_function_with(catalog, expr, false))
390 || block
391 .distinct_on
392 .iter()
393 .any(|expr| expr_contains_volatile_function_with(catalog, expr, false))
394}
395
396pub fn query_contains_volatile_function(
398 catalog: &dyn VolatilityCatalog,
399 plan: &QueryPlan,
400) -> Result<bool, SQLError> {
401 query_contains_volatile_function_inner(catalog, plan, &mut BTreeSet::new())
402}
403
404fn query_contains_volatile_function_inner(
405 catalog: &dyn VolatilityCatalog,
406 plan: &QueryPlan,
407 visiting_views: &mut BTreeSet<String>,
408) -> Result<bool, SQLError> {
409 for cte in &plan.ctes {
410 if match &cte.body {
411 crate::plan::CtePlanBody::Query(query) => {
412 query_contains_volatile_function_inner(catalog, query, visiting_views)?
413 }
414 crate::plan::CtePlanBody::Command(_) => true,
415 } {
416 return Ok(true);
417 }
418 }
419 match &plan.root {
420 RelationalPlan::QueryBlock(block) => {
421 if select_contains_volatile_function(catalog, block) {
422 return Ok(true);
423 }
424 for subquery in &block.subqueries {
425 if query_contains_volatile_function_inner(catalog, subquery, visiting_views)? {
426 return Ok(true);
427 }
428 }
429 if let Some(source) = &block.from {
430 source_contains_volatile_function(catalog, source, visiting_views)
431 } else {
432 Ok(false)
433 }
434 }
435 RelationalPlan::SetOp {
436 left,
437 right,
438 order_by,
439 limit,
440 offset,
441 subqueries,
442 ..
443 } => {
444 if query_contains_volatile_function_inner(catalog, left, visiting_views)?
445 || query_contains_volatile_function_inner(catalog, right, visiting_views)?
446 || order_by
447 .iter()
448 .any(|order| expr_contains_volatile_function(catalog, &order.expr))
449 || limit
450 .as_ref()
451 .is_some_and(|expr| expr_contains_volatile_function(catalog, expr))
452 || offset
453 .as_ref()
454 .is_some_and(|expr| expr_contains_volatile_function(catalog, expr))
455 {
456 return Ok(true);
457 }
458 for subquery in subqueries {
459 if query_contains_volatile_function_inner(catalog, subquery, visiting_views)? {
460 return Ok(true);
461 }
462 }
463 Ok(false)
464 }
465 RelationalPlan::Values { rows, subqueries } => {
466 if rows
467 .iter()
468 .flatten()
469 .any(|expr| expr_contains_volatile_function(catalog, expr))
470 {
471 return Ok(true);
472 }
473 for subquery in subqueries {
474 if query_contains_volatile_function_inner(catalog, subquery, visiting_views)? {
475 return Ok(true);
476 }
477 }
478 Ok(false)
479 }
480 }
481}
482
483fn source_contains_volatile_function(
484 catalog: &dyn VolatilityCatalog,
485 source: &SourcePlan,
486 visiting_views: &mut BTreeSet<String>,
487) -> Result<bool, SQLError> {
488 match source {
489 SourcePlan::Table { name, .. } => {
490 let key = name.to_ascii_lowercase();
491 if !visiting_views.insert(key.clone()) {
492 return Ok(false);
493 }
494 let result = match catalog.view_query(name)? {
495 Some(view) => {
496 query_contains_volatile_function_inner(catalog, &view, visiting_views)
497 }
498 None => Ok(false),
499 };
500 visiting_views.remove(&key);
501 result
502 }
503 SourcePlan::Join {
504 left, right, on, ..
505 } => {
506 if on
507 .as_ref()
508 .is_some_and(|expr| expr_contains_volatile_function(catalog, expr))
509 {
510 return Ok(true);
511 }
512 Ok(
513 source_contains_volatile_function(catalog, left, visiting_views)?
514 || source_contains_volatile_function(catalog, right, visiting_views)?,
515 )
516 }
517 SourcePlan::Values { rows, .. } => Ok(rows
518 .iter()
519 .flatten()
520 .any(|expr| expr_contains_volatile_function(catalog, expr))),
521 SourcePlan::Function {
522 name,
523 binding,
524 args,
525 ..
526 } => Ok(
527 function_volatility_with_binding(catalog, name, binding.as_ref(), args.len())
528 == FunctionVolatility::Volatile
529 || args
530 .iter()
531 .any(|expr| expr_contains_volatile_function(catalog, expr)),
532 ),
533 SourcePlan::FunctionGroup { functions, .. } => Ok(functions.iter().any(|function| {
534 function_volatility_with_binding(
535 catalog,
536 &function.name,
537 function.binding.as_ref(),
538 function.args.len(),
539 ) == FunctionVolatility::Volatile
540 || function
541 .args
542 .iter()
543 .any(|expr| expr_contains_volatile_function(catalog, expr))
544 })),
545 SourcePlan::Subquery { body, .. } => {
546 query_contains_volatile_function_inner(catalog, body, visiting_views)
547 }
548 }
549}
550
551pub fn unified_plan_contains_volatile_function(
555 catalog: &dyn VolatilityCatalog,
556 plan: &UnifiedPlan,
557) -> bool {
558 let mut inspected = plan.clone();
559 let mut volatile = false;
560 inspected.rewrite_scalar_expressions(&mut |expr| {
561 if volatile {
562 return;
563 }
564 match expr {
565 ScalarExpr::Func {
566 name,
567 binding,
568 args,
569 ..
570 } => {
571 volatile =
572 function_volatility_with_binding(catalog, name, binding.as_ref(), args.len())
573 == FunctionVolatility::Volatile;
574 }
575 ScalarExpr::WindowCall { name, args, .. } => {
576 volatile =
577 function_volatility(catalog, name, args.len()) == FunctionVolatility::Volatile;
578 }
579 _ => {}
580 }
581 });
582 volatile
583}
584
585#[cfg(test)]
586mod tests {
587 use super::{
588 expr_contains_volatile_function, FunctionBinding, FunctionVolatility, QueryPlan, SQLError,
589 ScalarExpr, VolatilityCatalog,
590 };
591
592 struct EmptyCatalog;
593 impl VolatilityCatalog for EmptyCatalog {
594 fn host_function_volatility(&self, _: &str) -> Option<FunctionVolatility> {
595 None
596 }
597 fn routine_volatilities(
598 &self,
599 _: &str,
600 _: Option<&FunctionBinding>,
601 ) -> Option<Vec<FunctionVolatility>> {
602 None
603 }
604 fn view_query(&self, _: &str) -> Result<Option<QueryPlan>, SQLError> {
605 Ok(None)
606 }
607 }
608 use crate::ast::{FrameExclusion, FrameMode};
609 use crate::{ScalarFrameBound, ScalarWindowFrame, ScalarWindowSpec};
610
611 #[test]
612 fn collation_volatility_follows_only_its_operand() {
613 struct Shadowed;
614 impl VolatilityCatalog for Shadowed {
615 fn host_function_volatility(&self, _: &str) -> Option<FunctionVolatility> {
616 Some(FunctionVolatility::Volatile)
617 }
618 fn routine_volatilities(
619 &self,
620 _: &str,
621 _: Option<&FunctionBinding>,
622 ) -> Option<Vec<FunctionVolatility>> {
623 unreachable!("host function wins ordinary calls")
624 }
625 fn view_query(&self, _: &str) -> Result<Option<QueryPlan>, SQLError> {
626 unreachable!("no view")
627 }
628 }
629 for (input, expected) in [
630 ("'x' COLLATE \"C\"", false),
631 ("nextval('effect')::text COLLATE \"C\"", true),
632 ("\"COLLATE\"('x')", true),
633 ("true IS TRUE", false),
634 ("(nextval('effect') > 0) IS NOT FALSE", true),
635 ("\"IS TRUE\"(true)", true),
636 ("(ARRAY[1,2])[1]", false),
637 ("(ARRAY[1,2])[:1]", false),
638 ("(ARRAY[1,2])[nextval('effect')]", true),
639 ("(ARRAY[1,2])[:nextval('effect')]", true),
640 ("slice(ARRAY[1,2], 1, 2)", true),
641 ("subscript(ARRAY[1,2], 1)", true),
642 ] {
643 let crate::Statement::Select(mut query) = crate::compile(&format!("SELECT {input}"))
644 .unwrap()
645 .remove(0)
646 else {
647 panic!("SELECT")
648 };
649 let scalar =
650 crate::plan::ExpressionPlan::lower(query.projections.remove(0).expr).scalar;
651 assert_eq!(
652 expr_contains_volatile_function(&Shadowed, &scalar),
653 expected,
654 "{input}"
655 );
656 }
657 let binding =
658 FunctionBinding::dispatched(crate::ast::FunctionDispatch::Collate { oid: Some(950) });
659 assert_eq!(
660 super::builtin_function_volatility("nextval", Some(&binding), 2),
661 FunctionVolatility::Immutable
662 );
663 }
664
665 #[test]
666 fn builtin_constants_preserve_postgresql_statement_and_session_inputs() {
667 for name in [
669 "concat",
670 "concat_ws",
671 "format",
672 "pg_typeof",
673 "row_to_json",
674 "to_json",
675 "to_jsonb",
676 "json_build_object",
677 "jsonb_build_array",
678 "to_char",
679 "to_date",
680 "to_number",
681 "has_table_privilege",
682 "has_column_privilege",
683 ] {
684 assert_eq!(
685 super::builtin_function_volatility(name, None, 2),
686 FunctionVolatility::Stable,
687 "{name}"
688 );
689 }
690 for name in ["repeat", "upper"] {
691 assert_eq!(
692 super::builtin_function_volatility(name, None, 2),
693 FunctionVolatility::Immutable,
694 "{name}"
695 );
696 }
697 assert_eq!(
698 super::builtin_function_volatility("nextval", None, 1),
699 FunctionVolatility::Volatile
700 );
701 }
702
703 #[test]
704 fn sequence_introspection_volatility_matches_postgresql() {
705 for (name, expected) in [
706 ("pg_get_sequence_data", FunctionVolatility::Volatile),
707 ("pg_sequence_last_value", FunctionVolatility::Volatile),
708 ("pg_sequence_parameters", FunctionVolatility::Stable),
709 ] {
710 for qualified in [name.to_string(), format!("pg_catalog.{name}")] {
711 assert_eq!(
712 super::function_volatility(&EmptyCatalog, &qualified, 1),
713 expected,
714 "{qualified}"
715 );
716 }
717 }
718 }
719
720 #[test]
721 fn timestamp_zone_volatility_preserves_session_dependence() {
722 assert_eq!(
723 super::builtin_function_volatility("timezone", None, 1),
724 FunctionVolatility::Stable
725 );
726 assert_eq!(
727 super::builtin_function_volatility("timezone", None, 2),
728 FunctionVolatility::Immutable
729 );
730 }
731
732 #[test]
733 fn volatility_inspection_includes_window_frame_expressions() {
734 let expression = ScalarExpr::WindowCall {
735 name: "sum".into(),
736 args: vec![ScalarExpr::Column("amount".into())],
737 spec: ScalarWindowSpec {
738 definition: None,
739 partition_by: Vec::new(),
740 order_by: Vec::new(),
741 frame: Some(ScalarWindowFrame {
742 mode: FrameMode::Rows,
743 start: ScalarFrameBound::Preceding(Box::new(ScalarExpr::Func {
744 order_syntax: crate::ast::FunctionOrderSyntax::Ordinary,
745 name: "random".into(),
746 binding: None,
747 args: Vec::new(),
748 distinct: false,
749 order_by: Vec::new(),
750 filter: None,
751 })),
752 end: ScalarFrameBound::CurrentRow,
753 between: true,
754 exclusion: FrameExclusion::NoOthers,
755 }),
756 },
757 filter: None,
758 modifiers: crate::ast::WindowCallModifiers::default(),
759 };
760 assert!(expr_contains_volatile_function(&EmptyCatalog, &expression));
761 }
762 #[test]
763 fn scalar_plan_volatility_inspects_its_subquery_body() {
764 for (sql, expected) in [
765 ("SELECT (SELECT 1 / 0)", false),
766 ("SELECT (SELECT nextval('ticks'))", true),
767 ] {
768 let crate::plan::UnifiedPlan::Query(query) =
769 crate::plan::UnifiedPlan::lower(crate::compile(sql).unwrap().remove(0))
770 else {
771 panic!("query");
772 };
773 let crate::plan::RelationalPlan::QueryBlock(mut block) = query.root else {
774 panic!("query block");
775 };
776 let plan = crate::plan::ExpressionPlan {
777 scalar: block.projections.remove(0).expr,
778 subqueries: block.subqueries,
779 };
780 assert_eq!(
781 super::expression_plan_contains_volatile_function(&EmptyCatalog, &plan).unwrap(),
782 expected,
783 "{sql}"
784 );
785 }
786 }
787}