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#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
18pub enum BuilderValidationError {
19 #[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 kind: String,
28 },
29 #[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 kind: String,
38 path: String,
40 },
41 #[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 method: String,
51 },
52}
53
54#[derive(Clone, Debug, PartialEq)]
60pub struct QueryBuilder {
61 ast: QueryAst,
62}
63
64impl QueryBuilder {
65 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[must_use]
537 pub fn limit(mut self, limit: usize) -> Self {
538 self.ast.final_limit = Some(limit);
539 self
540 }
541
542 #[must_use]
544 pub fn ast(&self) -> &QueryAst {
545 &self.ast
546 }
547
548 #[must_use]
550 pub fn into_ast(self) -> QueryAst {
551 self.ast
552 }
553
554 pub fn compile(&self) -> Result<CompiledQuery, CompileError> {
561 compile_query(&self.ast)
562 }
563
564 pub fn compile_grouped(&self) -> Result<CompiledGroupedQuery, CompileError> {
571 compile_grouped_query(&self.ast)
572 }
573}
574
575#[derive(Clone, Debug, PartialEq)]
579pub struct EdgeExpansionBuilder {
580 parent: QueryBuilder,
581 slot: EdgeExpansionSlot,
582}
583
584impl EdgeExpansionBuilder {
585 #[must_use]
590 pub fn edge_filter(mut self, predicate: Predicate) -> Self {
591 self.slot.edge_filter = Some(predicate);
592 self
593 }
594
595 #[must_use]
598 pub fn endpoint_filter(mut self, predicate: Predicate) -> Self {
599 self.slot.endpoint_filter = Some(predicate);
600 self
601 }
602
603 #[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}