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" if argument_count == 1 => Some(false),
44        "array_cat" | "array_append" | "array_prepend" | "array_remove" | "array_positions"
45            if argument_count == 2 =>
46        {
47            Some(false)
48        }
49        "array_position" if matches!(argument_count, 2 | 3) => Some(false),
50        "array_replace" if argument_count == 3 => Some(false),
51        "array_fill" if matches!(argument_count, 2 | 3) => Some(false),
52        "array_to_string" if argument_count == 3 => Some(false),
53        "string_to_array" | "string_to_table" if matches!(argument_count, 2 | 3) => Some(false),
54        "pg_has_role" if matches!(argument_count, 2 | 3) => Some(true),
55        "pg_get_userbyid" if argument_count == 1 => Some(true),
56        "current_setting" if matches!(argument_count, 1 | 2) => Some(true),
57        "set_config" if argument_count == 3 => Some(false),
58        "pg_sleep" | "pg_sleep_for" | "pg_sleep_until" if argument_count == 1 => Some(true),
59        "has_table_privilege" if matches!(argument_count, 2 | 3) => Some(true),
60        "has_column_privilege" if matches!(argument_count, 3 | 4) => Some(true),
61        "has_database_privilege" if matches!(argument_count, 2 | 3) => Some(true),
62        "has_schema_privilege" if matches!(argument_count, 2 | 3) => Some(true),
63        "has_sequence_privilege" | "has_function_privilege" | "has_type_privilege"
64            if matches!(argument_count, 2 | 3) =>
65        {
66            Some(true)
67        }
68        "pg_get_constraintdef" if matches!(argument_count, 1 | 2) => Some(true),
69        "pg_get_function_arguments"
70        | "pg_get_function_identity_arguments"
71        | "pg_get_function_result"
72        | "pg_get_functiondef"
73        | "pg_get_function_sqlbody"
74            if argument_count == 1 =>
75        {
76            Some(true)
77        }
78        "pg_get_sequence_data" | "pg_sequence_last_value" | "pg_sequence_parameters"
79            if argument_count == 1 =>
80        {
81            Some(true)
82        }
83        "nextval" | "currval" if argument_count == 1 => Some(true),
84        "lastval" if argument_count == 0 => Some(true),
85        "setval" if matches!(argument_count, 2 | 3) => Some(true),
86        "overlaps" if argument_count == 4 => Some(false),
87        "abs"
88        | "acos"
89        | "array_dims"
90        | "array_ndims"
91        | "array_reverse"
92        | "ascii"
93        | "asin"
94        | "atan"
95        | "bit_length"
96        | "cardinality"
97        | "casefold"
98        | "cbrt"
99        | "ceil"
100        | "ceiling"
101        | "char_length"
102        | "character_length"
103        | "chr"
104        | "cos"
105        | "cosh"
106        | "current_schemas"
107        | "degrees"
108        | "exp"
109        | "factorial"
110        | "floor"
111        | "gamma"
112        | "initcap"
113        | "isfinite"
114        | "json_array_length"
115        | "jsonb_array_length"
116        | "json_typeof"
117        | "jsonb_typeof"
118        | "jsonb_pretty"
119        | "justify_days"
120        | "justify_hours"
121        | "justify_interval"
122        | "length"
123        | "lgamma"
124        | "ln"
125        | "log10"
126        | "log2"
127        | "lower"
128        | "md5"
129        | "octet_length"
130        | "quote_ident"
131        | "quote_literal"
132        | "radians"
133        | "reverse"
134        | "row_to_json"
135        | "sign"
136        | "sin"
137        | "sinh"
138        | "sqrt"
139        | "tan"
140        | "tanh"
141        | "to_bin"
142        | "to_hex"
143        | "to_oct"
144        | "to_json"
145        | "to_jsonb"
146        | "to_regclass"
147        | "to_regcollation"
148        | "to_regnamespace"
149        | "to_regproc"
150        | "to_regprocedure"
151        | "to_regrole"
152        | "to_regtype"
153        | "to_timestamp"
154        | "upper"
155        | "uuid_extract_timestamp"
156        | "uuid_extract_version"
157            if argument_count == 1 =>
158        {
159            Some(true)
160        }
161        "random" if argument_count == 2 => Some(true),
162        "age" | "btrim" | "ltrim" | "rtrim" | "trim" | "log" | "round" | "trunc"
163        | "json_strip_nulls" | "jsonb_strip_nulls"
164            if matches!(argument_count, 1 | 2) =>
165        {
166            Some(true)
167        }
168        "array_sort" if matches!(argument_count, 1..=3) => Some(true),
169        "date_trunc" if matches!(argument_count, 2 | 3) => Some(true),
170        "array_length" | "array_lower" | "array_upper" | "atan2" | "date_part" | "decode"
171        | "encode" | "extract" | "gcd" | "get_byte" | "lcm" | "left" | "mod" | "power" | "pow"
172        | "repeat" | "right" | "starts_with" | "position" | "strpos" | "to_char" | "to_date"
173        | "to_number" | "trim_array" | "point" | "st_distance" | "st_within"
174            if argument_count == 2 =>
175        {
176            Some(true)
177        }
178        "like" | "ilike" | "similar_to" | "regex_match_op" | "regex_imatch_op"
179            if argument_count == 2 =>
180        {
181            Some(true)
182        }
183        "like" | "ilike" | "similar_to" if argument_count == 3 => Some(false),
184        "array_to_string" if argument_count == 2 => Some(true),
185        "substring" | "substr" | "lpad" | "rpad" if matches!(argument_count, 2 | 3) => Some(true),
186        "regexp_count" if matches!(argument_count, 2..=4) => Some(true),
187        "regexp_instr" if matches!(argument_count, 2..=7) => Some(true),
188        "regexp_like" | "regexp_match" | "regexp_matches" if matches!(argument_count, 2 | 3) => {
189            Some(true)
190        }
191        "regexp_replace" if matches!(argument_count, 3..=6) => Some(true),
192        "regexp_substr" if matches!(argument_count, 2..=6) => Some(true),
193        "replace" | "split_part" | "translate" | "make_date" if argument_count == 3 => Some(true),
194        "overlay" | "jsonb_set" | "jsonb_insert" if matches!(argument_count, 3 | 4) => Some(true),
195        "json_extract_path"
196        | "jsonb_extract_path"
197        | "json_extract_path_text"
198        | "jsonb_extract_path_text"
199            if argument_count >= 2 =>
200        {
201            Some(true)
202        }
203        "json_contains" | "json_contained_by" | "json_delete_path" | "json_has_key"
204        | "json_has_any_key" | "json_has_all_keys" | "jsonb_path_exists" | "jsonpath_exists"
205        | "jsonb_path_match" | "jsonpath_match"
206            if argument_count == 2 =>
207        {
208            Some(true)
209        }
210        "make_timestamp" if matches!(argument_count, 6 | 7) => Some(true),
211        "make_interval" if argument_count <= 7 => Some(true),
212        "width_bucket" if argument_count == 4 => Some(true),
213        "st_dwithin" if matches!(argument_count, 2 | 3) => Some(true),
214        _ => None,
215    }
216}
217
218/// 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.
219#[must_use]
220pub fn bound_scalar_function_strictness(
221    name: &str,
222    binding: Option<&FunctionBinding>,
223    argument_count: usize,
224) -> Option<bool> {
225    let Some(binding) = binding else {
226        return builtin_scalar_function_strictness(name, argument_count);
227    };
228    if let Some(dispatch) = binding.dispatch {
229        return match dispatch {
230            FunctionDispatch::NumericOperator(_) => Some(true),
231            FunctionDispatch::ArraySubscripts
232            | FunctionDispatch::Subscript
233            | FunctionDispatch::FieldSelect
234            | FunctionDispatch::BetweenSymmetric
235            | FunctionDispatch::ToBinInt4
236            | FunctionDispatch::ToBinInt8
237            | FunctionDispatch::ToHexInt4
238            | FunctionDispatch::ToHexInt8
239            | FunctionDispatch::ToOctInt4
240            | FunctionDispatch::ToOctInt8
241            | FunctionDispatch::RandomInt4Range
242            | FunctionDispatch::RandomInt8Range
243            | FunctionDispatch::RandomNumericRange
244            | FunctionDispatch::ArraySortJson
245            | FunctionDispatch::JsonExtract { .. }
246            | FunctionDispatch::Range { .. } => Some(true),
247            FunctionDispatch::ArraySlices
248            | FunctionDispatch::ArrayConcat
249            | FunctionDispatch::ArrayAppend
250            | FunctionDispatch::ArrayPrepend
251            | FunctionDispatch::Slice
252            | FunctionDispatch::AnyOperator
253            | FunctionDispatch::AllOperator
254            | FunctionDispatch::IsDistinct => Some(false),
255            FunctionDispatch::Enum { operation, .. } => Some(operation.is_strict()),
256            FunctionDispatch::NamedArgument | FunctionDispatch::VariadicArgument => None,
257        };
258    }
259    binding
260        .builtin
261        .then(|| builtin_scalar_function_strictness(&binding.name, argument_count))
262        .flatten()
263}
264
265/// Evaluate a call's argument list, unwrapping `name => value`
266pub fn eval_bound_builtin_function_call(
267    binding: &FunctionBinding,
268    call_args: Vec<(Option<String>, Value)>,
269    ctx: &EvalContext<'_>,
270) -> Result<Value> {
271    if let Some(error) = &binding.resolution_error {
272        return Err(error.sql_error());
273    }
274    if let Some(hook) = ctx.engine {
275        hook.require_builtin_execute(binding)?;
276    }
277    let Some(dispatch) = binding.dispatch else {
278        let value = eval_builtin_function_call(&binding.name, call_args, ctx)?;
279        // Fixed signatures retain widths that the shared integer and floating carriers cannot enforce on their own.
280        if matches!(value, Value::Int(_) | Value::Float(_)) {
281            if let Some(
282                ty @ (crate::ColumnType::SmallInteger
283                | crate::ColumnType::Integer
284                | crate::ColumnType::Real),
285            ) = crate::fixed_builtin_return_type(binding)
286            {
287                return super::cast_value(&value, &ty.sql_name());
288            }
289        }
290        return Ok(value);
291    };
292    if let Some(result) = random::eval_dispatched_random_function(dispatch, &call_args, ctx) {
293        return result;
294    }
295    if call_args.iter().any(|(name, _)| name.is_some()) {
296        return Err(SQLError::Internal(format!(
297            "bound {} expression retained a named argument",
298            dispatch.label()
299        )));
300    }
301    let evaluated = call_args
302        .into_iter()
303        .map(|(_, value)| value)
304        .collect::<Vec<_>>();
305    if let FunctionDispatch::Enum {
306        operation,
307        type_oid,
308    } = dispatch
309    {
310        return super::enums::enum_function_value_with_state(
311            ctx.engine.and_then(super::EngineHook::enum_labels),
312            operation,
313            type_oid,
314            &evaluated,
315            ctx.enum_comparison_state(),
316        );
317    }
318    if let Some(result) = scalar_postgres::eval_comparison_with_context(
319        dispatch,
320        &evaluated,
321        &uqa_core::memory::ProductionControl::uncontrolled(),
322        ctx,
323    ) {
324        return result.map(|value| {
325            value
326                .into_uncontrolled()
327                .expect("ordinary comparison result")
328        });
329    }
330    eval_dispatched_builtin_with_control(
331        binding,
332        dispatch,
333        &evaluated,
334        &uqa_core::memory::ProductionControl::uncontrolled(),
335        ctx,
336    )
337    .map(|value| {
338        value
339            .into_uncontrolled()
340            .expect("ordinary dispatched builtin result")
341    })
342}
343
344pub(super) fn eval_dispatched_builtin_with_control(
345    binding: &FunctionBinding,
346    dispatch: FunctionDispatch,
347    evaluated: &[Value],
348    control: &uqa_core::memory::ProductionControl<'_>,
349    context: &EvalContext<'_>,
350) -> Result<uqa_core::memory::Produced<Value>> {
351    if dispatch == FunctionDispatch::FieldSelect {
352        if let Some(field) = &binding.composite_field {
353            // Arguments have already run: descriptor changes must not suppress their observable effects.
354            return super::composites::fields::select_with_control(
355                field,
356                evaluated,
357                context.engine,
358                control,
359            );
360        }
361    }
362    if let Some(result) = scalar_postgres::eval_dispatched_postgres_function_with_control(
363        dispatch, evaluated, control,
364    ) {
365        return result;
366    }
367    match dispatch {
368        FunctionDispatch::NumericOperator(operator) => {
369            super::numeric_operator::eval_bound_operator_with_control(
370                operator, binding, evaluated, control,
371            )
372        }
373        FunctionDispatch::JsonExtract { as_text, path } => {
374            super::json::json_extract_operator_with_control(evaluated, as_text, path, control)
375        }
376        FunctionDispatch::ArraySortJson => {
377            scalar_array::eval_dispatched_json_array_sort_with_control(evaluated, control)
378        }
379        FunctionDispatch::ArrayConcat
380        | FunctionDispatch::ArrayAppend
381        | FunctionDispatch::ArrayPrepend => {
382            let name = match dispatch {
383                FunctionDispatch::ArrayConcat => "array_cat",
384                FunctionDispatch::ArrayAppend => "array_append",
385                _ => "array_prepend",
386            };
387            scalar_array::eval_array_functions_with_control(name, evaluated, control)
388                .expect("registered array concatenation operation")
389        }
390        FunctionDispatch::Range {
391            operation,
392            subtype,
393            multirange,
394        } => scalar_range::eval_dispatched_range_function_with_control(
395            operation, subtype, multirange, evaluated, control,
396        ),
397        // Catalog-free evaluation: comparisons use label keys; label lookups report the missing catalog.
398        FunctionDispatch::Enum {
399            operation,
400            type_oid,
401        } => super::enums::enum_function_value_with_control(
402            None, operation, type_oid, evaluated, None, control,
403        ),
404        FunctionDispatch::NamedArgument | FunctionDispatch::VariadicArgument => Err(
405            SQLError::Internal("call-argument syntax marker reached scalar execution".into()),
406        ),
407        _ => Err(SQLError::Internal(format!(
408            "{} has no scalar executor",
409            dispatch.label()
410        ))),
411    }
412}