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 });
114 self
115 }
116
117 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[must_use]
388 pub fn limit(mut self, limit: usize) -> Self {
389 self.ast.final_limit = Some(limit);
390 self
391 }
392
393 #[must_use]
395 pub fn ast(&self) -> &QueryAst {
396 &self.ast
397 }
398
399 #[must_use]
401 pub fn into_ast(self) -> QueryAst {
402 self.ast
403 }
404
405 pub fn compile(&self) -> Result<CompiledQuery, CompileError> {
412 compile_query(&self.ast)
413 }
414
415 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}