Skip to main content

uqa_sql/semantics/
volatility.rs

1//
2// Unified Query Algebra
3//
4// Copyright (c) 2023-2026 Cognica, Inc.
5//
6
7//! SQL function volatility and expression rewrite safety over catalog metadata.
8//!
9//! Volatility is a semantic property, not merely an optimizer hint.  A
10//! `VOLATILE` call may not be duplicated, elided, moved to a different join
11//! level, or hidden behind a statement-local view cache.  Keep the decision in
12//! one place so the view, CTE, predicate-pushdown, column-pruning, and `DPccp`
13//! paths cannot drift apart.
14
15use std::collections::BTreeSet;
16
17use crate::ast::{FunctionBinding, FunctionVolatility};
18use crate::plan::{QueryBlockPlan, QueryPlan, RelationalPlan, SourcePlan, UnifiedPlan};
19use crate::SQLError;
20use crate::ScalarExpr;
21
22/// Metadata needed to classify SQL expressions without invoking a routine or reading a row.
23pub 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
35/// Resolve the volatility of the implementation that can run for `name`.
36///
37/// Rust extension callbacks default to `VOLATILE`, while registrations with
38/// explicit options use their declared volatility. SQL routine overloads are
39/// combined conservatively: a name is only non-volatile when every overload
40/// registered under it is non-volatile. This remains correct before runtime
41/// argument coercion selects a particular overload.
42pub 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    // Registrations made through the original APIs retain the conservative
80    // VOLATILE default. Explicit options let pure callbacks participate in
81    // the same optimizer rules as declared SQL routines.
82    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    // UQA retrieval/graph functions not listed above read the statement's
91    // catalog snapshot.  Session/catalog introspection functions have the same
92    // statement-stable contract.  All remaining built-ins are value-pure.
93    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
146// These built-ins mutate catalog/session state or derive a fresh value on every evaluation.
147fn 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            // Retrieval calibration learns and persists parameters on a
181            // cache miss; it therefore is not a read-only scalar operation.
182            | "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
208/// Query-level walks inspect a block's subquery plans themselves, so their expression scan treats a subquery reference as opaque-but-inspected (`conservative_subqueries == false`) instead of assuming volatility.
209fn 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            // Query-valued children are inspected by the enclosing QueryPlan. At expression-only rewrite sites, retaining the conservative rule prevents an opaque child query from being duplicated or reordered.
235            ScalarExpr::ScalarSubquery(_)
236            | ScalarExpr::Exists { .. }
237            | ScalarExpr::InSubquery { .. } => volatile = conservative_subqueries,
238            _ => {}
239        }
240    });
241    volatile
242}
243
244/// The block's own subquery plans are inspected separately by the query-level walk, so subquery references here are not conservatively volatile.
245pub 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
287/// Inspect a complete query, including transitive view dependencies.
288pub 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
442/// Whether scalar optimizer rewrites or `DPccp` join enumeration must be kept
443/// away from a plan.  `rewrite_scalar_expressions` is exhaustive over query,
444/// mutation, CTE, prepared/explained, and expression-plan children.
445pub 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}