1use crate::registry::FunctionKind;
10use crate::{SQLError, ScalarExpr};
11use std::sync::LazyLock;
12use uqa_core::Value;
13
14pub 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}
20pub fn function_has_implicit_row_input(binding: &crate::ast::FunctionBinding) -> bool {
22 function_implicit_row_input(binding).is_some()
23}
24
25pub 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
33pub 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
57fn 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
72fn 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
161pub 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
224pub 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
288pub 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
298pub 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}