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