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    let identity = name.to_ascii_lowercase();
67    let lower = builtin_function_dispatch_name(&identity);
68
69    // These implementations either mutate catalog/session state or derive a fresh value on every evaluation.
70    if matches!(
71        lower.as_str(),
72        "random"
73            | "setseed"
74            | "pg_notify"
75            | "pg_notification_queue_usage"
76            | "array_sample"
77            | "nextval"
78            | "currval"
79            | "lastval"
80            | "setval"
81            | "clock_timestamp"
82            | "timeofday"
83            | "gen_random_uuid"
84            | "uuidv4"
85            | "uuidv7"
86            | "create_analyzer"
87            | "drop_analyzer"
88            | "set_table_analyzer"
89            | "graph_create"
90            | "graph_drop"
91            | "create_graph"
92            | "drop_graph"
93            | "graph_exists"
94            | "create_vlabel"
95            | "create_elabel"
96            | "drop_label"
97            | "alter_graph"
98            | "cypher"
99            | "deep_learn"
100            // Retrieval calibration learns and persists parameters on a
101            // cache miss; it therefore is not a read-only scalar operation.
102            | "bayesian_match"
103            | "bayesian_match_with_prior"
104            | "fts_match"
105            | "multi_field_match"
106    ) {
107        return FunctionVolatility::Volatile;
108    }
109
110    // Registrations made through the original APIs retain the conservative
111    // VOLATILE default. Explicit options let pure callbacks participate in
112    // the same optimizer rules as declared SQL routines.
113    if let Some(volatility) = catalog.host_function_volatility(&identity) {
114        return volatility;
115    }
116
117    if let Some(volatility) = sql_routine_volatility(catalog, &identity, binding) {
118        return volatility;
119    }
120
121    // UQA retrieval/graph functions not listed above read the statement's
122    // catalog snapshot.  Session/catalog introspection functions have the same
123    // statement-stable contract.  All remaining built-ins are value-pure.
124    if crate::registry::is_registered(&lower)
125        || matches!(
126            lower.as_str(),
127            "current_schema"
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                | "pg_backend_pid"
138                | "version"
139                | "pg_listening_channels"
140                | "to_regclass"
141                | "to_regnamespace"
142                | "to_regproc"
143                | "to_regprocedure"
144                | "to_regrole"
145                | "to_regtype"
146                | "current_database"
147                | "current_catalog"
148                | "current_user"
149                | "session_user"
150                | "list_analyzers"
151                | "fts_index_stats"
152                | "pg_get_expr"
153                | "pg_get_partkeydef"
154                | "pg_get_serial_sequence"
155                | "pg_get_triggerdef"
156                | "pg_get_ruledef"
157                | "pg_get_viewdef"
158                | "pg_get_indexdef"
159                | "format_type"
160                | "pg_has_role"
161                | "has_database_privilege"
162                | "has_schema_privilege"
163                | "has_sequence_privilege"
164        )
165        || (lower == "age" && argument_count == 1)
166    {
167        FunctionVolatility::Stable
168    } else {
169        FunctionVolatility::Immutable
170    }
171}
172
173fn sql_routine_volatility(
174    catalog: &dyn VolatilityCatalog,
175    identity: &str,
176    binding: Option<&FunctionBinding>,
177) -> Option<FunctionVolatility> {
178    let overloads = catalog.routine_volatilities(identity, binding)?;
179    if overloads.contains(&FunctionVolatility::Volatile) {
180        return Some(FunctionVolatility::Volatile);
181    }
182    if overloads.contains(&FunctionVolatility::Stable) {
183        return Some(FunctionVolatility::Stable);
184    }
185    Some(FunctionVolatility::Immutable)
186}
187
188pub fn expr_contains_volatile_function(catalog: &dyn VolatilityCatalog, expr: &ScalarExpr) -> bool {
189    expr_contains_volatile_function_with(catalog, expr, true)
190}
191
192/// 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.
193fn expr_contains_volatile_function_with(
194    catalog: &dyn VolatilityCatalog,
195    expr: &ScalarExpr,
196    conservative_subqueries: bool,
197) -> bool {
198    let mut volatile = false;
199    expr.visit(&mut |part| {
200        if volatile {
201            return;
202        }
203        match part {
204            ScalarExpr::Func {
205                name,
206                binding,
207                args,
208                ..
209            } => {
210                volatile =
211                    function_volatility_with_binding(catalog, name, binding.as_ref(), args.len())
212                        == FunctionVolatility::Volatile;
213            }
214            ScalarExpr::WindowCall { name, args, .. } => {
215                volatile =
216                    function_volatility(catalog, name, args.len()) == FunctionVolatility::Volatile;
217            }
218            // 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.
219            ScalarExpr::ScalarSubquery(_)
220            | ScalarExpr::Exists { .. }
221            | ScalarExpr::InSubquery { .. } => volatile = conservative_subqueries,
222            _ => {}
223        }
224    });
225    volatile
226}
227
228/// The block's own subquery plans are inspected separately by the query-level walk, so subquery references here are not conservatively volatile.
229pub fn select_contains_volatile_function(
230    catalog: &dyn VolatilityCatalog,
231    block: &QueryBlockPlan,
232) -> bool {
233    block
234        .projections
235        .iter()
236        .any(|projection| expr_contains_volatile_function_with(catalog, &projection.expr, false))
237        || block
238            .r#where
239            .as_ref()
240            .is_some_and(|expr| expr_contains_volatile_function_with(catalog, expr, false))
241        || block
242            .group_by
243            .iter()
244            .any(|expr| expr_contains_volatile_function_with(catalog, expr, false))
245        || block.grouping_sets.iter().any(|set| {
246            set.iter()
247                .any(|expr| expr_contains_volatile_function_with(catalog, expr, false))
248        })
249        || block
250            .having
251            .as_ref()
252            .is_some_and(|expr| expr_contains_volatile_function_with(catalog, expr, false))
253        || block
254            .order_by
255            .iter()
256            .any(|order| expr_contains_volatile_function_with(catalog, &order.expr, false))
257        || block
258            .limit
259            .as_ref()
260            .is_some_and(|expr| expr_contains_volatile_function_with(catalog, expr, false))
261        || block
262            .offset
263            .as_ref()
264            .is_some_and(|expr| expr_contains_volatile_function_with(catalog, expr, false))
265        || block
266            .distinct_on
267            .iter()
268            .any(|expr| expr_contains_volatile_function_with(catalog, expr, false))
269}
270
271/// Inspect a complete query, including transitive view dependencies.
272pub fn query_contains_volatile_function(
273    catalog: &dyn VolatilityCatalog,
274    plan: &QueryPlan,
275) -> Result<bool, SQLError> {
276    query_contains_volatile_function_inner(catalog, plan, &mut BTreeSet::new())
277}
278
279fn query_contains_volatile_function_inner(
280    catalog: &dyn VolatilityCatalog,
281    plan: &QueryPlan,
282    visiting_views: &mut BTreeSet<String>,
283) -> Result<bool, SQLError> {
284    for cte in &plan.ctes {
285        if match &cte.body {
286            crate::plan::CtePlanBody::Query(query) => {
287                query_contains_volatile_function_inner(catalog, query, visiting_views)?
288            }
289            crate::plan::CtePlanBody::Command(_) => true,
290        } {
291            return Ok(true);
292        }
293    }
294    match &plan.root {
295        RelationalPlan::QueryBlock(block) => {
296            if select_contains_volatile_function(catalog, block) {
297                return Ok(true);
298            }
299            for subquery in &block.subqueries {
300                if query_contains_volatile_function_inner(catalog, subquery, visiting_views)? {
301                    return Ok(true);
302                }
303            }
304            if let Some(source) = &block.from {
305                source_contains_volatile_function(catalog, source, visiting_views)
306            } else {
307                Ok(false)
308            }
309        }
310        RelationalPlan::SetOp {
311            left,
312            right,
313            order_by,
314            limit,
315            offset,
316            subqueries,
317            ..
318        } => {
319            if query_contains_volatile_function_inner(catalog, left, visiting_views)?
320                || query_contains_volatile_function_inner(catalog, right, visiting_views)?
321                || order_by
322                    .iter()
323                    .any(|order| expr_contains_volatile_function(catalog, &order.expr))
324                || limit
325                    .as_ref()
326                    .is_some_and(|expr| expr_contains_volatile_function(catalog, expr))
327                || offset
328                    .as_ref()
329                    .is_some_and(|expr| expr_contains_volatile_function(catalog, expr))
330            {
331                return Ok(true);
332            }
333            for subquery in subqueries {
334                if query_contains_volatile_function_inner(catalog, subquery, visiting_views)? {
335                    return Ok(true);
336                }
337            }
338            Ok(false)
339        }
340        RelationalPlan::Values { rows, subqueries } => {
341            if rows
342                .iter()
343                .flatten()
344                .any(|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    }
356}
357
358fn source_contains_volatile_function(
359    catalog: &dyn VolatilityCatalog,
360    source: &SourcePlan,
361    visiting_views: &mut BTreeSet<String>,
362) -> Result<bool, SQLError> {
363    match source {
364        SourcePlan::Table { name, .. } => {
365            let key = name.to_ascii_lowercase();
366            if !visiting_views.insert(key.clone()) {
367                return Ok(false);
368            }
369            let result = match catalog.view_query(name)? {
370                Some(view) => {
371                    query_contains_volatile_function_inner(catalog, &view, visiting_views)
372                }
373                None => Ok(false),
374            };
375            visiting_views.remove(&key);
376            result
377        }
378        SourcePlan::Join {
379            left, right, on, ..
380        } => {
381            if on
382                .as_ref()
383                .is_some_and(|expr| expr_contains_volatile_function(catalog, expr))
384            {
385                return Ok(true);
386            }
387            Ok(
388                source_contains_volatile_function(catalog, left, visiting_views)?
389                    || source_contains_volatile_function(catalog, right, visiting_views)?,
390            )
391        }
392        SourcePlan::Values { rows, .. } => Ok(rows
393            .iter()
394            .flatten()
395            .any(|expr| expr_contains_volatile_function(catalog, expr))),
396        SourcePlan::Function {
397            name,
398            binding,
399            args,
400            ..
401        } => Ok(
402            function_volatility_with_binding(catalog, name, binding.as_ref(), args.len())
403                == FunctionVolatility::Volatile
404                || args
405                    .iter()
406                    .any(|expr| expr_contains_volatile_function(catalog, expr)),
407        ),
408        SourcePlan::FunctionGroup { functions, .. } => Ok(functions.iter().any(|function| {
409            function_volatility_with_binding(
410                catalog,
411                &function.name,
412                function.binding.as_ref(),
413                function.args.len(),
414            ) == FunctionVolatility::Volatile
415                || function
416                    .args
417                    .iter()
418                    .any(|expr| expr_contains_volatile_function(catalog, expr))
419        })),
420        SourcePlan::Subquery { body, .. } => {
421            query_contains_volatile_function_inner(catalog, body, visiting_views)
422        }
423    }
424}
425
426/// Whether scalar optimizer rewrites or `DPccp` join enumeration must be kept
427/// away from a plan.  `rewrite_scalar_expressions` is exhaustive over query,
428/// mutation, CTE, prepared/explained, and expression-plan children.
429pub fn unified_plan_contains_volatile_function(
430    catalog: &dyn VolatilityCatalog,
431    plan: &UnifiedPlan,
432) -> bool {
433    let mut inspected = plan.clone();
434    let mut volatile = false;
435    inspected.rewrite_scalar_expressions(&mut |expr| {
436        if volatile {
437            return;
438        }
439        match expr {
440            ScalarExpr::Func {
441                name,
442                binding,
443                args,
444                ..
445            } => {
446                volatile =
447                    function_volatility_with_binding(catalog, name, binding.as_ref(), args.len())
448                        == FunctionVolatility::Volatile;
449            }
450            ScalarExpr::WindowCall { name, args, .. } => {
451                volatile =
452                    function_volatility(catalog, name, args.len()) == FunctionVolatility::Volatile;
453            }
454            _ => {}
455        }
456    });
457    volatile
458}
459
460#[cfg(test)]
461mod tests {
462    use super::{
463        expr_contains_volatile_function, FunctionBinding, FunctionVolatility, QueryPlan, SQLError,
464        ScalarExpr, VolatilityCatalog,
465    };
466
467    struct EmptyCatalog;
468    impl VolatilityCatalog for EmptyCatalog {
469        fn host_function_volatility(&self, _: &str) -> Option<FunctionVolatility> {
470            None
471        }
472        fn routine_volatilities(
473            &self,
474            _: &str,
475            _: Option<&FunctionBinding>,
476        ) -> Option<Vec<FunctionVolatility>> {
477            None
478        }
479        fn view_query(&self, _: &str) -> Result<Option<QueryPlan>, SQLError> {
480            Ok(None)
481        }
482    }
483    use crate::ast::FrameMode;
484    use crate::{ScalarFrameBound, ScalarWindowFrame, ScalarWindowSpec};
485
486    #[test]
487    fn volatility_inspection_includes_window_frame_expressions() {
488        let expression = ScalarExpr::WindowCall {
489            name: "sum".into(),
490            args: vec![ScalarExpr::Column("amount".into())],
491            spec: ScalarWindowSpec {
492                partition_by: Vec::new(),
493                order_by: Vec::new(),
494                frame: Some(ScalarWindowFrame {
495                    mode: FrameMode::Rows,
496                    start: ScalarFrameBound::Preceding(Box::new(ScalarExpr::Func {
497                        name: "random".into(),
498                        binding: None,
499                        args: Vec::new(),
500                        distinct: false,
501                        order_by: Vec::new(),
502                        filter: None,
503                    })),
504                    end: ScalarFrameBound::CurrentRow,
505                }),
506            },
507        };
508        assert!(expr_contains_volatile_function(&EmptyCatalog, &expression));
509    }
510}