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                | "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
145// These built-ins mutate catalog/session state or derive a fresh value on every evaluation.
146fn 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            // Retrieval calibration learns and persists parameters on a
180            // cache miss; it therefore is not a read-only scalar operation.
181            | "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
207/// 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.
208fn 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            // 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.
234            ScalarExpr::ScalarSubquery(_)
235            | ScalarExpr::Exists { .. }
236            | ScalarExpr::InSubquery { .. } => volatile = conservative_subqueries,
237            _ => {}
238        }
239    });
240    volatile
241}
242
243/// The block's own subquery plans are inspected separately by the query-level walk, so subquery references here are not conservatively volatile.
244pub 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
286/// Inspect a complete query, including transitive view dependencies.
287pub 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
441/// Whether scalar optimizer rewrites or `DPccp` join enumeration must be kept
442/// away from a plan.  `rewrite_scalar_expressions` is exhaustive over query,
443/// mutation, CTE, prepared/explained, and expression-plan children.
444pub 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}