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, Eq)]
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]
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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[must_use]
498 pub fn limit(mut self, limit: usize) -> Self {
499 self.ast.final_limit = Some(limit);
500 self
501 }
502
503 #[must_use]
505 pub fn ast(&self) -> &QueryAst {
506 &self.ast
507 }
508
509 #[must_use]
511 pub fn into_ast(self) -> QueryAst {
512 self.ast
513 }
514
515 pub fn compile(&self) -> Result<CompiledQuery, CompileError> {
522 compile_query(&self.ast)
523 }
524
525 pub fn compile_grouped(&self) -> Result<CompiledGroupedQuery, CompileError> {
532 compile_grouped_query(&self.ast)
533 }
534}
535
536#[derive(Clone, Debug, PartialEq, Eq)]
540pub struct EdgeExpansionBuilder {
541 parent: QueryBuilder,
542 slot: EdgeExpansionSlot,
543}
544
545impl EdgeExpansionBuilder {
546 #[must_use]
551 pub fn edge_filter(mut self, predicate: Predicate) -> Self {
552 self.slot.edge_filter = Some(predicate);
553 self
554 }
555
556 #[must_use]
559 pub fn endpoint_filter(mut self, predicate: Predicate) -> Self {
560 self.slot.endpoint_filter = Some(predicate);
561 self
562 }
563
564 #[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}