Skip to main content

uqa_sql/expr/
builtin.rs

1//
2// Unified Query Algebra
3//
4// Copyright (c) 2023-2026 Cognica, Inc.
5//
6
7//! Built-in strictness policy and structurally bound execution.
8
9use uqa_core::Value;
10
11use crate::ast::{FunctionBinding, FunctionDispatch};
12use crate::error::{Result, SQLError};
13
14use super::call_arguments::normalized_function_name;
15use super::call_dispatch::eval_builtin_function_call;
16use super::context::EvalContext;
17use super::{random, scalar_array, scalar_postgres, scalar_range};
18
19#[expect(
20    clippy::too_many_lines,
21    reason = "builtin dispatch preserves arity, NULL, and error precedence"
22)]
23pub fn builtin_scalar_function_strictness(name: &str, argument_count: usize) -> Option<bool> {
24    let normalized = normalized_function_name(name);
25    match normalized.as_ref() {
26        "int4range" | "int8range" | "numrange" | "daterange" | "tsrange" | "tstzrange"
27            if matches!(argument_count, 2 | 3) =>
28        {
29            Some(false)
30        }
31        "int4multirange" | "int8multirange" | "nummultirange" | "datemultirange"
32        | "tsmultirange" | "tstzmultirange"
33            if argument_count <= 1 =>
34        {
35            Some(true)
36        }
37        "multirange" if argument_count == 1 => Some(true),
38        "coalesce" | "greatest" | "least" if argument_count >= 1 => Some(false),
39        "nullif" | "concat_op" if argument_count == 2 => Some(false),
40        "concat" | "format" | "json_build_array" | "jsonb_build_array" | "json_build_object"
41        | "jsonb_build_object" | "num_nulls" | "num_nonnulls" => Some(false),
42        "concat_ws" if argument_count >= 1 => Some(false),
43        "quote_nullable" | "pg_typeof" | "typeof" | "pg_collation_for" if argument_count == 1 => {
44            Some(false)
45        }
46        "array_cat" | "array_append" | "array_prepend" | "array_remove" | "array_positions"
47            if argument_count == 2 =>
48        {
49            Some(false)
50        }
51        "array_position" if matches!(argument_count, 2 | 3) => Some(false),
52        "array_replace" if argument_count == 3 => Some(false),
53        "array_fill" if matches!(argument_count, 2 | 3) => Some(false),
54        "array_to_string" if argument_count == 3 => Some(false),
55        "string_to_array" | "string_to_table" if matches!(argument_count, 2 | 3) => Some(false),
56        "pg_has_role" if matches!(argument_count, 2 | 3) => Some(true),
57        "pg_get_userbyid" if argument_count == 1 => Some(true),
58        "current_setting" if matches!(argument_count, 1 | 2) => Some(true),
59        "set_config" if argument_count == 3 => Some(false),
60        "pg_sleep" | "pg_sleep_for" | "pg_sleep_until" if argument_count == 1 => Some(true),
61        "has_table_privilege" if matches!(argument_count, 2 | 3) => Some(true),
62        "has_column_privilege" if matches!(argument_count, 3 | 4) => Some(true),
63        "has_database_privilege" if matches!(argument_count, 2 | 3) => Some(true),
64        "has_schema_privilege" if matches!(argument_count, 2 | 3) => Some(true),
65        "has_sequence_privilege" | "has_function_privilege" | "has_type_privilege"
66            if matches!(argument_count, 2 | 3) =>
67        {
68            Some(true)
69        }
70        "pg_get_constraintdef" if matches!(argument_count, 1 | 2) => Some(true),
71        "pg_get_function_arguments"
72        | "pg_get_function_identity_arguments"
73        | "pg_get_function_result"
74        | "pg_get_functiondef"
75        | "pg_get_function_sqlbody"
76            if argument_count == 1 =>
77        {
78            Some(true)
79        }
80        "pg_get_sequence_data" | "pg_sequence_last_value" | "pg_sequence_parameters"
81            if argument_count == 1 =>
82        {
83            Some(true)
84        }
85        "nextval" | "currval" if argument_count == 1 => Some(true),
86        "lastval" if argument_count == 0 => Some(true),
87        "setval" if matches!(argument_count, 2 | 3) => Some(true),
88        "overlaps" if argument_count == 4 => Some(false),
89        "abs"
90        | "acos"
91        | "array_dims"
92        | "array_ndims"
93        | "array_reverse"
94        | "ascii"
95        | "asin"
96        | "atan"
97        | "bit_length"
98        | "cardinality"
99        | "casefold"
100        | "cbrt"
101        | "ceil"
102        | "ceiling"
103        | "char_length"
104        | "character_length"
105        | "chr"
106        | "cos"
107        | "cosh"
108        | "current_schemas"
109        | "degrees"
110        | "exp"
111        | "factorial"
112        | "floor"
113        | "gamma"
114        | "initcap"
115        | "isfinite"
116        | "json_array_length"
117        | "jsonb_array_length"
118        | "json_typeof"
119        | "jsonb_typeof"
120        | "jsonb_pretty"
121        | "justify_days"
122        | "justify_hours"
123        | "justify_interval"
124        | "length"
125        | "lgamma"
126        | "ln"
127        | "log10"
128        | "log2"
129        | "lower"
130        | "md5"
131        | "octet_length"
132        | "quote_ident"
133        | "quote_literal"
134        | "radians"
135        | "reverse"
136        | "row_to_json"
137        | "sign"
138        | "sin"
139        | "sinh"
140        | "sqrt"
141        | "tan"
142        | "tanh"
143        | "to_bin"
144        | "to_hex"
145        | "to_oct"
146        | "to_json"
147        | "to_jsonb"
148        | "to_regclass"
149        | "to_regcollation"
150        | "to_regnamespace"
151        | "to_regproc"
152        | "to_regprocedure"
153        | "to_regrole"
154        | "to_regtype"
155        | "to_timestamp"
156        | "upper"
157        | "uuid_extract_timestamp"
158        | "uuid_extract_version"
159            if argument_count == 1 =>
160        {
161            Some(true)
162        }
163        "random" if argument_count == 2 => Some(true),
164        "age" | "timezone" | "btrim" | "ltrim" | "rtrim" | "trim" | "log" | "round" | "trunc"
165        | "json_strip_nulls" | "jsonb_strip_nulls"
166            if matches!(argument_count, 1 | 2) =>
167        {
168            Some(true)
169        }
170        "array_sort" if matches!(argument_count, 1..=3) => Some(true),
171        "date_trunc" if matches!(argument_count, 2 | 3) => Some(true),
172        "array_length" | "array_lower" | "array_upper" | "atan2" | "date_part" | "decode"
173        | "encode" | "extract" | "gcd" | "get_byte" | "lcm" | "left" | "mod" | "power" | "pow"
174        | "repeat" | "right" | "starts_with" | "position" | "strpos" | "to_char" | "to_date"
175        | "to_number" | "trim_array" | "point" | "st_distance" | "st_within"
176            if argument_count == 2 =>
177        {
178            Some(true)
179        }
180        "like_escape" if argument_count == 2 => Some(true),
181        "similar_to_escape" if matches!(argument_count, 1 | 2) => Some(true),
182        "like" | "ilike" | "similar_to" | "regex_match_op" | "regex_imatch_op"
183            if argument_count == 2 =>
184        {
185            Some(true)
186        }
187        "like" | "ilike" | "similar_to" if argument_count == 3 => Some(false),
188        "array_to_string" if argument_count == 2 => Some(true),
189        "substring" | "substr" | "lpad" | "rpad" if matches!(argument_count, 2 | 3) => Some(true),
190        "regexp_count" if matches!(argument_count, 2..=4) => Some(true),
191        "regexp_instr" if matches!(argument_count, 2..=7) => Some(true),
192        "regexp_like" | "regexp_match" | "regexp_matches" if matches!(argument_count, 2 | 3) => {
193            Some(true)
194        }
195        "regexp_replace" if matches!(argument_count, 3..=6) => Some(true),
196        "regexp_substr" if matches!(argument_count, 2..=6) => Some(true),
197        "replace" | "split_part" | "translate" | "make_date" if argument_count == 3 => Some(true),
198        "overlay" | "jsonb_set" | "jsonb_insert" if matches!(argument_count, 3 | 4) => Some(true),
199        "json_extract_path"
200        | "jsonb_extract_path"
201        | "json_extract_path_text"
202        | "jsonb_extract_path_text"
203            if argument_count >= 2 =>
204        {
205            Some(true)
206        }
207        "json_contains" | "json_contained_by" | "json_delete_path" | "json_has_key"
208        | "json_has_any_key" | "json_has_all_keys" | "jsonb_path_exists" | "jsonpath_exists"
209        | "jsonb_path_match" | "jsonpath_match"
210            if argument_count == 2 =>
211        {
212            Some(true)
213        }
214        "make_timestamp" if matches!(argument_count, 6 | 7) => Some(true),
215        "make_interval" if argument_count <= 7 => Some(true),
216        "width_bucket" if argument_count == 4 => Some(true),
217        "st_dwithin" if matches!(argument_count, 2 | 3) => Some(true),
218        _ => None,
219    }
220}
221
222/// Return the `PostgreSQL` 18 strictness contract selected by a structural function binding. Parser-owned syntax and overload-specific built-ins must be classified by [`FunctionDispatch`], never by their diagnostic display label.
223#[must_use]
224pub fn bound_scalar_function_strictness(
225    name: &str,
226    binding: Option<&FunctionBinding>,
227    argument_count: usize,
228) -> Option<bool> {
229    let Some(binding) = binding else {
230        return builtin_scalar_function_strictness(name, argument_count);
231    };
232    if let Some(dispatch) = binding.dispatch {
233        return match dispatch {
234            FunctionDispatch::Concat { oid } => oid.map_or(Some(false), |oid| {
235                crate::type_resolution::binary_operator_by_oid(i64::from(oid))
236                    .filter(|operator| operator.name == "||")
237                    .map(|operator| !matches!(operator.oid, 349 | 374 | 375))
238            }),
239            FunctionDispatch::NumericOperator(_) => Some(true),
240            FunctionDispatch::Pattern { .. } | FunctionDispatch::PatternEscape { .. } => Some(true),
241            FunctionDispatch::Collate { .. } => None,
242            FunctionDispatch::MergeAction => Some(false),
243            FunctionDispatch::Subscript
244            | FunctionDispatch::FieldSelect
245            | FunctionDispatch::BetweenSymmetric
246            | FunctionDispatch::ToBinInt4
247            | FunctionDispatch::ToBinInt8
248            | FunctionDispatch::ToHexInt4
249            | FunctionDispatch::ToHexInt8
250            | FunctionDispatch::ToOctInt4
251            | FunctionDispatch::ToOctInt8
252            | FunctionDispatch::RandomInt4Range
253            | FunctionDispatch::RandomInt8Range
254            | FunctionDispatch::RandomNumericRange
255            | FunctionDispatch::ArraySortJson
256            | FunctionDispatch::JsonPath { .. }
257            | FunctionDispatch::JsonExtract { .. }
258            | FunctionDispatch::Range { .. } => Some(true),
259            FunctionDispatch::ArraySubscripts
260            | FunctionDispatch::ArraySlices
261            | FunctionDispatch::ArraySlicesWithBounds(_)
262            | FunctionDispatch::CollationFor { .. }
263            | FunctionDispatch::ArrayConcat
264            | FunctionDispatch::ArrayAppend
265            | FunctionDispatch::ArrayPrepend
266            | FunctionDispatch::Slice
267            | FunctionDispatch::AnyOperator
268            | FunctionDispatch::AllOperator
269            | FunctionDispatch::ValueNullTest { .. }
270            | FunctionDispatch::BooleanTest(_)
271            | FunctionDispatch::ValueIsDistinct
272            | FunctionDispatch::IsDistinct => Some(false),
273            FunctionDispatch::Enum { operation, .. } => Some(operation.is_strict()),
274            FunctionDispatch::NamedArgument | FunctionDispatch::VariadicArgument => None,
275        };
276    }
277    binding
278        .builtin
279        .then(|| builtin_scalar_function_strictness(&binding.name, argument_count))
280        .flatten()
281}
282
283/// Evaluate a call's argument list, unwrapping `name => value`
284pub fn eval_bound_builtin_function_call(
285    binding: &FunctionBinding,
286    call_args: Vec<(Option<String>, Value)>,
287    ctx: &EvalContext<'_>,
288) -> Result<Value> {
289    if let Some(error) = &binding.resolution_error {
290        return Err(error.sql_error());
291    }
292    if let Some(hook) = ctx.engine {
293        hook.require_builtin_execute(binding)?;
294    }
295    let Some(dispatch) = binding.dispatch else {
296        let value = eval_builtin_function_call(&binding.name, call_args, ctx)?;
297        // Fixed signatures retain widths that the shared integer and floating carriers cannot enforce on their own.
298        if matches!(value, Value::Int(_) | Value::Float(_)) {
299            if let Some(
300                ty @ (crate::ColumnType::SmallInteger
301                | crate::ColumnType::Integer
302                | crate::ColumnType::Real),
303            ) = crate::fixed_builtin_return_type(binding)
304            {
305                return super::cast_value(&value, &ty.sql_name());
306            }
307        }
308        return Ok(value);
309    };
310    if let Some(result) = random::eval_dispatched_random_function(dispatch, &call_args, ctx) {
311        return result;
312    }
313    if call_args.iter().any(|(name, _)| name.is_some()) {
314        return Err(SQLError::Internal(format!(
315            "bound {} expression retained a named argument",
316            dispatch.label()
317        )));
318    }
319    let evaluated = call_args
320        .into_iter()
321        .map(|(_, value)| value)
322        .collect::<Vec<_>>();
323    if let FunctionDispatch::Enum {
324        operation,
325        type_oid,
326    } = dispatch
327    {
328        return super::enums::enum_function_value_with_state(
329            ctx.engine.and_then(super::EngineHook::enum_labels),
330            operation,
331            type_oid,
332            &evaluated,
333            ctx.enum_comparison_state(),
334        );
335    }
336    if let Some(result) = scalar_postgres::eval_comparison_with_context(
337        dispatch,
338        &evaluated,
339        &uqa_core::memory::ProductionControl::uncontrolled(),
340        ctx,
341    ) {
342        return result.map(|value| {
343            value
344                .into_uncontrolled()
345                .expect("ordinary comparison result")
346        });
347    }
348    eval_dispatched_builtin_with_control(
349        binding,
350        dispatch,
351        &evaluated,
352        &uqa_core::memory::ProductionControl::uncontrolled(),
353        ctx,
354    )
355    .map(|value| {
356        value
357            .into_uncontrolled()
358            .expect("ordinary dispatched builtin result")
359    })
360}
361
362pub(super) fn eval_dispatched_builtin_with_control(
363    binding: &FunctionBinding,
364    dispatch: FunctionDispatch,
365    evaluated: &[Value],
366    control: &uqa_core::memory::ProductionControl<'_>,
367    context: &EvalContext<'_>,
368) -> Result<uqa_core::memory::Produced<Value>> {
369    if dispatch == FunctionDispatch::FieldSelect {
370        if let Some(field) = &binding.composite_field {
371            // Arguments have already run: descriptor changes must not suppress their observable effects.
372            return super::composites::fields::select_with_control(
373                field,
374                evaluated,
375                context.engine,
376                control,
377            );
378        }
379    }
380    if let Some(result) = scalar_postgres::eval_dispatched_postgres_function_with_control(
381        dispatch, evaluated, control,
382    ) {
383        return result;
384    }
385    match dispatch {
386        FunctionDispatch::MergeAction => {
387            crate::semantics::runtime_scalars::merge_action_value_with_control(
388                evaluated.len(),
389                context.row_lookup()?,
390                control,
391            )
392        }
393        FunctionDispatch::CollationFor {
394            collation_oid,
395            noncollatable_type_oid,
396        } => super::collation_for::evaluate(
397            collation_oid,
398            noncollatable_type_oid,
399            context.engine,
400            control,
401        ),
402        FunctionDispatch::NumericOperator(operator) => {
403            super::numeric_operator::eval_bound_operator_with_control(
404                operator, binding, evaluated, control,
405            )
406        }
407        FunctionDispatch::JsonPath { as_text, jsonb } => {
408            super::json::json_path_function_with_control(evaluated, as_text, jsonb, control)
409        }
410        FunctionDispatch::Concat { oid } => {
411            super::concatenation::evaluate(binding, oid, evaluated, control, context)
412        }
413        FunctionDispatch::Pattern { operator, .. } => {
414            super::pattern::evaluate(operator, evaluated, control)
415        }
416        FunctionDispatch::PatternEscape { similar } => {
417            super::pattern::escape(similar, evaluated, control)
418        }
419        FunctionDispatch::JsonExtract { as_text, path } => {
420            super::json::json_extract_operator_with_control(evaluated, as_text, path, control)
421        }
422        FunctionDispatch::ArraySortJson => {
423            let catalog = context.engine.map(super::EngineValueCatalog);
424            scalar_array::eval_dispatched_json_array_sort_with_control(
425                evaluated,
426                control,
427                catalog
428                    .as_ref()
429                    .map(|catalog| catalog as &dyn super::SQLValueCatalog),
430            )
431        }
432        FunctionDispatch::ArrayConcat
433        | FunctionDispatch::ArrayAppend
434        | FunctionDispatch::ArrayPrepend => {
435            let name = match dispatch {
436                FunctionDispatch::ArrayConcat => "array_cat",
437                FunctionDispatch::ArrayAppend => "array_append",
438                _ => "array_prepend",
439            };
440            scalar_array::eval_array_functions_with_control(name, evaluated, control)
441                .expect("registered array concatenation operation")
442        }
443        FunctionDispatch::Range {
444            operation,
445            subtype,
446            multirange,
447        } => scalar_range::eval_dispatched_range_function_with_control(
448            operation, subtype, multirange, evaluated, control,
449        ),
450        // Catalog-free evaluation: comparisons use label keys; label lookups report the missing catalog.
451        FunctionDispatch::Enum {
452            operation,
453            type_oid,
454        } => super::enums::enum_function_value_with_control(
455            None, operation, type_oid, evaluated, None, control,
456        ),
457        FunctionDispatch::NamedArgument | FunctionDispatch::VariadicArgument => Err(
458            SQLError::Internal("call-argument syntax marker reached scalar execution".into()),
459        ),
460        _ => Err(SQLError::Internal(format!(
461            "{} has no scalar executor",
462            dispatch.label()
463        ))),
464    }
465}