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 builtin = builtin_function_volatility(name, binding, argument_count);
67    if matches!(
68        binding.and_then(|binding| binding.dispatch),
69        Some(crate::ast::FunctionDispatch::NumericOperator(_))
70    ) || builtin == FunctionVolatility::Volatile
71    {
72        return builtin;
73    }
74    let identity = name.to_ascii_lowercase();
75
76    // Registrations made through the original APIs retain the conservative
77    // VOLATILE default. Explicit options let pure callbacks participate in
78    // the same optimizer rules as declared SQL routines.
79    if let Some(volatility) = catalog.host_function_volatility(&identity) {
80        return volatility;
81    }
82
83    if let Some(volatility) = sql_routine_volatility(catalog, &identity, binding) {
84        return volatility;
85    }
86    builtin
87}
88
89/// Classify an already selected built-in without consulting host or SQL routine registrations. Callers that have not resolved the routine must use the catalog-aware entry point instead.
90pub fn builtin_function_volatility(
91    name: &str,
92    binding: Option<&FunctionBinding>,
93    argument_count: usize,
94) -> FunctionVolatility {
95    if matches!(
96        binding.and_then(|binding| binding.dispatch),
97        Some(crate::ast::FunctionDispatch::NumericOperator(_))
98    ) {
99        return FunctionVolatility::Immutable;
100    }
101    let lower = builtin_function_dispatch_name(&name.to_ascii_lowercase());
102    if builtin_is_volatile(&lower) {
103        return FunctionVolatility::Volatile;
104    }
105
106    // UQA retrieval/graph functions not listed above read the statement's
107    // catalog snapshot.  Session/catalog introspection functions have the same
108    // statement-stable contract.  All remaining built-ins are value-pure.
109    if crate::registry::is_registered(&lower)
110        || matches!(
111            lower.as_str(),
112            "current_schema"
113                | "concat"
114                | "concat_ws"
115                | "format"
116                | "pg_typeof"
117                | "typeof"
118                | "row_to_json"
119                | "to_json"
120                | "to_jsonb"
121                | "json_build_object"
122                | "jsonb_build_object"
123                | "json_build_array"
124                | "jsonb_build_array"
125                | "to_char"
126                | "to_date"
127                | "to_number"
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                | "current_setting"
138                | "pg_backend_pid"
139                | "pg_my_temp_schema"
140                | "pg_is_other_temp_schema"
141                | "pg_trigger_depth"
142                | "version"
143                | "pg_listening_channels"
144                | "to_regclass"
145                | "to_regcollation"
146                | "to_regnamespace"
147                | "to_regproc"
148                | "to_regprocedure"
149                | "to_regrole"
150                | "to_regtype"
151                | "current_database"
152                | "current_catalog"
153                | "current_user"
154                | "session_user"
155                | "list_analyzers"
156                | "fts_index_stats"
157                | "pg_get_expr"
158                | "pg_get_partkeydef"
159                | "pg_get_serial_sequence"
160                | "pg_sequence_parameters"
161                | "pg_get_triggerdef"
162                | "pg_get_ruledef"
163                | "pg_get_viewdef"
164                | "pg_get_indexdef"
165                | "pg_get_constraintdef"
166                | "pg_get_function_arguments"
167                | "pg_get_function_identity_arguments"
168                | "pg_get_function_result"
169                | "pg_get_functiondef"
170            | "pg_get_function_sqlbody"
171                | "format_type"
172                | "pg_describe_object"
173                | "pg_has_role"
174                | "pg_get_userbyid"
175                | "has_database_privilege"
176                | "has_table_privilege"
177                | "has_column_privilege"
178                | "has_schema_privilege"
179                | "has_sequence_privilege"
180                | "has_function_privilege"
181                | "has_type_privilege"
182                // The enum support functions that read a type's label list see labels added later in the transaction.
183                | "enum_first"
184                | "enum_last"
185                | "enum_range"
186        )
187        || builtin_overload_is_stable(&lower, binding, argument_count)
188    {
189        FunctionVolatility::Stable
190    } else {
191        FunctionVolatility::Immutable
192    }
193}
194
195fn builtin_overload_is_stable(
196    name: &str,
197    binding: Option<&FunctionBinding>,
198    argument_count: usize,
199) -> bool {
200    (name == "age" && argument_count == 1)
201        || (name == "to_timestamp" && argument_count == 2)
202        || (matches!(name, "quote_literal" | "quote_nullable")
203            && binding.is_none_or(|binding| binding.argument_types.as_slice() != ["text"]))
204        || (matches!(name, "date_part" | "extract" | "date_trunc")
205            && argument_count == 2
206            && binding.is_none_or(|binding| {
207                binding
208                    .argument_types
209                    .iter()
210                    .any(|ty| matches!(ty.as_str(), "timestamptz" | "timestamp with time zone"))
211            }))
212}
213
214// These built-ins mutate catalog/session state or derive a fresh value on every evaluation.
215fn builtin_is_volatile(name: &str) -> bool {
216    matches!(
217        name,
218        "random"
219            | "setseed"
220            | "set_config"
221            | "pg_sleep"
222            | "pg_sleep_for"
223            | "pg_sleep_until"
224            | "pg_notify"
225            | "pg_notification_queue_usage"
226            | "array_sample"
227            | "nextval"
228            | "currval"
229            | "lastval"
230            | "setval"
231            | "pg_get_sequence_data"
232            | "pg_sequence_last_value"
233            | "clock_timestamp"
234            | "timeofday"
235            | "gen_random_uuid"
236            | "uuidv4"
237            | "uuidv7"
238            | "create_analyzer"
239            | "drop_analyzer"
240            | "set_table_analyzer"
241            | "graph_create"
242            | "graph_drop"
243            | "create_graph"
244            | "drop_graph"
245            | "graph_exists"
246            | "create_vlabel"
247            | "create_elabel"
248            | "drop_label"
249            | "alter_graph"
250            | "cypher"
251            | "deep_learn"
252            // Retrieval calibration learns and persists parameters on a
253            // cache miss; it therefore is not a read-only scalar operation.
254            | "bayesian_match"
255            | "bayesian_match_with_prior"
256            | "fts_match"
257            | "multi_field_match"
258    )
259}
260
261fn sql_routine_volatility(
262    catalog: &dyn VolatilityCatalog,
263    identity: &str,
264    binding: Option<&FunctionBinding>,
265) -> Option<FunctionVolatility> {
266    let overloads = catalog.routine_volatilities(identity, binding)?;
267    if overloads.contains(&FunctionVolatility::Volatile) {
268        return Some(FunctionVolatility::Volatile);
269    }
270    if overloads.contains(&FunctionVolatility::Stable) {
271        return Some(FunctionVolatility::Stable);
272    }
273    Some(FunctionVolatility::Immutable)
274}
275
276pub fn expr_contains_volatile_function(catalog: &dyn VolatilityCatalog, expr: &ScalarExpr) -> bool {
277    expr_contains_volatile_function_with(catalog, expr, true)
278}
279
280/// 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.
281fn expr_contains_volatile_function_with(
282    catalog: &dyn VolatilityCatalog,
283    expr: &ScalarExpr,
284    conservative_subqueries: bool,
285) -> bool {
286    let mut volatile = false;
287    expr.visit(&mut |part| {
288        if volatile {
289            return;
290        }
291        match part {
292            ScalarExpr::Func {
293                name,
294                binding,
295                args,
296                ..
297            } => {
298                volatile =
299                    function_volatility_with_binding(catalog, name, binding.as_ref(), args.len())
300                        == FunctionVolatility::Volatile;
301            }
302            ScalarExpr::WindowCall { name, args, .. } => {
303                volatile =
304                    function_volatility(catalog, name, args.len()) == FunctionVolatility::Volatile;
305            }
306            // 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.
307            ScalarExpr::ScalarSubquery(_)
308            | ScalarExpr::Exists { .. }
309            | ScalarExpr::InSubquery { .. } => volatile = conservative_subqueries,
310            _ => {}
311        }
312    });
313    volatile
314}
315
316/// Inspect a scalar plan with its query arena instead of treating every subquery as an opaque volatile call.
317pub fn expression_plan_contains_volatile_function(
318    catalog: &dyn VolatilityCatalog,
319    plan: &crate::plan::ExpressionPlan,
320) -> Result<bool, SQLError> {
321    if expr_contains_volatile_function_with(catalog, &plan.scalar, false) {
322        return Ok(true);
323    }
324    for query in &plan.subqueries {
325        if query_contains_volatile_function(catalog, query)? {
326            return Ok(true);
327        }
328    }
329    Ok(false)
330}
331
332/// The block's own subquery plans are inspected separately by the query-level walk, so subquery references here are not conservatively volatile.
333pub fn select_contains_volatile_function(
334    catalog: &dyn VolatilityCatalog,
335    block: &QueryBlockPlan,
336) -> bool {
337    block
338        .projections
339        .iter()
340        .any(|projection| expr_contains_volatile_function_with(catalog, &projection.expr, false))
341        || block
342            .r#where
343            .as_ref()
344            .is_some_and(|expr| expr_contains_volatile_function_with(catalog, expr, false))
345        || block
346            .group_by
347            .iter()
348            .any(|expr| expr_contains_volatile_function_with(catalog, expr, false))
349        || block.grouping_sets.iter().any(|set| {
350            set.iter()
351                .any(|expr| expr_contains_volatile_function_with(catalog, expr, false))
352        })
353        || block
354            .having
355            .as_ref()
356            .is_some_and(|expr| expr_contains_volatile_function_with(catalog, expr, false))
357        || block
358            .order_by
359            .iter()
360            .any(|order| expr_contains_volatile_function_with(catalog, &order.expr, false))
361        || block
362            .limit
363            .as_ref()
364            .is_some_and(|expr| expr_contains_volatile_function_with(catalog, expr, false))
365        || block
366            .offset
367            .as_ref()
368            .is_some_and(|expr| expr_contains_volatile_function_with(catalog, expr, false))
369        || block
370            .distinct_on
371            .iter()
372            .any(|expr| expr_contains_volatile_function_with(catalog, expr, false))
373}
374
375/// Inspect a complete query, including transitive view dependencies.
376pub fn query_contains_volatile_function(
377    catalog: &dyn VolatilityCatalog,
378    plan: &QueryPlan,
379) -> Result<bool, SQLError> {
380    query_contains_volatile_function_inner(catalog, plan, &mut BTreeSet::new())
381}
382
383fn query_contains_volatile_function_inner(
384    catalog: &dyn VolatilityCatalog,
385    plan: &QueryPlan,
386    visiting_views: &mut BTreeSet<String>,
387) -> Result<bool, SQLError> {
388    for cte in &plan.ctes {
389        if match &cte.body {
390            crate::plan::CtePlanBody::Query(query) => {
391                query_contains_volatile_function_inner(catalog, query, visiting_views)?
392            }
393            crate::plan::CtePlanBody::Command(_) => true,
394        } {
395            return Ok(true);
396        }
397    }
398    match &plan.root {
399        RelationalPlan::QueryBlock(block) => {
400            if select_contains_volatile_function(catalog, block) {
401                return Ok(true);
402            }
403            for subquery in &block.subqueries {
404                if query_contains_volatile_function_inner(catalog, subquery, visiting_views)? {
405                    return Ok(true);
406                }
407            }
408            if let Some(source) = &block.from {
409                source_contains_volatile_function(catalog, source, visiting_views)
410            } else {
411                Ok(false)
412            }
413        }
414        RelationalPlan::SetOp {
415            left,
416            right,
417            order_by,
418            limit,
419            offset,
420            subqueries,
421            ..
422        } => {
423            if query_contains_volatile_function_inner(catalog, left, visiting_views)?
424                || query_contains_volatile_function_inner(catalog, right, visiting_views)?
425                || order_by
426                    .iter()
427                    .any(|order| expr_contains_volatile_function(catalog, &order.expr))
428                || limit
429                    .as_ref()
430                    .is_some_and(|expr| expr_contains_volatile_function(catalog, expr))
431                || offset
432                    .as_ref()
433                    .is_some_and(|expr| expr_contains_volatile_function(catalog, expr))
434            {
435                return Ok(true);
436            }
437            for subquery in subqueries {
438                if query_contains_volatile_function_inner(catalog, subquery, visiting_views)? {
439                    return Ok(true);
440                }
441            }
442            Ok(false)
443        }
444        RelationalPlan::Values { rows, subqueries } => {
445            if rows
446                .iter()
447                .flatten()
448                .any(|expr| expr_contains_volatile_function(catalog, expr))
449            {
450                return Ok(true);
451            }
452            for subquery in subqueries {
453                if query_contains_volatile_function_inner(catalog, subquery, visiting_views)? {
454                    return Ok(true);
455                }
456            }
457            Ok(false)
458        }
459    }
460}
461
462fn source_contains_volatile_function(
463    catalog: &dyn VolatilityCatalog,
464    source: &SourcePlan,
465    visiting_views: &mut BTreeSet<String>,
466) -> Result<bool, SQLError> {
467    match source {
468        SourcePlan::Table { name, .. } => {
469            let key = name.to_ascii_lowercase();
470            if !visiting_views.insert(key.clone()) {
471                return Ok(false);
472            }
473            let result = match catalog.view_query(name)? {
474                Some(view) => {
475                    query_contains_volatile_function_inner(catalog, &view, visiting_views)
476                }
477                None => Ok(false),
478            };
479            visiting_views.remove(&key);
480            result
481        }
482        SourcePlan::Join {
483            left, right, on, ..
484        } => {
485            if on
486                .as_ref()
487                .is_some_and(|expr| expr_contains_volatile_function(catalog, expr))
488            {
489                return Ok(true);
490            }
491            Ok(
492                source_contains_volatile_function(catalog, left, visiting_views)?
493                    || source_contains_volatile_function(catalog, right, visiting_views)?,
494            )
495        }
496        SourcePlan::Values { rows, .. } => Ok(rows
497            .iter()
498            .flatten()
499            .any(|expr| expr_contains_volatile_function(catalog, expr))),
500        SourcePlan::Function {
501            name,
502            binding,
503            args,
504            ..
505        } => Ok(
506            function_volatility_with_binding(catalog, name, binding.as_ref(), args.len())
507                == FunctionVolatility::Volatile
508                || args
509                    .iter()
510                    .any(|expr| expr_contains_volatile_function(catalog, expr)),
511        ),
512        SourcePlan::FunctionGroup { functions, .. } => Ok(functions.iter().any(|function| {
513            function_volatility_with_binding(
514                catalog,
515                &function.name,
516                function.binding.as_ref(),
517                function.args.len(),
518            ) == FunctionVolatility::Volatile
519                || function
520                    .args
521                    .iter()
522                    .any(|expr| expr_contains_volatile_function(catalog, expr))
523        })),
524        SourcePlan::Subquery { body, .. } => {
525            query_contains_volatile_function_inner(catalog, body, visiting_views)
526        }
527    }
528}
529
530/// Whether scalar optimizer rewrites or `DPccp` join enumeration must be kept
531/// away from a plan.  `rewrite_scalar_expressions` is exhaustive over query,
532/// mutation, CTE, prepared/explained, and expression-plan children.
533pub fn unified_plan_contains_volatile_function(
534    catalog: &dyn VolatilityCatalog,
535    plan: &UnifiedPlan,
536) -> bool {
537    let mut inspected = plan.clone();
538    let mut volatile = false;
539    inspected.rewrite_scalar_expressions(&mut |expr| {
540        if volatile {
541            return;
542        }
543        match expr {
544            ScalarExpr::Func {
545                name,
546                binding,
547                args,
548                ..
549            } => {
550                volatile =
551                    function_volatility_with_binding(catalog, name, binding.as_ref(), args.len())
552                        == FunctionVolatility::Volatile;
553            }
554            ScalarExpr::WindowCall { name, args, .. } => {
555                volatile =
556                    function_volatility(catalog, name, args.len()) == FunctionVolatility::Volatile;
557            }
558            _ => {}
559        }
560    });
561    volatile
562}
563
564#[cfg(test)]
565mod tests {
566    use super::{
567        expr_contains_volatile_function, FunctionBinding, FunctionVolatility, QueryPlan, SQLError,
568        ScalarExpr, VolatilityCatalog,
569    };
570
571    struct EmptyCatalog;
572    impl VolatilityCatalog for EmptyCatalog {
573        fn host_function_volatility(&self, _: &str) -> Option<FunctionVolatility> {
574            None
575        }
576        fn routine_volatilities(
577            &self,
578            _: &str,
579            _: Option<&FunctionBinding>,
580        ) -> Option<Vec<FunctionVolatility>> {
581            None
582        }
583        fn view_query(&self, _: &str) -> Result<Option<QueryPlan>, SQLError> {
584            Ok(None)
585        }
586    }
587    use crate::ast::{FrameExclusion, FrameMode};
588    use crate::{ScalarFrameBound, ScalarWindowFrame, ScalarWindowSpec};
589
590    #[test]
591    fn builtin_constants_preserve_postgresql_statement_and_session_inputs() {
592        // Checked against PostgreSQL 18.4 pg_proc.provolatile before enabling bound built-in constant folding.
593        for name in [
594            "concat",
595            "concat_ws",
596            "format",
597            "pg_typeof",
598            "row_to_json",
599            "to_json",
600            "to_jsonb",
601            "json_build_object",
602            "jsonb_build_array",
603            "to_char",
604            "to_date",
605            "to_number",
606            "has_table_privilege",
607            "has_column_privilege",
608        ] {
609            assert_eq!(
610                super::builtin_function_volatility(name, None, 2),
611                FunctionVolatility::Stable,
612                "{name}"
613            );
614        }
615        for name in ["repeat", "upper"] {
616            assert_eq!(
617                super::builtin_function_volatility(name, None, 2),
618                FunctionVolatility::Immutable,
619                "{name}"
620            );
621        }
622        assert_eq!(
623            super::builtin_function_volatility("nextval", None, 1),
624            FunctionVolatility::Volatile
625        );
626    }
627
628    #[test]
629    fn sequence_introspection_volatility_matches_postgresql() {
630        for (name, expected) in [
631            ("pg_get_sequence_data", FunctionVolatility::Volatile),
632            ("pg_sequence_last_value", FunctionVolatility::Volatile),
633            ("pg_sequence_parameters", FunctionVolatility::Stable),
634        ] {
635            for qualified in [name.to_string(), format!("pg_catalog.{name}")] {
636                assert_eq!(
637                    super::function_volatility(&EmptyCatalog, &qualified, 1),
638                    expected,
639                    "{qualified}"
640                );
641            }
642        }
643    }
644
645    #[test]
646    fn volatility_inspection_includes_window_frame_expressions() {
647        let expression = ScalarExpr::WindowCall {
648            name: "sum".into(),
649            args: vec![ScalarExpr::Column("amount".into())],
650            spec: ScalarWindowSpec {
651                definition: None,
652                partition_by: Vec::new(),
653                order_by: Vec::new(),
654                frame: Some(ScalarWindowFrame {
655                    mode: FrameMode::Rows,
656                    start: ScalarFrameBound::Preceding(Box::new(ScalarExpr::Func {
657                        order_syntax: crate::ast::FunctionOrderSyntax::Ordinary,
658                        name: "random".into(),
659                        binding: None,
660                        args: Vec::new(),
661                        distinct: false,
662                        order_by: Vec::new(),
663                        filter: None,
664                    })),
665                    end: ScalarFrameBound::CurrentRow,
666                    between: true,
667                    exclusion: FrameExclusion::NoOthers,
668                }),
669            },
670            filter: None,
671            modifiers: crate::ast::WindowCallModifiers::default(),
672        };
673        assert!(expr_contains_volatile_function(&EmptyCatalog, &expression));
674    }
675    #[test]
676    fn scalar_plan_volatility_inspects_its_subquery_body() {
677        for (sql, expected) in [
678            ("SELECT (SELECT 1 / 0)", false),
679            ("SELECT (SELECT nextval('ticks'))", true),
680        ] {
681            let crate::plan::UnifiedPlan::Query(query) =
682                crate::plan::UnifiedPlan::lower(crate::compile(sql).unwrap().remove(0))
683            else {
684                panic!("query");
685            };
686            let crate::plan::RelationalPlan::QueryBlock(mut block) = query.root else {
687                panic!("query block");
688            };
689            let plan = crate::plan::ExpressionPlan {
690                scalar: block.projections.remove(0).expr,
691                subqueries: block.subqueries,
692            };
693            assert_eq!(
694                super::expression_plan_contains_volatile_function(&EmptyCatalog, &plan).unwrap(),
695                expected,
696                "{sql}"
697            );
698        }
699    }
700}