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