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