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 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
44fn 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
59fn 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
148pub 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
211pub 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
275pub 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
285pub 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}