1use crate::{
2 ComparisonOp, CompileError, CompiledGroupedQuery, CompiledQuery, ExpansionSlot, Predicate,
3 QueryAst, QueryStep, ScalarValue, TextQuery, TraverseDirection, compile_grouped_query,
4 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 final_limit: None,
74 },
75 }
76 }
77
78 #[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 #[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 #[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 filter: None,
114 });
115 self
116 }
117
118 #[must_use]
120 pub fn filter_logical_id_eq(mut self, logical_id: impl Into<String>) -> Self {
121 self.ast
122 .steps
123 .push(QueryStep::Filter(Predicate::LogicalIdEq(logical_id.into())));
124 self
125 }
126
127 #[must_use]
129 pub fn filter_kind_eq(mut self, kind: impl Into<String>) -> Self {
130 self.ast
131 .steps
132 .push(QueryStep::Filter(Predicate::KindEq(kind.into())));
133 self
134 }
135
136 #[must_use]
138 pub fn filter_source_ref_eq(mut self, source_ref: impl Into<String>) -> Self {
139 self.ast
140 .steps
141 .push(QueryStep::Filter(Predicate::SourceRefEq(source_ref.into())));
142 self
143 }
144
145 #[must_use]
147 pub fn filter_content_ref_not_null(mut self) -> Self {
148 self.ast
149 .steps
150 .push(QueryStep::Filter(Predicate::ContentRefNotNull));
151 self
152 }
153
154 #[must_use]
156 pub fn filter_content_ref_eq(mut self, content_ref: impl Into<String>) -> Self {
157 self.ast
158 .steps
159 .push(QueryStep::Filter(Predicate::ContentRefEq(
160 content_ref.into(),
161 )));
162 self
163 }
164
165 #[must_use]
167 pub fn filter_json_text_eq(
168 mut self,
169 path: impl Into<String>,
170 value: impl Into<String>,
171 ) -> Self {
172 self.ast
173 .steps
174 .push(QueryStep::Filter(Predicate::JsonPathEq {
175 path: path.into(),
176 value: ScalarValue::Text(value.into()),
177 }));
178 self
179 }
180
181 #[must_use]
183 pub fn filter_json_bool_eq(mut self, path: impl Into<String>, value: bool) -> Self {
184 self.ast
185 .steps
186 .push(QueryStep::Filter(Predicate::JsonPathEq {
187 path: path.into(),
188 value: ScalarValue::Bool(value),
189 }));
190 self
191 }
192
193 #[must_use]
195 pub fn filter_json_integer_gt(mut self, path: impl Into<String>, value: i64) -> Self {
196 self.ast
197 .steps
198 .push(QueryStep::Filter(Predicate::JsonPathCompare {
199 path: path.into(),
200 op: ComparisonOp::Gt,
201 value: ScalarValue::Integer(value),
202 }));
203 self
204 }
205
206 #[must_use]
208 pub fn filter_json_integer_gte(mut self, path: impl Into<String>, value: i64) -> Self {
209 self.ast
210 .steps
211 .push(QueryStep::Filter(Predicate::JsonPathCompare {
212 path: path.into(),
213 op: ComparisonOp::Gte,
214 value: ScalarValue::Integer(value),
215 }));
216 self
217 }
218
219 #[must_use]
221 pub fn filter_json_integer_lt(mut self, path: impl Into<String>, value: i64) -> Self {
222 self.ast
223 .steps
224 .push(QueryStep::Filter(Predicate::JsonPathCompare {
225 path: path.into(),
226 op: ComparisonOp::Lt,
227 value: ScalarValue::Integer(value),
228 }));
229 self
230 }
231
232 #[must_use]
234 pub fn filter_json_integer_lte(mut self, path: impl Into<String>, value: i64) -> Self {
235 self.ast
236 .steps
237 .push(QueryStep::Filter(Predicate::JsonPathCompare {
238 path: path.into(),
239 op: ComparisonOp::Lte,
240 value: ScalarValue::Integer(value),
241 }));
242 self
243 }
244
245 #[must_use]
247 pub fn filter_json_timestamp_gt(self, path: impl Into<String>, value: i64) -> Self {
248 self.filter_json_integer_gt(path, value)
249 }
250
251 #[must_use]
253 pub fn filter_json_timestamp_gte(self, path: impl Into<String>, value: i64) -> Self {
254 self.filter_json_integer_gte(path, value)
255 }
256
257 #[must_use]
259 pub fn filter_json_timestamp_lt(self, path: impl Into<String>, value: i64) -> Self {
260 self.filter_json_integer_lt(path, value)
261 }
262
263 #[must_use]
265 pub fn filter_json_timestamp_lte(self, path: impl Into<String>, value: i64) -> Self {
266 self.filter_json_integer_lte(path, value)
267 }
268
269 #[must_use]
279 pub fn filter_json_fused_text_eq_unchecked(
280 mut self,
281 path: impl Into<String>,
282 value: impl Into<String>,
283 ) -> Self {
284 self.ast
285 .steps
286 .push(QueryStep::Filter(Predicate::JsonPathFusedEq {
287 path: path.into(),
288 value: value.into(),
289 }));
290 self
291 }
292
293 #[must_use]
297 pub fn filter_json_fused_timestamp_gt_unchecked(
298 mut self,
299 path: impl Into<String>,
300 value: i64,
301 ) -> Self {
302 self.ast
303 .steps
304 .push(QueryStep::Filter(Predicate::JsonPathFusedTimestampCmp {
305 path: path.into(),
306 op: ComparisonOp::Gt,
307 value,
308 }));
309 self
310 }
311
312 #[must_use]
316 pub fn filter_json_fused_timestamp_gte_unchecked(
317 mut self,
318 path: impl Into<String>,
319 value: i64,
320 ) -> Self {
321 self.ast
322 .steps
323 .push(QueryStep::Filter(Predicate::JsonPathFusedTimestampCmp {
324 path: path.into(),
325 op: ComparisonOp::Gte,
326 value,
327 }));
328 self
329 }
330
331 #[must_use]
335 pub fn filter_json_fused_timestamp_lt_unchecked(
336 mut self,
337 path: impl Into<String>,
338 value: i64,
339 ) -> Self {
340 self.ast
341 .steps
342 .push(QueryStep::Filter(Predicate::JsonPathFusedTimestampCmp {
343 path: path.into(),
344 op: ComparisonOp::Lt,
345 value,
346 }));
347 self
348 }
349
350 #[must_use]
354 pub fn filter_json_fused_timestamp_lte_unchecked(
355 mut self,
356 path: impl Into<String>,
357 value: i64,
358 ) -> Self {
359 self.ast
360 .steps
361 .push(QueryStep::Filter(Predicate::JsonPathFusedTimestampCmp {
362 path: path.into(),
363 op: ComparisonOp::Lte,
364 value,
365 }));
366 self
367 }
368
369 #[must_use]
373 pub fn filter_json_fused_bool_eq_unchecked(
374 mut self,
375 path: impl Into<String>,
376 value: bool,
377 ) -> Self {
378 self.ast
379 .steps
380 .push(QueryStep::Filter(Predicate::JsonPathFusedBoolEq {
381 path: path.into(),
382 value,
383 }));
384 self
385 }
386
387 #[must_use]
395 pub fn filter_json_fused_text_in_unchecked(
396 mut self,
397 path: impl Into<String>,
398 values: Vec<String>,
399 ) -> Self {
400 assert!(
401 !values.is_empty(),
402 "filter_json_fused_text_in: values must not be empty"
403 );
404 self.ast
405 .steps
406 .push(QueryStep::Filter(Predicate::JsonPathFusedIn {
407 path: path.into(),
408 values,
409 }));
410 self
411 }
412
413 #[must_use]
422 pub fn filter_json_text_in(mut self, path: impl Into<String>, values: Vec<String>) -> Self {
423 assert!(
424 !values.is_empty(),
425 "filter_json_text_in: values must not be empty"
426 );
427 self.ast
428 .steps
429 .push(QueryStep::Filter(Predicate::JsonPathIn {
430 path: path.into(),
431 values: values.into_iter().map(ScalarValue::Text).collect(),
432 }));
433 self
434 }
435
436 #[must_use]
444 pub fn expand(
445 mut self,
446 slot: impl Into<String>,
447 direction: TraverseDirection,
448 label: impl Into<String>,
449 max_depth: usize,
450 filter: Option<Predicate>,
451 edge_filter: Option<Predicate>,
452 ) -> Self {
453 self.ast.expansions.push(ExpansionSlot {
454 slot: slot.into(),
455 direction,
456 label: label.into(),
457 max_depth,
458 filter,
459 edge_filter,
460 });
461 self
462 }
463
464 #[must_use]
466 pub fn limit(mut self, limit: usize) -> Self {
467 self.ast.final_limit = Some(limit);
468 self
469 }
470
471 #[must_use]
473 pub fn ast(&self) -> &QueryAst {
474 &self.ast
475 }
476
477 #[must_use]
479 pub fn into_ast(self) -> QueryAst {
480 self.ast
481 }
482
483 pub fn compile(&self) -> Result<CompiledQuery, CompileError> {
490 compile_query(&self.ast)
491 }
492
493 pub fn compile_grouped(&self) -> Result<CompiledGroupedQuery, CompileError> {
500 compile_grouped_query(&self.ast)
501 }
502}
503
504#[cfg(test)]
505#[allow(clippy::panic)]
506mod tests {
507 use crate::{Predicate, QueryBuilder, QueryStep, ScalarValue, TextQuery, TraverseDirection};
508
509 #[test]
510 fn builder_accumulates_expected_steps() {
511 let query = QueryBuilder::nodes("Meeting")
512 .text_search("budget", 5)
513 .traverse(TraverseDirection::Out, "HAS_TASK", 2)
514 .filter_json_text_eq("$.status", "active")
515 .limit(10);
516
517 assert_eq!(query.ast().steps.len(), 3);
518 assert_eq!(query.ast().final_limit, Some(10));
519 }
520
521 #[test]
522 fn builder_filter_json_bool_eq_produces_correct_predicate() {
523 let query = QueryBuilder::nodes("Feature").filter_json_bool_eq("$.enabled", true);
524
525 assert_eq!(query.ast().steps.len(), 1);
526 match &query.ast().steps[0] {
527 QueryStep::Filter(Predicate::JsonPathEq { path, value }) => {
528 assert_eq!(path, "$.enabled");
529 assert_eq!(*value, ScalarValue::Bool(true));
530 }
531 other => panic!("expected JsonPathEq/Bool, got {other:?}"),
532 }
533 }
534
535 #[test]
536 fn builder_text_search_parses_into_typed_query() {
537 let query = QueryBuilder::nodes("Meeting").text_search("ship NOT blocked", 10);
538
539 match &query.ast().steps[0] {
540 QueryStep::TextSearch { query, limit } => {
541 assert_eq!(*limit, 10);
542 assert_eq!(
543 *query,
544 TextQuery::And(vec![
545 TextQuery::Term("ship".into()),
546 TextQuery::Not(Box::new(TextQuery::Term("blocked".into()))),
547 ])
548 );
549 }
550 other => panic!("expected TextSearch, got {other:?}"),
551 }
552 }
553}