1use std::fmt::Write;
2
3use crate::fusion::partition_search_filters;
4use crate::plan::{choose_driving_table, execution_hints, shape_signature};
5use crate::search::{
6 CompiledRetrievalPlan, CompiledSearch, CompiledSearchPlan, CompiledVectorSearch,
7};
8use crate::{
9 ComparisonOp, DrivingTable, EdgeExpansionSlot, ExpansionSlot, Predicate, QueryAst, QueryStep,
10 ScalarValue, TextQuery, TraverseDirection, derive_relaxed, render_text_query_fts5,
11};
12
13#[derive(Clone, Debug, PartialEq, Eq)]
15pub enum BindValue {
16 Text(String),
18 Integer(i64),
20 Bool(bool),
22}
23
24#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
26pub struct ShapeHash(pub u64);
27
28#[derive(Clone, Debug, PartialEq, Eq)]
30pub struct CompiledQuery {
31 pub sql: String,
33 pub binds: Vec<BindValue>,
35 pub shape_hash: ShapeHash,
37 pub driving_table: DrivingTable,
39 pub hints: crate::ExecutionHints,
41}
42
43#[derive(Clone, Debug, PartialEq, Eq)]
45pub struct CompiledGroupedQuery {
46 pub root: CompiledQuery,
48 pub expansions: Vec<ExpansionSlot>,
50 pub edge_expansions: Vec<EdgeExpansionSlot>,
52 pub shape_hash: ShapeHash,
54 pub hints: crate::ExecutionHints,
56}
57
58#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
60pub enum CompileError {
61 #[error("multiple traversal steps are not supported in v1")]
62 TooManyTraversals,
63 #[error("flat query compilation does not support expansions; use compile_grouped")]
64 FlatCompileDoesNotSupportExpansions,
65 #[error("duplicate expansion slot name: {0}")]
66 DuplicateExpansionSlot(String),
67 #[error("expansion slot name must be non-empty")]
68 EmptyExpansionSlotName,
69 #[error("too many expansion slots: max {MAX_EXPANSION_SLOTS}, got {0}")]
70 TooManyExpansionSlots(usize),
71 #[error("too many bind parameters: max 15, got {0}")]
72 TooManyBindParameters(usize),
73 #[error("traversal depth {0} exceeds maximum of {MAX_TRAVERSAL_DEPTH}")]
74 TraversalTooDeep(usize),
75 #[error("invalid JSON path: must match $(.key)+ pattern, got {0:?}")]
76 InvalidJsonPath(String),
77 #[error("compile_search requires exactly one TextSearch step in the AST")]
78 MissingTextSearchStep,
79 #[error("compile_vector_search requires exactly one VectorSearch step in the AST")]
80 MissingVectorSearchStep,
81 #[error("compile_retrieval_plan requires exactly one Search step in the AST")]
82 MissingSearchStep,
83 #[error("compile_retrieval_plan requires exactly one Search step in the AST, found multiple")]
84 MultipleSearchSteps,
85}
86
87fn validate_json_path(path: &str) -> Result<(), CompileError> {
92 let valid = path.starts_with('$')
93 && path.len() > 1
94 && path[1..].split('.').all(|segment| {
95 segment.is_empty()
96 || segment
97 .chars()
98 .all(|c| c.is_ascii_alphanumeric() || c == '_')
99 && !segment.is_empty()
100 })
101 && path.contains('.');
102 if !valid {
103 return Err(CompileError::InvalidJsonPath(path.to_owned()));
104 }
105 Ok(())
106}
107
108#[allow(clippy::too_many_lines)]
116fn append_fusable_clause(
117 sql: &mut String,
118 binds: &mut Vec<BindValue>,
119 alias: &str,
120 predicate: &Predicate,
121) -> Result<(), CompileError> {
122 match predicate {
123 Predicate::KindEq(kind) => {
124 binds.push(BindValue::Text(kind.clone()));
125 let idx = binds.len();
126 let _ = write!(sql, "\n AND {alias}.kind = ?{idx}");
127 }
128 Predicate::LogicalIdEq(logical_id) => {
129 binds.push(BindValue::Text(logical_id.clone()));
130 let idx = binds.len();
131 let _ = write!(
132 sql,
133 "\n AND {alias}.logical_id = ?{idx}"
134 );
135 }
136 Predicate::SourceRefEq(source_ref) => {
137 binds.push(BindValue::Text(source_ref.clone()));
138 let idx = binds.len();
139 let _ = write!(
140 sql,
141 "\n AND {alias}.source_ref = ?{idx}"
142 );
143 }
144 Predicate::ContentRefEq(uri) => {
145 binds.push(BindValue::Text(uri.clone()));
146 let idx = binds.len();
147 let _ = write!(
148 sql,
149 "\n AND {alias}.content_ref = ?{idx}"
150 );
151 }
152 Predicate::ContentRefNotNull => {
153 let _ = write!(
154 sql,
155 "\n AND {alias}.content_ref IS NOT NULL"
156 );
157 }
158 Predicate::JsonPathFusedEq { path, value } => {
159 validate_json_path(path)?;
160 binds.push(BindValue::Text(path.clone()));
161 let path_index = binds.len();
162 binds.push(BindValue::Text(value.clone()));
163 let value_index = binds.len();
164 let _ = write!(
165 sql,
166 "\n AND json_extract({alias}.properties, ?{path_index}) = ?{value_index}"
167 );
168 }
169 Predicate::JsonPathFusedTimestampCmp { path, op, value } => {
170 validate_json_path(path)?;
171 binds.push(BindValue::Text(path.clone()));
172 let path_index = binds.len();
173 binds.push(BindValue::Integer(*value));
174 let value_index = binds.len();
175 let operator = match op {
176 ComparisonOp::Gt => ">",
177 ComparisonOp::Gte => ">=",
178 ComparisonOp::Lt => "<",
179 ComparisonOp::Lte => "<=",
180 };
181 let _ = write!(
182 sql,
183 "\n AND json_extract({alias}.properties, ?{path_index}) {operator} ?{value_index}"
184 );
185 }
186 Predicate::JsonPathFusedBoolEq { path, value } => {
187 validate_json_path(path)?;
188 binds.push(BindValue::Text(path.clone()));
189 let path_index = binds.len();
190 binds.push(BindValue::Integer(i64::from(*value)));
191 let value_index = binds.len();
192 let _ = write!(
193 sql,
194 "\n AND json_extract({alias}.properties, ?{path_index}) = ?{value_index}"
195 );
196 }
197 Predicate::JsonPathFusedIn { path, values } => {
198 validate_json_path(path)?;
199 binds.push(BindValue::Text(path.clone()));
200 let first_param = binds.len();
201 for v in values {
202 binds.push(BindValue::Text(v.clone()));
203 }
204 let placeholders = (1..=values.len())
205 .map(|i| format!("?{}", first_param + i))
206 .collect::<Vec<_>>()
207 .join(", ");
208 let _ = write!(
209 sql,
210 "\n AND json_extract({alias}.properties, ?{first_param}) IN ({placeholders})"
211 );
212 }
213 Predicate::JsonPathEq { .. }
214 | Predicate::JsonPathCompare { .. }
215 | Predicate::JsonPathIn { .. } => {
216 unreachable!("append_fusable_clause received a residual predicate");
217 }
218 Predicate::EdgePropertyEq { .. } | Predicate::EdgePropertyCompare { .. } => {
219 unreachable!(
220 "append_fusable_clause received an edge-property predicate; edge filters are handled in compile_edge_filter"
221 );
222 }
223 }
224 Ok(())
225}
226
227const MAX_BIND_PARAMETERS: usize = 15;
228const MAX_EXPANSION_SLOTS: usize = 8;
229
230const MAX_TRAVERSAL_DEPTH: usize = 50;
235
236#[allow(clippy::too_many_lines)]
271pub fn compile_query(ast: &QueryAst) -> Result<CompiledQuery, CompileError> {
272 if !ast.expansions.is_empty() {
273 return Err(CompileError::FlatCompileDoesNotSupportExpansions);
274 }
275
276 let traversals = ast
277 .steps
278 .iter()
279 .filter(|step| matches!(step, QueryStep::Traverse { .. }))
280 .count();
281 if traversals > 1 {
282 return Err(CompileError::TooManyTraversals);
283 }
284
285 let excessive_depth = ast.steps.iter().find_map(|step| {
286 if let QueryStep::Traverse { max_depth, .. } = step
287 && *max_depth > MAX_TRAVERSAL_DEPTH
288 {
289 return Some(*max_depth);
290 }
291 None
292 });
293 if let Some(depth) = excessive_depth {
294 return Err(CompileError::TraversalTooDeep(depth));
295 }
296
297 let driving_table = choose_driving_table(ast);
298 let hints = execution_hints(ast);
299 let shape_hash = ShapeHash(hash_signature(&shape_signature(ast)));
300
301 let base_limit = ast
302 .steps
303 .iter()
304 .find_map(|step| match step {
305 QueryStep::VectorSearch { limit, .. } | QueryStep::TextSearch { limit, .. } => {
306 Some(*limit)
307 }
308 _ => None,
309 })
310 .or(ast.final_limit)
311 .unwrap_or(25);
312
313 let final_limit = ast.final_limit.unwrap_or(base_limit);
314 let traversal = ast.steps.iter().find_map(|step| {
315 if let QueryStep::Traverse {
316 direction,
317 label,
318 max_depth,
319 filter: _,
320 } = step
321 {
322 Some((*direction, label.as_str(), *max_depth))
323 } else {
324 None
325 }
326 });
327
328 let (fusable_filters, residual_filters) = partition_search_filters(&ast.steps);
333
334 let mut binds = Vec::new();
335 let base_candidates = match driving_table {
336 DrivingTable::VecNodes => {
337 let query = ast
338 .steps
339 .iter()
340 .find_map(|step| {
341 if let QueryStep::VectorSearch { query, .. } = step {
342 Some(query.as_str())
343 } else {
344 None
345 }
346 })
347 .unwrap_or_else(|| unreachable!("VecNodes chosen but no VectorSearch step in AST"));
348 binds.push(BindValue::Text(query.to_owned()));
349 binds.push(BindValue::Text(ast.root_kind.clone()));
350 let mut sql = format!(
365 "base_candidates AS (
366 SELECT DISTINCT src.logical_id
367 FROM (
368 SELECT chunk_id FROM vec_nodes_active
369 WHERE embedding MATCH ?1
370 LIMIT {base_limit}
371 ) vc
372 JOIN chunks c ON c.id = vc.chunk_id
373 JOIN nodes src ON src.logical_id = c.node_logical_id AND src.superseded_at IS NULL
374 WHERE src.kind = ?2",
375 );
376 for predicate in &fusable_filters {
377 append_fusable_clause(&mut sql, &mut binds, "src", predicate)?;
378 }
379 sql.push_str("\n )");
380 sql
381 }
382 DrivingTable::FtsNodes => {
383 let text_query = ast
384 .steps
385 .iter()
386 .find_map(|step| {
387 if let QueryStep::TextSearch { query, .. } = step {
388 Some(query)
389 } else {
390 None
391 }
392 })
393 .unwrap_or_else(|| unreachable!("FtsNodes chosen but no TextSearch step in AST"));
394 let rendered = render_text_query_fts5(text_query);
398 binds.push(BindValue::Text(rendered.clone()));
401 binds.push(BindValue::Text(ast.root_kind.clone()));
402 binds.push(BindValue::Text(rendered));
403 binds.push(BindValue::Text(ast.root_kind.clone()));
404 let mut sql = String::from(
409 "base_candidates AS (
410 SELECT DISTINCT n.logical_id
411 FROM (
412 SELECT src.logical_id
413 FROM fts_nodes f
414 JOIN chunks c ON c.id = f.chunk_id
415 JOIN nodes src ON src.logical_id = c.node_logical_id AND src.superseded_at IS NULL
416 WHERE fts_nodes MATCH ?1
417 AND src.kind = ?2
418 UNION
419 SELECT fp.node_logical_id AS logical_id
420 FROM fts_node_properties fp
421 JOIN nodes src ON src.logical_id = fp.node_logical_id AND src.superseded_at IS NULL
422 WHERE fts_node_properties MATCH ?3
423 AND fp.kind = ?4
424 ) u
425 JOIN nodes n ON n.logical_id = u.logical_id AND n.superseded_at IS NULL
426 WHERE 1 = 1",
427 );
428 for predicate in &fusable_filters {
429 append_fusable_clause(&mut sql, &mut binds, "n", predicate)?;
430 }
431 let _ = write!(
432 &mut sql,
433 "\n LIMIT {base_limit}\n )"
434 );
435 sql
436 }
437 DrivingTable::Nodes => {
438 binds.push(BindValue::Text(ast.root_kind.clone()));
439 let mut sql = "base_candidates AS (
440 SELECT DISTINCT src.logical_id
441 FROM nodes src
442 WHERE src.superseded_at IS NULL
443 AND src.kind = ?1"
444 .to_owned();
445 for step in &ast.steps {
450 if let QueryStep::Filter(predicate) = step {
451 match predicate {
452 Predicate::LogicalIdEq(logical_id) => {
453 binds.push(BindValue::Text(logical_id.clone()));
454 let bind_index = binds.len();
455 let _ = write!(
456 &mut sql,
457 "\n AND src.logical_id = ?{bind_index}"
458 );
459 }
460 Predicate::JsonPathEq { path, value } => {
461 validate_json_path(path)?;
462 binds.push(BindValue::Text(path.clone()));
463 let path_index = binds.len();
464 binds.push(match value {
465 ScalarValue::Text(text) => BindValue::Text(text.clone()),
466 ScalarValue::Integer(integer) => BindValue::Integer(*integer),
467 ScalarValue::Bool(boolean) => BindValue::Bool(*boolean),
468 });
469 let value_index = binds.len();
470 let _ = write!(
471 &mut sql,
472 "\n AND json_extract(src.properties, ?{path_index}) = ?{value_index}"
473 );
474 }
475 Predicate::JsonPathCompare { path, op, value } => {
476 validate_json_path(path)?;
477 binds.push(BindValue::Text(path.clone()));
478 let path_index = binds.len();
479 binds.push(match value {
480 ScalarValue::Text(text) => BindValue::Text(text.clone()),
481 ScalarValue::Integer(integer) => BindValue::Integer(*integer),
482 ScalarValue::Bool(boolean) => BindValue::Bool(*boolean),
483 });
484 let value_index = binds.len();
485 let operator = match op {
486 ComparisonOp::Gt => ">",
487 ComparisonOp::Gte => ">=",
488 ComparisonOp::Lt => "<",
489 ComparisonOp::Lte => "<=",
490 };
491 let _ = write!(
492 &mut sql,
493 "\n AND json_extract(src.properties, ?{path_index}) {operator} ?{value_index}"
494 );
495 }
496 Predicate::SourceRefEq(source_ref) => {
497 binds.push(BindValue::Text(source_ref.clone()));
498 let bind_index = binds.len();
499 let _ = write!(
500 &mut sql,
501 "\n AND src.source_ref = ?{bind_index}"
502 );
503 }
504 Predicate::ContentRefNotNull => {
505 let _ = write!(
506 &mut sql,
507 "\n AND src.content_ref IS NOT NULL"
508 );
509 }
510 Predicate::ContentRefEq(uri) => {
511 binds.push(BindValue::Text(uri.clone()));
512 let bind_index = binds.len();
513 let _ = write!(
514 &mut sql,
515 "\n AND src.content_ref = ?{bind_index}"
516 );
517 }
518 Predicate::KindEq(_)
519 | Predicate::EdgePropertyEq { .. }
520 | Predicate::EdgePropertyCompare { .. } => {
521 }
524 Predicate::JsonPathFusedEq { path, value } => {
525 validate_json_path(path)?;
526 binds.push(BindValue::Text(path.clone()));
527 let path_index = binds.len();
528 binds.push(BindValue::Text(value.clone()));
529 let value_index = binds.len();
530 let _ = write!(
531 &mut sql,
532 "\n AND json_extract(src.properties, ?{path_index}) = ?{value_index}"
533 );
534 }
535 Predicate::JsonPathFusedTimestampCmp { path, op, value } => {
536 validate_json_path(path)?;
537 binds.push(BindValue::Text(path.clone()));
538 let path_index = binds.len();
539 binds.push(BindValue::Integer(*value));
540 let value_index = binds.len();
541 let operator = match op {
542 ComparisonOp::Gt => ">",
543 ComparisonOp::Gte => ">=",
544 ComparisonOp::Lt => "<",
545 ComparisonOp::Lte => "<=",
546 };
547 let _ = write!(
548 &mut sql,
549 "\n AND json_extract(src.properties, ?{path_index}) {operator} ?{value_index}"
550 );
551 }
552 Predicate::JsonPathFusedBoolEq { path, value } => {
553 validate_json_path(path)?;
554 binds.push(BindValue::Text(path.clone()));
555 let path_index = binds.len();
556 binds.push(BindValue::Integer(i64::from(*value)));
557 let value_index = binds.len();
558 let _ = write!(
559 &mut sql,
560 "\n AND json_extract(src.properties, ?{path_index}) = ?{value_index}"
561 );
562 }
563 Predicate::JsonPathIn { path, values } => {
564 validate_json_path(path)?;
565 binds.push(BindValue::Text(path.clone()));
566 let first_param = binds.len();
567 for v in values {
568 binds.push(match v {
569 ScalarValue::Text(text) => BindValue::Text(text.clone()),
570 ScalarValue::Integer(integer) => BindValue::Integer(*integer),
571 ScalarValue::Bool(boolean) => BindValue::Bool(*boolean),
572 });
573 }
574 let placeholders = (1..=values.len())
575 .map(|i| format!("?{}", first_param + i))
576 .collect::<Vec<_>>()
577 .join(", ");
578 let _ = write!(
579 &mut sql,
580 "\n AND json_extract(src.properties, ?{first_param}) IN ({placeholders})"
581 );
582 }
583 Predicate::JsonPathFusedIn { path, values } => {
584 validate_json_path(path)?;
587 binds.push(BindValue::Text(path.clone()));
588 let first_param = binds.len();
589 for v in values {
590 binds.push(BindValue::Text(v.clone()));
591 }
592 let placeholders = (1..=values.len())
593 .map(|i| format!("?{}", first_param + i))
594 .collect::<Vec<_>>()
595 .join(", ");
596 let _ = write!(
597 &mut sql,
598 "\n AND json_extract(src.properties, ?{first_param}) IN ({placeholders})"
599 );
600 }
601 }
602 }
603 }
604 let _ = write!(
605 &mut sql,
606 "\n LIMIT {base_limit}\n )"
607 );
608 sql
609 }
610 };
611
612 let mut sql = format!("WITH RECURSIVE\n{base_candidates}");
613 let source_alias = if traversal.is_some() { "t" } else { "bc" };
614
615 if let Some((direction, label, max_depth)) = traversal {
616 binds.push(BindValue::Text(label.to_owned()));
617 let label_index = binds.len();
618 let (join_condition, next_logical_id) = match direction {
619 TraverseDirection::Out => ("e.source_logical_id = t.logical_id", "e.target_logical_id"),
620 TraverseDirection::In => ("e.target_logical_id = t.logical_id", "e.source_logical_id"),
621 };
622
623 let _ = write!(
624 &mut sql,
625 ",
626traversed(logical_id, depth, visited) AS (
627 SELECT bc.logical_id, 0, printf(',%s,', bc.logical_id)
628 FROM base_candidates bc
629 UNION ALL
630 SELECT {next_logical_id}, t.depth + 1, t.visited || {next_logical_id} || ','
631 FROM traversed t
632 JOIN edges e ON {join_condition}
633 AND e.kind = ?{label_index}
634 AND e.superseded_at IS NULL
635 WHERE t.depth < {max_depth}
636 AND instr(t.visited, printf(',%s,', {next_logical_id})) = 0
637 LIMIT {}
638)",
639 hints.hard_limit
640 );
641 }
642
643 let _ = write!(
644 &mut sql,
645 "
646SELECT DISTINCT n.row_id, n.logical_id, n.kind, n.properties, n.content_ref
647FROM {} {source_alias}
648JOIN nodes n ON n.logical_id = {source_alias}.logical_id
649 AND n.superseded_at IS NULL
650WHERE 1 = 1",
651 if traversal.is_some() {
652 "traversed"
653 } else {
654 "base_candidates"
655 }
656 );
657
658 if driving_table == DrivingTable::Nodes {
668 for step in &ast.steps {
669 if let QueryStep::Filter(Predicate::KindEq(kind)) = step {
670 binds.push(BindValue::Text(kind.clone()));
671 let bind_index = binds.len();
672 let _ = write!(&mut sql, "\n AND n.kind = ?{bind_index}");
673 }
674 }
675 } else {
676 for predicate in &residual_filters {
677 match predicate {
678 Predicate::JsonPathEq { path, value } => {
679 validate_json_path(path)?;
680 binds.push(BindValue::Text(path.clone()));
681 let path_index = binds.len();
682 binds.push(match value {
683 ScalarValue::Text(text) => BindValue::Text(text.clone()),
684 ScalarValue::Integer(integer) => BindValue::Integer(*integer),
685 ScalarValue::Bool(boolean) => BindValue::Bool(*boolean),
686 });
687 let value_index = binds.len();
688 let _ = write!(
689 &mut sql,
690 "\n AND json_extract(n.properties, ?{path_index}) = ?{value_index}",
691 );
692 }
693 Predicate::JsonPathCompare { path, op, value } => {
694 validate_json_path(path)?;
695 binds.push(BindValue::Text(path.clone()));
696 let path_index = binds.len();
697 binds.push(match value {
698 ScalarValue::Text(text) => BindValue::Text(text.clone()),
699 ScalarValue::Integer(integer) => BindValue::Integer(*integer),
700 ScalarValue::Bool(boolean) => BindValue::Bool(*boolean),
701 });
702 let value_index = binds.len();
703 let operator = match op {
704 ComparisonOp::Gt => ">",
705 ComparisonOp::Gte => ">=",
706 ComparisonOp::Lt => "<",
707 ComparisonOp::Lte => "<=",
708 };
709 let _ = write!(
710 &mut sql,
711 "\n AND json_extract(n.properties, ?{path_index}) {operator} ?{value_index}",
712 );
713 }
714 Predicate::JsonPathIn { path, values } => {
715 validate_json_path(path)?;
716 binds.push(BindValue::Text(path.clone()));
717 let first_param = binds.len();
718 for v in values {
719 binds.push(match v {
720 ScalarValue::Text(text) => BindValue::Text(text.clone()),
721 ScalarValue::Integer(integer) => BindValue::Integer(*integer),
722 ScalarValue::Bool(boolean) => BindValue::Bool(*boolean),
723 });
724 }
725 let placeholders = (1..=values.len())
726 .map(|i| format!("?{}", first_param + i))
727 .collect::<Vec<_>>()
728 .join(", ");
729 let _ = write!(
730 &mut sql,
731 "\n AND json_extract(n.properties, ?{first_param}) IN ({placeholders})",
732 );
733 }
734 Predicate::KindEq(_)
735 | Predicate::LogicalIdEq(_)
736 | Predicate::SourceRefEq(_)
737 | Predicate::ContentRefEq(_)
738 | Predicate::ContentRefNotNull
739 | Predicate::JsonPathFusedEq { .. }
740 | Predicate::JsonPathFusedTimestampCmp { .. }
741 | Predicate::JsonPathFusedBoolEq { .. }
742 | Predicate::JsonPathFusedIn { .. }
743 | Predicate::EdgePropertyEq { .. }
744 | Predicate::EdgePropertyCompare { .. } => {
745 }
749 }
750 }
751 }
752
753 let _ = write!(&mut sql, "\nLIMIT {final_limit}");
754
755 if binds.len() > MAX_BIND_PARAMETERS {
756 return Err(CompileError::TooManyBindParameters(binds.len()));
757 }
758
759 Ok(CompiledQuery {
760 sql,
761 binds,
762 shape_hash,
763 driving_table,
764 hints,
765 })
766}
767
768pub fn compile_grouped_query(ast: &QueryAst) -> Result<CompiledGroupedQuery, CompileError> {
776 if ast.expansions.len() > MAX_EXPANSION_SLOTS {
777 return Err(CompileError::TooManyExpansionSlots(ast.expansions.len()));
778 }
779
780 let mut seen = std::collections::BTreeSet::new();
781 for expansion in &ast.expansions {
782 if expansion.slot.trim().is_empty() {
783 return Err(CompileError::EmptyExpansionSlotName);
784 }
785 if expansion.max_depth > MAX_TRAVERSAL_DEPTH {
786 return Err(CompileError::TraversalTooDeep(expansion.max_depth));
787 }
788 if !seen.insert(expansion.slot.clone()) {
789 return Err(CompileError::DuplicateExpansionSlot(expansion.slot.clone()));
790 }
791 }
792 for edge_expansion in &ast.edge_expansions {
793 if edge_expansion.slot.trim().is_empty() {
794 return Err(CompileError::EmptyExpansionSlotName);
795 }
796 if edge_expansion.max_depth > MAX_TRAVERSAL_DEPTH {
797 return Err(CompileError::TraversalTooDeep(edge_expansion.max_depth));
798 }
799 if !seen.insert(edge_expansion.slot.clone()) {
800 return Err(CompileError::DuplicateExpansionSlot(
801 edge_expansion.slot.clone(),
802 ));
803 }
804 }
805
806 let mut root_ast = ast.clone();
807 root_ast.expansions.clear();
808 let root = compile_query(&root_ast)?;
809 let hints = execution_hints(ast);
810 let shape_hash = ShapeHash(hash_signature(&shape_signature(ast)));
811
812 Ok(CompiledGroupedQuery {
813 root,
814 expansions: ast.expansions.clone(),
815 edge_expansions: ast.edge_expansions.clone(),
816 shape_hash,
817 hints,
818 })
819}
820
821pub fn compile_search(ast: &QueryAst) -> Result<CompiledSearch, CompileError> {
833 let mut text_query = None;
834 let mut limit = None;
835 for step in &ast.steps {
836 match step {
837 QueryStep::TextSearch {
838 query,
839 limit: step_limit,
840 } => {
841 text_query = Some(query.clone());
842 limit = Some(*step_limit);
843 }
844 QueryStep::Filter(_)
845 | QueryStep::Search { .. }
846 | QueryStep::VectorSearch { .. }
847 | QueryStep::Traverse { .. } => {
848 }
852 }
853 }
854 let text_query = text_query.ok_or(CompileError::MissingTextSearchStep)?;
855 let limit = limit.unwrap_or(25);
856 let (fusable_filters, residual_filters) = partition_search_filters(&ast.steps);
857 Ok(CompiledSearch {
858 root_kind: ast.root_kind.clone(),
859 text_query,
860 limit,
861 fusable_filters,
862 residual_filters,
863 attribution_requested: false,
864 })
865}
866
867#[doc(hidden)]
881pub fn compile_search_plan(ast: &QueryAst) -> Result<CompiledSearchPlan, CompileError> {
882 let strict = compile_search(ast)?;
883 let (relaxed_query, was_degraded_at_plan_time) = derive_relaxed(&strict.text_query);
884 let relaxed = relaxed_query.map(|q| CompiledSearch {
885 root_kind: strict.root_kind.clone(),
886 text_query: q,
887 limit: strict.limit,
888 fusable_filters: strict.fusable_filters.clone(),
889 residual_filters: strict.residual_filters.clone(),
890 attribution_requested: strict.attribution_requested,
891 });
892 Ok(CompiledSearchPlan {
893 strict,
894 relaxed,
895 was_degraded_at_plan_time,
896 })
897}
898
899pub fn compile_search_plan_from_queries(
926 ast: &QueryAst,
927 strict: TextQuery,
928 relaxed: Option<TextQuery>,
929 limit: usize,
930 attribution_requested: bool,
931) -> Result<CompiledSearchPlan, CompileError> {
932 let (fusable_filters, residual_filters) = partition_search_filters(&ast.steps);
933 let strict_compiled = CompiledSearch {
934 root_kind: ast.root_kind.clone(),
935 text_query: strict,
936 limit,
937 fusable_filters: fusable_filters.clone(),
938 residual_filters: residual_filters.clone(),
939 attribution_requested,
940 };
941 let relaxed_compiled = relaxed.map(|q| CompiledSearch {
942 root_kind: ast.root_kind.clone(),
943 text_query: q,
944 limit,
945 fusable_filters,
946 residual_filters,
947 attribution_requested,
948 });
949 Ok(CompiledSearchPlan {
950 strict: strict_compiled,
951 relaxed: relaxed_compiled,
952 was_degraded_at_plan_time: false,
953 })
954}
955
956pub fn compile_vector_search(ast: &QueryAst) -> Result<CompiledVectorSearch, CompileError> {
971 let mut query_text = None;
972 let mut limit = None;
973 for step in &ast.steps {
974 match step {
975 QueryStep::VectorSearch {
976 query,
977 limit: step_limit,
978 } => {
979 query_text = Some(query.clone());
980 limit = Some(*step_limit);
981 }
982 QueryStep::Filter(_)
983 | QueryStep::Search { .. }
984 | QueryStep::TextSearch { .. }
985 | QueryStep::Traverse { .. } => {
986 }
990 }
991 }
992 let query_text = query_text.ok_or(CompileError::MissingVectorSearchStep)?;
993 let limit = limit.unwrap_or(25);
994 let (fusable_filters, residual_filters) = partition_search_filters(&ast.steps);
995 Ok(CompiledVectorSearch {
996 root_kind: ast.root_kind.clone(),
997 query_text,
998 limit,
999 fusable_filters,
1000 residual_filters,
1001 attribution_requested: false,
1002 })
1003}
1004
1005pub fn compile_retrieval_plan(ast: &QueryAst) -> Result<CompiledRetrievalPlan, CompileError> {
1028 let mut raw_query: Option<&str> = None;
1029 let mut limit: Option<usize> = None;
1030 for step in &ast.steps {
1031 if let QueryStep::Search {
1032 query,
1033 limit: step_limit,
1034 } = step
1035 {
1036 if raw_query.is_some() {
1037 return Err(CompileError::MultipleSearchSteps);
1038 }
1039 raw_query = Some(query.as_str());
1040 limit = Some(*step_limit);
1041 }
1042 }
1043 let raw_query = raw_query.ok_or(CompileError::MissingSearchStep)?;
1044 let limit = limit.unwrap_or(25);
1045
1046 let strict_text_query = TextQuery::parse(raw_query);
1047 let (relaxed_text_query, was_degraded_at_plan_time) = derive_relaxed(&strict_text_query);
1048
1049 let (fusable_filters, residual_filters) = partition_search_filters(&ast.steps);
1050
1051 let strict = CompiledSearch {
1052 root_kind: ast.root_kind.clone(),
1053 text_query: strict_text_query,
1054 limit,
1055 fusable_filters: fusable_filters.clone(),
1056 residual_filters: residual_filters.clone(),
1057 attribution_requested: false,
1058 };
1059 let relaxed = relaxed_text_query.map(|q| CompiledSearch {
1060 root_kind: ast.root_kind.clone(),
1061 text_query: q,
1062 limit,
1063 fusable_filters,
1064 residual_filters,
1065 attribution_requested: false,
1066 });
1067 let text = CompiledSearchPlan {
1068 strict,
1069 relaxed,
1070 was_degraded_at_plan_time,
1071 };
1072
1073 Ok(CompiledRetrievalPlan {
1081 text,
1082 vector: None,
1083 was_degraded_at_plan_time,
1084 })
1085}
1086
1087fn hash_signature(signature: &str) -> u64 {
1090 const OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
1091 const PRIME: u64 = 0x0000_0100_0000_01b3;
1092 let mut hash = OFFSET;
1093 for byte in signature.bytes() {
1094 hash ^= u64::from(byte);
1095 hash = hash.wrapping_mul(PRIME);
1096 }
1097 hash
1098}
1099
1100#[cfg(test)]
1101#[allow(clippy::expect_used, clippy::items_after_statements)]
1102mod tests {
1103 use rstest::rstest;
1104
1105 use crate::{
1106 CompileError, DrivingTable, QueryBuilder, TraverseDirection, compile_grouped_query,
1107 compile_query,
1108 };
1109
1110 #[test]
1111 fn vector_query_compiles_to_chunk_resolution() {
1112 let compiled = compile_query(
1113 &QueryBuilder::nodes("Meeting")
1114 .vector_search("budget", 5)
1115 .limit(5)
1116 .into_ast(),
1117 )
1118 .expect("compiled query");
1119
1120 assert_eq!(compiled.driving_table, DrivingTable::VecNodes);
1121 assert!(compiled.sql.contains("JOIN chunks c ON c.id = vc.chunk_id"));
1122 assert!(
1123 compiled
1124 .sql
1125 .contains("JOIN nodes src ON src.logical_id = c.node_logical_id")
1126 );
1127 }
1128
1129 #[rstest]
1130 #[case(5, 7)]
1131 #[case(3, 11)]
1132 fn structural_limits_change_shape_hash(#[case] left: usize, #[case] right: usize) {
1133 let left_compiled = compile_query(
1134 &QueryBuilder::nodes("Meeting")
1135 .text_search("budget", left)
1136 .limit(left)
1137 .into_ast(),
1138 )
1139 .expect("left query");
1140 let right_compiled = compile_query(
1141 &QueryBuilder::nodes("Meeting")
1142 .text_search("budget", right)
1143 .limit(right)
1144 .into_ast(),
1145 )
1146 .expect("right query");
1147
1148 assert_ne!(left_compiled.shape_hash, right_compiled.shape_hash);
1149 }
1150
1151 #[test]
1152 fn traversal_query_is_depth_bounded() {
1153 let compiled = compile_query(
1154 &QueryBuilder::nodes("Meeting")
1155 .text_search("budget", 5)
1156 .traverse(TraverseDirection::Out, "HAS_TASK", 3)
1157 .limit(10)
1158 .into_ast(),
1159 )
1160 .expect("compiled traversal");
1161
1162 assert!(compiled.sql.contains("WITH RECURSIVE"));
1163 assert!(compiled.sql.contains("WHERE t.depth < 3"));
1164 }
1165
1166 #[test]
1167 fn text_search_compiles_to_union_over_chunk_and_property_fts() {
1168 let compiled = compile_query(
1169 &QueryBuilder::nodes("Meeting")
1170 .text_search("budget", 25)
1171 .limit(25)
1172 .into_ast(),
1173 )
1174 .expect("compiled text search");
1175
1176 assert_eq!(compiled.driving_table, DrivingTable::FtsNodes);
1177 assert!(
1179 compiled.sql.contains("fts_nodes MATCH"),
1180 "must search chunk-backed FTS"
1181 );
1182 assert!(
1183 compiled.sql.contains("fts_node_properties MATCH"),
1184 "must search property-backed FTS"
1185 );
1186 assert!(compiled.sql.contains("UNION"), "must UNION both sources");
1187 assert_eq!(compiled.binds.len(), 4);
1189 }
1190
1191 #[test]
1192 fn logical_id_filter_is_compiled() {
1193 let compiled = compile_query(
1194 &QueryBuilder::nodes("Meeting")
1195 .filter_logical_id_eq("meeting-123")
1196 .filter_json_text_eq("$.status", "active")
1197 .limit(1)
1198 .into_ast(),
1199 )
1200 .expect("compiled query");
1201
1202 assert!(compiled.sql.contains("n.logical_id ="));
1206 assert!(compiled.sql.contains("src.logical_id ="));
1207 assert!(compiled.sql.contains("json_extract"));
1208 use crate::BindValue;
1210 assert_eq!(
1211 compiled
1212 .binds
1213 .iter()
1214 .filter(|b| matches!(b, BindValue::Text(s) if s == "meeting-123"))
1215 .count(),
1216 1
1217 );
1218 }
1219
1220 #[test]
1221 fn compile_rejects_invalid_json_path() {
1222 use crate::{Predicate, QueryStep, ScalarValue};
1223 let mut ast = QueryBuilder::nodes("Meeting").into_ast();
1224 ast.steps.push(QueryStep::Filter(Predicate::JsonPathEq {
1226 path: "$') OR 1=1 --".to_owned(),
1227 value: ScalarValue::Text("x".to_owned()),
1228 }));
1229 use crate::CompileError;
1230 let result = compile_query(&ast);
1231 assert!(
1232 matches!(result, Err(CompileError::InvalidJsonPath(_))),
1233 "expected InvalidJsonPath, got {result:?}"
1234 );
1235 }
1236
1237 #[test]
1238 fn compile_accepts_valid_json_paths() {
1239 use crate::{Predicate, QueryStep, ScalarValue};
1240 for valid_path in ["$.status", "$.foo.bar", "$.a_b.c2"] {
1241 let mut ast = QueryBuilder::nodes("Meeting").into_ast();
1242 ast.steps.push(QueryStep::Filter(Predicate::JsonPathEq {
1243 path: valid_path.to_owned(),
1244 value: ScalarValue::Text("v".to_owned()),
1245 }));
1246 assert!(
1247 compile_query(&ast).is_ok(),
1248 "expected valid path {valid_path:?} to compile"
1249 );
1250 }
1251 }
1252
1253 #[test]
1254 fn compile_rejects_too_many_bind_parameters() {
1255 use crate::{Predicate, QueryStep, ScalarValue};
1256 let mut ast = QueryBuilder::nodes("Meeting").into_ast();
1257 for i in 0..8 {
1260 ast.steps.push(QueryStep::Filter(Predicate::JsonPathEq {
1261 path: format!("$.f{i}"),
1262 value: ScalarValue::Text("v".to_owned()),
1263 }));
1264 }
1265 use crate::CompileError;
1266 let result = compile_query(&ast);
1267 assert!(
1268 matches!(result, Err(CompileError::TooManyBindParameters(17))),
1269 "expected TooManyBindParameters(17), got {result:?}"
1270 );
1271 }
1272
1273 #[test]
1274 fn compile_rejects_excessive_traversal_depth() {
1275 let result = compile_query(
1276 &QueryBuilder::nodes("Meeting")
1277 .text_search("budget", 5)
1278 .traverse(TraverseDirection::Out, "HAS_TASK", 51)
1279 .limit(10)
1280 .into_ast(),
1281 );
1282 assert!(
1283 matches!(result, Err(CompileError::TraversalTooDeep(51))),
1284 "expected TraversalTooDeep(51), got {result:?}"
1285 );
1286 }
1287
1288 #[test]
1289 fn grouped_queries_with_same_structure_share_shape_hash() {
1290 let left = compile_grouped_query(
1291 &QueryBuilder::nodes("Meeting")
1292 .text_search("budget", 5)
1293 .expand("tasks", TraverseDirection::Out, "HAS_TASK", 1, None, None)
1294 .limit(10)
1295 .into_ast(),
1296 )
1297 .expect("left grouped query");
1298 let right = compile_grouped_query(
1299 &QueryBuilder::nodes("Meeting")
1300 .text_search("planning", 5)
1301 .expand("tasks", TraverseDirection::Out, "HAS_TASK", 1, None, None)
1302 .limit(10)
1303 .into_ast(),
1304 )
1305 .expect("right grouped query");
1306
1307 assert_eq!(left.shape_hash, right.shape_hash);
1308 }
1309
1310 #[test]
1311 fn compile_grouped_rejects_duplicate_expansion_slot_names() {
1312 let result = compile_grouped_query(
1313 &QueryBuilder::nodes("Meeting")
1314 .expand("tasks", TraverseDirection::Out, "HAS_TASK", 1, None, None)
1315 .expand(
1316 "tasks",
1317 TraverseDirection::Out,
1318 "HAS_DECISION",
1319 1,
1320 None,
1321 None,
1322 )
1323 .into_ast(),
1324 );
1325
1326 assert!(
1327 matches!(result, Err(CompileError::DuplicateExpansionSlot(ref slot)) if slot == "tasks"),
1328 "expected DuplicateExpansionSlot(\"tasks\"), got {result:?}"
1329 );
1330 }
1331
1332 #[test]
1333 fn flat_compile_rejects_queries_with_expansions() {
1334 let result = compile_query(
1335 &QueryBuilder::nodes("Meeting")
1336 .expand("tasks", TraverseDirection::Out, "HAS_TASK", 1, None, None)
1337 .into_ast(),
1338 );
1339
1340 assert!(
1341 matches!(
1342 result,
1343 Err(CompileError::FlatCompileDoesNotSupportExpansions)
1344 ),
1345 "expected FlatCompileDoesNotSupportExpansions, got {result:?}"
1346 );
1347 }
1348
1349 #[test]
1350 fn json_path_compiled_as_bind_parameter() {
1351 let compiled = compile_query(
1352 &QueryBuilder::nodes("Meeting")
1353 .filter_json_text_eq("$.status", "active")
1354 .limit(1)
1355 .into_ast(),
1356 )
1357 .expect("compiled query");
1358
1359 assert!(
1361 !compiled.sql.contains("'$.status'"),
1362 "JSON path must not appear as a SQL string literal"
1363 );
1364 assert!(
1365 compiled.sql.contains("json_extract(src.properties, ?"),
1366 "JSON path must be a bind parameter (pushed into base_candidates for Nodes driver)"
1367 );
1368 use crate::BindValue;
1370 assert!(
1371 compiled
1372 .binds
1373 .iter()
1374 .any(|b| matches!(b, BindValue::Text(s) if s == "$.status"))
1375 );
1376 assert!(
1377 compiled
1378 .binds
1379 .iter()
1380 .any(|b| matches!(b, BindValue::Text(s) if s == "active"))
1381 );
1382 }
1383
1384 #[test]
1393 fn nodes_driver_pushes_json_eq_filter_into_base_candidates() {
1394 let compiled = compile_query(
1395 &QueryBuilder::nodes("Meeting")
1396 .filter_json_text_eq("$.status", "active")
1397 .limit(5)
1398 .into_ast(),
1399 )
1400 .expect("compiled query");
1401
1402 assert_eq!(compiled.driving_table, DrivingTable::Nodes);
1403 assert!(
1406 compiled.sql.contains("json_extract(src.properties, ?"),
1407 "json_extract must reference src (base_candidates), got:\n{}",
1408 compiled.sql,
1409 );
1410 assert!(
1411 !compiled.sql.contains("json_extract(n.properties, ?"),
1412 "json_extract must NOT appear in outer WHERE for Nodes driver, got:\n{}",
1413 compiled.sql,
1414 );
1415 }
1416
1417 #[test]
1418 fn nodes_driver_pushes_json_compare_filter_into_base_candidates() {
1419 let compiled = compile_query(
1420 &QueryBuilder::nodes("Meeting")
1421 .filter_json_integer_gte("$.priority", 5)
1422 .limit(10)
1423 .into_ast(),
1424 )
1425 .expect("compiled query");
1426
1427 assert_eq!(compiled.driving_table, DrivingTable::Nodes);
1428 assert!(
1429 compiled.sql.contains("json_extract(src.properties, ?"),
1430 "comparison filter must be in base_candidates, got:\n{}",
1431 compiled.sql,
1432 );
1433 assert!(
1434 !compiled.sql.contains("json_extract(n.properties, ?"),
1435 "comparison filter must NOT be in outer WHERE for Nodes driver",
1436 );
1437 assert!(
1438 compiled.sql.contains(">= ?"),
1439 "expected >= operator in SQL, got:\n{}",
1440 compiled.sql,
1441 );
1442 }
1443
1444 #[test]
1445 fn nodes_driver_pushes_source_ref_filter_into_base_candidates() {
1446 let compiled = compile_query(
1447 &QueryBuilder::nodes("Meeting")
1448 .filter_source_ref_eq("ref-123")
1449 .limit(5)
1450 .into_ast(),
1451 )
1452 .expect("compiled query");
1453
1454 assert_eq!(compiled.driving_table, DrivingTable::Nodes);
1455 assert!(
1456 compiled.sql.contains("src.source_ref = ?"),
1457 "source_ref filter must be in base_candidates, got:\n{}",
1458 compiled.sql,
1459 );
1460 assert!(
1461 !compiled.sql.contains("n.source_ref = ?"),
1462 "source_ref filter must NOT be in outer WHERE for Nodes driver",
1463 );
1464 }
1465
1466 #[test]
1467 fn nodes_driver_pushes_multiple_filters_into_base_candidates() {
1468 let compiled = compile_query(
1469 &QueryBuilder::nodes("Meeting")
1470 .filter_logical_id_eq("meeting-1")
1471 .filter_json_text_eq("$.status", "active")
1472 .filter_json_integer_gte("$.priority", 5)
1473 .filter_source_ref_eq("ref-abc")
1474 .limit(1)
1475 .into_ast(),
1476 )
1477 .expect("compiled query");
1478
1479 assert_eq!(compiled.driving_table, DrivingTable::Nodes);
1480 assert!(
1482 compiled.sql.contains("src.logical_id = ?"),
1483 "logical_id filter must be in base_candidates",
1484 );
1485 assert!(
1486 compiled.sql.contains("json_extract(src.properties, ?"),
1487 "JSON filters must be in base_candidates",
1488 );
1489 assert!(
1490 compiled.sql.contains("src.source_ref = ?"),
1491 "source_ref filter must be in base_candidates",
1492 );
1493 use crate::BindValue;
1495 assert_eq!(
1496 compiled
1497 .binds
1498 .iter()
1499 .filter(|b| matches!(b, BindValue::Text(s) if s == "meeting-1"))
1500 .count(),
1501 1,
1502 "logical_id bind must not be duplicated"
1503 );
1504 assert_eq!(
1505 compiled
1506 .binds
1507 .iter()
1508 .filter(|b| matches!(b, BindValue::Text(s) if s == "ref-abc"))
1509 .count(),
1510 1,
1511 "source_ref bind must not be duplicated"
1512 );
1513 }
1514
1515 #[test]
1516 fn fts_driver_keeps_json_filter_residual_but_fuses_kind() {
1517 let compiled = compile_query(
1521 &QueryBuilder::nodes("Meeting")
1522 .text_search("budget", 5)
1523 .filter_json_text_eq("$.status", "active")
1524 .filter_kind_eq("Meeting")
1525 .limit(5)
1526 .into_ast(),
1527 )
1528 .expect("compiled query");
1529
1530 assert_eq!(compiled.driving_table, DrivingTable::FtsNodes);
1531 assert!(
1533 compiled.sql.contains("json_extract(n.properties, ?"),
1534 "JSON filter must stay residual in outer WHERE, got:\n{}",
1535 compiled.sql,
1536 );
1537 let (cte, outer) = compiled
1540 .sql
1541 .split_once("SELECT DISTINCT n.row_id")
1542 .expect("query has final SELECT");
1543 assert!(
1544 cte.contains("AND n.kind = ?"),
1545 "KindEq must be fused inside base_candidates CTE, got CTE:\n{cte}"
1546 );
1547 assert!(
1549 !outer.contains("AND n.kind = ?"),
1550 "KindEq must NOT appear in outer WHERE for FTS driver, got outer:\n{outer}"
1551 );
1552 }
1553
1554 #[test]
1555 fn fts_driver_fuses_kind_filter() {
1556 let compiled = compile_query(
1557 &QueryBuilder::nodes("Goal")
1558 .text_search("budget", 5)
1559 .filter_kind_eq("Goal")
1560 .limit(5)
1561 .into_ast(),
1562 )
1563 .expect("compiled query");
1564
1565 assert_eq!(compiled.driving_table, DrivingTable::FtsNodes);
1566 let (cte, outer) = compiled
1567 .sql
1568 .split_once("SELECT DISTINCT n.row_id")
1569 .expect("query has final SELECT");
1570 assert!(
1571 cte.contains("AND n.kind = ?"),
1572 "KindEq must be fused inside base_candidates, got:\n{cte}"
1573 );
1574 assert!(
1575 !outer.contains("AND n.kind = ?"),
1576 "KindEq must NOT be in outer WHERE, got:\n{outer}"
1577 );
1578 }
1579
1580 #[test]
1581 fn vec_driver_fuses_kind_filter() {
1582 let compiled = compile_query(
1583 &QueryBuilder::nodes("Goal")
1584 .vector_search("budget", 5)
1585 .filter_kind_eq("Goal")
1586 .limit(5)
1587 .into_ast(),
1588 )
1589 .expect("compiled query");
1590
1591 assert_eq!(compiled.driving_table, DrivingTable::VecNodes);
1592 let (cte, outer) = compiled
1593 .sql
1594 .split_once("SELECT DISTINCT n.row_id")
1595 .expect("query has final SELECT");
1596 assert!(
1597 cte.contains("AND src.kind = ?"),
1598 "KindEq must be fused inside base_candidates, got:\n{cte}"
1599 );
1600 assert!(
1601 !outer.contains("AND n.kind = ?"),
1602 "KindEq must NOT be in outer WHERE, got:\n{outer}"
1603 );
1604 }
1605
1606 #[test]
1607 fn fts5_query_bind_uses_rendered_literals() {
1608 let compiled = compile_query(
1609 &QueryBuilder::nodes("Meeting")
1610 .text_search("User's name", 5)
1611 .limit(5)
1612 .into_ast(),
1613 )
1614 .expect("compiled query");
1615
1616 use crate::BindValue;
1617 assert!(
1618 compiled
1619 .binds
1620 .iter()
1621 .any(|b| matches!(b, BindValue::Text(s) if s == "\"User's\" \"name\"")),
1622 "FTS5 query bind should use rendered literal terms; got {:?}",
1623 compiled.binds
1624 );
1625 }
1626
1627 #[test]
1628 fn fts5_query_bind_supports_or_operator() {
1629 let compiled = compile_query(
1630 &QueryBuilder::nodes("Meeting")
1631 .text_search("ship OR docs", 5)
1632 .limit(5)
1633 .into_ast(),
1634 )
1635 .expect("compiled query");
1636
1637 use crate::BindValue;
1638 assert!(
1639 compiled
1640 .binds
1641 .iter()
1642 .any(|b| matches!(b, BindValue::Text(s) if s == "\"ship\" OR \"docs\"")),
1643 "FTS5 query bind should preserve supported OR; got {:?}",
1644 compiled.binds
1645 );
1646 }
1647
1648 #[test]
1649 fn fts5_query_bind_supports_not_operator() {
1650 let compiled = compile_query(
1651 &QueryBuilder::nodes("Meeting")
1652 .text_search("ship NOT blocked", 5)
1653 .limit(5)
1654 .into_ast(),
1655 )
1656 .expect("compiled query");
1657
1658 use crate::BindValue;
1659 assert!(
1660 compiled
1661 .binds
1662 .iter()
1663 .any(|b| matches!(b, BindValue::Text(s) if s == "\"ship\" NOT \"blocked\"")),
1664 "FTS5 query bind should preserve supported NOT; got {:?}",
1665 compiled.binds
1666 );
1667 }
1668
1669 #[test]
1670 fn fts5_query_bind_literalizes_clause_leading_not() {
1671 let compiled = compile_query(
1672 &QueryBuilder::nodes("Meeting")
1673 .text_search("NOT blocked", 5)
1674 .limit(5)
1675 .into_ast(),
1676 )
1677 .expect("compiled query");
1678
1679 use crate::BindValue;
1680 assert!(
1681 compiled
1682 .binds
1683 .iter()
1684 .any(|b| matches!(b, BindValue::Text(s) if s == "\"NOT\" \"blocked\"")),
1685 "Clause-leading NOT should degrade to literals; got {:?}",
1686 compiled.binds
1687 );
1688 }
1689
1690 #[test]
1691 fn fts5_query_bind_literalizes_or_not_sequence() {
1692 let compiled = compile_query(
1693 &QueryBuilder::nodes("Meeting")
1694 .text_search("ship OR NOT blocked", 5)
1695 .limit(5)
1696 .into_ast(),
1697 )
1698 .expect("compiled query");
1699
1700 use crate::BindValue;
1701 assert!(
1702 compiled.binds.iter().any(
1703 |b| matches!(b, BindValue::Text(s) if s == "\"ship\" \"OR\" \"NOT\" \"blocked\"")
1704 ),
1705 "`OR NOT` should degrade to literals rather than emit invalid FTS5; got {:?}",
1706 compiled.binds
1707 );
1708 }
1709
1710 #[test]
1711 fn compile_retrieval_plan_accepts_search_step() {
1712 use crate::{
1713 CompileError, Predicate, QueryAst, QueryStep, TextQuery, compile_retrieval_plan,
1714 };
1715 let ast = QueryAst {
1716 root_kind: "Goal".to_owned(),
1717 steps: vec![
1718 QueryStep::Search {
1719 query: "ship quarterly docs".to_owned(),
1720 limit: 7,
1721 },
1722 QueryStep::Filter(Predicate::KindEq("Goal".to_owned())),
1723 ],
1724 expansions: vec![],
1725 edge_expansions: vec![],
1726 final_limit: None,
1727 };
1728 let plan = compile_retrieval_plan(&ast).expect("compiles");
1729 assert_eq!(plan.text.strict.root_kind, "Goal");
1730 assert_eq!(plan.text.strict.limit, 7);
1731 assert_eq!(plan.text.strict.fusable_filters.len(), 1);
1733 assert!(plan.text.strict.residual_filters.is_empty());
1734 assert_eq!(
1737 plan.text.strict.text_query,
1738 TextQuery::And(vec![
1739 TextQuery::Term("ship".into()),
1740 TextQuery::Term("quarterly".into()),
1741 TextQuery::Term("docs".into()),
1742 ])
1743 );
1744 let relaxed = plan.text.relaxed.as_ref().expect("relaxed branch present");
1746 assert_eq!(
1747 relaxed.text_query,
1748 TextQuery::Or(vec![
1749 TextQuery::Term("ship".into()),
1750 TextQuery::Term("quarterly".into()),
1751 TextQuery::Term("docs".into()),
1752 ])
1753 );
1754 assert_eq!(relaxed.fusable_filters.len(), 1);
1755 assert!(!plan.was_degraded_at_plan_time);
1756 let _ = std::any::TypeId::of::<CompileError>();
1758 }
1759
1760 #[test]
1761 fn compile_retrieval_plan_rejects_ast_without_search_step() {
1762 use crate::{CompileError, QueryBuilder, compile_retrieval_plan};
1763 let ast = QueryBuilder::nodes("Goal")
1764 .filter_kind_eq("Goal")
1765 .into_ast();
1766 let result = compile_retrieval_plan(&ast);
1767 assert!(
1768 matches!(result, Err(CompileError::MissingSearchStep)),
1769 "expected MissingSearchStep, got {result:?}"
1770 );
1771 }
1772
1773 #[test]
1774 fn compile_retrieval_plan_rejects_ast_with_multiple_search_steps() {
1775 use crate::{CompileError, QueryAst, QueryStep, compile_retrieval_plan};
1780 let ast = QueryAst {
1781 root_kind: "Goal".to_owned(),
1782 steps: vec![
1783 QueryStep::Search {
1784 query: "alpha".to_owned(),
1785 limit: 5,
1786 },
1787 QueryStep::Search {
1788 query: "bravo".to_owned(),
1789 limit: 10,
1790 },
1791 ],
1792 expansions: vec![],
1793 edge_expansions: vec![],
1794 final_limit: None,
1795 };
1796 let result = compile_retrieval_plan(&ast);
1797 assert!(
1798 matches!(result, Err(CompileError::MultipleSearchSteps)),
1799 "expected MultipleSearchSteps, got {result:?}"
1800 );
1801 }
1802
1803 #[test]
1804 fn compile_retrieval_plan_v1_always_leaves_vector_empty() {
1805 use crate::{QueryAst, QueryStep, compile_retrieval_plan};
1811 for query in ["ship quarterly docs", "single", "", " "] {
1812 let ast = QueryAst {
1813 root_kind: "Goal".to_owned(),
1814 steps: vec![QueryStep::Search {
1815 query: query.to_owned(),
1816 limit: 10,
1817 }],
1818 expansions: vec![],
1819 edge_expansions: vec![],
1820 final_limit: None,
1821 };
1822 let plan = compile_retrieval_plan(&ast).expect("compiles");
1823 assert!(
1824 plan.vector.is_none(),
1825 "Phase 12 v1 must always leave the vector branch empty (query = {query:?})"
1826 );
1827 }
1828 }
1829
1830 #[test]
1831 fn fused_json_text_eq_pushes_into_search_cte_inner_where() {
1832 let mut ast = QueryBuilder::nodes("Goal")
1837 .text_search("budget", 5)
1838 .into_ast();
1839 ast.steps.push(crate::QueryStep::Filter(
1840 crate::Predicate::JsonPathFusedEq {
1841 path: "$.status".to_owned(),
1842 value: "active".to_owned(),
1843 },
1844 ));
1845 let compiled = compile_query(&ast).expect("compile");
1846
1847 assert!(
1849 compiled.sql.contains("AND json_extract(n.properties, ?"),
1850 "fused json text-eq must land on n.properties inside the CTE; got {}",
1851 compiled.sql
1852 );
1853 assert!(
1856 !compiled.sql.contains("h.properties"),
1857 "sql should not mention h.properties (only compiled_search uses that alias)"
1858 );
1859 }
1860
1861 #[test]
1862 fn fused_json_timestamp_cmp_emits_each_operator() {
1863 for (op, op_str) in [
1864 (crate::ComparisonOp::Gt, ">"),
1865 (crate::ComparisonOp::Gte, ">="),
1866 (crate::ComparisonOp::Lt, "<"),
1867 (crate::ComparisonOp::Lte, "<="),
1868 ] {
1869 let mut ast = QueryBuilder::nodes("Goal")
1870 .text_search("budget", 5)
1871 .into_ast();
1872 ast.steps.push(crate::QueryStep::Filter(
1873 crate::Predicate::JsonPathFusedTimestampCmp {
1874 path: "$.written_at".to_owned(),
1875 op,
1876 value: 1_700_000_000,
1877 },
1878 ));
1879 let compiled = compile_query(&ast).expect("compile");
1880 let needle = "json_extract(n.properties, ?";
1881 assert!(
1882 compiled.sql.contains(needle) && compiled.sql.contains(op_str),
1883 "operator {op_str} must appear in emitted SQL for fused timestamp cmp"
1884 );
1885 }
1886 }
1887
1888 #[test]
1889 fn non_fused_json_filters_still_emit_outer_where() {
1890 let compiled = compile_query(
1897 &QueryBuilder::nodes("Goal")
1898 .text_search("budget", 5)
1899 .filter_json_text_eq("$.status", "active")
1900 .into_ast(),
1901 )
1902 .expect("compile");
1903
1904 assert!(
1911 compiled
1912 .sql
1913 .contains("\n AND json_extract(n.properties, ?"),
1914 "non-fused filter_json_text_eq must emit into outer WHERE, got {}",
1915 compiled.sql
1916 );
1917 }
1918
1919 #[test]
1920 fn fused_json_text_eq_pushes_into_vector_cte_inner_where() {
1921 let mut ast = QueryBuilder::nodes("Goal")
1925 .vector_search("budget", 5)
1926 .into_ast();
1927 ast.steps.push(crate::QueryStep::Filter(
1928 crate::Predicate::JsonPathFusedEq {
1929 path: "$.status".to_owned(),
1930 value: "active".to_owned(),
1931 },
1932 ));
1933 let compiled = compile_query(&ast).expect("compile");
1934 assert_eq!(compiled.driving_table, DrivingTable::VecNodes);
1935 assert!(
1936 compiled.sql.contains("AND json_extract(src.properties, ?"),
1937 "fused json text-eq on vector path must land on src.properties, got {}",
1938 compiled.sql
1939 );
1940 }
1941
1942 #[test]
1943 fn fts5_query_bind_preserves_lowercase_not_as_literal_text() {
1944 let compiled = compile_query(
1945 &QueryBuilder::nodes("Meeting")
1946 .text_search("not a ship", 5)
1947 .limit(5)
1948 .into_ast(),
1949 )
1950 .expect("compiled query");
1951
1952 use crate::BindValue;
1953 assert!(
1954 compiled
1955 .binds
1956 .iter()
1957 .any(|b| matches!(b, BindValue::Text(s) if s == "\"not\" \"a\" \"ship\"")),
1958 "Lowercase not should remain a literal term sequence; got {:?}",
1959 compiled.binds
1960 );
1961 }
1962
1963 #[test]
1964 fn traverse_filter_field_accepted_in_ast() {
1965 use crate::{Predicate, QueryStep};
1969 let step = QueryStep::Traverse {
1970 direction: TraverseDirection::Out,
1971 label: "HAS_TASK".to_owned(),
1972 max_depth: 1,
1973 filter: None,
1974 };
1975 assert!(matches!(step, QueryStep::Traverse { filter: None, .. }));
1976
1977 let step_with_filter = QueryStep::Traverse {
1978 direction: TraverseDirection::Out,
1979 label: "HAS_TASK".to_owned(),
1980 max_depth: 1,
1981 filter: Some(Predicate::KindEq("Task".to_owned())),
1982 };
1983 assert!(matches!(
1984 step_with_filter,
1985 QueryStep::Traverse {
1986 filter: Some(_),
1987 ..
1988 }
1989 ));
1990 }
1991}