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, ExpansionSlot, Predicate, QueryAst, QueryStep, ScalarValue,
10    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    /// Structural shape hash covering the root query and all expansion slots.
51    pub shape_hash: ShapeHash,
52    /// Execution hints derived from the grouped query shape.
53    pub hints: crate::ExecutionHints,
54}
55
56/// Errors that can occur during query compilation.
57#[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
85/// Security fix H-1: Validate JSON path against a strict allowlist pattern to
86/// prevent SQL injection. Retained as defense-in-depth even though the path is
87/// now parameterized (see `FIX(review)` in `compile_query`). Only paths like
88/// `$.foo`, `$.foo.bar_baz` are allowed.
89fn 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/// Append a fusable predicate as an `AND` clause referencing `alias`.
107///
108/// Only the fusable variants (those that can be evaluated against columns on
109/// the `nodes` table join inside a search CTE) are supported — callers must
110/// pre-partition predicates via
111/// [`crate::fusion::partition_search_filters`]. Residual predicates panic via
112/// `unreachable!`.
113#[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
228// FIX(review): max_depth was unbounded — usize::MAX produces an effectively infinite CTE.
229// Options: (A) silent clamp at compile, (B) reject with CompileError, (C) validate in builder.
230// Chose (B): consistent with existing TooManyTraversals/TooManyBindParameters pattern.
231// The compiler is the validation boundary; silent clamping would surprise callers.
232const MAX_TRAVERSAL_DEPTH: usize = 50;
233
234/// Compile a [`QueryAst`] into a [`CompiledQuery`] ready for execution.
235///
236/// # Compilation strategy
237///
238/// The compiled SQL is structured as a `WITH RECURSIVE` CTE named
239/// `base_candidates` followed by a final `SELECT ... JOIN nodes` projection.
240///
241/// For the **Nodes** driving table (no FTS/vector search), all filter
242/// predicates (`LogicalIdEq`, `JsonPathEq`, `JsonPathCompare`,
243/// `SourceRefEq`) are pushed into the `base_candidates` CTE so that the
244/// CTE's `LIMIT` applies *after* filtering. Without this pushdown the LIMIT
245/// would truncate the candidate set before property filters run, silently
246/// excluding nodes whose properties satisfy the filter but whose insertion
247/// order falls outside the limit window.
248///
249/// For **FTS** and **vector** driving tables, fusable predicates
250/// (`KindEq`, `LogicalIdEq`, `SourceRefEq`, `ContentRefEq`,
251/// `ContentRefNotNull`) are pushed into the `base_candidates` CTE so that
252/// the CTE's `LIMIT` applies *after* filtering; residual predicates
253/// (`JsonPathEq`, `JsonPathCompare`) remain in the outer `WHERE` because
254/// they require `json_extract` on the outer `nodes.properties` column.
255///
256/// # Errors
257///
258/// Returns [`CompileError::TooManyTraversals`] if more than one traversal step
259/// is present, or [`CompileError::TooManyBindParameters`] if the resulting SQL
260/// would require more than 15 bind parameters.
261///
262/// # Panics
263///
264/// Panics (via `unreachable!`) if the AST is internally inconsistent — for
265/// example, if `choose_driving_table` selects `VecNodes` but no
266/// `VectorSearch` step is present in the AST. This cannot happen through the
267/// public [`QueryBuilder`] API.
268#[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    // Partition Filter predicates for the search-driven paths into fusable
327    // (injected into the search CTE's WHERE) and residual (left in the outer
328    // WHERE) sets. The Nodes path pushes *every* predicate into the CTE
329    // directly and ignores this partition.
330    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            // sqlite-vec requires the LIMIT/k constraint to be visible directly on the
349            // vec0 KNN scan. Using a sub-select isolates the vec0 LIMIT so the join
350            // with chunks/nodes does not prevent the query planner from recognising it.
351            //
352            // ASYMMETRY (known gap, P2-3): the inner `LIMIT {base_limit}` runs
353            // BEFORE the fusable-filter `WHERE` below, so fused predicates on
354            // `src` (e.g. `kind_eq`) filter a candidate pool that has already
355            // been narrowed to `base_limit` KNN neighbours. A
356            // `vector_search("x", 5).filter_kind_eq("Goal")` can therefore
357            // return fewer than 5 Goal hits even when more exist. Fixing this
358            // requires overfetching from vec0 and re-ranking/re-limiting after
359            // the filter — explicitly out of scope for Phase 2 filter fusion.
360            // The FTS branch below does NOT share this asymmetry because its
361            // outer LIMIT wraps the post-filter SELECT.
362            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            // Render the typed text-query subset into safe FTS5 syntax. Only
393            // supported operators are emitted as control syntax; all literal
394            // terms and phrases remain quoted and escaped.
395            let rendered = render_text_query_fts5(text_query);
396            // Each FTS5 virtual table requires its own MATCH bind parameter;
397            // reusing indices across the UNION is not supported by SQLite.
398            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            // Wrap the chunk/property UNION in an outer SELECT that joins
403            // `nodes` once so fusable filters (kind/logical_id/source_ref/
404            // content_ref) can reference node columns directly, bringing them
405            // inside the CTE's LIMIT window.
406            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            // Push filter predicates into base_candidates so the LIMIT applies
444            // after filtering, not before. Without this, the CTE may truncate
445            // the candidate set before property/source_ref filters run, causing
446            // nodes that satisfy the filter to be excluded from results.
447            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                            // KindEq: already filtered by ast.root_kind above.
520                            // EdgeProperty*: not valid in the main query filter path.
521                        }
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                            // On the Nodes driver all predicates are pushed inline;
583                            // treat like JsonPathIn but values are always text.
584                            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    // Outer WHERE emission. The Nodes driving table pushes every filter
657    // into `base_candidates` already, so only `KindEq` (handled separately
658    // via `root_kind`) needs to be re-emitted outside — we iterate
659    // `ast.steps` to catch it. For the search-driven paths (FtsNodes,
660    // VecNodes) we iterate the `residual_filters` partition directly
661    // instead of re-classifying predicates via `is_fusable()`. This makes
662    // `partition_search_filters` the single source of truth for the
663    // fusable/residual split: adding a new fusable variant automatically
664    // drops it from the outer WHERE without a separate audit of this loop.
665    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                    // Fusable — already injected into base_candidates by
744                    // `partition_search_filters`. Edge property predicates
745                    // are not valid in the main query path.
746                }
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
766/// Compile a [`QueryAst`] into a [`CompiledGroupedQuery`] for grouped execution.
767///
768/// # Errors
769///
770/// Returns a [`CompileError`] if the AST exceeds expansion-slot limits,
771/// contains empty slot names, or specifies a traversal depth beyond the
772/// configured maximum.
773pub 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
805/// Compile a [`QueryAst`] into a [`CompiledSearch`] describing an adaptive
806/// text-search execution.
807///
808/// Unlike [`compile_query`], this path does not emit SQL directly: the
809/// coordinator owns the search SELECT so it can project the richer row shape
810/// (score, source, snippet, projection id) that flat queries do not need.
811///
812/// # Errors
813///
814/// Returns [`CompileError::MissingTextSearchStep`] if the AST contains no
815/// [`QueryStep::TextSearch`] step.
816pub 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                // Filter steps are partitioned below; Search/Vector/Traverse
833                // steps are not composable with text search in the adaptive
834                // surface yet.
835            }
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/// Compile a [`QueryAst`] into a [`CompiledSearchPlan`] whose strict branch
852/// is the user's [`TextQuery`] and whose relaxed branch is derived via
853/// [`derive_relaxed`].
854///
855/// Reserved for Phase 7 SDK bindings that will construct plans from typed
856/// AST fragments. The coordinator currently builds its adaptive plan
857/// directly inside `execute_compiled_search` from an already-compiled
858/// [`CompiledSearch`], so this helper has no in-tree caller; it is kept
859/// as a public entry point for forthcoming surface bindings.
860///
861/// # Errors
862/// Returns [`CompileError::MissingTextSearchStep`] if the AST contains no
863/// [`QueryStep::TextSearch`] step.
864#[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
883/// Compile a caller-provided strict/relaxed [`TextQuery`] pair into a
884/// [`CompiledSearchPlan`] against a [`QueryAst`] that supplies the kind
885/// root, filters, and limit.
886///
887/// This is the two-query entry point used by `Engine::fallback_search`. The
888/// caller's relaxed [`TextQuery`] is used verbatim — it is NOT passed through
889/// [`derive_relaxed`], and the 4-alternative
890/// [`crate::RELAXED_BRANCH_CAP`] is NOT applied. As a result
891/// [`CompiledSearchPlan::was_degraded_at_plan_time`] is always `false` on
892/// this path.
893///
894/// The AST supplies:
895///  - `root_kind` — reused for both branches
896///  - filter steps — partitioned once via [`partition_search_filters`] and
897///    shared unchanged across both branches
898///  - `limit` from the text-search step (or the default used by
899///    [`compile_search`]) when present; if the AST has no `TextSearch` step,
900///    the caller-supplied `limit` is used
901///
902/// Any `TextSearch` step already on the AST is IGNORED — `strict` and
903/// `relaxed` come from the caller. `Vector`/`Traverse` steps are also
904/// ignored for symmetry with [`compile_search`].
905///
906/// # Errors
907/// Returns [`CompileError`] if filter partitioning produces an unsupported
908/// shape (currently none; reserved for forward compatibility).
909pub 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
940/// Compile a [`QueryAst`] into a [`CompiledVectorSearch`] describing a
941/// vector-only retrieval execution.
942///
943/// Mirrors [`compile_search`] structurally. The AST must contain exactly one
944/// [`QueryStep::VectorSearch`] step; filters following the search step are
945/// partitioned by [`partition_search_filters`] into fusable and residual
946/// sets. Unlike [`compile_search`] this path does not produce a
947/// [`TextQuery`]; the caller's raw query string is preserved verbatim for
948/// the coordinator to bind to `embedding MATCH ?`.
949///
950/// # Errors
951///
952/// Returns [`CompileError::MissingVectorSearchStep`] if the AST contains no
953/// [`QueryStep::VectorSearch`] step.
954pub 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                // Filter steps are partitioned below; Search/TextSearch/
971                // Traverse steps are not composable with vector search in
972                // the standalone vector retrieval path.
973            }
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
989/// Compile a [`QueryAst`] containing a [`QueryStep::Search`] into a
990/// [`CompiledRetrievalPlan`] describing the bounded set of retrieval branches
991/// the Phase 12 planner may run.
992///
993/// The raw query string carried by the `Search` step is parsed into a
994/// strict [`TextQuery`] (via [`TextQuery::parse`]) and a relaxed sibling is
995/// derived via [`derive_relaxed`]. Both branches share the post-search
996/// fusable/residual filter partition. The resulting
997/// [`CompiledRetrievalPlan::text`] field carries them in the same Phase 6
998/// [`CompiledSearchPlan`] shape as `text_search()` / `fallback_search()`.
999///
1000/// **v1 scope**: `vector` is unconditionally `None`. Read-time embedding of
1001/// natural-language queries is not wired in v1; see
1002/// [`CompiledRetrievalPlan`] for the rationale and the future-phase plan.
1003/// Callers who need vector retrieval today must use the `vector_search()`
1004/// override directly with a caller-provided vector literal.
1005///
1006/// # Errors
1007///
1008/// Returns [`CompileError::MissingSearchStep`] if the AST contains no
1009/// [`QueryStep::Search`] step, or
1010/// [`CompileError::MultipleSearchSteps`] if the AST contains more than one.
1011pub 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    // v1 scope (Phase 12): the planner's vector branch slot is structurally
1058    // present on `CompiledRetrievalPlan` so the coordinator's three-block
1059    // fusion path is fully wired, but read-time embedding of natural-language
1060    // queries is deliberately deferred to a future phase. `compile_retrieval_plan`
1061    // therefore always leaves `vector = None`; callers who want vector
1062    // retrieval today must use `vector_search()` directly with a caller-
1063    // provided vector literal.
1064    Ok(CompiledRetrievalPlan {
1065        text,
1066        vector: None,
1067        was_degraded_at_plan_time,
1068    })
1069}
1070
1071/// FNV-1a 64-bit hash — deterministic across Rust versions and program
1072/// invocations, unlike `DefaultHasher`.
1073fn 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        // Must contain UNION of both FTS tables.
1162        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        // Must have 4 bind parameters: sanitized query + kind for each table.
1172        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        // LogicalIdEq is applied in base_candidates (src alias) for the Nodes driver,
1187        // NOT duplicated in the final WHERE. The JOIN condition still contains
1188        // "n.logical_id =" which satisfies this check.
1189        assert!(compiled.sql.contains("n.logical_id ="));
1190        assert!(compiled.sql.contains("src.logical_id ="));
1191        assert!(compiled.sql.contains("json_extract"));
1192        // Only one bind for the logical_id (not two).
1193        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        // Attempt SQL injection via JSON path.
1209        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        // kind occupies 1 bind; each json filter now occupies 2 binds (path + value).
1242        // 7 json filters → 1 + 14 = 15 (ok), 8 → 1 + 16 = 17 (exceeds limit of 15).
1243        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        // Path must be parameterized, not interpolated into the SQL string.
1344        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        // Path and value should both be in the bind list.
1353        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    // --- Filter pushdown regression tests ---
1369    //
1370    // These tests verify that filter predicates are pushed into the
1371    // base_candidates CTE for the Nodes driving table, so the CTE LIMIT
1372    // applies after filtering rather than before.  Without pushdown, the
1373    // LIMIT may truncate the candidate set before the filter runs, causing
1374    // matching nodes to be silently excluded.
1375
1376    #[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        // Filter must appear inside base_candidates (src alias), not the
1388        // outer WHERE (n alias).
1389        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        // All filters should be in base_candidates, none in outer WHERE
1465        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        // Each bind value should appear exactly once (not duplicated in outer WHERE)
1478        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        // Phase 2: JSON filters are residual (stay in outer WHERE); KindEq is
1502        // fusable (pushed into base_candidates so the CTE LIMIT applies after
1503        // filtering).
1504        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        // Residual: JSON predicate stays in outer WHERE on n.properties.
1516        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        // Fusable: the second n.kind bind should live inside base_candidates.
1522        // The CTE block ends before the final SELECT.
1523        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        // Outer WHERE must not contain a duplicate n.kind filter.
1532        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        // Filter following the Search step must land in the fusable bucket.
1715        assert_eq!(plan.text.strict.fusable_filters.len(), 1);
1716        assert!(plan.text.strict.residual_filters.is_empty());
1717        // Strict text query is the parsed form of the raw string; "ship
1718        // quarterly docs" parses to an implicit AND of three terms.
1719        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        // Three-term implicit-AND has a useful relaxation: per-term OR.
1728        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        // CompileError unused in the success path.
1740        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        // P12-N-1: the compiler must not silently last-wins when the caller
1759        // hands it an AST with two `QueryStep::Search` entries. Instead it
1760        // must return an explicit `MultipleSearchSteps` error so the
1761        // mis-shaped AST is surfaced at plan time.
1762        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        // Phase 12 v1 scope: regardless of the query shape, the unified
1788        // planner never wires a vector branch into the compiled plan
1789        // because read-time embedding of natural-language queries is not
1790        // implemented in v1. Pin the constraint so a future phase that
1791        // wires the embedding generator must explicitly relax this test.
1792        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        // Item 7 contract: a fused JSON text-eq predicate on a text search
1814        // is pushed into the `base_candidates` CTE inner WHERE clause so the
1815        // CTE LIMIT applies *after* the filter runs. Compare to
1816        // `filter_json_text_eq` which lands in the outer WHERE as residual.
1817        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        // Inner CTE WHERE (under the `n` alias on the chunk/property UNION).
1829        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        // It must NOT also appear in the outer `h.properties` / flat
1835        // projection WHERE — the fusable partition removes it.
1836        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        // Regression guard: the existing non-fused filter_json_* family
1872        // is unchanged — its predicates continue to be classified as
1873        // residual on search-driven paths and emitted against the outer
1874        // `n.properties` WHERE clause (which is textually identical to
1875        // the inner CTE emission; the difference is *where* in the SQL
1876        // it lives).
1877        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        // The residual emission lives in the outer SELECT's WHERE and
1886        // targets `n.properties`. Fusion would instead prefix the line
1887        // with `                          AND` (26 spaces) inside the
1888        // CTE. We assert the residual form here by checking the
1889        // leading whitespace on the emitted clause matches the outer
1890        // WHERE indentation ("\n  AND ") rather than the CTE one.
1891        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        // Mirror of the text-search case for the vector driving path:
1903        // the fused JSON text-eq predicate must land inside the
1904        // `base_candidates` CTE aliased to `src`.
1905        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        // Regression test: QueryStep::Traverse must carry an optional filter
1947        // predicate. filter: None must be exactly equivalent to the old
1948        // three-field form. This test fails to compile before Pack 2 lands.
1949        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}