Skip to main content

datafusion_opensearch/
udf.rs

1//! OpenSearch-native predicates as DataFusion scalar UDFs (feature `udf`).
2//!
3//! Some of the most useful OpenSearch queries have no SQL spelling — an analyzed full-text `match`,
4//! or a geo bounding-box / distance / polygon filter. This module exposes them as Boolean scalar
5//! UDFs so they travel through the planner like any other predicate, and
6//! [`pushdown::expr_to_query`](crate::pushdown) recognises them **by name** and emits the native
7//! query. The provider reports such filters `Exact`, so DataFusion drops its in-memory copy.
8//!
9//! Register them on a `SessionContext` (or embed them in an `Expr` via `.call([...])`):
10//!
11//! ```
12//! use datafusion::prelude::SessionContext;
13//! let ctx = SessionContext::new();
14//! ctx.register_udf(datafusion_opensearch::os_match_udf().as_ref().clone());
15//! ctx.register_udf(datafusion_opensearch::os_geo_distance_udf().as_ref().clone());
16//! // SELECT id FROM docs WHERE os_match(title, 'engine fault') AND os_geo_distance(pos, 24.9, 60.2, 5000)
17//! ```
18//!
19//! Because pushdown keys off the registered name, the names below are part of the contract. In a
20//! distributed setting (e.g. Ballista) every node that plans or executes must register the same
21//! UDFs, since plans serialise scalar functions by name.
22
23use std::sync::{Arc, OnceLock};
24
25use datafusion::arrow::array::{Array, BooleanArray, StringArray};
26use datafusion::arrow::datatypes::DataType;
27use datafusion::common::Result;
28use datafusion::logical_expr::{ColumnarValue, ScalarFunctionArgs, ScalarUDF, ScalarUDFImpl, Signature, Volatility};
29use datafusion::scalar::ScalarValue;
30
31/// `os_match(field, query)` — full-text `match`. The registered name pushdown keys off.
32pub const OS_MATCH: &str = "os_match";
33/// `os_geo_bbox(field, top_left_lon, top_left_lat, bottom_right_lon, bottom_right_lat)` → `geo_bounding_box`.
34pub const OS_GEO_BBOX: &str = "os_geo_bbox";
35/// `os_geo_distance(field, center_lon, center_lat, radius_metres)` → `geo_distance`.
36pub const OS_GEO_DISTANCE: &str = "os_geo_distance";
37/// `os_geo_polygon(field, '<json [[lon,lat],…]>')` → `geo_polygon`.
38pub const OS_GEO_POLYGON: &str = "os_geo_polygon";
39
40/// Tokenise text the way the in-memory `os_match` fallback compares it: split on non-alphanumeric
41/// characters, lowercase, drop empties. Deliberately simple — the pushed-down `match` query uses
42/// the index's own analyzer; this only governs the fallback when a filter is evaluated in memory.
43pub fn tokens(s: &str) -> Vec<String> {
44    s.split(|c: char| !c.is_alphanumeric())
45        .filter(|t| !t.is_empty())
46        .map(str::to_lowercase)
47        .collect()
48}
49
50/// OR token semantics: any query token present among the document's tokens (empty query → no match).
51fn matches(doc: &str, query: &str) -> bool {
52    let q = tokens(query);
53    !q.is_empty() && {
54        let d = tokens(doc);
55        q.iter().any(|t| d.contains(t))
56    }
57}
58
59/// The `os_match` UDF: `(Utf8 field, Utf8 query) -> Boolean`.
60#[derive(Debug, PartialEq, Eq, Hash)]
61struct MatchUdf {
62    signature: Signature,
63}
64
65impl MatchUdf {
66    fn new() -> Self {
67        Self {
68            signature: Signature::exact(vec![DataType::Utf8, DataType::Utf8], Volatility::Immutable),
69        }
70    }
71}
72
73impl ScalarUDFImpl for MatchUdf {
74    fn name(&self) -> &str {
75        OS_MATCH
76    }
77    fn signature(&self) -> &Signature {
78        &self.signature
79    }
80    fn return_type(&self, _: &[DataType]) -> Result<DataType> {
81        Ok(DataType::Boolean)
82    }
83    /// The in-memory fallback, used only if a `match` filter is evaluated by DataFusion rather
84    /// than pushed down: tokenised OR match against a constant query. A non-literal query → false.
85    fn invoke_with_args(&self, args: ScalarFunctionArgs) -> Result<ColumnarValue> {
86        let n = args.number_rows;
87        let query = match args.args.get(1) {
88            Some(ColumnarValue::Scalar(ScalarValue::Utf8(Some(q)))) => q.clone(),
89            _ => return Ok(ColumnarValue::Array(Arc::new(BooleanArray::from(vec![false; n])))),
90        };
91        let col = args.args[0].clone().into_array(n)?;
92        let out: BooleanArray = match col.as_any().downcast_ref::<StringArray>() {
93            Some(sa) => (0..sa.len())
94                .map(|i| Some(!sa.is_null(i) && matches(sa.value(i), &query)))
95                .collect(),
96            None => BooleanArray::from(vec![false; n]),
97        };
98        Ok(ColumnarValue::Array(Arc::new(out)))
99    }
100}
101
102/// The shared `os_match` UDF (built once).
103pub fn os_match_udf() -> Arc<ScalarUDF> {
104    static UDF: OnceLock<Arc<ScalarUDF>> = OnceLock::new();
105    UDF.get_or_init(|| Arc::new(ScalarUDF::from(MatchUdf::new()))).clone()
106}
107
108/// A geo predicate UDF — a Boolean function whose ONLY purpose is to carry a geo query and its
109/// literal bounds through the planner to pushdown, which emits the native OpenSearch query. Geo
110/// filters are always pushed (`Exact`), so the in-memory `invoke` is a fail-CLOSED stub (returns
111/// false — never widens the result) rather than a full geo evaluation.
112#[derive(Debug, PartialEq, Eq, Hash)]
113struct GeoUdf {
114    name: &'static str,
115    signature: Signature,
116}
117
118impl ScalarUDFImpl for GeoUdf {
119    fn name(&self) -> &str {
120        self.name
121    }
122    fn signature(&self) -> &Signature {
123        &self.signature
124    }
125    fn return_type(&self, _: &[DataType]) -> Result<DataType> {
126        Ok(DataType::Boolean)
127    }
128    fn invoke_with_args(&self, args: ScalarFunctionArgs) -> Result<ColumnarValue> {
129        Ok(ColumnarValue::Array(Arc::new(BooleanArray::from(vec![
130            false;
131            args.number_rows
132        ]))))
133    }
134}
135
136fn geo_udf(name: &'static str, arity: usize) -> Arc<ScalarUDF> {
137    // `any(arity)`: the args are a geo column + numeric literals; only pushdown reads them, so the
138    // signature just needs to accept the call, not coerce types.
139    Arc::new(ScalarUDF::from(GeoUdf {
140        name,
141        signature: Signature::any(arity, Volatility::Immutable),
142    }))
143}
144
145/// `os_geo_bbox(field, tl_lon, tl_lat, br_lon, br_lat)` → OpenSearch `geo_bounding_box`.
146pub fn os_geo_bbox_udf() -> Arc<ScalarUDF> {
147    static UDF: OnceLock<Arc<ScalarUDF>> = OnceLock::new();
148    UDF.get_or_init(|| geo_udf(OS_GEO_BBOX, 5)).clone()
149}
150
151/// `os_geo_distance(field, center_lon, center_lat, radius_metres)` → OpenSearch `geo_distance`.
152pub fn os_geo_distance_udf() -> Arc<ScalarUDF> {
153    static UDF: OnceLock<Arc<ScalarUDF>> = OnceLock::new();
154    UDF.get_or_init(|| geo_udf(OS_GEO_DISTANCE, 4)).clone()
155}
156
157/// `os_geo_polygon(field, '<json [[lon,lat],…]>')` → OpenSearch `geo_polygon`. The ring rides ONE
158/// JSON-string literal (a scalar UDF can't take a variable-length point list); only pushdown parses it.
159pub fn os_geo_polygon_udf() -> Arc<ScalarUDF> {
160    static UDF: OnceLock<Arc<ScalarUDF>> = OnceLock::new();
161    UDF.get_or_init(|| geo_udf(OS_GEO_POLYGON, 2)).clone()
162}
163
164/// All four UDFs, for bulk registration: `for f in all_udfs() { ctx.register_udf(f.as_ref().clone()) }`.
165pub fn all_udfs() -> [Arc<ScalarUDF>; 4] {
166    [
167        os_match_udf(),
168        os_geo_bbox_udf(),
169        os_geo_distance_udf(),
170        os_geo_polygon_udf(),
171    ]
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    #[test]
179    fn tokenise_splits_lowercases_and_drops_empties() {
180        assert_eq!(tokens("Engine Overheating!"), vec!["engine", "overheating"]);
181        assert_eq!(tokens("fault(sensor,7)"), vec!["fault", "sensor", "7"]);
182        assert!(tokens("  ").is_empty());
183    }
184
185    #[test]
186    fn or_token_match_is_case_insensitive_and_not_substring() {
187        assert!(matches("engine overheating detected", "ENGINE")); // case-insensitive
188        assert!(matches("brakes worn", "engine brakes")); // OR: any token suffices
189        assert!(!matches("engine", "eng")); // substring is NOT a token match
190        assert!(!matches("anything", "  ")); // empty query → false
191    }
192
193    #[test]
194    fn udfs_are_named_and_boolean() {
195        let udf = os_match_udf();
196        assert_eq!(udf.name(), OS_MATCH);
197        assert_eq!(
198            udf.return_type(&[DataType::Utf8, DataType::Utf8]).unwrap(),
199            DataType::Boolean
200        );
201        let names: Vec<String> = all_udfs().iter().map(|u| u.name().to_string()).collect();
202        assert_eq!(names, [OS_MATCH, OS_GEO_BBOX, OS_GEO_DISTANCE, OS_GEO_POLYGON]);
203    }
204}