Skip to main content

linkrs_query/
extensions.rs

1//! Experimental query-pipeline extension points.
2//!
3//! Unstable API: trait signatures are intentionally small (single method
4//! with a default `None`) and may evolve without the usual compatibility
5//! guarantees while the pipeline integration matures.
6//!
7//! Mirrors the ladybug four-piece set (`TransformerExtension` /
8//! `BinderExtension` / `PlannerExtension` / `MapperExtension`) with this
9//! project's own types. All hooks are opt-in: an empty registry leaves the
10//! builtin pipeline untouched.
11//!
12//! Semantics match ladybug: extensions are tried in registration order, the
13//! first `Some` wins, `None` falls through to the builtin logic. A panicking
14//! extension is isolated with `catch_unwind`, logged, and treated as `None`
15//! so a broken extension can never break queries.
16//!
17//! This module is experimental: trait signatures are intentionally small
18//! (single method + default `None`) and may evolve.
19
20use std::sync::Arc;
21
22use linkrs_core::error::DBResult;
23use parking_lot::RwLock;
24
25use crate::binder::BoundStatement;
26use crate::parser::ast::stmt::Ast;
27use crate::planning::plan::ExecutionPlan;
28use crate::planning::plan::SubPlan;
29
30/// Input context for [`BinderExtension::bind`].
31#[derive(Debug, Clone, Default)]
32pub struct BinderExtensionContext {
33    pub space_name: Option<String>,
34    pub space_id: u64,
35}
36
37/// Rewrite the raw query text before parsing.
38///
39/// Return `None` to decline (builtin parsing proceeds).
40pub trait ParserExtension: Send + Sync + std::fmt::Debug {
41    fn name(&self) -> &'static str {
42        std::any::type_name::<Self>()
43    }
44
45    fn transform(&self, _query_text: &str) -> Option<DBResult<String>> {
46        None
47    }
48}
49
50/// Bind a parsed AST into a [`BoundStatement`], bypassing the builtin binder.
51///
52/// Return `None` to decline (builtin binding proceeds).
53pub trait BinderExtension: Send + Sync + std::fmt::Debug {
54    fn name(&self) -> &'static str {
55        std::any::type_name::<Self>()
56    }
57
58    fn bind(
59        &self,
60        _ast: &Arc<Ast>,
61        _ctx: &BinderExtensionContext,
62    ) -> Option<DBResult<BoundStatement>> {
63        None
64    }
65}
66
67/// Plan a bound statement into a [`SubPlan`], bypassing builtin planners.
68///
69/// Return `None` to decline (builtin planning proceeds).
70pub trait PlannerExtension: Send + Sync + std::fmt::Debug {
71    fn name(&self) -> &'static str {
72        std::any::type_name::<Self>()
73    }
74
75    fn plan(&self, _bound: &BoundStatement) -> Option<DBResult<SubPlan>> {
76        None
77    }
78}
79
80/// Inspect or rewrite an optimized [`ExecutionPlan`] at the mapper boundary
81/// (after logical optimization, before physical plan building).
82///
83/// Return `None` to decline (builtin plan is used as-is).
84pub trait MapperExtension: Send + Sync + std::fmt::Debug {
85    fn name(&self) -> &'static str {
86        std::any::type_name::<Self>()
87    }
88
89    fn map(&self, _plan: &ExecutionPlan) -> Option<DBResult<ExecutionPlan>> {
90        None
91    }
92}
93
94/// Ordered registry for pipeline extensions.
95///
96/// `Arc`-shared so the pipeline manager and tests can hold the same instance.
97#[derive(Debug, Default)]
98pub struct ExtensionRegistry {
99    parsers: RwLock<Vec<Arc<dyn ParserExtension>>>,
100    binders: RwLock<Vec<Arc<dyn BinderExtension>>>,
101    planners: RwLock<Vec<Arc<dyn PlannerExtension>>>,
102    mappers: RwLock<Vec<Arc<dyn MapperExtension>>>,
103}
104
105impl ExtensionRegistry {
106    pub fn new() -> Self {
107        Self::default()
108    }
109
110    pub fn add_parser_extension(&self, extension: Arc<dyn ParserExtension>) {
111        self.parsers.write().push(extension);
112    }
113
114    pub fn add_binder_extension(&self, extension: Arc<dyn BinderExtension>) {
115        self.binders.write().push(extension);
116    }
117
118    pub fn add_planner_extension(&self, extension: Arc<dyn PlannerExtension>) {
119        self.planners.write().push(extension);
120    }
121
122    pub fn add_mapper_extension(&self, extension: Arc<dyn MapperExtension>) {
123        self.mappers.write().push(extension);
124    }
125
126    pub fn parser_extension_count(&self) -> usize {
127        self.parsers.read().len()
128    }
129
130    pub fn binder_extension_count(&self) -> usize {
131        self.binders.read().len()
132    }
133
134    pub fn planner_extension_count(&self) -> usize {
135        self.planners.read().len()
136    }
137
138    pub fn mapper_extension_count(&self) -> usize {
139        self.mappers.read().len()
140    }
141
142    fn snapshot_parsers(&self) -> Vec<Arc<dyn ParserExtension>> {
143        let guard = self.parsers.read();
144        if guard.is_empty() {
145            return Vec::new();
146        }
147        guard.clone()
148    }
149
150    fn snapshot_binders(&self) -> Vec<Arc<dyn BinderExtension>> {
151        let guard = self.binders.read();
152        if guard.is_empty() {
153            return Vec::new();
154        }
155        guard.clone()
156    }
157
158    fn snapshot_planners(&self) -> Vec<Arc<dyn PlannerExtension>> {
159        let guard = self.planners.read();
160        if guard.is_empty() {
161            return Vec::new();
162        }
163        guard.clone()
164    }
165
166    fn snapshot_mappers(&self) -> Vec<Arc<dyn MapperExtension>> {
167        let guard = self.mappers.read();
168        if guard.is_empty() {
169            return Vec::new();
170        }
171        guard.clone()
172    }
173
174    /// First `Some` wins; panics are isolated to `None`.
175    pub fn try_parse_transform(&self, query_text: &str) -> Option<DBResult<String>> {
176        for extension in self.snapshot_parsers() {
177            let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
178                extension.transform(query_text)
179            }));
180            match outcome {
181                Ok(Some(result)) => return Some(result),
182                Ok(None) => continue,
183                Err(_) => {
184                    log::error!(
185                        "parser extension {} panicked; falling back to builtin",
186                        extension.name()
187                    );
188                }
189            }
190        }
191        None
192    }
193
194    /// First `Some` wins; panics are isolated to `None`.
195    pub fn try_bind(
196        &self,
197        ast: &Arc<Ast>,
198        ctx: &BinderExtensionContext,
199    ) -> Option<DBResult<BoundStatement>> {
200        for extension in self.snapshot_binders() {
201            let outcome =
202                std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| extension.bind(ast, ctx)));
203            match outcome {
204                Ok(Some(result)) => return Some(result),
205                Ok(None) => continue,
206                Err(_) => {
207                    log::error!(
208                        "binder extension {} panicked; falling back to builtin",
209                        extension.name()
210                    );
211                }
212            }
213        }
214        None
215    }
216
217    /// First `Some` wins; panics are isolated to `None`.
218    pub fn try_plan(&self, bound: &BoundStatement) -> Option<DBResult<SubPlan>> {
219        for extension in self.snapshot_planners() {
220            let outcome =
221                std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| extension.plan(bound)));
222            match outcome {
223                Ok(Some(result)) => return Some(result),
224                Ok(None) => continue,
225                Err(_) => {
226                    log::error!(
227                        "planner extension {} panicked; falling back to builtin",
228                        extension.name()
229                    );
230                }
231            }
232        }
233        None
234    }
235
236    /// First `Some` wins; panics are isolated to `None`.
237    pub fn try_map(&self, plan: &ExecutionPlan) -> Option<DBResult<ExecutionPlan>> {
238        for extension in self.snapshot_mappers() {
239            let outcome =
240                std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| extension.map(plan)));
241            match outcome {
242                Ok(Some(result)) => return Some(result),
243                Ok(None) => continue,
244                Err(_) => {
245                    log::error!(
246                        "mapper extension {} panicked; falling back to builtin",
247                        extension.name()
248                    );
249                }
250            }
251        }
252        None
253    }
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259    use crate::parser::Parser;
260    use std::sync::atomic::{AtomicUsize, Ordering};
261
262    fn parse_ast(query: &str) -> Arc<Ast> {
263        let mut parser = Parser::new(query);
264        let result = parser.parse().expect("test query parses");
265        assert!(!parser.has_errors());
266        result.ast
267    }
268
269    #[derive(Debug)]
270    struct ShowInterceptor {
271        hits: Arc<AtomicUsize>,
272    }
273
274    impl BinderExtension for ShowInterceptor {
275        fn bind(
276            &self,
277            ast: &Arc<Ast>,
278            _ctx: &BinderExtensionContext,
279        ) -> Option<DBResult<BoundStatement>> {
280            if matches!(ast.stmt(), crate::parser::ast::Stmt::Show(_)) {
281                self.hits.fetch_add(1, Ordering::SeqCst);
282                // Rewrite: collapse any SHOW into a synthetic SHOW SPACES form
283                // by reusing the parsed statement as opaque payload.
284                return Some(Ok(BoundStatement::Other(Box::new(ast.stmt().clone()))));
285            }
286            None
287        }
288    }
289
290    #[derive(Debug)]
291    struct PanickingBinder;
292
293    impl BinderExtension for PanickingBinder {
294        fn bind(
295            &self,
296            _ast: &Arc<Ast>,
297            _ctx: &BinderExtensionContext,
298        ) -> Option<DBResult<BoundStatement>> {
299            panic!("extension boom");
300        }
301    }
302
303    #[derive(Debug)]
304    struct PanickingPlanner;
305
306    impl PlannerExtension for PanickingPlanner {
307        fn plan(&self, _bound: &BoundStatement) -> Option<DBResult<SubPlan>> {
308            panic!("planner boom");
309        }
310    }
311
312    #[derive(Debug)]
313    struct OtherClaimingPlanner {
314        hits: Arc<AtomicUsize>,
315    }
316
317    impl PlannerExtension for OtherClaimingPlanner {
318        fn plan(&self, bound: &BoundStatement) -> Option<DBResult<SubPlan>> {
319            if matches!(bound, BoundStatement::Other(_)) {
320                self.hits.fetch_add(1, Ordering::SeqCst);
321                return Some(Ok(SubPlan::new(None, None)));
322            }
323            None
324        }
325    }
326
327    #[derive(Debug)]
328    struct IdentityMapper {
329        hits: Arc<AtomicUsize>,
330    }
331
332    impl MapperExtension for IdentityMapper {
333        fn map(&self, plan: &ExecutionPlan) -> Option<DBResult<ExecutionPlan>> {
334            self.hits.fetch_add(1, Ordering::SeqCst);
335            Some(Ok(plan.clone()))
336        }
337    }
338
339    #[derive(Debug)]
340    struct PrefixParser;
341
342    impl ParserExtension for PrefixParser {
343        fn transform(&self, query_text: &str) -> Option<DBResult<String>> {
344            if query_text.starts_with("/* ext */") {
345                return Some(Ok(query_text.trim_start_matches("/* ext */").to_string()));
346            }
347            None
348        }
349    }
350
351    #[test]
352    fn binder_extension_intercepts_and_rewrites() {
353        let registry = ExtensionRegistry::new();
354        let hits = Arc::new(AtomicUsize::new(0));
355        registry.add_binder_extension(Arc::new(ShowInterceptor {
356            hits: Arc::clone(&hits),
357        }));
358
359        let ast = parse_ast("SHOW SPACES");
360        let rewritten = registry
361            .try_bind(&ast, &BinderExtensionContext::default())
362            .expect("interceptor claims SHOW");
363        assert!(rewritten.is_ok());
364        assert_eq!(hits.load(Ordering::SeqCst), 1);
365
366        // Non-matching statements fall through to builtin (None).
367        let other = parse_ast("MATCH (n) RETURN n");
368        assert!(registry
369            .try_bind(&other, &BinderExtensionContext::default())
370            .is_none());
371    }
372
373    #[test]
374    fn panicking_extensions_fall_back_to_builtin() {
375        let registry = ExtensionRegistry::new();
376        registry.add_binder_extension(Arc::new(PanickingBinder));
377        registry.add_planner_extension(Arc::new(PanickingPlanner));
378
379        let ast = parse_ast("MATCH (n) RETURN n");
380        assert!(registry
381            .try_bind(&ast, &BinderExtensionContext::default())
382            .is_none());
383
384        let bound = BoundStatement::Other(Box::new(ast.stmt().clone()));
385        assert!(registry.try_plan(&bound).is_none());
386    }
387
388    #[test]
389    fn planner_extension_intercepts_matching_bound_statement() {
390        let registry = ExtensionRegistry::new();
391        let hits = Arc::new(AtomicUsize::new(0));
392        registry.add_planner_extension(Arc::new(OtherClaimingPlanner {
393            hits: Arc::clone(&hits),
394        }));
395
396        let ast = parse_ast("SHOW SPACES");
397        let bound = BoundStatement::Other(Box::new(ast.stmt().clone()));
398        let planned = registry
399            .try_plan(&bound)
400            .expect("other-claiming planner handles Other");
401        assert!(planned.is_ok());
402        assert_eq!(hits.load(Ordering::SeqCst), 1);
403    }
404
405    #[test]
406    fn parser_and_mapper_stubs_register_and_invoke() {
407        let registry = ExtensionRegistry::new();
408        registry.add_parser_extension(Arc::new(PrefixParser));
409        assert_eq!(registry.parser_extension_count(), 1);
410
411        let rewritten = registry
412            .try_parse_transform("/* ext */MATCH (n) RETURN n")
413            .expect("prefix parser claims marked queries");
414        assert_eq!(rewritten.unwrap(), "MATCH (n) RETURN n");
415        assert!(registry.try_parse_transform("MATCH (n) RETURN n").is_none());
416
417        // Mapper stub proves the registration/callable contract; an empty
418        // optimized plan round-trips through the identity mapper.
419        let hits = Arc::new(AtomicUsize::new(0));
420        registry.add_mapper_extension(Arc::new(IdentityMapper {
421            hits: Arc::clone(&hits),
422        }));
423        let plan = ExecutionPlan::new(None);
424        let mapped = registry
425            .try_map(&plan)
426            .expect("identity mapper claims everything");
427        assert!(mapped.is_ok());
428        assert_eq!(hits.load(Ordering::SeqCst), 1);
429    }
430}