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_regcollation"
146 | "to_regnamespace"
147 | "to_regproc"
148 | "to_regprocedure"
149 | "to_regrole"
150 | "to_regtype"
151 | "current_database"
152 | "current_catalog"
153 | "current_user"
154 | "session_user"
155 | "list_analyzers"
156 | "fts_index_stats"
157 | "pg_get_expr"
158 | "pg_get_partkeydef"
159 | "pg_get_serial_sequence"
160 | "pg_sequence_parameters"
161 | "pg_get_triggerdef"
162 | "pg_get_ruledef"
163 | "pg_get_viewdef"
164 | "pg_get_indexdef"
165 | "pg_get_constraintdef"
166 | "pg_get_function_arguments"
167 | "pg_get_function_identity_arguments"
168 | "pg_get_function_result"
169 | "pg_get_functiondef"
170 | "pg_get_function_sqlbody"
171 | "format_type"
172 | "pg_describe_object"
173 | "pg_has_role"
174 | "pg_get_userbyid"
175 | "has_database_privilege"
176 | "has_table_privilege"
177 | "has_column_privilege"
178 | "has_schema_privilege"
179 | "has_sequence_privilege"
180 | "has_function_privilege"
181 | "has_type_privilege"
182 | "enum_first"
184 | "enum_last"
185 | "enum_range"
186 )
187 || builtin_overload_is_stable(&lower, binding, argument_count)
188 {
189 FunctionVolatility::Stable
190 } else {
191 FunctionVolatility::Immutable
192 }
193}
194
195fn builtin_overload_is_stable(
196 name: &str,
197 binding: Option<&FunctionBinding>,
198 argument_count: usize,
199) -> bool {
200 (name == "age" && argument_count == 1)
201 || (name == "to_timestamp" && argument_count == 2)
202 || (matches!(name, "quote_literal" | "quote_nullable")
203 && binding.is_none_or(|binding| binding.argument_types.as_slice() != ["text"]))
204 || (matches!(name, "date_part" | "extract" | "date_trunc")
205 && argument_count == 2
206 && binding.is_none_or(|binding| {
207 binding
208 .argument_types
209 .iter()
210 .any(|ty| matches!(ty.as_str(), "timestamptz" | "timestamp with time zone"))
211 }))
212}
213
214fn builtin_is_volatile(name: &str) -> bool {
216 matches!(
217 name,
218 "random"
219 | "setseed"
220 | "set_config"
221 | "pg_sleep"
222 | "pg_sleep_for"
223 | "pg_sleep_until"
224 | "pg_notify"
225 | "pg_notification_queue_usage"
226 | "array_sample"
227 | "nextval"
228 | "currval"
229 | "lastval"
230 | "setval"
231 | "pg_get_sequence_data"
232 | "pg_sequence_last_value"
233 | "clock_timestamp"
234 | "timeofday"
235 | "gen_random_uuid"
236 | "uuidv4"
237 | "uuidv7"
238 | "create_analyzer"
239 | "drop_analyzer"
240 | "set_table_analyzer"
241 | "graph_create"
242 | "graph_drop"
243 | "create_graph"
244 | "drop_graph"
245 | "graph_exists"
246 | "create_vlabel"
247 | "create_elabel"
248 | "drop_label"
249 | "alter_graph"
250 | "cypher"
251 | "deep_learn"
252 | "bayesian_match"
255 | "bayesian_match_with_prior"
256 | "fts_match"
257 | "multi_field_match"
258 )
259}
260
261fn sql_routine_volatility(
262 catalog: &dyn VolatilityCatalog,
263 identity: &str,
264 binding: Option<&FunctionBinding>,
265) -> Option<FunctionVolatility> {
266 let overloads = catalog.routine_volatilities(identity, binding)?;
267 if overloads.contains(&FunctionVolatility::Volatile) {
268 return Some(FunctionVolatility::Volatile);
269 }
270 if overloads.contains(&FunctionVolatility::Stable) {
271 return Some(FunctionVolatility::Stable);
272 }
273 Some(FunctionVolatility::Immutable)
274}
275
276pub fn expr_contains_volatile_function(catalog: &dyn VolatilityCatalog, expr: &ScalarExpr) -> bool {
277 expr_contains_volatile_function_with(catalog, expr, true)
278}
279
280fn expr_contains_volatile_function_with(
282 catalog: &dyn VolatilityCatalog,
283 expr: &ScalarExpr,
284 conservative_subqueries: bool,
285) -> bool {
286 let mut volatile = false;
287 expr.visit(&mut |part| {
288 if volatile {
289 return;
290 }
291 match part {
292 ScalarExpr::Func {
293 name,
294 binding,
295 args,
296 ..
297 } => {
298 volatile =
299 function_volatility_with_binding(catalog, name, binding.as_ref(), args.len())
300 == FunctionVolatility::Volatile;
301 }
302 ScalarExpr::WindowCall { name, args, .. } => {
303 volatile =
304 function_volatility(catalog, name, args.len()) == FunctionVolatility::Volatile;
305 }
306 ScalarExpr::ScalarSubquery(_)
308 | ScalarExpr::Exists { .. }
309 | ScalarExpr::InSubquery { .. } => volatile = conservative_subqueries,
310 _ => {}
311 }
312 });
313 volatile
314}
315
316pub fn expression_plan_contains_volatile_function(
318 catalog: &dyn VolatilityCatalog,
319 plan: &crate::plan::ExpressionPlan,
320) -> Result<bool, SQLError> {
321 if expr_contains_volatile_function_with(catalog, &plan.scalar, false) {
322 return Ok(true);
323 }
324 for query in &plan.subqueries {
325 if query_contains_volatile_function(catalog, query)? {
326 return Ok(true);
327 }
328 }
329 Ok(false)
330}
331
332pub fn select_contains_volatile_function(
334 catalog: &dyn VolatilityCatalog,
335 block: &QueryBlockPlan,
336) -> bool {
337 block
338 .projections
339 .iter()
340 .any(|projection| expr_contains_volatile_function_with(catalog, &projection.expr, false))
341 || block
342 .r#where
343 .as_ref()
344 .is_some_and(|expr| expr_contains_volatile_function_with(catalog, expr, false))
345 || block
346 .group_by
347 .iter()
348 .any(|expr| expr_contains_volatile_function_with(catalog, expr, false))
349 || block.grouping_sets.iter().any(|set| {
350 set.iter()
351 .any(|expr| expr_contains_volatile_function_with(catalog, expr, false))
352 })
353 || block
354 .having
355 .as_ref()
356 .is_some_and(|expr| expr_contains_volatile_function_with(catalog, expr, false))
357 || block
358 .order_by
359 .iter()
360 .any(|order| expr_contains_volatile_function_with(catalog, &order.expr, false))
361 || block
362 .limit
363 .as_ref()
364 .is_some_and(|expr| expr_contains_volatile_function_with(catalog, expr, false))
365 || block
366 .offset
367 .as_ref()
368 .is_some_and(|expr| expr_contains_volatile_function_with(catalog, expr, false))
369 || block
370 .distinct_on
371 .iter()
372 .any(|expr| expr_contains_volatile_function_with(catalog, expr, false))
373}
374
375pub fn query_contains_volatile_function(
377 catalog: &dyn VolatilityCatalog,
378 plan: &QueryPlan,
379) -> Result<bool, SQLError> {
380 query_contains_volatile_function_inner(catalog, plan, &mut BTreeSet::new())
381}
382
383fn query_contains_volatile_function_inner(
384 catalog: &dyn VolatilityCatalog,
385 plan: &QueryPlan,
386 visiting_views: &mut BTreeSet<String>,
387) -> Result<bool, SQLError> {
388 for cte in &plan.ctes {
389 if match &cte.body {
390 crate::plan::CtePlanBody::Query(query) => {
391 query_contains_volatile_function_inner(catalog, query, visiting_views)?
392 }
393 crate::plan::CtePlanBody::Command(_) => true,
394 } {
395 return Ok(true);
396 }
397 }
398 match &plan.root {
399 RelationalPlan::QueryBlock(block) => {
400 if select_contains_volatile_function(catalog, block) {
401 return Ok(true);
402 }
403 for subquery in &block.subqueries {
404 if query_contains_volatile_function_inner(catalog, subquery, visiting_views)? {
405 return Ok(true);
406 }
407 }
408 if let Some(source) = &block.from {
409 source_contains_volatile_function(catalog, source, visiting_views)
410 } else {
411 Ok(false)
412 }
413 }
414 RelationalPlan::SetOp {
415 left,
416 right,
417 order_by,
418 limit,
419 offset,
420 subqueries,
421 ..
422 } => {
423 if query_contains_volatile_function_inner(catalog, left, visiting_views)?
424 || query_contains_volatile_function_inner(catalog, right, visiting_views)?
425 || order_by
426 .iter()
427 .any(|order| expr_contains_volatile_function(catalog, &order.expr))
428 || limit
429 .as_ref()
430 .is_some_and(|expr| expr_contains_volatile_function(catalog, expr))
431 || offset
432 .as_ref()
433 .is_some_and(|expr| expr_contains_volatile_function(catalog, expr))
434 {
435 return Ok(true);
436 }
437 for subquery in subqueries {
438 if query_contains_volatile_function_inner(catalog, subquery, visiting_views)? {
439 return Ok(true);
440 }
441 }
442 Ok(false)
443 }
444 RelationalPlan::Values { rows, subqueries } => {
445 if rows
446 .iter()
447 .flatten()
448 .any(|expr| expr_contains_volatile_function(catalog, expr))
449 {
450 return Ok(true);
451 }
452 for subquery in subqueries {
453 if query_contains_volatile_function_inner(catalog, subquery, visiting_views)? {
454 return Ok(true);
455 }
456 }
457 Ok(false)
458 }
459 }
460}
461
462fn source_contains_volatile_function(
463 catalog: &dyn VolatilityCatalog,
464 source: &SourcePlan,
465 visiting_views: &mut BTreeSet<String>,
466) -> Result<bool, SQLError> {
467 match source {
468 SourcePlan::Table { name, .. } => {
469 let key = name.to_ascii_lowercase();
470 if !visiting_views.insert(key.clone()) {
471 return Ok(false);
472 }
473 let result = match catalog.view_query(name)? {
474 Some(view) => {
475 query_contains_volatile_function_inner(catalog, &view, visiting_views)
476 }
477 None => Ok(false),
478 };
479 visiting_views.remove(&key);
480 result
481 }
482 SourcePlan::Join {
483 left, right, on, ..
484 } => {
485 if on
486 .as_ref()
487 .is_some_and(|expr| expr_contains_volatile_function(catalog, expr))
488 {
489 return Ok(true);
490 }
491 Ok(
492 source_contains_volatile_function(catalog, left, visiting_views)?
493 || source_contains_volatile_function(catalog, right, visiting_views)?,
494 )
495 }
496 SourcePlan::Values { rows, .. } => Ok(rows
497 .iter()
498 .flatten()
499 .any(|expr| expr_contains_volatile_function(catalog, expr))),
500 SourcePlan::Function {
501 name,
502 binding,
503 args,
504 ..
505 } => Ok(
506 function_volatility_with_binding(catalog, name, binding.as_ref(), args.len())
507 == FunctionVolatility::Volatile
508 || args
509 .iter()
510 .any(|expr| expr_contains_volatile_function(catalog, expr)),
511 ),
512 SourcePlan::FunctionGroup { functions, .. } => Ok(functions.iter().any(|function| {
513 function_volatility_with_binding(
514 catalog,
515 &function.name,
516 function.binding.as_ref(),
517 function.args.len(),
518 ) == FunctionVolatility::Volatile
519 || function
520 .args
521 .iter()
522 .any(|expr| expr_contains_volatile_function(catalog, expr))
523 })),
524 SourcePlan::Subquery { body, .. } => {
525 query_contains_volatile_function_inner(catalog, body, visiting_views)
526 }
527 }
528}
529
530pub fn unified_plan_contains_volatile_function(
534 catalog: &dyn VolatilityCatalog,
535 plan: &UnifiedPlan,
536) -> bool {
537 let mut inspected = plan.clone();
538 let mut volatile = false;
539 inspected.rewrite_scalar_expressions(&mut |expr| {
540 if volatile {
541 return;
542 }
543 match expr {
544 ScalarExpr::Func {
545 name,
546 binding,
547 args,
548 ..
549 } => {
550 volatile =
551 function_volatility_with_binding(catalog, name, binding.as_ref(), args.len())
552 == FunctionVolatility::Volatile;
553 }
554 ScalarExpr::WindowCall { name, args, .. } => {
555 volatile =
556 function_volatility(catalog, name, args.len()) == FunctionVolatility::Volatile;
557 }
558 _ => {}
559 }
560 });
561 volatile
562}
563
564#[cfg(test)]
565mod tests {
566 use super::{
567 expr_contains_volatile_function, FunctionBinding, FunctionVolatility, QueryPlan, SQLError,
568 ScalarExpr, VolatilityCatalog,
569 };
570
571 struct EmptyCatalog;
572 impl VolatilityCatalog for EmptyCatalog {
573 fn host_function_volatility(&self, _: &str) -> Option<FunctionVolatility> {
574 None
575 }
576 fn routine_volatilities(
577 &self,
578 _: &str,
579 _: Option<&FunctionBinding>,
580 ) -> Option<Vec<FunctionVolatility>> {
581 None
582 }
583 fn view_query(&self, _: &str) -> Result<Option<QueryPlan>, SQLError> {
584 Ok(None)
585 }
586 }
587 use crate::ast::{FrameExclusion, FrameMode};
588 use crate::{ScalarFrameBound, ScalarWindowFrame, ScalarWindowSpec};
589
590 #[test]
591 fn builtin_constants_preserve_postgresql_statement_and_session_inputs() {
592 for name in [
594 "concat",
595 "concat_ws",
596 "format",
597 "pg_typeof",
598 "row_to_json",
599 "to_json",
600 "to_jsonb",
601 "json_build_object",
602 "jsonb_build_array",
603 "to_char",
604 "to_date",
605 "to_number",
606 "has_table_privilege",
607 "has_column_privilege",
608 ] {
609 assert_eq!(
610 super::builtin_function_volatility(name, None, 2),
611 FunctionVolatility::Stable,
612 "{name}"
613 );
614 }
615 for name in ["repeat", "upper"] {
616 assert_eq!(
617 super::builtin_function_volatility(name, None, 2),
618 FunctionVolatility::Immutable,
619 "{name}"
620 );
621 }
622 assert_eq!(
623 super::builtin_function_volatility("nextval", None, 1),
624 FunctionVolatility::Volatile
625 );
626 }
627
628 #[test]
629 fn sequence_introspection_volatility_matches_postgresql() {
630 for (name, expected) in [
631 ("pg_get_sequence_data", FunctionVolatility::Volatile),
632 ("pg_sequence_last_value", FunctionVolatility::Volatile),
633 ("pg_sequence_parameters", FunctionVolatility::Stable),
634 ] {
635 for qualified in [name.to_string(), format!("pg_catalog.{name}")] {
636 assert_eq!(
637 super::function_volatility(&EmptyCatalog, &qualified, 1),
638 expected,
639 "{qualified}"
640 );
641 }
642 }
643 }
644
645 #[test]
646 fn volatility_inspection_includes_window_frame_expressions() {
647 let expression = ScalarExpr::WindowCall {
648 name: "sum".into(),
649 args: vec![ScalarExpr::Column("amount".into())],
650 spec: ScalarWindowSpec {
651 definition: None,
652 partition_by: Vec::new(),
653 order_by: Vec::new(),
654 frame: Some(ScalarWindowFrame {
655 mode: FrameMode::Rows,
656 start: ScalarFrameBound::Preceding(Box::new(ScalarExpr::Func {
657 order_syntax: crate::ast::FunctionOrderSyntax::Ordinary,
658 name: "random".into(),
659 binding: None,
660 args: Vec::new(),
661 distinct: false,
662 order_by: Vec::new(),
663 filter: None,
664 })),
665 end: ScalarFrameBound::CurrentRow,
666 between: true,
667 exclusion: FrameExclusion::NoOthers,
668 }),
669 },
670 filter: None,
671 modifiers: crate::ast::WindowCallModifiers::default(),
672 };
673 assert!(expr_contains_volatile_function(&EmptyCatalog, &expression));
674 }
675 #[test]
676 fn scalar_plan_volatility_inspects_its_subquery_body() {
677 for (sql, expected) in [
678 ("SELECT (SELECT 1 / 0)", false),
679 ("SELECT (SELECT nextval('ticks'))", true),
680 ] {
681 let crate::plan::UnifiedPlan::Query(query) =
682 crate::plan::UnifiedPlan::lower(crate::compile(sql).unwrap().remove(0))
683 else {
684 panic!("query");
685 };
686 let crate::plan::RelationalPlan::QueryBlock(mut block) = query.root else {
687 panic!("query block");
688 };
689 let plan = crate::plan::ExpressionPlan {
690 scalar: block.projections.remove(0).expr,
691 subqueries: block.subqueries,
692 };
693 assert_eq!(
694 super::expression_plan_contains_volatile_function(&EmptyCatalog, &plan).unwrap(),
695 expected,
696 "{sql}"
697 );
698 }
699 }
700}