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