Skip to main content

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 &reg.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 &reg.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}