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