Skip to main content

uqa_sql/semantics/
functions.rs

1//
2// Unified Query Algebra
3//
4// Copyright (c) 2023-2026 Cognica, Inc.
5//
6
7//! Function naming, shape, and relation-field argument semantics.
8
9use crate::registry::FunctionKind;
10use crate::{SQLError, ScalarExpr};
11use std::sync::LazyLock;
12use uqa_core::Value;
13
14/// Executor-only carrier for `PostgreSQL` 18's `merge_action()` value. The attribute has no SQL name and therefore cannot collide with a target or source column named `_merge_action`.
15pub fn merge_action_attribute() -> crate::ast::InternalColumnRef {
16    static ATTRIBUTE: LazyLock<crate::ast::InternalColumnRef> =
17        LazyLock::new(|| crate::ast::InternalRelationId::allocate().column(0));
18    *ATTRIBUTE
19}
20/// Whether a selected builtin reads the current row through an implicit executor attribute. Such a call is not a constant even when it has no explicit operands; this dependency is independent of routine volatility.
21pub fn function_has_implicit_row_input(binding: &crate::ast::FunctionBinding) -> bool {
22    function_implicit_row_input(binding).is_some()
23}
24
25/// The owned internal identity read by a native call with no written row operand.
26pub fn function_implicit_row_input(
27    binding: &crate::ast::FunctionBinding,
28) -> Option<crate::ast::InternalColumnRef> {
29    (binding.builtin && binding.dispatch == Some(crate::ast::FunctionDispatch::MergeAction))
30        .then(merge_action_attribute)
31}
32
33/// Resolve reserved system-schema aliases only when the local name belongs to
34/// that schema's built-in surface. Ordinary qualified names stay intact for
35/// runtime callbacks and user-defined routine lookup.
36pub fn builtin_function_dispatch_name(name: &str) -> String {
37    let lower = name.to_ascii_lowercase();
38    let Some((schema, local)) = lower.split_once('.') else {
39        return lower;
40    };
41    let is_builtin = match schema {
42        "ag_catalog" => is_age_catalog_function(local),
43        "pg_catalog" => {
44            crate::registry::is_registered(local)
45                || crate::type_resolution::is_fixed_builtin(local)
46                || is_pg_catalog_function(local)
47        }
48        _ => false,
49    };
50    if is_builtin {
51        local.to_string()
52    } else {
53        lower
54    }
55}
56
57/// The functions of the `ag_catalog` schema that a qualified call names.
58fn is_age_catalog_function(local: &str) -> bool {
59    matches!(
60        local,
61        "cypher"
62            | "create_graph"
63            | "drop_graph"
64            | "graph_exists"
65            | "create_vlabel"
66            | "create_elabel"
67            | "drop_label"
68            | "alter_graph"
69    )
70}
71
72/// The built-in functions outside the scalar registry that a `pg_catalog`-qualified call names.
73fn is_pg_catalog_function(local: &str) -> bool {
74    matches!(
75        local,
76        "generate_series"
77            | "unnest"
78            | "regexp_split_to_table"
79            | "string_to_table"
80            | "json_array_elements"
81            | "jsonb_array_elements"
82            | "json_array_elements_text"
83            | "jsonb_array_elements_text"
84            | "json_each"
85            | "jsonb_each"
86            | "json_each_text"
87            | "jsonb_each_text"
88            | "aclexplode"
89            | "json_object_keys"
90            | "jsonb_object_keys"
91            | "upper"
92            | "lower"
93            | "bit_length"
94            | "char_length"
95            | "character_length"
96            | "crc32"
97            | "crc32c"
98            | "get_byte"
99            | "gamma"
100            | "json_strip_nulls"
101            | "jsonb_strip_nulls"
102            | "length"
103            | "lgamma"
104            | "md5"
105            | "octet_length"
106            | "reverse"
107            | "random"
108            | "setseed"
109            | "nextval"
110            | "currval"
111            | "lastval"
112            | "setval"
113            | "current_schema"
114            | "current_schemas"
115            | "current_setting"
116            | "set_config"
117            | "pg_sleep"
118            | "pg_sleep_for"
119            | "pg_sleep_until"
120            | "pg_backend_pid"
121            | "pg_my_temp_schema"
122            | "pg_is_other_temp_schema"
123            | "pg_trigger_depth"
124            | "pg_listening_channels"
125            | "pg_notify"
126            | "pg_notification_queue_usage"
127            | "pg_get_expr"
128            | "pg_get_partkeydef"
129            | "pg_get_serial_sequence"
130            | "pg_get_sequence_data"
131            | "pg_sequence_last_value"
132            | "pg_sequence_parameters"
133            | "pg_get_triggerdef"
134            | "pg_get_ruledef"
135            | "pg_get_viewdef"
136            | "pg_get_indexdef"
137            | "pg_get_constraintdef"
138            | "pg_get_function_arguments"
139            | "pg_get_function_identity_arguments"
140            | "pg_get_function_result"
141            | "pg_get_functiondef"
142            | "pg_get_function_sqlbody"
143            | "format_type"
144            | "pg_describe_object"
145            | "pg_has_role"
146            | "pg_get_userbyid"
147            | "has_table_privilege"
148            | "has_column_privilege"
149            | "has_database_privilege"
150            | "has_schema_privilege"
151            | "has_sequence_privilege"
152            | "has_function_privilege"
153            | "has_type_privilege"
154    )
155}
156
157pub fn is_builtin_aggregate(expr: &ScalarExpr) -> bool {
158    matches!(expr, ScalarExpr::Func { name, binding, .. } if is_builtin_aggregate_call(name, binding.as_ref()))
159}
160
161/// Classify the routine selected for a call, retaining qualified built-ins while allowing user scalar overloads with the same local name.
162pub fn is_builtin_aggregate_call(
163    name: &str,
164    binding: Option<&crate::ast::FunctionBinding>,
165) -> bool {
166    if binding.is_some_and(|binding| !binding.builtin || binding.dispatch.is_some()) {
167        return false;
168    }
169    let name = binding
170        .map_or(name, |binding| binding.name.as_str())
171        .to_ascii_lowercase();
172    let local = name.strip_prefix("pg_catalog.").unwrap_or(&name);
173    crate::ast::is_builtin_aggregate_function(local)
174}
175
176pub enum MultiFieldMatchShape<'a> {
177    FieldsThenQuery {
178        fields: Vec<&'a ScalarExpr>,
179        query_idx: usize,
180    },
181    Pairs {
182        fields: Vec<&'a ScalarExpr>,
183    },
184}
185
186pub fn multi_field_match_shape(args: &[ScalarExpr]) -> Result<MultiFieldMatchShape<'_>, SQLError> {
187    let first_non_column = args.iter().position(|arg| {
188        !matches!(
189            arg,
190            ScalarExpr::Column(_) | ScalarExpr::QualifiedColumn { .. }
191        )
192    });
193    if let Some(query_idx) = first_non_column {
194        if query_idx >= 2 {
195            return Ok(MultiFieldMatchShape::FieldsThenQuery {
196                fields: args[..query_idx].iter().collect(),
197                query_idx,
198            });
199        }
200    }
201    if args.len() < 4 || !args.len().is_multiple_of(2) {
202        if let Some(query_idx) = first_non_column {
203            if query_idx < 2 && args.len() >= 3 {
204                return Err(SQLError::TypeMismatch(format!(
205                    "multi_field_match field arguments must be column references, \
206                     but argument {} is an expression; store computed text in an \
207                     indexed column instead of concatenating at query time",
208                    query_idx + 1
209                )));
210            }
211        }
212        return Err(SQLError::BadArity {
213            name: "multi_field_match".into(),
214            expected: ">= 3 (fields..., query[, weights...]) or even >= 4 (field, query pairs)"
215                .into(),
216            actual: args.len(),
217        });
218    }
219    Ok(MultiFieldMatchShape::Pairs {
220        fields: (0..args.len() / 2).map(|i| &args[2 * i]).collect(),
221    })
222}
223
224/// Return whether a registered function consumes this argument as a relation-field identifier rather than as a scalar row value.
225pub fn is_semantic_field_argument(
226    function: &str,
227    args: &[ScalarExpr],
228    argument_index: usize,
229) -> Result<bool, SQLError> {
230    let dispatch_name = crate::semantics::builtin_function_dispatch_name(function);
231    let Some(kind) = crate::registry::lookup(&dispatch_name) else {
232        return Ok(false);
233    };
234    let is_field = match kind {
235        FunctionKind::TextMatch | FunctionKind::BayesianMatch | FunctionKind::KNNMatch => {
236            argument_index == 0
237        }
238        FunctionKind::FTSMatch => argument_index == 0 && !fts_query_is_jsonpath(args.get(1)),
239        FunctionKind::BayesianMatchWithPrior => matches!(argument_index, 0 | 2),
240        FunctionKind::CalibratedVectorMatch => argument_index == 0,
241        FunctionKind::MultiFieldMatch => match multi_field_match_shape(args)? {
242            MultiFieldMatchShape::FieldsThenQuery { query_idx, .. } => argument_index < query_idx,
243            MultiFieldMatchShape::Pairs { .. } => argument_index.is_multiple_of(2),
244        },
245        FunctionKind::StagedRetrieval => {
246            !matches!(args.first(), Some(ScalarExpr::Func { .. }))
247                && argument_index.is_multiple_of(3)
248        }
249        FunctionKind::UQAFacets => true,
250        FunctionKind::ScoreBM25 | FunctionKind::ScoreBayesianBM25 => {
251            args.len() == 2 && argument_index == 0
252        }
253        FunctionKind::FuseLogOdds
254        | FunctionKind::PositiveEvidencePool
255        | FunctionKind::BayesianEvidenceFusion
256        | FunctionKind::GraphPagerank
257        | FunctionKind::GraphHits
258        | FunctionKind::GraphBetweenness
259        | FunctionKind::GraphTraverse
260        | FunctionKind::GraphNeighbors
261        | FunctionKind::DeepPredict
262        | FunctionKind::UQAHighlight
263        | FunctionKind::TraverseMatch
264        | FunctionKind::TemporalTraverse
265        | FunctionKind::RPQ
266        | FunctionKind::GraphCreate
267        | FunctionKind::GraphDrop
268        | FunctionKind::GraphExists
269        | FunctionKind::GraphLabelCreate
270        | FunctionKind::GraphLabelDrop
271        | FunctionKind::GraphAlter
272        | FunctionKind::GraphEdges
273        | FunctionKind::AttentionFusion
274        | FunctionKind::LearnedFusion
275        | FunctionKind::SparseThreshold
276        | FunctionKind::DeepLearn
277        | FunctionKind::Convolve
278        | FunctionKind::Pool
279        | FunctionKind::Flatten
280        | FunctionKind::Dense
281        | FunctionKind::Softmax
282        | FunctionKind::Layer
283        | FunctionKind::Model => false,
284    };
285    Ok(is_field)
286}
287
288/// The `@@` operator doubles as a `JSONPath` match when the right-hand
289/// side is a `$...` path literal; that form evaluates row-level JSON and
290/// needs no text index.
291pub fn fts_query_is_jsonpath(query_arg: Option<&ScalarExpr>) -> bool {
292    matches!(
293        query_arg,
294        Some(ScalarExpr::Literal(Value::Str(path))) if path.trim_start().starts_with('$')
295    )
296}
297
298/// Whether a scalar expression contains a posting-list retrieval operator.
299/// Relational executors use the same classification as access-path planning
300/// so registered retrieval calls never fall through to scalar evaluation.
301pub fn contains_retrieval(expression: &ScalarExpr) -> bool {
302    match expression {
303        ScalarExpr::Func {
304            name,
305            args,
306            order_by,
307            filter,
308            ..
309        } => {
310            retrieval_function(name)
311                || args.iter().any(contains_retrieval)
312                || order_by.iter().any(|order| contains_retrieval(&order.expr))
313                || filter.as_deref().is_some_and(contains_retrieval)
314        }
315        ScalarExpr::Array(items)
316        | ScalarExpr::Row(items)
317        | ScalarExpr::CompositeRow { items, .. }
318        | ScalarExpr::And(items)
319        | ScalarExpr::Or(items) => items.iter().any(contains_retrieval),
320        ScalarExpr::Binary { lhs, rhs, .. } => contains_retrieval(lhs) || contains_retrieval(rhs),
321        ScalarExpr::UnaryMinus(inner)
322        | ScalarExpr::Not(inner)
323        | ScalarExpr::IsNull { expr: inner, .. }
324        | ScalarExpr::Cast { expr: inner, .. }
325        | ScalarExpr::Collated { expr: inner, .. } => contains_retrieval(inner),
326        ScalarExpr::Between { expr, low, high } => {
327            contains_retrieval(expr) || contains_retrieval(low) || contains_retrieval(high)
328        }
329        ScalarExpr::InList { expr, list, .. } => {
330            contains_retrieval(expr) || list.iter().any(contains_retrieval)
331        }
332        ScalarExpr::WindowCall {
333            args, spec, filter, ..
334        } => {
335            args.iter().any(contains_retrieval)
336                || filter.as_deref().is_some_and(contains_retrieval)
337                || spec.partition_by.iter().any(contains_retrieval)
338                || spec
339                    .order_by
340                    .iter()
341                    .any(|order| contains_retrieval(&order.expr))
342        }
343        ScalarExpr::Case {
344            base,
345            when,
346            else_branch,
347        } => {
348            base.as_deref().is_some_and(contains_retrieval)
349                || when.iter().any(|(condition, result)| {
350                    contains_retrieval(condition) || contains_retrieval(result)
351                })
352                || else_branch.as_deref().is_some_and(contains_retrieval)
353        }
354        ScalarExpr::InSubquery { expr, .. } => contains_retrieval(expr),
355        ScalarExpr::Default
356        | ScalarExpr::Star
357        | ScalarExpr::QualifiedStar(_)
358        | ScalarExpr::Column(_)
359        | ScalarExpr::Position(_)
360        | ScalarExpr::InternalColumn(_)
361        | ScalarExpr::QualifiedColumn { .. }
362        | ScalarExpr::Literal(_)
363        | ScalarExpr::TypedLiteral { .. }
364        | ScalarExpr::Param(_)
365        | ScalarExpr::ScalarSubquery(_)
366        | ScalarExpr::Exists { .. } => false,
367    }
368}
369
370pub fn retrieval_function(name: &str) -> bool {
371    matches!(
372        name.to_ascii_lowercase().as_str(),
373        "text_match"
374            | "bayesian_match"
375            | "fts_match"
376            | "bayesian_match_with_prior"
377            | "calibrated_vector_match"
378            | "knn_match"
379            | "fuse_log_odds"
380            | "pool_positive_evidence"
381            | "fuse_bayesian_evidence"
382            | "multi_field_match"
383            | "staged_retrieval"
384            | "attention"
385            | "fuse_attention"
386            | "fuse_multihead"
387            | "learned_fusion"
388            | "fuse_learned"
389            | "sparse_threshold"
390            | "graph_pagerank"
391            | "pagerank"
392            | "graph_hits"
393            | "hits"
394            | "graph_betweenness"
395            | "betweenness"
396            | "graph_traverse"
397            | "traverse_match"
398            | "graph_neighbors"
399            | "graph_edges"
400            | "temporal_traverse"
401            | "rpq"
402            | "deep_predict"
403    )
404}
405
406pub fn expect_column_name(expr: &ScalarExpr, label: &str) -> Result<String, SQLError> {
407    match expr {
408        ScalarExpr::Column(name) => Ok(name.clone()),
409        ScalarExpr::QualifiedColumn { column, .. } => Ok(column.clone()),
410        other => Err(SQLError::TypeMismatch(format!(
411            "{label} must be a column reference, got {other:?}"
412        ))),
413    }
414}
415
416#[cfg(test)]
417mod tests {
418    use super::builtin_function_dispatch_name;
419
420    #[test]
421    fn builtin_dispatch_preserves_schema_identity() {
422        for name in [
423            "pg_get_sequence_data",
424            "pg_sequence_last_value",
425            "pg_sequence_parameters",
426            "date_trunc",
427            "mod",
428        ] {
429            assert_eq!(builtin_function_dispatch_name(name), name);
430            assert_eq!(
431                builtin_function_dispatch_name(&format!("pg_catalog.{name}")),
432                name
433            );
434            assert_eq!(
435                builtin_function_dispatch_name(&format!("PG_CATALOG.{}", name.to_uppercase())),
436                name
437            );
438            let user_function = format!("public.{name}");
439            assert_eq!(
440                builtin_function_dispatch_name(&user_function),
441                user_function
442            );
443        }
444        assert_eq!(
445            builtin_function_dispatch_name("pg_catalog.custom_function"),
446            "pg_catalog.custom_function"
447        );
448    }
449}