Skip to main content

fathomdb_query/
builder.rs

1use crate::{
2    ComparisonOp, CompileError, CompiledGroupedQuery, CompiledQuery, EdgeExpansionSlot,
3    ExpansionSlot, Predicate, QueryAst, QueryStep, ScalarValue, TextQuery, TraverseDirection,
4    compile_grouped_query, 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                edge_expansions: Vec::new(),
74                final_limit: None,
75            },
76        }
77    }
78
79    /// Add a vector similarity search step.
80    #[must_use]
81    pub fn vector_search(mut self, query: impl Into<String>, limit: usize) -> Self {
82        self.ast.steps.push(QueryStep::VectorSearch {
83            query: query.into(),
84            limit,
85        });
86        self
87    }
88
89    /// Add a full-text search step.
90    ///
91    /// The input is parsed into `FathomDB`'s safe supported subset: literal
92    /// terms, quoted phrases, uppercase `OR`, uppercase `NOT`, and implicit
93    /// `AND` by adjacency. Unsupported syntax remains literal rather than being
94    /// passed through as raw FTS5 control syntax.
95    #[must_use]
96    pub fn text_search(mut self, query: impl Into<String>, limit: usize) -> Self {
97        let query = TextQuery::parse(&query.into());
98        self.ast.steps.push(QueryStep::TextSearch { query, limit });
99        self
100    }
101
102    /// Add a graph traversal step following edges of the given label.
103    #[must_use]
104    pub fn traverse(
105        mut self,
106        direction: TraverseDirection,
107        label: impl Into<String>,
108        max_depth: usize,
109    ) -> Self {
110        self.ast.steps.push(QueryStep::Traverse {
111            direction,
112            label: label.into(),
113            max_depth,
114            filter: None,
115        });
116        self
117    }
118
119    /// Filter results to a single logical ID.
120    #[must_use]
121    pub fn filter_logical_id_eq(mut self, logical_id: impl Into<String>) -> Self {
122        self.ast
123            .steps
124            .push(QueryStep::Filter(Predicate::LogicalIdEq(logical_id.into())));
125        self
126    }
127
128    /// Filter results to nodes matching the given kind.
129    #[must_use]
130    pub fn filter_kind_eq(mut self, kind: impl Into<String>) -> Self {
131        self.ast
132            .steps
133            .push(QueryStep::Filter(Predicate::KindEq(kind.into())));
134        self
135    }
136
137    /// Filter results to nodes matching the given `source_ref`.
138    #[must_use]
139    pub fn filter_source_ref_eq(mut self, source_ref: impl Into<String>) -> Self {
140        self.ast
141            .steps
142            .push(QueryStep::Filter(Predicate::SourceRefEq(source_ref.into())));
143        self
144    }
145
146    /// Filter results to nodes where `content_ref` is not NULL.
147    #[must_use]
148    pub fn filter_content_ref_not_null(mut self) -> Self {
149        self.ast
150            .steps
151            .push(QueryStep::Filter(Predicate::ContentRefNotNull));
152        self
153    }
154
155    /// Filter results to nodes matching the given `content_ref` URI.
156    #[must_use]
157    pub fn filter_content_ref_eq(mut self, content_ref: impl Into<String>) -> Self {
158        self.ast
159            .steps
160            .push(QueryStep::Filter(Predicate::ContentRefEq(
161                content_ref.into(),
162            )));
163        self
164    }
165
166    /// Filter results where a JSON property at `path` equals the given text value.
167    #[must_use]
168    pub fn filter_json_text_eq(
169        mut self,
170        path: impl Into<String>,
171        value: impl Into<String>,
172    ) -> Self {
173        self.ast
174            .steps
175            .push(QueryStep::Filter(Predicate::JsonPathEq {
176                path: path.into(),
177                value: ScalarValue::Text(value.into()),
178            }));
179        self
180    }
181
182    /// Filter results where a JSON property at `path` equals the given boolean value.
183    #[must_use]
184    pub fn filter_json_bool_eq(mut self, path: impl Into<String>, value: bool) -> Self {
185        self.ast
186            .steps
187            .push(QueryStep::Filter(Predicate::JsonPathEq {
188                path: path.into(),
189                value: ScalarValue::Bool(value),
190            }));
191        self
192    }
193
194    /// Filter results where a JSON integer at `path` is greater than `value`.
195    #[must_use]
196    pub fn filter_json_integer_gt(mut self, path: impl Into<String>, value: i64) -> Self {
197        self.ast
198            .steps
199            .push(QueryStep::Filter(Predicate::JsonPathCompare {
200                path: path.into(),
201                op: ComparisonOp::Gt,
202                value: ScalarValue::Integer(value),
203            }));
204        self
205    }
206
207    /// Filter results where a JSON integer at `path` is greater than or equal to `value`.
208    #[must_use]
209    pub fn filter_json_integer_gte(mut self, path: impl Into<String>, value: i64) -> Self {
210        self.ast
211            .steps
212            .push(QueryStep::Filter(Predicate::JsonPathCompare {
213                path: path.into(),
214                op: ComparisonOp::Gte,
215                value: ScalarValue::Integer(value),
216            }));
217        self
218    }
219
220    /// Filter results where a JSON integer at `path` is less than `value`.
221    #[must_use]
222    pub fn filter_json_integer_lt(mut self, path: impl Into<String>, value: i64) -> Self {
223        self.ast
224            .steps
225            .push(QueryStep::Filter(Predicate::JsonPathCompare {
226                path: path.into(),
227                op: ComparisonOp::Lt,
228                value: ScalarValue::Integer(value),
229            }));
230        self
231    }
232
233    /// Filter results where a JSON integer at `path` is less than or equal to `value`.
234    #[must_use]
235    pub fn filter_json_integer_lte(mut self, path: impl Into<String>, value: i64) -> Self {
236        self.ast
237            .steps
238            .push(QueryStep::Filter(Predicate::JsonPathCompare {
239                path: path.into(),
240                op: ComparisonOp::Lte,
241                value: ScalarValue::Integer(value),
242            }));
243        self
244    }
245
246    /// Filter results where a JSON timestamp at `path` is after `value` (epoch seconds).
247    #[must_use]
248    pub fn filter_json_timestamp_gt(self, path: impl Into<String>, value: i64) -> Self {
249        self.filter_json_integer_gt(path, value)
250    }
251
252    /// Filter results where a JSON timestamp at `path` is at or after `value`.
253    #[must_use]
254    pub fn filter_json_timestamp_gte(self, path: impl Into<String>, value: i64) -> Self {
255        self.filter_json_integer_gte(path, value)
256    }
257
258    /// Filter results where a JSON timestamp at `path` is before `value`.
259    #[must_use]
260    pub fn filter_json_timestamp_lt(self, path: impl Into<String>, value: i64) -> Self {
261        self.filter_json_integer_lt(path, value)
262    }
263
264    /// Filter results where a JSON timestamp at `path` is at or before `value`.
265    #[must_use]
266    pub fn filter_json_timestamp_lte(self, path: impl Into<String>, value: i64) -> Self {
267        self.filter_json_integer_lte(path, value)
268    }
269
270    /// Append a fused JSON text-equality predicate without validating
271    /// whether the caller has a property-FTS schema for the kind.
272    ///
273    /// Callers must have already validated the fusion gate; the
274    /// tethered [`crate::QueryBuilder`] has no engine handle to consult
275    /// a schema. Mis-use — calling this without prior schema
276    /// validation — produces SQL that pushes a `json_extract` predicate
277    /// into the search CTE's inner WHERE clause. That is valid SQL but
278    /// defeats the "developer opt-in" contract.
279    #[must_use]
280    pub fn filter_json_fused_text_eq_unchecked(
281        mut self,
282        path: impl Into<String>,
283        value: impl Into<String>,
284    ) -> Self {
285        self.ast
286            .steps
287            .push(QueryStep::Filter(Predicate::JsonPathFusedEq {
288                path: path.into(),
289                value: value.into(),
290            }));
291        self
292    }
293
294    /// Append a fused JSON timestamp-greater-than predicate without
295    /// validating the fusion gate. See
296    /// [`Self::filter_json_fused_text_eq_unchecked`] for the contract.
297    #[must_use]
298    pub fn filter_json_fused_timestamp_gt_unchecked(
299        mut self,
300        path: impl Into<String>,
301        value: i64,
302    ) -> Self {
303        self.ast
304            .steps
305            .push(QueryStep::Filter(Predicate::JsonPathFusedTimestampCmp {
306                path: path.into(),
307                op: ComparisonOp::Gt,
308                value,
309            }));
310        self
311    }
312
313    /// Append a fused JSON timestamp-greater-or-equal predicate without
314    /// validating the fusion gate. See
315    /// [`Self::filter_json_fused_text_eq_unchecked`] for the contract.
316    #[must_use]
317    pub fn filter_json_fused_timestamp_gte_unchecked(
318        mut self,
319        path: impl Into<String>,
320        value: i64,
321    ) -> Self {
322        self.ast
323            .steps
324            .push(QueryStep::Filter(Predicate::JsonPathFusedTimestampCmp {
325                path: path.into(),
326                op: ComparisonOp::Gte,
327                value,
328            }));
329        self
330    }
331
332    /// Append a fused JSON timestamp-less-than predicate without
333    /// validating the fusion gate. See
334    /// [`Self::filter_json_fused_text_eq_unchecked`] for the contract.
335    #[must_use]
336    pub fn filter_json_fused_timestamp_lt_unchecked(
337        mut self,
338        path: impl Into<String>,
339        value: i64,
340    ) -> Self {
341        self.ast
342            .steps
343            .push(QueryStep::Filter(Predicate::JsonPathFusedTimestampCmp {
344                path: path.into(),
345                op: ComparisonOp::Lt,
346                value,
347            }));
348        self
349    }
350
351    /// Append a fused JSON timestamp-less-or-equal predicate without
352    /// validating the fusion gate. See
353    /// [`Self::filter_json_fused_text_eq_unchecked`] for the contract.
354    #[must_use]
355    pub fn filter_json_fused_timestamp_lte_unchecked(
356        mut self,
357        path: impl Into<String>,
358        value: i64,
359    ) -> Self {
360        self.ast
361            .steps
362            .push(QueryStep::Filter(Predicate::JsonPathFusedTimestampCmp {
363                path: path.into(),
364                op: ComparisonOp::Lte,
365                value,
366            }));
367        self
368    }
369
370    /// Append a fused JSON boolean-equality predicate without validating
371    /// the fusion gate. See
372    /// [`Self::filter_json_fused_text_eq_unchecked`] for the contract.
373    #[must_use]
374    pub fn filter_json_fused_bool_eq_unchecked(
375        mut self,
376        path: impl Into<String>,
377        value: bool,
378    ) -> Self {
379        self.ast
380            .steps
381            .push(QueryStep::Filter(Predicate::JsonPathFusedBoolEq {
382                path: path.into(),
383                value,
384            }));
385        self
386    }
387
388    /// Append a fused JSON text IN-set predicate without validating the
389    /// fusion gate. See [`Self::filter_json_fused_text_eq_unchecked`] for
390    /// the contract.
391    ///
392    /// # Panics
393    ///
394    /// Panics if `values` is empty — `SQLite` `IN` with an empty list is a syntax error.
395    #[must_use]
396    pub fn filter_json_fused_text_in_unchecked(
397        mut self,
398        path: impl Into<String>,
399        values: Vec<String>,
400    ) -> Self {
401        assert!(
402            !values.is_empty(),
403            "filter_json_fused_text_in: values must not be empty"
404        );
405        self.ast
406            .steps
407            .push(QueryStep::Filter(Predicate::JsonPathFusedIn {
408                path: path.into(),
409                values,
410            }));
411        self
412    }
413
414    /// Filter results where a JSON text property at `path` is one of `values`.
415    ///
416    /// This is the non-fused variant; the predicate is applied as a residual
417    /// WHERE clause. No FTS schema is required.
418    ///
419    /// # Panics
420    ///
421    /// Panics if `values` is empty — `SQLite` `IN` with an empty list is a syntax error.
422    #[must_use]
423    pub fn filter_json_text_in(mut self, path: impl Into<String>, values: Vec<String>) -> Self {
424        assert!(
425            !values.is_empty(),
426            "filter_json_text_in: values must not be empty"
427        );
428        self.ast
429            .steps
430            .push(QueryStep::Filter(Predicate::JsonPathIn {
431                path: path.into(),
432                values: values.into_iter().map(ScalarValue::Text).collect(),
433            }));
434        self
435    }
436
437    /// Add an expansion slot that traverses edges of the given label for each root result.
438    ///
439    /// Pass `filter: None` to preserve the existing behavior. `filter: Some(_)` is
440    /// accepted by the AST but the compilation is not yet implemented (Pack 3).
441    /// Pass `edge_filter: None` to preserve pre-Pack-D behavior (no edge filtering).
442    /// `edge_filter: Some(EdgePropertyEq { .. })` filters traversed edges by their
443    /// JSON properties; only edges matching the predicate are followed.
444    #[must_use]
445    pub fn expand(
446        mut self,
447        slot: impl Into<String>,
448        direction: TraverseDirection,
449        label: impl Into<String>,
450        max_depth: usize,
451        filter: Option<Predicate>,
452        edge_filter: Option<Predicate>,
453    ) -> Self {
454        self.ast.expansions.push(ExpansionSlot {
455            slot: slot.into(),
456            direction,
457            label: label.into(),
458            max_depth,
459            filter,
460            edge_filter,
461        });
462        self
463    }
464
465    /// Begin registering an edge-projecting expansion slot. Chain
466    /// [`EdgeExpansionBuilder::edge_filter`] /
467    /// [`EdgeExpansionBuilder::endpoint_filter`] to attach predicates,
468    /// then call [`EdgeExpansionBuilder::done`] to return this builder.
469    ///
470    /// Emits `(EdgeRow, NodeRow)` tuples per root on execution. The
471    /// endpoint node is the target on `Out` traversal, source on `In`.
472    /// Slot name must be unique across both node- and edge-expansions
473    /// within the same query; collisions are reported by
474    /// [`Self::compile_grouped`].
475    #[must_use]
476    pub fn traverse_edges(
477        self,
478        slot: impl Into<String>,
479        direction: TraverseDirection,
480        label: impl Into<String>,
481        max_depth: usize,
482    ) -> EdgeExpansionBuilder {
483        EdgeExpansionBuilder {
484            parent: self,
485            slot: EdgeExpansionSlot {
486                slot: slot.into(),
487                direction,
488                label: label.into(),
489                max_depth,
490                endpoint_filter: None,
491                edge_filter: None,
492            },
493        }
494    }
495
496    /// Set the maximum number of result rows.
497    #[must_use]
498    pub fn limit(mut self, limit: usize) -> Self {
499        self.ast.final_limit = Some(limit);
500        self
501    }
502
503    /// Borrow the underlying [`QueryAst`].
504    #[must_use]
505    pub fn ast(&self) -> &QueryAst {
506        &self.ast
507    }
508
509    /// Consume the builder and return the underlying [`QueryAst`].
510    #[must_use]
511    pub fn into_ast(self) -> QueryAst {
512        self.ast
513    }
514
515    /// Compile this builder's AST into an executable [`CompiledQuery`].
516    ///
517    /// # Errors
518    ///
519    /// Returns [`CompileError`] if the query violates structural constraints
520    /// (e.g. too many traversal steps or too many bind parameters).
521    pub fn compile(&self) -> Result<CompiledQuery, CompileError> {
522        compile_query(&self.ast)
523    }
524
525    /// Compile this builder's AST into an executable grouped query.
526    ///
527    /// # Errors
528    ///
529    /// Returns [`CompileError`] if the query violates grouped-query structural
530    /// constraints such as duplicate slot names or excessive depth.
531    pub fn compile_grouped(&self) -> Result<CompiledGroupedQuery, CompileError> {
532        compile_grouped_query(&self.ast)
533    }
534}
535
536/// Chained builder for an edge-projecting expansion slot. Returned by
537/// [`QueryBuilder::traverse_edges`]; call [`Self::done`] to append the
538/// slot to the parent `QueryBuilder` and resume chaining.
539#[derive(Clone, Debug, PartialEq, Eq)]
540pub struct EdgeExpansionBuilder {
541    parent: QueryBuilder,
542    slot: EdgeExpansionSlot,
543}
544
545impl EdgeExpansionBuilder {
546    /// Attach an edge-property predicate. Only `EdgePropertyEq` and
547    /// `EdgePropertyCompare` are valid here; other variants are accepted
548    /// by the AST but will be rejected downstream during edge-expansion
549    /// compilation.
550    #[must_use]
551    pub fn edge_filter(mut self, predicate: Predicate) -> Self {
552        self.slot.edge_filter = Some(predicate);
553        self
554    }
555
556    /// Attach an endpoint-node predicate (applied to the target node on
557    /// `Out` traversal, source node on `In`).
558    #[must_use]
559    pub fn endpoint_filter(mut self, predicate: Predicate) -> Self {
560        self.slot.endpoint_filter = Some(predicate);
561        self
562    }
563
564    /// Finalize the edge-expansion slot and return the parent
565    /// `QueryBuilder` for further chaining.
566    #[must_use]
567    pub fn done(mut self) -> QueryBuilder {
568        self.parent.ast.edge_expansions.push(self.slot);
569        self.parent
570    }
571}
572
573#[cfg(test)]
574#[allow(clippy::panic)]
575mod tests {
576    use crate::{Predicate, QueryBuilder, QueryStep, ScalarValue, TextQuery, TraverseDirection};
577
578    #[test]
579    fn builder_accumulates_expected_steps() {
580        let query = QueryBuilder::nodes("Meeting")
581            .text_search("budget", 5)
582            .traverse(TraverseDirection::Out, "HAS_TASK", 2)
583            .filter_json_text_eq("$.status", "active")
584            .limit(10);
585
586        assert_eq!(query.ast().steps.len(), 3);
587        assert_eq!(query.ast().final_limit, Some(10));
588    }
589
590    #[test]
591    fn builder_filter_json_bool_eq_produces_correct_predicate() {
592        let query = QueryBuilder::nodes("Feature").filter_json_bool_eq("$.enabled", true);
593
594        assert_eq!(query.ast().steps.len(), 1);
595        match &query.ast().steps[0] {
596            QueryStep::Filter(Predicate::JsonPathEq { path, value }) => {
597                assert_eq!(path, "$.enabled");
598                assert_eq!(*value, ScalarValue::Bool(true));
599            }
600            other => panic!("expected JsonPathEq/Bool, got {other:?}"),
601        }
602    }
603
604    #[test]
605    fn builder_text_search_parses_into_typed_query() {
606        let query = QueryBuilder::nodes("Meeting").text_search("ship NOT blocked", 10);
607
608        match &query.ast().steps[0] {
609            QueryStep::TextSearch { query, limit } => {
610                assert_eq!(*limit, 10);
611                assert_eq!(
612                    *query,
613                    TextQuery::And(vec![
614                        TextQuery::Term("ship".into()),
615                        TextQuery::Not(Box::new(TextQuery::Term("blocked".into()))),
616                    ])
617                );
618            }
619            other => panic!("expected TextSearch, got {other:?}"),
620        }
621    }
622}