nodedb_sql/functions/registry.rs
1// SPDX-License-Identifier: Apache-2.0
2
3//! Built-in function registry for SQL planning.
4//!
5//! Tracks known functions, their categories, arg specs, return types,
6//! and whether they trigger special engine routing (e.g., vector_distance
7//! → VectorSearch).
8
9use nodedb_types::Volatility;
10use nodedb_types::columnar::ColumnType;
11
12use super::builtins::builtin_functions;
13
14/// Semantic version for tracking when a function was introduced.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
16pub struct Version {
17 pub major: u8,
18 pub minor: u8,
19 pub patch: u8,
20}
21
22impl Version {
23 pub const fn new(major: u8, minor: u8, patch: u8) -> Self {
24 Self {
25 major,
26 minor,
27 patch,
28 }
29 }
30}
31
32/// Specification for a single function argument position.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub struct ArgTypeSpec {
35 /// Argument name, for documentation and error messages.
36 pub name: &'static str,
37 /// Accepted column types. Empty slice means any type is accepted (wildcard).
38 pub accepted: &'static [ColumnType],
39 /// If true on the last argument, this argument may repeat zero or more times.
40 pub variadic: bool,
41}
42
43/// Function category.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum FunctionCategory {
46 Scalar,
47 Aggregate,
48 Window,
49}
50
51/// Whether a function triggers special engine routing.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum SearchTrigger {
54 None,
55 VectorSearch,
56 MultiVectorSearch,
57 /// Sparse-vector inverted-index search: `sparse_score(field, '{dim: weight, ...}')`
58 /// in ORDER BY routes to `SqlPlan::SparseSearch` and lowers to
59 /// `VectorOp::SparseSearch` (dot-product top-k over the sparse index).
60 SparseSearch,
61 TextSearch,
62 HybridSearch,
63 TextMatch,
64 SpatialDWithin,
65 SpatialContains,
66 SpatialIntersects,
67 SpatialWithin,
68 TimeBucket,
69 /// Array engine table-valued read: `ARRAY_SLICE(name, slice_obj, attrs?, limit?)`.
70 ArraySlice,
71 /// Array engine table-valued read: `ARRAY_PROJECT(name, attrs)`.
72 ArrayProject,
73 /// Array engine table-valued aggregate:
74 /// `ARRAY_AGG(name, attr, reducer, group_by_dim?)`.
75 ArrayAgg,
76 /// Array engine table-valued elementwise:
77 /// `ARRAY_ELEMENTWISE(left, right, op, attr)`.
78 ArrayElementwise,
79 /// Array engine maintenance scalar (returns BOOL): `ARRAY_FLUSH(name)`.
80 ArrayFlush,
81 /// Array engine maintenance scalar (returns BOOL): `ARRAY_COMPACT(name)`.
82 ArrayCompact,
83 /// Planner-intercepted graph-distance marker for three-source RRF fusion.
84 /// `graph_score(node_id_col, seed_id, depth => N, label => 'edge_label')`
85 /// is never evaluated per-row; the hybrid planner extracts its arguments
86 /// and builds a graph BFS spec in the physical plan.
87 GraphSearch,
88}
89
90/// Metadata about a known function.
91#[derive(Debug, Clone)]
92pub struct FunctionMeta {
93 pub name: &'static str,
94 pub category: FunctionCategory,
95 pub min_args: usize,
96 pub max_args: usize,
97 pub search_trigger: SearchTrigger,
98 /// Static return type, when known at plan time. `None` means the type
99 /// is context-dependent or unknown (resolved at runtime).
100 pub return_type: Option<ColumnType>,
101 /// Per-position argument type specifications.
102 pub arg_types: &'static [ArgTypeSpec],
103 /// Version in which this function was introduced.
104 pub since: Version,
105 /// How far a call's result can be reused. Orthogonal to `category`: a
106 /// function is `Scalar` and `Volatile` at once. A `Volatile` function is
107 /// never folded to a literal and never admitted to a plan cache.
108 pub volatility: Volatility,
109}
110
111impl FunctionMeta {
112 /// Mark this registration `Volatile`.
113 pub fn volatile(mut self) -> Self {
114 self.volatility = Volatility::Volatile;
115 self
116 }
117}
118
119/// The function registry.
120pub struct FunctionRegistry {
121 functions: Vec<FunctionMeta>,
122}
123
124impl FunctionRegistry {
125 /// Create the default registry with all built-in functions.
126 pub fn new() -> Self {
127 Self {
128 functions: builtin_functions(),
129 }
130 }
131
132 /// Look up a function by name (case-insensitive).
133 pub fn lookup(&self, name: &str) -> Option<&FunctionMeta> {
134 let lower = name.to_lowercase();
135 self.functions.iter().find(|f| f.name == lower)
136 }
137
138 /// Check if a function triggers special search routing.
139 pub fn search_trigger(&self, name: &str) -> SearchTrigger {
140 self.lookup(name)
141 .map(|f| f.search_trigger)
142 .unwrap_or(SearchTrigger::None)
143 }
144
145 /// Check if a function is an aggregate.
146 pub fn is_aggregate(&self, name: &str) -> bool {
147 self.lookup(name)
148 .is_some_and(|f| f.category == FunctionCategory::Aggregate)
149 }
150
151 /// Whether a function must re-evaluate on every execution.
152 /// An unknown name is not volatile — the existence gate rejects it first.
153 pub fn is_volatile(&self, name: &str) -> bool {
154 self.lookup(name)
155 .is_some_and(|f| f.volatility.is_volatile())
156 }
157
158 /// Check if a function is a window function.
159 pub fn is_window(&self, name: &str) -> bool {
160 self.lookup(name)
161 .is_some_and(|f| f.category == FunctionCategory::Window)
162 }
163}
164
165impl Default for FunctionRegistry {
166 fn default() -> Self {
167 Self::new()
168 }
169}
170
171#[cfg(test)]
172mod tests {
173 use super::*;
174
175 #[test]
176 fn lookup_builtin() {
177 let reg = FunctionRegistry::new();
178 assert!(reg.is_aggregate("COUNT"));
179 assert!(reg.is_aggregate("sum"));
180 assert!(!reg.is_aggregate("vector_distance"));
181 assert!(reg.is_window("row_number"));
182 assert_eq!(
183 reg.search_trigger("vector_distance"),
184 SearchTrigger::VectorSearch
185 );
186 assert_eq!(
187 reg.search_trigger("st_dwithin"),
188 SearchTrigger::SpatialDWithin
189 );
190 assert_eq!(reg.search_trigger("time_bucket"), SearchTrigger::TimeBucket);
191 }
192
193 #[test]
194 fn chunk_text_renamed_to_ndb_chunk_text() {
195 let reg = FunctionRegistry::new();
196 assert!(
197 reg.lookup("ndb_chunk_text").is_some(),
198 "ndb_chunk_text must be registered"
199 );
200 assert!(
201 reg.lookup("chunk_text").is_none(),
202 "old name chunk_text must not exist"
203 );
204 }
205
206 #[test]
207 fn unimplemented_functions_removed() {
208 let reg = FunctionRegistry::new();
209 assert!(reg.lookup("currency").is_none());
210 assert!(reg.lookup("distribute").is_none());
211 assert!(reg.lookup("allocate").is_none());
212 assert!(reg.lookup("resolve_permission").is_none());
213 assert!(reg.lookup("convert_currency").is_none());
214 }
215
216 #[test]
217 fn all_builtins_have_since_set() {
218 let reg = FunctionRegistry::new();
219 let v0_1_0 = Version::new(0, 1, 0);
220 for f in ®.functions {
221 assert_eq!(
222 f.since, v0_1_0,
223 "function '{}' must have since = Version::new(0, 1, 0)",
224 f.name
225 );
226 }
227 }
228
229 #[test]
230 fn all_builtins_arg_counts_consistent() {
231 let reg = FunctionRegistry::new();
232 for f in ®.functions {
233 let n = f.arg_types.len();
234 let last_variadic = f.arg_types.last().is_some_and(|a| a.variadic);
235 if last_variadic {
236 // min_args must be <= arg_types.len()
237 assert!(
238 f.min_args <= n,
239 "function '{}': min_args ({}) > arg_types.len() ({}) with variadic last arg",
240 f.name,
241 f.min_args,
242 n
243 );
244 } else {
245 // min_args <= n <= max_args
246 assert!(
247 f.min_args <= n,
248 "function '{}': min_args ({}) > arg_types.len() ({})",
249 f.name,
250 f.min_args,
251 n
252 );
253 assert!(
254 n <= f.max_args,
255 "function '{}': arg_types.len() ({}) > max_args ({})",
256 f.name,
257 n,
258 f.max_args
259 );
260 }
261 }
262 }
263
264 #[test]
265 fn return_type_spot_checks() {
266 let reg = FunctionRegistry::new();
267 assert_eq!(
268 reg.lookup("now").and_then(|f| f.return_type),
269 Some(ColumnType::Timestamptz),
270 "now() must return Timestamptz"
271 );
272 assert_eq!(
273 reg.lookup("count").and_then(|f| f.return_type),
274 Some(ColumnType::Int64),
275 "count must return Int64"
276 );
277 assert_eq!(
278 reg.lookup("doc_exists").and_then(|f| f.return_type),
279 Some(ColumnType::Bool),
280 "doc_exists must return Bool"
281 );
282 assert_eq!(
283 reg.lookup("st_contains").and_then(|f| f.return_type),
284 Some(ColumnType::Bool),
285 "st_contains must return Bool"
286 );
287 assert_eq!(
288 reg.lookup("pg_fts_match").and_then(|f| f.return_type),
289 Some(ColumnType::Bool),
290 "pg_fts_match must return Bool"
291 );
292 assert_eq!(
293 reg.lookup("lower").and_then(|f| f.return_type),
294 Some(ColumnType::String),
295 "lower must return String"
296 );
297 }
298
299 /// INVARIANT: every name the runtime evaluator
300 /// (`nodedb_query::functions::*::try_eval`) actually dispatches on MUST
301 /// resolve through `FunctionRegistry::lookup`, or `resolver::expr::
302 /// functions::convert_function_depth`'s plan-time existence gate rejects
303 /// a call the evaluator would otherwise happily run — the exact bug
304 /// class this whole registry audit exists to close.
305 ///
306 /// Each list below is a literal transcription of one
307 /// `nodedb-query/src/functions/<module>.rs` `try_eval` match arm's
308 /// name set (including every `|`-joined alias), NOT derived from this
309 /// registry — a name added to a `try_eval` match arm without a matching
310 /// registry entry must fail this test, not silently pass because both
311 /// sides drifted together. When either side changes, update the other.
312 #[test]
313 fn runtime_match_arms_are_all_registered() {
314 let reg = FunctionRegistry::new();
315 let assert_all = |module: &str, names: &[&str]| {
316 for name in names {
317 assert!(
318 reg.lookup(name).is_some(),
319 "nodedb_query::functions::{module}::try_eval handles '{name}' \
320 but FunctionRegistry has no entry for it — the \
321 plan-time gate would reject a call the runtime evaluator supports"
322 );
323 }
324 };
325
326 // `nodedb_query::functions::math::try_eval`.
327 assert_all(
328 "math",
329 &[
330 "abs", "round", "ceil", "ceiling", "floor", "power", "pow", "sqrt", "mod", "sign",
331 "log", "ln", "log10", "log2", "exp",
332 ],
333 );
334
335 // `nodedb_query::functions::string::try_eval`.
336 assert_all(
337 "string",
338 &[
339 "upper",
340 "lower",
341 "trim",
342 "ltrim",
343 "rtrim",
344 "length",
345 "char_length",
346 "character_length",
347 "substr",
348 "substring",
349 "concat",
350 "replace",
351 "reverse",
352 ],
353 );
354
355 // `nodedb_query::functions::conditional::try_eval`.
356 assert_all("conditional", &["coalesce", "nullif", "greatest", "least"]);
357
358 // `nodedb_query::functions::id::try_eval`.
359 assert_all(
360 "id",
361 &[
362 "uuid",
363 "uuid_v4",
364 "gen_random_uuid",
365 "uuid_v7",
366 "ulid",
367 "cuid2",
368 "nanoid",
369 "is_uuid",
370 "is_ulid",
371 "is_cuid2",
372 "is_nanoid",
373 "id_type",
374 "uuid_version",
375 "ulid_timestamp",
376 ],
377 );
378
379 // `nodedb_query::functions::datetime::try_eval`. Individual date
380 // parts (`year`, `month`, `day`, `hour`, `minute`, `second`,
381 // `dow`/`dayofweek`, `epoch`) are string literals consumed by
382 // `extract`/`date_part`/`date_trunc`, not standalone function
383 // names, so they are deliberately absent from this list.
384 assert_all(
385 "datetime",
386 &[
387 "now",
388 "current_timestamp",
389 "datetime",
390 "to_datetime",
391 "unix_secs",
392 "epoch_secs",
393 "unix_millis",
394 "epoch_millis",
395 "extract",
396 "date_part",
397 "date_trunc",
398 "datetrunc",
399 "date_add",
400 "datetime_add",
401 "date_sub",
402 "datetime_sub",
403 "date_diff",
404 "datediff",
405 "duration",
406 "to_duration",
407 "decimal",
408 "to_decimal",
409 "time_bucket",
410 ],
411 );
412
413 // `nodedb_query::functions::json::{mod,legacy}::try_eval`.
414 assert_all(
415 "json",
416 &[
417 "pg_json_get",
418 "pg_json_get_text",
419 "pg_json_path_get",
420 "pg_json_path_get_text",
421 "pg_json_contains",
422 "pg_json_contained_by",
423 "pg_json_has_key",
424 "pg_json_has_all_keys",
425 "pg_json_has_any_key",
426 "json_value",
427 "json_query",
428 "json_exists",
429 "json_extract",
430 "json_get",
431 "json_set",
432 "json_remove",
433 "json_keys",
434 "json_values",
435 "json_length",
436 "json_len",
437 "json_type",
438 "json_array",
439 "json_object",
440 "json_contains",
441 "json_merge",
442 "json_patch",
443 "to_jsonb",
444 ],
445 );
446
447 // `nodedb_query::functions::types::try_eval`.
448 assert_all("types", &["typeof", "type_of"]);
449
450 // `nodedb_query::functions::array::try_eval`. Distinct from the
451 // "Array engine" table-valued functions (`array_slice`/
452 // `array_project`/`array_agg`/`array_elementwise`/`array_flush`/
453 // `array_compact`), which are covered by
454 // `all_builtins_arg_counts_consistent` via their own registrations.
455 assert_all(
456 "array",
457 &[
458 "array_length",
459 "cardinality",
460 "array_append",
461 "array_prepend",
462 "array_remove",
463 "array_concat",
464 "array_cat",
465 "array_distinct",
466 "array_contains",
467 "array_position",
468 "array_reverse",
469 ],
470 );
471
472 // `nodedb_query::functions::fts::pg_fts_exec::try_eval_fts`.
473 assert_all(
474 "fts",
475 &[
476 "pg_fts_match",
477 "pg_to_tsquery",
478 "pg_plainto_tsquery",
479 "pg_websearch_to_tsquery",
480 "pg_phraseto_tsquery",
481 "pg_to_tsvector",
482 "pg_ts_rank",
483 "pg_ts_headline",
484 ],
485 );
486
487 // `nodedb_query::functions::system::try_eval`.
488 assert_all(
489 "system",
490 &["version", "format_type", "pg_get_expr", "col_description"],
491 );
492
493 // `nodedb_query::geo_functions::eval_geo_function`. Distinct from
494 // the other modules above: this one lives in `nodedb-query`'s
495 // top-level `geo_functions` module (not `functions::<name>`), and
496 // is dispatched into from `functions::eval_function` for any
497 // geo-prefixed/ST_-prefixed name — every `|`-joined alias on its
498 // match arms is flattened into this one list, same as `math`'s
499 // `ceil`/`ceiling` pairing above.
500 assert_all(
501 "geo_functions",
502 &[
503 "geo_distance",
504 "haversine_distance",
505 "geo_bearing",
506 "haversine_bearing",
507 "geo_point",
508 "st_point",
509 "geo_geohash",
510 "geo_geohash_decode",
511 "geo_geohash_neighbors",
512 "st_contains",
513 "st_intersects",
514 "st_within",
515 "st_disjoint",
516 "st_dwithin",
517 "st_distance",
518 "st_buffer",
519 "st_envelope",
520 "st_union",
521 "geo_length",
522 "geo_perimeter",
523 "geo_line",
524 "geo_polygon",
525 "geo_circle",
526 "geo_bbox",
527 "geo_as_geojson",
528 "geo_from_geojson",
529 "geo_as_wkt",
530 "geo_from_wkt",
531 "geo_x",
532 "geo_y",
533 "geo_num_points",
534 "geo_type",
535 "geo_is_valid",
536 "geo_h3",
537 "geo_h3_to_boundary",
538 "geo_h3_resolution",
539 "st_geohash",
540 "st_geohashdecode",
541 "h3_latlngtocell",
542 "h3_celltolatlng",
543 "st_intersection",
544 ],
545 );
546 }
547}