Skip to main content

fathomdb_query/
compile.rs

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/// A typed bind value for a compiled SQL query parameter.
14#[derive(Clone, Debug, PartialEq, Eq)]
15pub enum BindValue {
16    /// A UTF-8 text parameter.
17    Text(String),
18    /// A 64-bit signed integer parameter.
19    Integer(i64),
20    /// A boolean parameter.
21    Bool(bool),
22}
23
24/// A deterministic hash of a query's structural shape, independent of bind values.
25#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
26pub struct ShapeHash(pub u64);
27
28/// A fully compiled query ready for execution against `SQLite`.
29#[derive(Clone, Debug, PartialEq, Eq)]
30pub struct CompiledQuery {
31    /// The generated SQL text.
32    pub sql: String,
33    /// Positional bind parameters for the SQL.
34    pub binds: Vec<BindValue>,
35    /// Structural shape hash for caching.
36    pub shape_hash: ShapeHash,
37    /// The driving table chosen by the query planner.
38    pub driving_table: DrivingTable,
39    /// Execution hints derived from the query shape.
40    pub hints: crate::ExecutionHints,
41}
42
43/// A compiled grouped query containing a root query and expansion slots.
44#[derive(Clone, Debug, PartialEq, Eq)]
45pub struct CompiledGroupedQuery {
46    /// The root flat query.
47    pub root: CompiledQuery,
48    /// Expansion slots to evaluate per root result.
49    pub expansions: Vec<ExpansionSlot>,
50    /// Edge-projecting expansion slots to evaluate per root result.
51    pub edge_expansions: Vec<EdgeExpansionSlot>,
52    /// Structural shape hash covering the root query and all expansion slots.
53    pub shape_hash: ShapeHash,
54    /// Execution hints derived from the grouped query shape.
55    pub hints: crate::ExecutionHints,
56}
57
58/// Errors that can occur during query compilation.
59#[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
87/// Security fix H-1: Validate JSON path against a strict allowlist pattern to
88/// prevent SQL injection. Retained as defense-in-depth even though the path is
89/// now parameterized (see `FIX(review)` in `compile_query`). Only paths like
90/// `$.foo`, `$.foo.bar_baz` are allowed.
91fn 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/// Append a fusable predicate as an `AND` clause referencing `alias`.
109///
110/// Only the fusable variants (those that can be evaluated against columns on
111/// the `nodes` table join inside a search CTE) are supported — callers must
112/// pre-partition predicates via
113/// [`crate::fusion::partition_search_filters`]. Residual predicates panic via
114/// `unreachable!`.
115#[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
230// FIX(review): max_depth was unbounded — usize::MAX produces an effectively infinite CTE.
231// Options: (A) silent clamp at compile, (B) reject with CompileError, (C) validate in builder.
232// Chose (B): consistent with existing TooManyTraversals/TooManyBindParameters pattern.
233// The compiler is the validation boundary; silent clamping would surprise callers.
234const MAX_TRAVERSAL_DEPTH: usize = 50;
235
236/// Compile a [`QueryAst`] into a [`CompiledQuery`] ready for execution.
237///
238/// # Compilation strategy
239///
240/// The compiled SQL is structured as a `WITH RECURSIVE` CTE named
241/// `base_candidates` followed by a final `SELECT ... JOIN nodes` projection.
242///
243/// For the **Nodes** driving table (no FTS/vector search), all filter
244/// predicates (`LogicalIdEq`, `JsonPathEq`, `JsonPathCompare`,
245/// `SourceRefEq`) are pushed into the `base_candidates` CTE so that the
246/// CTE's `LIMIT` applies *after* filtering. Without this pushdown the LIMIT
247/// would truncate the candidate set before property filters run, silently
248/// excluding nodes whose properties satisfy the filter but whose insertion
249/// order falls outside the limit window.
250///
251/// For **FTS** and **vector** driving tables, fusable predicates
252/// (`KindEq`, `LogicalIdEq`, `SourceRefEq`, `ContentRefEq`,
253/// `ContentRefNotNull`) are pushed into the `base_candidates` CTE so that
254/// the CTE's `LIMIT` applies *after* filtering; residual predicates
255/// (`JsonPathEq`, `JsonPathCompare`) remain in the outer `WHERE` because
256/// they require `json_extract` on the outer `nodes.properties` column.
257///
258/// # Errors
259///
260/// Returns [`CompileError::TooManyTraversals`] if more than one traversal step
261/// is present, or [`CompileError::TooManyBindParameters`] if the resulting SQL
262/// would require more than 15 bind parameters.
263///
264/// # Panics
265///
266/// Panics (via `unreachable!`) if the AST is internally inconsistent — for
267/// example, if `choose_driving_table` selects `VecNodes` but no
268/// `VectorSearch` step is present in the AST. This cannot happen through the
269/// public [`QueryBuilder`] API.
270#[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    // Partition Filter predicates for the search-driven paths into fusable
329    // (injected into the search CTE's WHERE) and residual (left in the outer
330    // WHERE) sets. The Nodes path pushes *every* predicate into the CTE
331    // directly and ignores this partition.
332    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            // sqlite-vec requires the LIMIT/k constraint to be visible directly on the
351            // vec0 KNN scan. Using a sub-select isolates the vec0 LIMIT so the join
352            // with chunks/nodes does not prevent the query planner from recognising it.
353            //
354            // ASYMMETRY (known gap, P2-3): the inner `LIMIT {base_limit}` runs
355            // BEFORE the fusable-filter `WHERE` below, so fused predicates on
356            // `src` (e.g. `kind_eq`) filter a candidate pool that has already
357            // been narrowed to `base_limit` KNN neighbours. A
358            // `vector_search("x", 5).filter_kind_eq("Goal")` can therefore
359            // return fewer than 5 Goal hits even when more exist. Fixing this
360            // requires overfetching from vec0 and re-ranking/re-limiting after
361            // the filter — explicitly out of scope for Phase 2 filter fusion.
362            // The FTS branch below does NOT share this asymmetry because its
363            // outer LIMIT wraps the post-filter SELECT.
364            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            // Render the typed text-query subset into safe FTS5 syntax. Only
395            // supported operators are emitted as control syntax; all literal
396            // terms and phrases remain quoted and escaped.
397            let rendered = render_text_query_fts5(text_query);
398            // Each FTS5 virtual table requires its own MATCH bind parameter;
399            // reusing indices across the UNION is not supported by SQLite.
400            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            // Wrap the chunk/property UNION in an outer SELECT that joins
405            // `nodes` once so fusable filters (kind/logical_id/source_ref/
406            // content_ref) can reference node columns directly, bringing them
407            // inside the CTE's LIMIT window.
408            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            // Push filter predicates into base_candidates so the LIMIT applies
446            // after filtering, not before. Without this, the CTE may truncate
447            // the candidate set before property/source_ref filters run, causing
448            // nodes that satisfy the filter to be excluded from results.
449            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                            // KindEq: already filtered by ast.root_kind above.
522                            // EdgeProperty*: not valid in the main query filter path.
523                        }
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                            // On the Nodes driver all predicates are pushed inline;
585                            // treat like JsonPathIn but values are always text.
586                            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    // Outer WHERE emission. The Nodes driving table pushes every filter
659    // into `base_candidates` already, so only `KindEq` (handled separately
660    // via `root_kind`) needs to be re-emitted outside — we iterate
661    // `ast.steps` to catch it. For the search-driven paths (FtsNodes,
662    // VecNodes) we iterate the `residual_filters` partition directly
663    // instead of re-classifying predicates via `is_fusable()`. This makes
664    // `partition_search_filters` the single source of truth for the
665    // fusable/residual split: adding a new fusable variant automatically
666    // drops it from the outer WHERE without a separate audit of this loop.
667    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                    // Fusable — already injected into base_candidates by
746                    // `partition_search_filters`. Edge property predicates
747                    // are not valid in the main query path.
748                }
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
768/// Compile a [`QueryAst`] into a [`CompiledGroupedQuery`] for grouped execution.
769///
770/// # Errors
771///
772/// Returns a [`CompileError`] if the AST exceeds expansion-slot limits,
773/// contains empty slot names, or specifies a traversal depth beyond the
774/// configured maximum.
775pub 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
821/// Compile a [`QueryAst`] into a [`CompiledSearch`] describing an adaptive
822/// text-search execution.
823///
824/// Unlike [`compile_query`], this path does not emit SQL directly: the
825/// coordinator owns the search SELECT so it can project the richer row shape
826/// (score, source, snippet, projection id) that flat queries do not need.
827///
828/// # Errors
829///
830/// Returns [`CompileError::MissingTextSearchStep`] if the AST contains no
831/// [`QueryStep::TextSearch`] step.
832pub 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                // Filter steps are partitioned below; Search/Vector/Traverse
849                // steps are not composable with text search in the adaptive
850                // surface yet.
851            }
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/// Compile a [`QueryAst`] into a [`CompiledSearchPlan`] whose strict branch
868/// is the user's [`TextQuery`] and whose relaxed branch is derived via
869/// [`derive_relaxed`].
870///
871/// Reserved for Phase 7 SDK bindings that will construct plans from typed
872/// AST fragments. The coordinator currently builds its adaptive plan
873/// directly inside `execute_compiled_search` from an already-compiled
874/// [`CompiledSearch`], so this helper has no in-tree caller; it is kept
875/// as a public entry point for forthcoming surface bindings.
876///
877/// # Errors
878/// Returns [`CompileError::MissingTextSearchStep`] if the AST contains no
879/// [`QueryStep::TextSearch`] step.
880#[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
899/// Compile a caller-provided strict/relaxed [`TextQuery`] pair into a
900/// [`CompiledSearchPlan`] against a [`QueryAst`] that supplies the kind
901/// root, filters, and limit.
902///
903/// This is the two-query entry point used by `Engine::fallback_search`. The
904/// caller's relaxed [`TextQuery`] is used verbatim — it is NOT passed through
905/// [`derive_relaxed`], and the 4-alternative
906/// [`crate::RELAXED_BRANCH_CAP`] is NOT applied. As a result
907/// [`CompiledSearchPlan::was_degraded_at_plan_time`] is always `false` on
908/// this path.
909///
910/// The AST supplies:
911///  - `root_kind` — reused for both branches
912///  - filter steps — partitioned once via [`partition_search_filters`] and
913///    shared unchanged across both branches
914///  - `limit` from the text-search step (or the default used by
915///    [`compile_search`]) when present; if the AST has no `TextSearch` step,
916///    the caller-supplied `limit` is used
917///
918/// Any `TextSearch` step already on the AST is IGNORED — `strict` and
919/// `relaxed` come from the caller. `Vector`/`Traverse` steps are also
920/// ignored for symmetry with [`compile_search`].
921///
922/// # Errors
923/// Returns [`CompileError`] if filter partitioning produces an unsupported
924/// shape (currently none; reserved for forward compatibility).
925pub 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
956/// Compile a [`QueryAst`] into a [`CompiledVectorSearch`] describing a
957/// vector-only retrieval execution.
958///
959/// Mirrors [`compile_search`] structurally. The AST must contain exactly one
960/// [`QueryStep::VectorSearch`] step; filters following the search step are
961/// partitioned by [`partition_search_filters`] into fusable and residual
962/// sets. Unlike [`compile_search`] this path does not produce a
963/// [`TextQuery`]; the caller's raw query string is preserved verbatim for
964/// the coordinator to bind to `embedding MATCH ?`.
965///
966/// # Errors
967///
968/// Returns [`CompileError::MissingVectorSearchStep`] if the AST contains no
969/// [`QueryStep::VectorSearch`] step.
970pub 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                // Filter steps are partitioned below; Search/TextSearch/
987                // Traverse steps are not composable with vector search in
988                // the standalone vector retrieval path.
989            }
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
1005/// Compile a [`QueryAst`] containing a [`QueryStep::Search`] into a
1006/// [`CompiledRetrievalPlan`] describing the bounded set of retrieval branches
1007/// the Phase 12 planner may run.
1008///
1009/// The raw query string carried by the `Search` step is parsed into a
1010/// strict [`TextQuery`] (via [`TextQuery::parse`]) and a relaxed sibling is
1011/// derived via [`derive_relaxed`]. Both branches share the post-search
1012/// fusable/residual filter partition. The resulting
1013/// [`CompiledRetrievalPlan::text`] field carries them in the same Phase 6
1014/// [`CompiledSearchPlan`] shape as `text_search()` / `fallback_search()`.
1015///
1016/// **v1 scope**: `vector` is unconditionally `None`. Read-time embedding of
1017/// natural-language queries is not wired in v1; see
1018/// [`CompiledRetrievalPlan`] for the rationale and the future-phase plan.
1019/// Callers who need vector retrieval today must use the `vector_search()`
1020/// override directly with a caller-provided vector literal.
1021///
1022/// # Errors
1023///
1024/// Returns [`CompileError::MissingSearchStep`] if the AST contains no
1025/// [`QueryStep::Search`] step, or
1026/// [`CompileError::MultipleSearchSteps`] if the AST contains more than one.
1027pub 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    // v1 scope (Phase 12): the planner's vector branch slot is structurally
1074    // present on `CompiledRetrievalPlan` so the coordinator's three-block
1075    // fusion path is fully wired, but read-time embedding of natural-language
1076    // queries is deliberately deferred to a future phase. `compile_retrieval_plan`
1077    // therefore always leaves `vector = None`; callers who want vector
1078    // retrieval today must use `vector_search()` directly with a caller-
1079    // provided vector literal.
1080    Ok(CompiledRetrievalPlan {
1081        text,
1082        vector: None,
1083        was_degraded_at_plan_time,
1084    })
1085}
1086
1087/// FNV-1a 64-bit hash — deterministic across Rust versions and program
1088/// invocations, unlike `DefaultHasher`.
1089fn 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        // Must contain UNION of both FTS tables.
1178        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        // Must have 4 bind parameters: sanitized query + kind for each table.
1188        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        // LogicalIdEq is applied in base_candidates (src alias) for the Nodes driver,
1203        // NOT duplicated in the final WHERE. The JOIN condition still contains
1204        // "n.logical_id =" which satisfies this check.
1205        assert!(compiled.sql.contains("n.logical_id ="));
1206        assert!(compiled.sql.contains("src.logical_id ="));
1207        assert!(compiled.sql.contains("json_extract"));
1208        // Only one bind for the logical_id (not two).
1209        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        // Attempt SQL injection via JSON path.
1225        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        // kind occupies 1 bind; each json filter now occupies 2 binds (path + value).
1258        // 7 json filters → 1 + 14 = 15 (ok), 8 → 1 + 16 = 17 (exceeds limit of 15).
1259        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        // Path must be parameterized, not interpolated into the SQL string.
1360        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        // Path and value should both be in the bind list.
1369        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    // --- Filter pushdown regression tests ---
1385    //
1386    // These tests verify that filter predicates are pushed into the
1387    // base_candidates CTE for the Nodes driving table, so the CTE LIMIT
1388    // applies after filtering rather than before.  Without pushdown, the
1389    // LIMIT may truncate the candidate set before the filter runs, causing
1390    // matching nodes to be silently excluded.
1391
1392    #[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        // Filter must appear inside base_candidates (src alias), not the
1404        // outer WHERE (n alias).
1405        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        // All filters should be in base_candidates, none in outer WHERE
1481        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        // Each bind value should appear exactly once (not duplicated in outer WHERE)
1494        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        // Phase 2: JSON filters are residual (stay in outer WHERE); KindEq is
1518        // fusable (pushed into base_candidates so the CTE LIMIT applies after
1519        // filtering).
1520        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        // Residual: JSON predicate stays in outer WHERE on n.properties.
1532        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        // Fusable: the second n.kind bind should live inside base_candidates.
1538        // The CTE block ends before the final SELECT.
1539        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        // Outer WHERE must not contain a duplicate n.kind filter.
1548        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        // Filter following the Search step must land in the fusable bucket.
1732        assert_eq!(plan.text.strict.fusable_filters.len(), 1);
1733        assert!(plan.text.strict.residual_filters.is_empty());
1734        // Strict text query is the parsed form of the raw string; "ship
1735        // quarterly docs" parses to an implicit AND of three terms.
1736        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        // Three-term implicit-AND has a useful relaxation: per-term OR.
1745        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        // CompileError unused in the success path.
1757        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        // P12-N-1: the compiler must not silently last-wins when the caller
1776        // hands it an AST with two `QueryStep::Search` entries. Instead it
1777        // must return an explicit `MultipleSearchSteps` error so the
1778        // mis-shaped AST is surfaced at plan time.
1779        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        // Phase 12 v1 scope: regardless of the query shape, the unified
1806        // planner never wires a vector branch into the compiled plan
1807        // because read-time embedding of natural-language queries is not
1808        // implemented in v1. Pin the constraint so a future phase that
1809        // wires the embedding generator must explicitly relax this test.
1810        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        // Item 7 contract: a fused JSON text-eq predicate on a text search
1833        // is pushed into the `base_candidates` CTE inner WHERE clause so the
1834        // CTE LIMIT applies *after* the filter runs. Compare to
1835        // `filter_json_text_eq` which lands in the outer WHERE as residual.
1836        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        // Inner CTE WHERE (under the `n` alias on the chunk/property UNION).
1848        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        // It must NOT also appear in the outer `h.properties` / flat
1854        // projection WHERE — the fusable partition removes it.
1855        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        // Regression guard: the existing non-fused filter_json_* family
1891        // is unchanged — its predicates continue to be classified as
1892        // residual on search-driven paths and emitted against the outer
1893        // `n.properties` WHERE clause (which is textually identical to
1894        // the inner CTE emission; the difference is *where* in the SQL
1895        // it lives).
1896        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        // The residual emission lives in the outer SELECT's WHERE and
1905        // targets `n.properties`. Fusion would instead prefix the line
1906        // with `                          AND` (26 spaces) inside the
1907        // CTE. We assert the residual form here by checking the
1908        // leading whitespace on the emitted clause matches the outer
1909        // WHERE indentation ("\n  AND ") rather than the CTE one.
1910        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        // Mirror of the text-search case for the vector driving path:
1922        // the fused JSON text-eq predicate must land inside the
1923        // `base_candidates` CTE aliased to `src`.
1924        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        // Regression test: QueryStep::Traverse must carry an optional filter
1966        // predicate. filter: None must be exactly equivalent to the old
1967        // three-field form. This test fails to compile before Pack 2 lands.
1968        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}