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 if matches!(
67 binding.and_then(|binding| binding.dispatch),
68 Some(crate::ast::FunctionDispatch::NumericOperator(_))
69 ) {
70 return FunctionVolatility::Immutable;
71 }
72 let identity = name.to_ascii_lowercase();
73 let lower = builtin_function_dispatch_name(&identity);
74
75 if builtin_is_volatile(&lower) {
76 return FunctionVolatility::Volatile;
77 }
78
79 if let Some(volatility) = catalog.host_function_volatility(&identity) {
83 return volatility;
84 }
85
86 if let Some(volatility) = sql_routine_volatility(catalog, &identity, binding) {
87 return volatility;
88 }
89
90 if crate::registry::is_registered(&lower)
94 || matches!(
95 lower.as_str(),
96 "current_schema"
97 | "now"
98 | "current_date"
99 | "current_time"
100 | "current_timestamp"
101 | "localtime"
102 | "localtimestamp"
103 | "statement_timestamp"
104 | "transaction_timestamp"
105 | "current_schemas"
106 | "current_setting"
107 | "pg_backend_pid"
108 | "version"
109 | "pg_listening_channels"
110 | "to_regclass"
111 | "to_regnamespace"
112 | "to_regproc"
113 | "to_regprocedure"
114 | "to_regrole"
115 | "to_regtype"
116 | "current_database"
117 | "current_catalog"
118 | "current_user"
119 | "session_user"
120 | "list_analyzers"
121 | "fts_index_stats"
122 | "pg_get_expr"
123 | "pg_get_partkeydef"
124 | "pg_get_serial_sequence"
125 | "pg_sequence_parameters"
126 | "pg_get_triggerdef"
127 | "pg_get_ruledef"
128 | "pg_get_viewdef"
129 | "pg_get_indexdef"
130 | "format_type"
131 | "pg_has_role"
132 | "pg_get_userbyid"
133 | "has_database_privilege"
134 | "has_schema_privilege"
135 | "has_sequence_privilege"
136 | "has_function_privilege"
137 )
138 || (lower == "age" && argument_count == 1)
139 {
140 FunctionVolatility::Stable
141 } else {
142 FunctionVolatility::Immutable
143 }
144}
145
146fn builtin_is_volatile(name: &str) -> bool {
148 matches!(
149 name,
150 "random"
151 | "setseed"
152 | "pg_notify"
153 | "pg_notification_queue_usage"
154 | "array_sample"
155 | "nextval"
156 | "currval"
157 | "lastval"
158 | "setval"
159 | "pg_get_sequence_data"
160 | "pg_sequence_last_value"
161 | "clock_timestamp"
162 | "timeofday"
163 | "gen_random_uuid"
164 | "uuidv4"
165 | "uuidv7"
166 | "create_analyzer"
167 | "drop_analyzer"
168 | "set_table_analyzer"
169 | "graph_create"
170 | "graph_drop"
171 | "create_graph"
172 | "drop_graph"
173 | "graph_exists"
174 | "create_vlabel"
175 | "create_elabel"
176 | "drop_label"
177 | "alter_graph"
178 | "cypher"
179 | "deep_learn"
180 | "bayesian_match"
183 | "bayesian_match_with_prior"
184 | "fts_match"
185 | "multi_field_match"
186 )
187}
188
189fn sql_routine_volatility(
190 catalog: &dyn VolatilityCatalog,
191 identity: &str,
192 binding: Option<&FunctionBinding>,
193) -> Option<FunctionVolatility> {
194 let overloads = catalog.routine_volatilities(identity, binding)?;
195 if overloads.contains(&FunctionVolatility::Volatile) {
196 return Some(FunctionVolatility::Volatile);
197 }
198 if overloads.contains(&FunctionVolatility::Stable) {
199 return Some(FunctionVolatility::Stable);
200 }
201 Some(FunctionVolatility::Immutable)
202}
203
204pub fn expr_contains_volatile_function(catalog: &dyn VolatilityCatalog, expr: &ScalarExpr) -> bool {
205 expr_contains_volatile_function_with(catalog, expr, true)
206}
207
208fn expr_contains_volatile_function_with(
210 catalog: &dyn VolatilityCatalog,
211 expr: &ScalarExpr,
212 conservative_subqueries: bool,
213) -> bool {
214 let mut volatile = false;
215 expr.visit(&mut |part| {
216 if volatile {
217 return;
218 }
219 match part {
220 ScalarExpr::Func {
221 name,
222 binding,
223 args,
224 ..
225 } => {
226 volatile =
227 function_volatility_with_binding(catalog, name, binding.as_ref(), args.len())
228 == FunctionVolatility::Volatile;
229 }
230 ScalarExpr::WindowCall { name, args, .. } => {
231 volatile =
232 function_volatility(catalog, name, args.len()) == FunctionVolatility::Volatile;
233 }
234 ScalarExpr::ScalarSubquery(_)
236 | ScalarExpr::Exists { .. }
237 | ScalarExpr::InSubquery { .. } => volatile = conservative_subqueries,
238 _ => {}
239 }
240 });
241 volatile
242}
243
244pub fn select_contains_volatile_function(
246 catalog: &dyn VolatilityCatalog,
247 block: &QueryBlockPlan,
248) -> bool {
249 block
250 .projections
251 .iter()
252 .any(|projection| expr_contains_volatile_function_with(catalog, &projection.expr, false))
253 || block
254 .r#where
255 .as_ref()
256 .is_some_and(|expr| expr_contains_volatile_function_with(catalog, expr, false))
257 || block
258 .group_by
259 .iter()
260 .any(|expr| expr_contains_volatile_function_with(catalog, expr, false))
261 || block.grouping_sets.iter().any(|set| {
262 set.iter()
263 .any(|expr| expr_contains_volatile_function_with(catalog, expr, false))
264 })
265 || block
266 .having
267 .as_ref()
268 .is_some_and(|expr| expr_contains_volatile_function_with(catalog, expr, false))
269 || block
270 .order_by
271 .iter()
272 .any(|order| expr_contains_volatile_function_with(catalog, &order.expr, false))
273 || block
274 .limit
275 .as_ref()
276 .is_some_and(|expr| expr_contains_volatile_function_with(catalog, expr, false))
277 || block
278 .offset
279 .as_ref()
280 .is_some_and(|expr| expr_contains_volatile_function_with(catalog, expr, false))
281 || block
282 .distinct_on
283 .iter()
284 .any(|expr| expr_contains_volatile_function_with(catalog, expr, false))
285}
286
287pub fn query_contains_volatile_function(
289 catalog: &dyn VolatilityCatalog,
290 plan: &QueryPlan,
291) -> Result<bool, SQLError> {
292 query_contains_volatile_function_inner(catalog, plan, &mut BTreeSet::new())
293}
294
295fn query_contains_volatile_function_inner(
296 catalog: &dyn VolatilityCatalog,
297 plan: &QueryPlan,
298 visiting_views: &mut BTreeSet<String>,
299) -> Result<bool, SQLError> {
300 for cte in &plan.ctes {
301 if match &cte.body {
302 crate::plan::CtePlanBody::Query(query) => {
303 query_contains_volatile_function_inner(catalog, query, visiting_views)?
304 }
305 crate::plan::CtePlanBody::Command(_) => true,
306 } {
307 return Ok(true);
308 }
309 }
310 match &plan.root {
311 RelationalPlan::QueryBlock(block) => {
312 if select_contains_volatile_function(catalog, block) {
313 return Ok(true);
314 }
315 for subquery in &block.subqueries {
316 if query_contains_volatile_function_inner(catalog, subquery, visiting_views)? {
317 return Ok(true);
318 }
319 }
320 if let Some(source) = &block.from {
321 source_contains_volatile_function(catalog, source, visiting_views)
322 } else {
323 Ok(false)
324 }
325 }
326 RelationalPlan::SetOp {
327 left,
328 right,
329 order_by,
330 limit,
331 offset,
332 subqueries,
333 ..
334 } => {
335 if query_contains_volatile_function_inner(catalog, left, visiting_views)?
336 || query_contains_volatile_function_inner(catalog, right, visiting_views)?
337 || order_by
338 .iter()
339 .any(|order| expr_contains_volatile_function(catalog, &order.expr))
340 || limit
341 .as_ref()
342 .is_some_and(|expr| expr_contains_volatile_function(catalog, expr))
343 || offset
344 .as_ref()
345 .is_some_and(|expr| expr_contains_volatile_function(catalog, expr))
346 {
347 return Ok(true);
348 }
349 for subquery in subqueries {
350 if query_contains_volatile_function_inner(catalog, subquery, visiting_views)? {
351 return Ok(true);
352 }
353 }
354 Ok(false)
355 }
356 RelationalPlan::Values { rows, subqueries } => {
357 if rows
358 .iter()
359 .flatten()
360 .any(|expr| expr_contains_volatile_function(catalog, expr))
361 {
362 return Ok(true);
363 }
364 for subquery in subqueries {
365 if query_contains_volatile_function_inner(catalog, subquery, visiting_views)? {
366 return Ok(true);
367 }
368 }
369 Ok(false)
370 }
371 }
372}
373
374fn source_contains_volatile_function(
375 catalog: &dyn VolatilityCatalog,
376 source: &SourcePlan,
377 visiting_views: &mut BTreeSet<String>,
378) -> Result<bool, SQLError> {
379 match source {
380 SourcePlan::Table { name, .. } => {
381 let key = name.to_ascii_lowercase();
382 if !visiting_views.insert(key.clone()) {
383 return Ok(false);
384 }
385 let result = match catalog.view_query(name)? {
386 Some(view) => {
387 query_contains_volatile_function_inner(catalog, &view, visiting_views)
388 }
389 None => Ok(false),
390 };
391 visiting_views.remove(&key);
392 result
393 }
394 SourcePlan::Join {
395 left, right, on, ..
396 } => {
397 if on
398 .as_ref()
399 .is_some_and(|expr| expr_contains_volatile_function(catalog, expr))
400 {
401 return Ok(true);
402 }
403 Ok(
404 source_contains_volatile_function(catalog, left, visiting_views)?
405 || source_contains_volatile_function(catalog, right, visiting_views)?,
406 )
407 }
408 SourcePlan::Values { rows, .. } => Ok(rows
409 .iter()
410 .flatten()
411 .any(|expr| expr_contains_volatile_function(catalog, expr))),
412 SourcePlan::Function {
413 name,
414 binding,
415 args,
416 ..
417 } => Ok(
418 function_volatility_with_binding(catalog, name, binding.as_ref(), args.len())
419 == FunctionVolatility::Volatile
420 || args
421 .iter()
422 .any(|expr| expr_contains_volatile_function(catalog, expr)),
423 ),
424 SourcePlan::FunctionGroup { functions, .. } => Ok(functions.iter().any(|function| {
425 function_volatility_with_binding(
426 catalog,
427 &function.name,
428 function.binding.as_ref(),
429 function.args.len(),
430 ) == FunctionVolatility::Volatile
431 || function
432 .args
433 .iter()
434 .any(|expr| expr_contains_volatile_function(catalog, expr))
435 })),
436 SourcePlan::Subquery { body, .. } => {
437 query_contains_volatile_function_inner(catalog, body, visiting_views)
438 }
439 }
440}
441
442pub fn unified_plan_contains_volatile_function(
446 catalog: &dyn VolatilityCatalog,
447 plan: &UnifiedPlan,
448) -> bool {
449 let mut inspected = plan.clone();
450 let mut volatile = false;
451 inspected.rewrite_scalar_expressions(&mut |expr| {
452 if volatile {
453 return;
454 }
455 match expr {
456 ScalarExpr::Func {
457 name,
458 binding,
459 args,
460 ..
461 } => {
462 volatile =
463 function_volatility_with_binding(catalog, name, binding.as_ref(), args.len())
464 == FunctionVolatility::Volatile;
465 }
466 ScalarExpr::WindowCall { name, args, .. } => {
467 volatile =
468 function_volatility(catalog, name, args.len()) == FunctionVolatility::Volatile;
469 }
470 _ => {}
471 }
472 });
473 volatile
474}
475
476#[cfg(test)]
477mod tests {
478 use super::{
479 expr_contains_volatile_function, FunctionBinding, FunctionVolatility, QueryPlan, SQLError,
480 ScalarExpr, VolatilityCatalog,
481 };
482
483 struct EmptyCatalog;
484 impl VolatilityCatalog for EmptyCatalog {
485 fn host_function_volatility(&self, _: &str) -> Option<FunctionVolatility> {
486 None
487 }
488 fn routine_volatilities(
489 &self,
490 _: &str,
491 _: Option<&FunctionBinding>,
492 ) -> Option<Vec<FunctionVolatility>> {
493 None
494 }
495 fn view_query(&self, _: &str) -> Result<Option<QueryPlan>, SQLError> {
496 Ok(None)
497 }
498 }
499 use crate::ast::FrameMode;
500 use crate::{ScalarFrameBound, ScalarWindowFrame, ScalarWindowSpec};
501
502 #[test]
503 fn sequence_introspection_volatility_matches_postgresql() {
504 for (name, expected) in [
505 ("pg_get_sequence_data", FunctionVolatility::Volatile),
506 ("pg_sequence_last_value", FunctionVolatility::Volatile),
507 ("pg_sequence_parameters", FunctionVolatility::Stable),
508 ] {
509 for qualified in [name.to_string(), format!("pg_catalog.{name}")] {
510 assert_eq!(
511 super::function_volatility(&EmptyCatalog, &qualified, 1),
512 expected,
513 "{qualified}"
514 );
515 }
516 }
517 }
518
519 #[test]
520 fn volatility_inspection_includes_window_frame_expressions() {
521 let expression = ScalarExpr::WindowCall {
522 name: "sum".into(),
523 args: vec![ScalarExpr::Column("amount".into())],
524 spec: ScalarWindowSpec {
525 partition_by: Vec::new(),
526 order_by: Vec::new(),
527 frame: Some(ScalarWindowFrame {
528 mode: FrameMode::Rows,
529 start: ScalarFrameBound::Preceding(Box::new(ScalarExpr::Func {
530 name: "random".into(),
531 binding: None,
532 args: Vec::new(),
533 distinct: false,
534 order_by: Vec::new(),
535 filter: None,
536 })),
537 end: ScalarFrameBound::CurrentRow,
538 }),
539 },
540 };
541 assert!(expr_contains_volatile_function(&EmptyCatalog, &expression));
542 }
543}