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    /// Add an expansion slot that traverses edges of the given label for each root result.
370    ///
371    /// Pass `filter: None` to preserve the existing behavior. `filter: Some(_)` is
372    /// accepted by the AST but the compilation is not yet implemented (Pack 3).
373    #[must_use]
374    pub fn expand(
375        mut self,
376        slot: impl Into<String>,
377        direction: TraverseDirection,
378        label: impl Into<String>,
379        max_depth: usize,
380        filter: Option<Predicate>,
381    ) -> Self {
382        self.ast.expansions.push(ExpansionSlot {
383            slot: slot.into(),
384            direction,
385            label: label.into(),
386            max_depth,
387            filter,
388        });
389        self
390    }
391
392    /// Set the maximum number of result rows.
393    #[must_use]
394    pub fn limit(mut self, limit: usize) -> Self {
395        self.ast.final_limit = Some(limit);
396        self
397    }
398
399    /// Borrow the underlying [`QueryAst`].
400    #[must_use]
401    pub fn ast(&self) -> &QueryAst {
402        &self.ast
403    }
404
405    /// Consume the builder and return the underlying [`QueryAst`].
406    #[must_use]
407    pub fn into_ast(self) -> QueryAst {
408        self.ast
409    }
410
411    /// Compile this builder's AST into an executable [`CompiledQuery`].
412    ///
413    /// # Errors
414    ///
415    /// Returns [`CompileError`] if the query violates structural constraints
416    /// (e.g. too many traversal steps or too many bind parameters).
417    pub fn compile(&self) -> Result<CompiledQuery, CompileError> {
418        compile_query(&self.ast)
419    }
420
421    /// Compile this builder's AST into an executable grouped query.
422    ///
423    /// # Errors
424    ///
425    /// Returns [`CompileError`] if the query violates grouped-query structural
426    /// constraints such as duplicate slot names or excessive depth.
427    pub fn compile_grouped(&self) -> Result<CompiledGroupedQuery, CompileError> {
428        compile_grouped_query(&self.ast)
429    }
430}
431
432#[cfg(test)]
433#[allow(clippy::panic)]
434mod tests {
435    use crate::{Predicate, QueryBuilder, QueryStep, ScalarValue, TextQuery, TraverseDirection};
436
437    #[test]
438    fn builder_accumulates_expected_steps() {
439        let query = QueryBuilder::nodes("Meeting")
440            .text_search("budget", 5)
441            .traverse(TraverseDirection::Out, "HAS_TASK", 2)
442            .filter_json_text_eq("$.status", "active")
443            .limit(10);
444
445        assert_eq!(query.ast().steps.len(), 3);
446        assert_eq!(query.ast().final_limit, Some(10));
447    }
448
449    #[test]
450    fn builder_filter_json_bool_eq_produces_correct_predicate() {
451        let query = QueryBuilder::nodes("Feature").filter_json_bool_eq("$.enabled", true);
452
453        assert_eq!(query.ast().steps.len(), 1);
454        match &query.ast().steps[0] {
455            QueryStep::Filter(Predicate::JsonPathEq { path, value }) => {
456                assert_eq!(path, "$.enabled");
457                assert_eq!(*value, ScalarValue::Bool(true));
458            }
459            other => panic!("expected JsonPathEq/Bool, got {other:?}"),
460        }
461    }
462
463    #[test]
464    fn builder_text_search_parses_into_typed_query() {
465        let query = QueryBuilder::nodes("Meeting").text_search("ship NOT blocked", 10);
466
467        match &query.ast().steps[0] {
468            QueryStep::TextSearch { query, limit } => {
469                assert_eq!(*limit, 10);
470                assert_eq!(
471                    *query,
472                    TextQuery::And(vec![
473                        TextQuery::Term("ship".into()),
474                        TextQuery::Not(Box::new(TextQuery::Term("blocked".into()))),
475                    ])
476                );
477            }
478            other => panic!("expected TextSearch, got {other:?}"),
479        }
480    }
481}