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]
374 pub fn expand(
375 mut self,
376 slot: impl Into<String>,
377 direction: TraverseDirection,
378 label: impl Into<String>,
379 max_depth: usize,
380 filter: Option<Predicate>,
381 ) -> Self {
382 self.ast.expansions.push(ExpansionSlot {
383 slot: slot.into(),
384 direction,
385 label: label.into(),
386 max_depth,
387 filter,
388 });
389 self
390 }
391
392 #[must_use]
394 pub fn limit(mut self, limit: usize) -> Self {
395 self.ast.final_limit = Some(limit);
396 self
397 }
398
399 #[must_use]
401 pub fn ast(&self) -> &QueryAst {
402 &self.ast
403 }
404
405 #[must_use]
407 pub fn into_ast(self) -> QueryAst {
408 self.ast
409 }
410
411 pub fn compile(&self) -> Result<CompiledQuery, CompileError> {
418 compile_query(&self.ast)
419 }
420
421 pub fn compile_grouped(&self) -> Result<CompiledGroupedQuery, CompileError> {
428 compile_grouped_query(&self.ast)
429 }
430}
431
432#[cfg(test)]
433#[allow(clippy::panic)]
434mod tests {
435 use crate::{Predicate, QueryBuilder, QueryStep, ScalarValue, TextQuery, TraverseDirection};
436
437 #[test]
438 fn builder_accumulates_expected_steps() {
439 let query = QueryBuilder::nodes("Meeting")
440 .text_search("budget", 5)
441 .traverse(TraverseDirection::Out, "HAS_TASK", 2)
442 .filter_json_text_eq("$.status", "active")
443 .limit(10);
444
445 assert_eq!(query.ast().steps.len(), 3);
446 assert_eq!(query.ast().final_limit, Some(10));
447 }
448
449 #[test]
450 fn builder_filter_json_bool_eq_produces_correct_predicate() {
451 let query = QueryBuilder::nodes("Feature").filter_json_bool_eq("$.enabled", true);
452
453 assert_eq!(query.ast().steps.len(), 1);
454 match &query.ast().steps[0] {
455 QueryStep::Filter(Predicate::JsonPathEq { path, value }) => {
456 assert_eq!(path, "$.enabled");
457 assert_eq!(*value, ScalarValue::Bool(true));
458 }
459 other => panic!("expected JsonPathEq/Bool, got {other:?}"),
460 }
461 }
462
463 #[test]
464 fn builder_text_search_parses_into_typed_query() {
465 let query = QueryBuilder::nodes("Meeting").text_search("ship NOT blocked", 10);
466
467 match &query.ast().steps[0] {
468 QueryStep::TextSearch { query, limit } => {
469 assert_eq!(*limit, 10);
470 assert_eq!(
471 *query,
472 TextQuery::And(vec![
473 TextQuery::Term("ship".into()),
474 TextQuery::Not(Box::new(TextQuery::Term("blocked".into()))),
475 ])
476 );
477 }
478 other => panic!("expected TextSearch, got {other:?}"),
479 }
480 }
481}