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