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        });
114        self
115    }
116
117    /// Filter results to a single logical ID.
118    #[must_use]
119    pub fn filter_logical_id_eq(mut self, logical_id: impl Into<String>) -> Self {
120        self.ast
121            .steps
122            .push(QueryStep::Filter(Predicate::LogicalIdEq(logical_id.into())));
123        self
124    }
125
126    /// Filter results to nodes matching the given kind.
127    #[must_use]
128    pub fn filter_kind_eq(mut self, kind: impl Into<String>) -> Self {
129        self.ast
130            .steps
131            .push(QueryStep::Filter(Predicate::KindEq(kind.into())));
132        self
133    }
134
135    /// Filter results to nodes matching the given `source_ref`.
136    #[must_use]
137    pub fn filter_source_ref_eq(mut self, source_ref: impl Into<String>) -> Self {
138        self.ast
139            .steps
140            .push(QueryStep::Filter(Predicate::SourceRefEq(source_ref.into())));
141        self
142    }
143
144    /// Filter results to nodes where `content_ref` is not NULL.
145    #[must_use]
146    pub fn filter_content_ref_not_null(mut self) -> Self {
147        self.ast
148            .steps
149            .push(QueryStep::Filter(Predicate::ContentRefNotNull));
150        self
151    }
152
153    /// Filter results to nodes matching the given `content_ref` URI.
154    #[must_use]
155    pub fn filter_content_ref_eq(mut self, content_ref: impl Into<String>) -> Self {
156        self.ast
157            .steps
158            .push(QueryStep::Filter(Predicate::ContentRefEq(
159                content_ref.into(),
160            )));
161        self
162    }
163
164    /// Filter results where a JSON property at `path` equals the given text value.
165    #[must_use]
166    pub fn filter_json_text_eq(
167        mut self,
168        path: impl Into<String>,
169        value: impl Into<String>,
170    ) -> Self {
171        self.ast
172            .steps
173            .push(QueryStep::Filter(Predicate::JsonPathEq {
174                path: path.into(),
175                value: ScalarValue::Text(value.into()),
176            }));
177        self
178    }
179
180    /// Filter results where a JSON property at `path` equals the given boolean value.
181    #[must_use]
182    pub fn filter_json_bool_eq(mut self, path: impl Into<String>, value: bool) -> Self {
183        self.ast
184            .steps
185            .push(QueryStep::Filter(Predicate::JsonPathEq {
186                path: path.into(),
187                value: ScalarValue::Bool(value),
188            }));
189        self
190    }
191
192    /// Filter results where a JSON integer at `path` is greater than `value`.
193    #[must_use]
194    pub fn filter_json_integer_gt(mut self, path: impl Into<String>, value: i64) -> Self {
195        self.ast
196            .steps
197            .push(QueryStep::Filter(Predicate::JsonPathCompare {
198                path: path.into(),
199                op: ComparisonOp::Gt,
200                value: ScalarValue::Integer(value),
201            }));
202        self
203    }
204
205    /// Filter results where a JSON integer at `path` is greater than or equal to `value`.
206    #[must_use]
207    pub fn filter_json_integer_gte(mut self, path: impl Into<String>, value: i64) -> Self {
208        self.ast
209            .steps
210            .push(QueryStep::Filter(Predicate::JsonPathCompare {
211                path: path.into(),
212                op: ComparisonOp::Gte,
213                value: ScalarValue::Integer(value),
214            }));
215        self
216    }
217
218    /// Filter results where a JSON integer at `path` is less than `value`.
219    #[must_use]
220    pub fn filter_json_integer_lt(mut self, path: impl Into<String>, value: i64) -> Self {
221        self.ast
222            .steps
223            .push(QueryStep::Filter(Predicate::JsonPathCompare {
224                path: path.into(),
225                op: ComparisonOp::Lt,
226                value: ScalarValue::Integer(value),
227            }));
228        self
229    }
230
231    /// Filter results where a JSON integer at `path` is less than or equal to `value`.
232    #[must_use]
233    pub fn filter_json_integer_lte(mut self, path: impl Into<String>, value: i64) -> Self {
234        self.ast
235            .steps
236            .push(QueryStep::Filter(Predicate::JsonPathCompare {
237                path: path.into(),
238                op: ComparisonOp::Lte,
239                value: ScalarValue::Integer(value),
240            }));
241        self
242    }
243
244    /// Filter results where a JSON timestamp at `path` is after `value` (epoch seconds).
245    #[must_use]
246    pub fn filter_json_timestamp_gt(self, path: impl Into<String>, value: i64) -> Self {
247        self.filter_json_integer_gt(path, value)
248    }
249
250    /// Filter results where a JSON timestamp at `path` is at or after `value`.
251    #[must_use]
252    pub fn filter_json_timestamp_gte(self, path: impl Into<String>, value: i64) -> Self {
253        self.filter_json_integer_gte(path, value)
254    }
255
256    /// Filter results where a JSON timestamp at `path` is before `value`.
257    #[must_use]
258    pub fn filter_json_timestamp_lt(self, path: impl Into<String>, value: i64) -> Self {
259        self.filter_json_integer_lt(path, value)
260    }
261
262    /// Filter results where a JSON timestamp at `path` is at or before `value`.
263    #[must_use]
264    pub fn filter_json_timestamp_lte(self, path: impl Into<String>, value: i64) -> Self {
265        self.filter_json_integer_lte(path, value)
266    }
267
268    /// Append a fused JSON text-equality predicate without validating
269    /// whether the caller has a property-FTS schema for the kind.
270    ///
271    /// Callers must have already validated the fusion gate; the
272    /// tethered [`crate::QueryBuilder`] has no engine handle to consult
273    /// a schema. Mis-use — calling this without prior schema
274    /// validation — produces SQL that pushes a `json_extract` predicate
275    /// into the search CTE's inner WHERE clause. That is valid SQL but
276    /// defeats the "developer opt-in" contract.
277    #[must_use]
278    pub fn filter_json_fused_text_eq_unchecked(
279        mut self,
280        path: impl Into<String>,
281        value: impl Into<String>,
282    ) -> Self {
283        self.ast
284            .steps
285            .push(QueryStep::Filter(Predicate::JsonPathFusedEq {
286                path: path.into(),
287                value: value.into(),
288            }));
289        self
290    }
291
292    /// Append a fused JSON timestamp-greater-than predicate without
293    /// validating the fusion gate. See
294    /// [`Self::filter_json_fused_text_eq_unchecked`] for the contract.
295    #[must_use]
296    pub fn filter_json_fused_timestamp_gt_unchecked(
297        mut self,
298        path: impl Into<String>,
299        value: i64,
300    ) -> Self {
301        self.ast
302            .steps
303            .push(QueryStep::Filter(Predicate::JsonPathFusedTimestampCmp {
304                path: path.into(),
305                op: ComparisonOp::Gt,
306                value,
307            }));
308        self
309    }
310
311    /// Append a fused JSON timestamp-greater-or-equal predicate without
312    /// validating the fusion gate. See
313    /// [`Self::filter_json_fused_text_eq_unchecked`] for the contract.
314    #[must_use]
315    pub fn filter_json_fused_timestamp_gte_unchecked(
316        mut self,
317        path: impl Into<String>,
318        value: i64,
319    ) -> Self {
320        self.ast
321            .steps
322            .push(QueryStep::Filter(Predicate::JsonPathFusedTimestampCmp {
323                path: path.into(),
324                op: ComparisonOp::Gte,
325                value,
326            }));
327        self
328    }
329
330    /// Append a fused JSON timestamp-less-than predicate without
331    /// validating the fusion gate. See
332    /// [`Self::filter_json_fused_text_eq_unchecked`] for the contract.
333    #[must_use]
334    pub fn filter_json_fused_timestamp_lt_unchecked(
335        mut self,
336        path: impl Into<String>,
337        value: i64,
338    ) -> Self {
339        self.ast
340            .steps
341            .push(QueryStep::Filter(Predicate::JsonPathFusedTimestampCmp {
342                path: path.into(),
343                op: ComparisonOp::Lt,
344                value,
345            }));
346        self
347    }
348
349    /// Append a fused JSON timestamp-less-or-equal predicate without
350    /// validating the fusion gate. See
351    /// [`Self::filter_json_fused_text_eq_unchecked`] for the contract.
352    #[must_use]
353    pub fn filter_json_fused_timestamp_lte_unchecked(
354        mut self,
355        path: impl Into<String>,
356        value: i64,
357    ) -> Self {
358        self.ast
359            .steps
360            .push(QueryStep::Filter(Predicate::JsonPathFusedTimestampCmp {
361                path: path.into(),
362                op: ComparisonOp::Lte,
363                value,
364            }));
365        self
366    }
367
368    /// Add an expansion slot that traverses edges of the given label for each root result.
369    #[must_use]
370    pub fn expand(
371        mut self,
372        slot: impl Into<String>,
373        direction: TraverseDirection,
374        label: impl Into<String>,
375        max_depth: usize,
376    ) -> Self {
377        self.ast.expansions.push(ExpansionSlot {
378            slot: slot.into(),
379            direction,
380            label: label.into(),
381            max_depth,
382        });
383        self
384    }
385
386    /// Set the maximum number of result rows.
387    #[must_use]
388    pub fn limit(mut self, limit: usize) -> Self {
389        self.ast.final_limit = Some(limit);
390        self
391    }
392
393    /// Borrow the underlying [`QueryAst`].
394    #[must_use]
395    pub fn ast(&self) -> &QueryAst {
396        &self.ast
397    }
398
399    /// Consume the builder and return the underlying [`QueryAst`].
400    #[must_use]
401    pub fn into_ast(self) -> QueryAst {
402        self.ast
403    }
404
405    /// Compile this builder's AST into an executable [`CompiledQuery`].
406    ///
407    /// # Errors
408    ///
409    /// Returns [`CompileError`] if the query violates structural constraints
410    /// (e.g. too many traversal steps or too many bind parameters).
411    pub fn compile(&self) -> Result<CompiledQuery, CompileError> {
412        compile_query(&self.ast)
413    }
414
415    /// Compile this builder's AST into an executable grouped query.
416    ///
417    /// # Errors
418    ///
419    /// Returns [`CompileError`] if the query violates grouped-query structural
420    /// constraints such as duplicate slot names or excessive depth.
421    pub fn compile_grouped(&self) -> Result<CompiledGroupedQuery, CompileError> {
422        compile_grouped_query(&self.ast)
423    }
424}
425
426#[cfg(test)]
427#[allow(clippy::panic)]
428mod tests {
429    use crate::{Predicate, QueryBuilder, QueryStep, ScalarValue, TextQuery, TraverseDirection};
430
431    #[test]
432    fn builder_accumulates_expected_steps() {
433        let query = QueryBuilder::nodes("Meeting")
434            .text_search("budget", 5)
435            .traverse(TraverseDirection::Out, "HAS_TASK", 2)
436            .filter_json_text_eq("$.status", "active")
437            .limit(10);
438
439        assert_eq!(query.ast().steps.len(), 3);
440        assert_eq!(query.ast().final_limit, Some(10));
441    }
442
443    #[test]
444    fn builder_filter_json_bool_eq_produces_correct_predicate() {
445        let query = QueryBuilder::nodes("Feature").filter_json_bool_eq("$.enabled", true);
446
447        assert_eq!(query.ast().steps.len(), 1);
448        match &query.ast().steps[0] {
449            QueryStep::Filter(Predicate::JsonPathEq { path, value }) => {
450                assert_eq!(path, "$.enabled");
451                assert_eq!(*value, ScalarValue::Bool(true));
452            }
453            other => panic!("expected JsonPathEq/Bool, got {other:?}"),
454        }
455    }
456
457    #[test]
458    fn builder_text_search_parses_into_typed_query() {
459        let query = QueryBuilder::nodes("Meeting").text_search("ship NOT blocked", 10);
460
461        match &query.ast().steps[0] {
462            QueryStep::TextSearch { query, limit } => {
463                assert_eq!(*limit, 10);
464                assert_eq!(
465                    *query,
466                    TextQuery::And(vec![
467                        TextQuery::Term("ship".into()),
468                        TextQuery::Not(Box::new(TextQuery::Term("blocked".into()))),
469                    ])
470                );
471            }
472            other => panic!("expected TextSearch, got {other:?}"),
473        }
474    }
475}