Skip to main content

fathomdb_query/
builder.rs

1use crate::{
2    ComparisonOp, CompileError, CompiledGroupedQuery, CompiledQuery, ExpansionSlot, Predicate,
3    QueryAst, QueryStep, ScalarValue, TextQuery, TraverseDirection, compile_grouped_query,
4    compile_query,
5};
6
7/// Errors raised by tethered search builders when a caller opts into a
8/// fused filter variant whose preconditions are not satisfied.
9///
10/// These errors are surfaced at filter-add time (before any SQL runs)
11/// so developers who register a property-FTS schema for the kind see the
12/// fused method succeed, while callers who forgot to register a schema
13/// get a precise, actionable error instead of silent post-filter
14/// degradation. See the Memex near-term roadmap item 7 and
15/// `.claude/memory/project_fused_json_filters_contract.md` for the full
16/// contract.
17#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
18pub enum BuilderValidationError {
19    /// The caller invoked a `filter_json_fused_*` method on a tethered
20    /// builder that has no registered property-FTS schema for the kind
21    /// it targets.
22    #[error(
23        "kind {kind:?} has no registered property-FTS schema; register one with admin.register_fts_property_schema(..) before using filter_json_fused_* methods, or use the post-filter filter_json_* family for non-fused semantics"
24    )]
25    MissingPropertyFtsSchema {
26        /// Node kind the builder was targeting.
27        kind: String,
28    },
29    /// The caller invoked a `filter_json_fused_*` method with a path
30    /// that is not covered by the registered property-FTS schema for the
31    /// kind.
32    #[error(
33        "kind {kind:?} has a registered property-FTS schema but path {path:?} is not in its include list; add the path to the schema or use the post-filter filter_json_* family"
34    )]
35    PathNotIndexed {
36        /// Node kind the builder was targeting.
37        kind: String,
38        /// Path the caller attempted to filter on.
39        path: String,
40    },
41    /// The caller invoked a `filter_json_fused_*` method on a tethered
42    /// builder that has not been bound to a specific kind (for example,
43    /// `FallbackSearchBuilder` without a preceding `filter_kind_eq`).
44    /// The fusion gate cannot resolve a schema without a kind.
45    #[error(
46        "filter_json_fused_* methods require a specific kind; call filter_kind_eq(..) before {method:?} or switch to the post-filter filter_json_* family"
47    )]
48    KindRequiredForFusion {
49        /// Name of the fused filter method that was called.
50        method: String,
51    },
52}
53
54/// Fluent builder for constructing a [`QueryAst`].
55///
56/// Start with [`QueryBuilder::nodes`] and chain filtering, traversal, and
57/// expansion steps before calling [`compile`](QueryBuilder::compile) or
58/// [`compile_grouped`](QueryBuilder::compile_grouped).
59#[derive(Clone, Debug, PartialEq, Eq)]
60pub struct QueryBuilder {
61    ast: QueryAst,
62}
63
64impl QueryBuilder {
65    /// Create a builder that queries nodes of the given kind.
66    #[must_use]
67    pub fn nodes(kind: impl Into<String>) -> Self {
68        Self {
69            ast: QueryAst {
70                root_kind: kind.into(),
71                steps: Vec::new(),
72                expansions: Vec::new(),
73                final_limit: None,
74            },
75        }
76    }
77
78    /// Add a vector similarity search step.
79    #[must_use]
80    pub fn vector_search(mut self, query: impl Into<String>, limit: usize) -> Self {
81        self.ast.steps.push(QueryStep::VectorSearch {
82            query: query.into(),
83            limit,
84        });
85        self
86    }
87
88    /// Add a full-text search step.
89    ///
90    /// The input is parsed into `FathomDB`'s safe supported subset: literal
91    /// terms, quoted phrases, uppercase `OR`, uppercase `NOT`, and implicit
92    /// `AND` by adjacency. Unsupported syntax remains literal rather than being
93    /// passed through as raw FTS5 control syntax.
94    #[must_use]
95    pub fn text_search(mut self, query: impl Into<String>, limit: usize) -> Self {
96        let query = TextQuery::parse(&query.into());
97        self.ast.steps.push(QueryStep::TextSearch { query, limit });
98        self
99    }
100
101    /// Add a graph traversal step following edges of the given label.
102    #[must_use]
103    pub fn traverse(
104        mut self,
105        direction: TraverseDirection,
106        label: impl Into<String>,
107        max_depth: usize,
108    ) -> Self {
109        self.ast.steps.push(QueryStep::Traverse {
110            direction,
111            label: label.into(),
112            max_depth,
113            filter: None,
114        });
115        self
116    }
117
118    /// Filter results to a single logical ID.
119    #[must_use]
120    pub fn filter_logical_id_eq(mut self, logical_id: impl Into<String>) -> Self {
121        self.ast
122            .steps
123            .push(QueryStep::Filter(Predicate::LogicalIdEq(logical_id.into())));
124        self
125    }
126
127    /// Filter results to nodes matching the given kind.
128    #[must_use]
129    pub fn filter_kind_eq(mut self, kind: impl Into<String>) -> Self {
130        self.ast
131            .steps
132            .push(QueryStep::Filter(Predicate::KindEq(kind.into())));
133        self
134    }
135
136    /// Filter results to nodes matching the given `source_ref`.
137    #[must_use]
138    pub fn filter_source_ref_eq(mut self, source_ref: impl Into<String>) -> Self {
139        self.ast
140            .steps
141            .push(QueryStep::Filter(Predicate::SourceRefEq(source_ref.into())));
142        self
143    }
144
145    /// Filter results to nodes where `content_ref` is not NULL.
146    #[must_use]
147    pub fn filter_content_ref_not_null(mut self) -> Self {
148        self.ast
149            .steps
150            .push(QueryStep::Filter(Predicate::ContentRefNotNull));
151        self
152    }
153
154    /// Filter results to nodes matching the given `content_ref` URI.
155    #[must_use]
156    pub fn filter_content_ref_eq(mut self, content_ref: impl Into<String>) -> Self {
157        self.ast
158            .steps
159            .push(QueryStep::Filter(Predicate::ContentRefEq(
160                content_ref.into(),
161            )));
162        self
163    }
164
165    /// Filter results where a JSON property at `path` equals the given text value.
166    #[must_use]
167    pub fn filter_json_text_eq(
168        mut self,
169        path: impl Into<String>,
170        value: impl Into<String>,
171    ) -> Self {
172        self.ast
173            .steps
174            .push(QueryStep::Filter(Predicate::JsonPathEq {
175                path: path.into(),
176                value: ScalarValue::Text(value.into()),
177            }));
178        self
179    }
180
181    /// Filter results where a JSON property at `path` equals the given boolean value.
182    #[must_use]
183    pub fn filter_json_bool_eq(mut self, path: impl Into<String>, value: bool) -> Self {
184        self.ast
185            .steps
186            .push(QueryStep::Filter(Predicate::JsonPathEq {
187                path: path.into(),
188                value: ScalarValue::Bool(value),
189            }));
190        self
191    }
192
193    /// Filter results where a JSON integer at `path` is greater than `value`.
194    #[must_use]
195    pub fn filter_json_integer_gt(mut self, path: impl Into<String>, value: i64) -> Self {
196        self.ast
197            .steps
198            .push(QueryStep::Filter(Predicate::JsonPathCompare {
199                path: path.into(),
200                op: ComparisonOp::Gt,
201                value: ScalarValue::Integer(value),
202            }));
203        self
204    }
205
206    /// Filter results where a JSON integer at `path` is greater than or equal to `value`.
207    #[must_use]
208    pub fn filter_json_integer_gte(mut self, path: impl Into<String>, value: i64) -> Self {
209        self.ast
210            .steps
211            .push(QueryStep::Filter(Predicate::JsonPathCompare {
212                path: path.into(),
213                op: ComparisonOp::Gte,
214                value: ScalarValue::Integer(value),
215            }));
216        self
217    }
218
219    /// Filter results where a JSON integer at `path` is less than `value`.
220    #[must_use]
221    pub fn filter_json_integer_lt(mut self, path: impl Into<String>, value: i64) -> Self {
222        self.ast
223            .steps
224            .push(QueryStep::Filter(Predicate::JsonPathCompare {
225                path: path.into(),
226                op: ComparisonOp::Lt,
227                value: ScalarValue::Integer(value),
228            }));
229        self
230    }
231
232    /// Filter results where a JSON integer at `path` is less than or equal to `value`.
233    #[must_use]
234    pub fn filter_json_integer_lte(mut self, path: impl Into<String>, value: i64) -> Self {
235        self.ast
236            .steps
237            .push(QueryStep::Filter(Predicate::JsonPathCompare {
238                path: path.into(),
239                op: ComparisonOp::Lte,
240                value: ScalarValue::Integer(value),
241            }));
242        self
243    }
244
245    /// Filter results where a JSON timestamp at `path` is after `value` (epoch seconds).
246    #[must_use]
247    pub fn filter_json_timestamp_gt(self, path: impl Into<String>, value: i64) -> Self {
248        self.filter_json_integer_gt(path, value)
249    }
250
251    /// Filter results where a JSON timestamp at `path` is at or after `value`.
252    #[must_use]
253    pub fn filter_json_timestamp_gte(self, path: impl Into<String>, value: i64) -> Self {
254        self.filter_json_integer_gte(path, value)
255    }
256
257    /// Filter results where a JSON timestamp at `path` is before `value`.
258    #[must_use]
259    pub fn filter_json_timestamp_lt(self, path: impl Into<String>, value: i64) -> Self {
260        self.filter_json_integer_lt(path, value)
261    }
262
263    /// Filter results where a JSON timestamp at `path` is at or before `value`.
264    #[must_use]
265    pub fn filter_json_timestamp_lte(self, path: impl Into<String>, value: i64) -> Self {
266        self.filter_json_integer_lte(path, value)
267    }
268
269    /// Append a fused JSON text-equality predicate without validating
270    /// whether the caller has a property-FTS schema for the kind.
271    ///
272    /// Callers must have already validated the fusion gate; the
273    /// tethered [`crate::QueryBuilder`] has no engine handle to consult
274    /// a schema. Mis-use — calling this without prior schema
275    /// validation — produces SQL that pushes a `json_extract` predicate
276    /// into the search CTE's inner WHERE clause. That is valid SQL but
277    /// defeats the "developer opt-in" contract.
278    #[must_use]
279    pub fn filter_json_fused_text_eq_unchecked(
280        mut self,
281        path: impl Into<String>,
282        value: impl Into<String>,
283    ) -> Self {
284        self.ast
285            .steps
286            .push(QueryStep::Filter(Predicate::JsonPathFusedEq {
287                path: path.into(),
288                value: value.into(),
289            }));
290        self
291    }
292
293    /// Append a fused JSON timestamp-greater-than predicate without
294    /// validating the fusion gate. See
295    /// [`Self::filter_json_fused_text_eq_unchecked`] for the contract.
296    #[must_use]
297    pub fn filter_json_fused_timestamp_gt_unchecked(
298        mut self,
299        path: impl Into<String>,
300        value: i64,
301    ) -> Self {
302        self.ast
303            .steps
304            .push(QueryStep::Filter(Predicate::JsonPathFusedTimestampCmp {
305                path: path.into(),
306                op: ComparisonOp::Gt,
307                value,
308            }));
309        self
310    }
311
312    /// Append a fused JSON timestamp-greater-or-equal predicate without
313    /// validating the fusion gate. See
314    /// [`Self::filter_json_fused_text_eq_unchecked`] for the contract.
315    #[must_use]
316    pub fn filter_json_fused_timestamp_gte_unchecked(
317        mut self,
318        path: impl Into<String>,
319        value: i64,
320    ) -> Self {
321        self.ast
322            .steps
323            .push(QueryStep::Filter(Predicate::JsonPathFusedTimestampCmp {
324                path: path.into(),
325                op: ComparisonOp::Gte,
326                value,
327            }));
328        self
329    }
330
331    /// Append a fused JSON timestamp-less-than predicate without
332    /// validating the fusion gate. See
333    /// [`Self::filter_json_fused_text_eq_unchecked`] for the contract.
334    #[must_use]
335    pub fn filter_json_fused_timestamp_lt_unchecked(
336        mut self,
337        path: impl Into<String>,
338        value: i64,
339    ) -> Self {
340        self.ast
341            .steps
342            .push(QueryStep::Filter(Predicate::JsonPathFusedTimestampCmp {
343                path: path.into(),
344                op: ComparisonOp::Lt,
345                value,
346            }));
347        self
348    }
349
350    /// Append a fused JSON timestamp-less-or-equal predicate without
351    /// validating the fusion gate. See
352    /// [`Self::filter_json_fused_text_eq_unchecked`] for the contract.
353    #[must_use]
354    pub fn filter_json_fused_timestamp_lte_unchecked(
355        mut self,
356        path: impl Into<String>,
357        value: i64,
358    ) -> Self {
359        self.ast
360            .steps
361            .push(QueryStep::Filter(Predicate::JsonPathFusedTimestampCmp {
362                path: path.into(),
363                op: ComparisonOp::Lte,
364                value,
365            }));
366        self
367    }
368
369    /// Append a fused JSON boolean-equality predicate without validating
370    /// the fusion gate. See
371    /// [`Self::filter_json_fused_text_eq_unchecked`] for the contract.
372    #[must_use]
373    pub fn filter_json_fused_bool_eq_unchecked(
374        mut self,
375        path: impl Into<String>,
376        value: bool,
377    ) -> Self {
378        self.ast
379            .steps
380            .push(QueryStep::Filter(Predicate::JsonPathFusedBoolEq {
381                path: path.into(),
382                value,
383            }));
384        self
385    }
386
387    /// Append a fused JSON text IN-set predicate without validating the
388    /// fusion gate. See [`Self::filter_json_fused_text_eq_unchecked`] for
389    /// the contract.
390    ///
391    /// # Panics
392    ///
393    /// Panics if `values` is empty — `SQLite` `IN` with an empty list is a syntax error.
394    #[must_use]
395    pub fn filter_json_fused_text_in_unchecked(
396        mut self,
397        path: impl Into<String>,
398        values: Vec<String>,
399    ) -> Self {
400        assert!(
401            !values.is_empty(),
402            "filter_json_fused_text_in: values must not be empty"
403        );
404        self.ast
405            .steps
406            .push(QueryStep::Filter(Predicate::JsonPathFusedIn {
407                path: path.into(),
408                values,
409            }));
410        self
411    }
412
413    /// Filter results where a JSON text property at `path` is one of `values`.
414    ///
415    /// This is the non-fused variant; the predicate is applied as a residual
416    /// WHERE clause. No FTS schema is required.
417    ///
418    /// # Panics
419    ///
420    /// Panics if `values` is empty — `SQLite` `IN` with an empty list is a syntax error.
421    #[must_use]
422    pub fn filter_json_text_in(mut self, path: impl Into<String>, values: Vec<String>) -> Self {
423        assert!(
424            !values.is_empty(),
425            "filter_json_text_in: values must not be empty"
426        );
427        self.ast
428            .steps
429            .push(QueryStep::Filter(Predicate::JsonPathIn {
430                path: path.into(),
431                values: values.into_iter().map(ScalarValue::Text).collect(),
432            }));
433        self
434    }
435
436    /// Add an expansion slot that traverses edges of the given label for each root result.
437    ///
438    /// Pass `filter: None` to preserve the existing behavior. `filter: Some(_)` is
439    /// accepted by the AST but the compilation is not yet implemented (Pack 3).
440    /// Pass `edge_filter: None` to preserve pre-Pack-D behavior (no edge filtering).
441    /// `edge_filter: Some(EdgePropertyEq { .. })` filters traversed edges by their
442    /// JSON properties; only edges matching the predicate are followed.
443    #[must_use]
444    pub fn expand(
445        mut self,
446        slot: impl Into<String>,
447        direction: TraverseDirection,
448        label: impl Into<String>,
449        max_depth: usize,
450        filter: Option<Predicate>,
451        edge_filter: Option<Predicate>,
452    ) -> Self {
453        self.ast.expansions.push(ExpansionSlot {
454            slot: slot.into(),
455            direction,
456            label: label.into(),
457            max_depth,
458            filter,
459            edge_filter,
460        });
461        self
462    }
463
464    /// Set the maximum number of result rows.
465    #[must_use]
466    pub fn limit(mut self, limit: usize) -> Self {
467        self.ast.final_limit = Some(limit);
468        self
469    }
470
471    /// Borrow the underlying [`QueryAst`].
472    #[must_use]
473    pub fn ast(&self) -> &QueryAst {
474        &self.ast
475    }
476
477    /// Consume the builder and return the underlying [`QueryAst`].
478    #[must_use]
479    pub fn into_ast(self) -> QueryAst {
480        self.ast
481    }
482
483    /// Compile this builder's AST into an executable [`CompiledQuery`].
484    ///
485    /// # Errors
486    ///
487    /// Returns [`CompileError`] if the query violates structural constraints
488    /// (e.g. too many traversal steps or too many bind parameters).
489    pub fn compile(&self) -> Result<CompiledQuery, CompileError> {
490        compile_query(&self.ast)
491    }
492
493    /// Compile this builder's AST into an executable grouped query.
494    ///
495    /// # Errors
496    ///
497    /// Returns [`CompileError`] if the query violates grouped-query structural
498    /// constraints such as duplicate slot names or excessive depth.
499    pub fn compile_grouped(&self) -> Result<CompiledGroupedQuery, CompileError> {
500        compile_grouped_query(&self.ast)
501    }
502}
503
504#[cfg(test)]
505#[allow(clippy::panic)]
506mod tests {
507    use crate::{Predicate, QueryBuilder, QueryStep, ScalarValue, TextQuery, TraverseDirection};
508
509    #[test]
510    fn builder_accumulates_expected_steps() {
511        let query = QueryBuilder::nodes("Meeting")
512            .text_search("budget", 5)
513            .traverse(TraverseDirection::Out, "HAS_TASK", 2)
514            .filter_json_text_eq("$.status", "active")
515            .limit(10);
516
517        assert_eq!(query.ast().steps.len(), 3);
518        assert_eq!(query.ast().final_limit, Some(10));
519    }
520
521    #[test]
522    fn builder_filter_json_bool_eq_produces_correct_predicate() {
523        let query = QueryBuilder::nodes("Feature").filter_json_bool_eq("$.enabled", true);
524
525        assert_eq!(query.ast().steps.len(), 1);
526        match &query.ast().steps[0] {
527            QueryStep::Filter(Predicate::JsonPathEq { path, value }) => {
528                assert_eq!(path, "$.enabled");
529                assert_eq!(*value, ScalarValue::Bool(true));
530            }
531            other => panic!("expected JsonPathEq/Bool, got {other:?}"),
532        }
533    }
534
535    #[test]
536    fn builder_text_search_parses_into_typed_query() {
537        let query = QueryBuilder::nodes("Meeting").text_search("ship NOT blocked", 10);
538
539        match &query.ast().steps[0] {
540            QueryStep::TextSearch { query, limit } => {
541                assert_eq!(*limit, 10);
542                assert_eq!(
543                    *query,
544                    TextQuery::And(vec![
545                        TextQuery::Term("ship".into()),
546                        TextQuery::Not(Box::new(TextQuery::Term("blocked".into()))),
547                    ])
548                );
549            }
550            other => panic!("expected TextSearch, got {other:?}"),
551        }
552    }
553}