Skip to main content

khive_query/compilers/
sql.rs

1//! Compile GQL AST to parameterized SQL (JOIN chain or recursive CTE).
2
3use crate::ast::*;
4use crate::error::QueryError;
5use crate::validate::{validate_with_warnings, MAX_DEPTH};
6
7/// Observation roles used by the synthetic edge compiler.
8const SYNTHETIC_RELATIONS: &[&str] = &[
9    "observed_as_candidate",
10    "observed_as_selected",
11    "observed_as_target",
12    "observed_as_signal",
13];
14
15fn is_synthetic(rel: &str) -> bool {
16    SYNTHETIC_RELATIONS.contains(&rel)
17}
18
19fn synthetic_role(rel: &str) -> Option<&'static str> {
20    match rel {
21        "observed_as_candidate" => Some("candidate"),
22        "observed_as_selected" => Some("selected"),
23        "observed_as_target" => Some("target"),
24        "observed_as_signal" => Some("signal"),
25        _ => None,
26    }
27}
28
29/// Parameterized SQL emitted by the compiler, ready for execution by the runtime.
30#[derive(Debug)]
31pub struct CompiledQuery {
32    pub sql: String,
33    pub params: Vec<QueryValue>,
34    pub return_vars: Vec<ReturnItem>,
35    pub warnings: Vec<String>,
36}
37
38/// Runtime options injected by the caller to scope and cap query execution.
39pub struct CompileOptions {
40    /// Namespace scope. Empty = cross-namespace (all). Non-empty = filter to these namespaces.
41    pub scopes: Vec<String>,
42    /// Hard limit cap (server-side safety). Query limit is min(requested, max_limit).
43    pub max_limit: usize,
44}
45
46impl Default for CompileOptions {
47    fn default() -> Self {
48        Self {
49            scopes: Vec::new(),
50            max_limit: 500,
51        }
52    }
53}
54
55/// Compile a `GqlQuery` AST to a parameterized SQL string and bound parameters.
56pub fn compile(query: &GqlQuery, opts: &CompileOptions) -> Result<CompiledQuery, QueryError> {
57    if query.pattern.elements.is_empty() {
58        return Err(QueryError::Compile("empty pattern".into()));
59    }
60
61    // Validate edge relations + structural rules before emitting SQL.
62    let mut query = query.clone();
63    let warnings = validate_with_warnings(&mut query)?;
64
65    let mut compiled = if query.pattern.has_variable_length() {
66        compile_variable_length(&query, opts)?
67    } else {
68        compile_fixed_length(&query, opts)?
69    };
70    compiled.warnings = warnings;
71
72    // Defense-in-depth: assert the emitted SQL is SELECT-only.
73    // The parsers already reject write-shaped input; this guard ensures a future
74    // code path cannot accidentally emit a non-SELECT statement through the
75    // compiler. Fails closed with an explicit read-only message.
76    assert_select_only(&compiled.sql)?;
77
78    Ok(compiled)
79}
80
81/// Assert that the emitted SQL starts with SELECT (or WITH for recursive CTEs).
82///
83/// This is a compiler-level read-only invariant guard. The parsers reject
84/// write-shaped input before AST construction, but this check prevents a
85/// hypothetical future code path from emitting `INSERT`/`UPDATE`/`DELETE`
86/// SQL through the compile path. It is not a security boundary — the SQLite
87/// reader connection already enforces read-only at the driver level.
88fn assert_select_only(sql: &str) -> Result<(), QueryError> {
89    let first = sql.split_whitespace().next().unwrap_or("").to_uppercase();
90    if first == "SELECT" || first == "WITH" {
91        return Ok(());
92    }
93    Err(QueryError::Compile(
94        "the query verb is read-only; \
95         to mutate the graph use: create, update, link, merge, delete"
96            .into(),
97    ))
98}
99
100fn namespace_filter(alias: &str, opts: &CompileOptions, params: &mut Vec<QueryValue>) -> String {
101    if opts.scopes.is_empty() {
102        String::new()
103    } else if opts.scopes.len() == 1 {
104        params.push(QueryValue::Text(opts.scopes[0].clone()));
105        format!(" AND {alias}.namespace = ?{}", params.len())
106    } else {
107        let placeholders: Vec<String> = opts
108            .scopes
109            .iter()
110            .map(|s| {
111                params.push(QueryValue::Text(s.clone()));
112                format!("?{}", params.len())
113            })
114            .collect();
115        format!(" AND {alias}.namespace IN ({})", placeholders.join(", "))
116    }
117}
118
119/// Returns `(source_indices, target_indices)` for synthetic `observed_as_*` edge endpoints.
120fn synthetic_endpoint_node_indices(
121    elements: &[PatternElement],
122) -> (
123    std::collections::HashSet<usize>,
124    std::collections::HashSet<usize>,
125) {
126    let mut source_set = std::collections::HashSet::new();
127    let mut target_set = std::collections::HashSet::new();
128    let mut node_idx = 0usize;
129    let mut prev_node_idx: Option<usize> = None;
130    for element in elements {
131        match element {
132            PatternElement::Node(_) => {
133                prev_node_idx = Some(node_idx);
134                node_idx += 1;
135            }
136            PatternElement::Edge(ep) => {
137                let has_synthetic = ep.relations.iter().any(|r| is_synthetic(r));
138                if has_synthetic {
139                    if let Some(src_idx) = prev_node_idx {
140                        source_set.insert(src_idx);
141                        // The target is the next node (current node_idx).
142                        target_set.insert(node_idx);
143                    }
144                }
145            }
146        }
147    }
148    (source_set, target_set)
149}
150
151/// Compile fixed-length patterns to a JOIN chain.
152fn compile_fixed_length(
153    query: &GqlQuery,
154    opts: &CompileOptions,
155) -> Result<CompiledQuery, QueryError> {
156    let mut params: Vec<QueryValue> = Vec::new();
157    let mut from_parts: Vec<String> = Vec::new();
158    let mut join_parts: Vec<String> = Vec::new();
159    let mut where_parts: Vec<String> = Vec::new();
160    let mut select_parts: Vec<String> = Vec::new();
161
162    let mut node_aliases: Vec<String> = Vec::new();
163    let mut edge_aliases: Vec<String> = Vec::new();
164    let mut var_to_alias: std::collections::HashMap<String, (String, VarKind)> =
165        std::collections::HashMap::new();
166
167    // Pre-compute which node indices are endpoints of synthetic edges.
168    // Source nodes bind to `events`; target nodes bind to `notes`.
169    let (event_source_indices, note_target_indices) =
170        synthetic_endpoint_node_indices(&query.pattern.elements);
171
172    let mut node_idx = 0usize;
173    let mut edge_idx = 0usize;
174
175    for element in &query.pattern.elements {
176        match element {
177            PatternElement::Node(np) => {
178                let alias = format!("n{node_idx}");
179                node_aliases.push(alias.clone());
180
181                let is_event_source = event_source_indices.contains(&node_idx);
182                let is_note_target = note_target_indices.contains(&node_idx);
183
184                if node_idx == 0 {
185                    if is_event_source {
186                        from_parts.push(format!("events {alias}"));
187                    } else {
188                        // Note targets are joined by the synthetic edge handler, not FROM.
189                        if !is_note_target {
190                            from_parts.push(format!("entities {alias}"));
191                        }
192                    }
193                }
194
195                if is_event_source {
196                    // Events table does not have `deleted_at`; filter is omitted.
197                    // Namespace filter uses the `events.namespace` column directly.
198                    let ns_filter = namespace_filter(&alias, opts, &mut params);
199                    if !ns_filter.is_empty() {
200                        where_parts.push(ns_filter.trim_start_matches(" AND ").to_string());
201                    }
202                    // `kind` on an event node filters events.kind (e.g. "recall_executed").
203                    if let Some(ref kind) = np.kind {
204                        params.push(QueryValue::Text(kind.clone()));
205                        where_parts.push(format!("{alias}.kind = ?{}", params.len()));
206                    }
207                    // entity_type and properties are not columns on events — reject explicitly.
208                    if np.entity_type.is_some() {
209                        return Err(QueryError::Compile(
210                            "event nodes do not have an entity_type column".into(),
211                        ));
212                    }
213                    if !np.properties.is_empty() {
214                        return Err(QueryError::Compile(
215                            "event nodes do not support inline property filters; \
216                             use a WHERE clause on verb, outcome, or payload fields"
217                                .into(),
218                        ));
219                    }
220                } else if is_note_target {
221                    // Note targets: `notes` table (joined by the synthetic edge handler).
222                    where_parts.push(format!("{alias}.deleted_at IS NULL"));
223
224                    let ns_filter = namespace_filter(&alias, opts, &mut params);
225                    if !ns_filter.is_empty() {
226                        where_parts.push(ns_filter.trim_start_matches(" AND ").to_string());
227                    }
228
229                    if let Some(ref kind) = np.kind {
230                        params.push(QueryValue::Text(kind.clone()));
231                        where_parts.push(format!("{alias}.kind = ?{}", params.len()));
232                    }
233
234                    // entity_type does not exist on notes — reject explicitly.
235                    if np.entity_type.is_some() {
236                        return Err(QueryError::Compile(
237                            "observed note targets do not have an entity_type column".into(),
238                        ));
239                    }
240
241                    let mut props: Vec<_> = np.properties.iter().collect();
242                    props.sort_by_key(|(k, _)| k.as_str());
243                    for (key, val) in props {
244                        params.push(QueryValue::Text(val.clone()));
245                        if key == "name" || key == "content" {
246                            where_parts
247                                .push(format!("{alias}.{key} = ?{} COLLATE NOCASE", params.len()));
248                        } else {
249                            where_parts.push(format!(
250                                "json_extract({alias}.properties, '$.{}') = ?{} COLLATE NOCASE",
251                                key.replace('\'', "''"),
252                                params.len()
253                            ));
254                        }
255                    }
256                } else {
257                    where_parts.push(format!("{alias}.deleted_at IS NULL"));
258
259                    let ns_filter = namespace_filter(&alias, opts, &mut params);
260                    if !ns_filter.is_empty() {
261                        where_parts.push(ns_filter.trim_start_matches(" AND ").to_string());
262                    }
263
264                    if let Some(ref kind) = np.kind {
265                        params.push(QueryValue::Text(kind.clone()));
266                        where_parts.push(format!("{alias}.kind = ?{}", params.len()));
267                    }
268
269                    if let Some(ref et) = np.entity_type {
270                        params.push(QueryValue::Text(et.clone()));
271                        where_parts.push(format!("{alias}.entity_type = ?{}", params.len()));
272                    }
273
274                    let mut props: Vec<_> = np.properties.iter().collect();
275                    props.sort_by_key(|(k, _)| k.as_str());
276                    for (key, val) in props {
277                        params.push(QueryValue::Text(val.clone()));
278                        if key == "name" {
279                            where_parts
280                                .push(format!("{alias}.name = ?{} COLLATE NOCASE", params.len()));
281                        } else {
282                            where_parts.push(format!(
283                                "json_extract({alias}.properties, '$.{}') = ?{} COLLATE NOCASE",
284                                key.replace('\'', "''"),
285                                params.len()
286                            ));
287                        }
288                    }
289                }
290
291                if let Some(ref var) = np.variable {
292                    let kind = if is_event_source {
293                        VarKind::EventNode
294                    } else if is_note_target {
295                        VarKind::NoteNode
296                    } else {
297                        VarKind::Node
298                    };
299                    var_to_alias.insert(var.clone(), (alias.clone(), kind));
300                }
301
302                node_idx += 1;
303            }
304            PatternElement::Edge(ep) => {
305                let e_alias = format!("e{edge_idx}");
306                let prev_node = &node_aliases[node_aliases.len() - 1];
307                let next_alias = format!("n{}", node_idx);
308
309                edge_aliases.push(e_alias.clone());
310
311                // Detect synthetic event_observations edges (observed_as_* relations).
312                // A synthetic edge is one whose only relation(s) are observed_as_* names.
313                // Mixed synthetic+canonical relations are rejected: the two tables don't share
314                // a common join key that would make an OR across them meaningful.
315                let has_synthetic = ep.relations.iter().any(|r| is_synthetic(r));
316                let has_canonical = ep.relations.iter().any(|r| !is_synthetic(r));
317                if has_synthetic && has_canonical {
318                    return Err(QueryError::Compile(
319                        "cannot mix synthetic observed_as_* relations with canonical edge relations \
320                         in a single edge pattern"
321                            .into(),
322                    ));
323                }
324
325                if has_synthetic {
326                    // Synthetic edge: join event_observations.
327                    // Direction is always event → entity/note (OUT from the event node).
328                    // The event node is the source (prev_node); the entity/note is the target.
329                    if !matches!(ep.direction, EdgeDirection::Out) {
330                        return Err(QueryError::Compile(
331                            "synthetic observed_as_* edges are always event → entity (outbound only)".into(),
332                        ));
333                    }
334                    join_parts.push(format!(
335                        "JOIN event_observations {e_alias} ON {e_alias}.event_id = {prev_node}.id"
336                    ));
337                    // Roles: collect the unique role values from the synthetic relation names.
338                    let roles: Vec<&'static str> = ep
339                        .relations
340                        .iter()
341                        .filter_map(|r| synthetic_role(r))
342                        .collect();
343                    if roles.len() == 1 {
344                        params.push(QueryValue::Text(roles[0].to_string()));
345                        where_parts.push(format!("{e_alias}.role = ?{}", params.len()));
346                    } else if roles.len() > 1 {
347                        let placeholders: Vec<String> = roles
348                            .iter()
349                            .map(|r| {
350                                params.push(QueryValue::Text(r.to_string()));
351                                format!("?{}", params.len())
352                            })
353                            .collect();
354                        where_parts
355                            .push(format!("{e_alias}.role IN ({})", placeholders.join(", ")));
356                    }
357                    // Join the target node via event_observations.entity_id.
358                    // The `referent_kind` column discriminates between note and entity
359                    // Recall/rerank observations always target notes
360                    // (`referent_kind='note'`); we filter to note substrate and join
361                    // the `notes` table.  An explicit `AND e0.referent_kind='note'`
362                    // prevents cross-substrate ID collisions.
363                    join_parts.push(format!(
364                        "JOIN notes {next_alias} ON {next_alias}.id = {e_alias}.entity_id \
365                         AND {e_alias}.referent_kind = 'note'"
366                    ));
367                } else {
368                    // Standard canonical edge: join graph_edges.
369                    let (source_join, target_join) = match ep.direction {
370                        EdgeDirection::Out => (
371                            format!("{e_alias}.source_id = {prev_node}.id"),
372                            "target_id",
373                        ),
374                        EdgeDirection::In => (
375                            format!("{e_alias}.target_id = {prev_node}.id"),
376                            "source_id",
377                        ),
378                        EdgeDirection::Both => (
379                            format!(
380                                "({e_alias}.source_id = {prev_node}.id OR {e_alias}.target_id = {prev_node}.id)"
381                            ),
382                            "CASE_BOTH",
383                        ),
384                    };
385
386                    let next_join_col = if target_join == "CASE_BOTH" {
387                        format!(
388                            "CASE WHEN {e_alias}.source_id = {prev_node}.id THEN {e_alias}.target_id ELSE {e_alias}.source_id END"
389                        )
390                    } else {
391                        format!("{e_alias}.{target_join}")
392                    };
393
394                    join_parts.push(format!(
395                        "JOIN graph_edges {e_alias} ON {source_join} AND {e_alias}.deleted_at IS NULL"
396                    ));
397
398                    let ens_filter = namespace_filter(&e_alias, opts, &mut params);
399                    if !ens_filter.is_empty() {
400                        where_parts.push(ens_filter.trim_start_matches(" AND ").to_string());
401                    }
402
403                    join_parts.push(format!(
404                        "JOIN entities {next_alias} ON {next_alias}.id = {next_join_col}"
405                    ));
406
407                    if !ep.relations.is_empty() {
408                        if ep.relations.len() == 1 {
409                            params.push(QueryValue::Text(ep.relations[0].clone()));
410                            where_parts.push(format!("{e_alias}.relation = ?{}", params.len()));
411                        } else {
412                            let placeholders: Vec<String> = ep
413                                .relations
414                                .iter()
415                                .map(|r| {
416                                    params.push(QueryValue::Text(r.clone()));
417                                    format!("?{}", params.len())
418                                })
419                                .collect();
420                            where_parts.push(format!(
421                                "{e_alias}.relation IN ({})",
422                                placeholders.join(", ")
423                            ));
424                        }
425                    }
426                }
427
428                if let Some(ref var) = ep.variable {
429                    var_to_alias.insert(var.clone(), (e_alias.clone(), VarKind::Edge));
430                }
431
432                edge_idx += 1;
433            }
434        }
435    }
436
437    // WHERE clause conditions from GQL WHERE (supports AND / OR tree)
438    if let Some(where_sql) = compile_where_expr(&query.where_clause, &var_to_alias, &mut params)? {
439        where_parts.push(where_sql);
440    }
441
442    // SELECT clause
443    for item in &query.return_items {
444        let var = item.variable();
445        if let Some((alias, kind)) = var_to_alias.get(var) {
446            match item {
447                ReturnItem::Property(_, prop) => {
448                    let col = property_to_column(prop, kind)?;
449                    select_parts.push(format!("{alias}.{col} AS {var}_{prop}"));
450                }
451                ReturnItem::Variable(_) => match kind {
452                    VarKind::Node => {
453                        select_parts.push(format!(
454                            "{alias}.id AS {var}_id, {alias}.namespace AS {var}_namespace, \
455                             {alias}.kind AS {var}_kind, {alias}.entity_type AS {var}_entity_type, \
456                             {alias}.name AS {var}_name, \
457                             {alias}.properties AS {var}_properties, \
458                             {alias}.created_at AS {var}_created_at, \
459                             {alias}.updated_at AS {var}_updated_at"
460                        ));
461                    }
462                    VarKind::NoteNode => {
463                        select_parts.push(format!(
464                            "{alias}.id AS {var}_id, {alias}.namespace AS {var}_namespace, \
465                             {alias}.kind AS {var}_kind, {alias}.status AS {var}_status, \
466                             {alias}.content AS {var}_content, \
467                             {alias}.salience AS {var}_salience, \
468                             {alias}.properties AS {var}_properties, \
469                             {alias}.created_at AS {var}_created_at, \
470                             {alias}.updated_at AS {var}_updated_at"
471                        ));
472                    }
473                    VarKind::EventNode => {
474                        select_parts.push(format!(
475                            "{alias}.id AS {var}_id, {alias}.namespace AS {var}_namespace, \
476                             {alias}.verb AS {var}_verb, {alias}.substrate AS {var}_substrate, \
477                             {alias}.actor AS {var}_actor, {alias}.kind AS {var}_kind, \
478                             {alias}.outcome AS {var}_outcome, \
479                             {alias}.payload AS {var}_payload, \
480                             {alias}.created_at AS {var}_created_at"
481                        ));
482                    }
483                    VarKind::Edge => {
484                        select_parts.push(format!(
485                            "{alias}.id AS {var}_id, {alias}.source_id AS {var}_source, \
486                             {alias}.target_id AS {var}_target, \
487                             {alias}.relation AS {var}_relation, \
488                             {alias}.weight AS {var}_weight"
489                        ));
490                    }
491                },
492            }
493        } else {
494            return Err(QueryError::Compile(format!(
495                "unknown variable '{var}' in RETURN clause"
496            )));
497        }
498    }
499
500    let limit = query.limit.unwrap_or(opts.max_limit).min(opts.max_limit);
501    let limit_i64 = i64::try_from(limit)
502        .map_err(|_| QueryError::InvalidInput("limit exceeds i64::MAX".into()))?;
503    params.push(QueryValue::Integer(limit_i64));
504
505    let sql = format!(
506        "SELECT {} FROM {} {} WHERE {} LIMIT ?{}",
507        select_parts.join(", "),
508        from_parts.join(", "),
509        join_parts.join(" "),
510        where_parts.join(" AND "),
511        params.len(),
512    );
513
514    Ok(CompiledQuery {
515        sql,
516        params,
517        return_vars: query.return_items.clone(),
518        warnings: Vec::new(),
519    })
520}
521
522/// Compile a `WhereExpr` tree into a SQL fragment.
523fn compile_where_expr(
524    expr: &WhereExpr,
525    var_to_alias: &std::collections::HashMap<String, (String, VarKind)>,
526    params: &mut Vec<QueryValue>,
527) -> Result<Option<String>, QueryError> {
528    match expr {
529        WhereExpr::True => Ok(None),
530        WhereExpr::Condition(cond) => {
531            let sql = compile_single_condition(cond, var_to_alias, params)?;
532            Ok(Some(sql))
533        }
534        WhereExpr::And(l, r) => {
535            let ls = compile_where_expr(l, var_to_alias, params)?;
536            let rs = compile_where_expr(r, var_to_alias, params)?;
537            Ok(match (ls, rs) {
538                (None, None) => None,
539                (Some(s), None) | (None, Some(s)) => Some(s),
540                (Some(l), Some(r)) => Some(format!("{l} AND {r}")),
541            })
542        }
543        WhereExpr::Or(l, r) => {
544            let ls = compile_where_expr(l, var_to_alias, params)?;
545            let rs = compile_where_expr(r, var_to_alias, params)?;
546            Ok(match (ls, rs) {
547                (None, None) => None,
548                (Some(s), None) | (None, Some(s)) => Some(s),
549                (Some(l), Some(r)) => Some(format!("({l} OR {r})")),
550            })
551        }
552    }
553}
554
555fn compile_single_condition(
556    cond: &Condition,
557    var_to_alias: &std::collections::HashMap<String, (String, VarKind)>,
558    params: &mut Vec<QueryValue>,
559) -> Result<String, QueryError> {
560    let (alias, kind) = var_to_alias.get(&cond.variable).ok_or_else(|| {
561        QueryError::Compile(format!(
562            "unknown variable '{}' in WHERE clause",
563            cond.variable
564        ))
565    })?;
566
567    let col_expr = match kind {
568        VarKind::Node => {
569            if cond.property == "name"
570                || cond.property == "kind"
571                || cond.property == "entity_type"
572                || cond.property == "namespace"
573            {
574                format!("{alias}.{}", cond.property)
575            } else {
576                format!(
577                    "json_extract({alias}.properties, '$.{}')",
578                    cond.property.replace('\'', "''")
579                )
580            }
581        }
582        VarKind::NoteNode => {
583            if NOTE_COLUMNS.contains(&cond.property.as_str()) {
584                format!("{alias}.{}", cond.property)
585            } else {
586                format!(
587                    "json_extract({alias}.properties, '$.{}')",
588                    cond.property.replace('\'', "''")
589                )
590            }
591        }
592        VarKind::EventNode => {
593            // Events table has direct columns only; reject unknown fields.
594            if EVENT_COLUMNS.contains(&cond.property.as_str()) {
595                format!("{alias}.{}", cond.property)
596            } else {
597                return Err(QueryError::Validation(format!(
598                    "event property '{}' not queryable; valid columns: {}",
599                    cond.property,
600                    EVENT_COLUMNS.join(", ")
601                )));
602            }
603        }
604        VarKind::Edge => match cond.property.as_str() {
605            "relation" | "weight" => format!("{alias}.{}", cond.property),
606            other => {
607                return Err(QueryError::Validation(format!(
608                    "edge property '{other}' not queryable; use 'relation' or 'weight'"
609                )))
610            }
611        },
612    };
613
614    let op_str = match cond.op {
615        CompareOp::Eq => "=",
616        CompareOp::Neq => "!=",
617        CompareOp::Gt => ">",
618        CompareOp::Lt => "<",
619        CompareOp::Gte => ">=",
620        CompareOp::Lte => "<=",
621        CompareOp::Like => "LIKE",
622    };
623
624    let sql = match &cond.value {
625        ConditionValue::String(s) => {
626            params.push(QueryValue::Text(s.clone()));
627            let collate = if matches!(cond.op, CompareOp::Eq | CompareOp::Like) {
628                " COLLATE NOCASE"
629            } else {
630                ""
631            };
632            format!("{col_expr} {op_str} ?{}{}", params.len(), collate)
633        }
634        ConditionValue::Number(n) => {
635            if !n.is_finite() {
636                return Err(QueryError::InvalidInput(
637                    "non-finite float (NaN or Infinity) is not a valid query parameter".into(),
638                ));
639            }
640            params.push(QueryValue::Float(*n));
641            format!("{col_expr} {op_str} ?{}", params.len())
642        }
643        ConditionValue::Bool(b) => {
644            params.push(QueryValue::Integer(if *b { 1 } else { 0 }));
645            format!("{col_expr} {op_str} ?{}", params.len())
646        }
647    };
648    Ok(sql)
649}
650
651fn expr_endpoint_set(
652    expr: &WhereExpr,
653    start_var: Option<&str>,
654    end_var: Option<&str>,
655) -> (bool, bool) {
656    match expr {
657        WhereExpr::True => (false, false),
658        WhereExpr::Condition(c) => {
659            let is_start = start_var == Some(c.variable.as_str());
660            let is_end = end_var == Some(c.variable.as_str());
661            (is_start, is_end)
662        }
663        WhereExpr::And(l, r) | WhereExpr::Or(l, r) => {
664            let (ls, le) = expr_endpoint_set(l, start_var, end_var);
665            let (rs, re) = expr_endpoint_set(r, start_var, end_var);
666            (ls || rs, le || re)
667        }
668    }
669}
670
671/// Return `Err(Unsupported)` if any `Or` node spans both endpoint variables.
672fn reject_or_spanning_endpoints(
673    expr: &WhereExpr,
674    start: &NodePattern,
675    end: &NodePattern,
676) -> Result<(), QueryError> {
677    let start_var = start.variable.as_deref();
678    let end_var = end.variable.as_deref();
679    reject_or_spanning_impl(expr, start_var, end_var)
680}
681
682fn reject_or_spanning_impl(
683    expr: &WhereExpr,
684    start_var: Option<&str>,
685    end_var: Option<&str>,
686) -> Result<(), QueryError> {
687    match expr {
688        WhereExpr::True | WhereExpr::Condition(_) => Ok(()),
689        WhereExpr::And(l, r) => {
690            reject_or_spanning_impl(l, start_var, end_var)?;
691            reject_or_spanning_impl(r, start_var, end_var)
692        }
693        WhereExpr::Or(l, r) => {
694            let (l_start, l_end) = expr_endpoint_set(l, start_var, end_var);
695            let (r_start, r_end) = expr_endpoint_set(r, start_var, end_var);
696            let spans_start = l_start || r_start;
697            let spans_end = l_end || r_end;
698            if spans_start && spans_end {
699                return Err(QueryError::Unsupported(
700                    "WHERE clauses that span both endpoints in a variable-length pattern \
701                     are not yet supported; rewrite as separate queries or restrict each \
702                     OR branch to one endpoint"
703                        .into(),
704                ));
705            }
706            // Even if this OR is safe, recurse to catch nested ORs.
707            reject_or_spanning_impl(l, start_var, end_var)?;
708            reject_or_spanning_impl(r, start_var, end_var)
709        }
710    }
711}
712
713fn compile_var_len_condition(
714    cond: &Condition,
715    start_var: Option<&str>,
716    end_var: Option<&str>,
717    params: &mut Vec<QueryValue>,
718) -> Result<(String, &'static str), QueryError> {
719    let col_alias = if start_var == Some(cond.variable.as_str()) {
720        "s"
721    } else if end_var == Some(cond.variable.as_str()) {
722        "r"
723    } else {
724        return Err(QueryError::Compile(format!(
725            "variable '{}' in WHERE not supported in variable-length pattern \
726             (only start/end node variables)",
727            cond.variable
728        )));
729    };
730
731    let col_expr =
732        if cond.property == "name" || cond.property == "kind" || cond.property == "entity_type" {
733            format!("{col_alias}.{}", cond.property)
734        } else {
735            format!(
736                "json_extract({col_alias}.properties, '$.{}')",
737                cond.property.replace('\'', "''")
738            )
739        };
740
741    let op_str = match cond.op {
742        CompareOp::Eq => "=",
743        CompareOp::Neq => "!=",
744        CompareOp::Gt => ">",
745        CompareOp::Lt => "<",
746        CompareOp::Gte => ">=",
747        CompareOp::Lte => "<=",
748        CompareOp::Like => "LIKE",
749    };
750
751    let sql = match &cond.value {
752        ConditionValue::String(s) => {
753            params.push(QueryValue::Text(s.clone()));
754            let collate = if matches!(cond.op, CompareOp::Eq | CompareOp::Like) {
755                " COLLATE NOCASE"
756            } else {
757                ""
758            };
759            format!("{col_expr} {op_str} ?{}{collate}", params.len())
760        }
761        ConditionValue::Number(n) => {
762            if !n.is_finite() {
763                return Err(QueryError::InvalidInput(
764                    "non-finite float (NaN or Infinity) is not a valid query parameter".into(),
765                ));
766            }
767            params.push(QueryValue::Float(*n));
768            format!("{col_expr} {op_str} ?{}", params.len())
769        }
770        ConditionValue::Bool(b) => {
771            params.push(QueryValue::Integer(if *b { 1 } else { 0 }));
772            format!("{col_expr} {op_str} ?{}", params.len())
773        }
774    };
775    Ok((sql, col_alias))
776}
777
778/// Walk the `WhereExpr` tree for variable-length patterns, routing conditions to start or end.
779fn compile_variable_length_where(
780    expr: &WhereExpr,
781    start_var: Option<&str>,
782    end_var: Option<&str>,
783    params: &mut Vec<QueryValue>,
784    start_conditions: &mut Vec<String>,
785    end_conditions: &mut Vec<String>,
786) -> Result<Option<String>, QueryError> {
787    match expr {
788        WhereExpr::True => Ok(None),
789        WhereExpr::Condition(cond) => {
790            let (sql, alias) = compile_var_len_condition(cond, start_var, end_var, params)?;
791            if alias == "s" {
792                start_conditions.push(sql);
793            } else {
794                end_conditions.push(sql);
795            }
796            Ok(None)
797        }
798        WhereExpr::And(l, r) => {
799            compile_variable_length_where(
800                l,
801                start_var,
802                end_var,
803                params,
804                start_conditions,
805                end_conditions,
806            )?;
807            compile_variable_length_where(
808                r,
809                start_var,
810                end_var,
811                params,
812                start_conditions,
813                end_conditions,
814            )?;
815            Ok(None)
816        }
817        WhereExpr::Or(l, r) => {
818            // After reject_or_spanning_endpoints we know this Or does not straddle
819            // both endpoints.  Compile each branch to a SQL string, then combine
820            // with OR and push into the appropriate condition list.
821            let l_sql = compile_variable_length_where_to_sql(l, start_var, end_var, params)?;
822            let r_sql = compile_variable_length_where_to_sql(r, start_var, end_var, params)?;
823            match (l_sql, r_sql) {
824                (None, None) => {}
825                (Some((ls, la)), None) => {
826                    if la == "s" {
827                        start_conditions.push(ls);
828                    } else {
829                        end_conditions.push(ls);
830                    }
831                }
832                (None, Some((rs, ra))) => {
833                    if ra == "s" {
834                        start_conditions.push(rs);
835                    } else {
836                        end_conditions.push(rs);
837                    }
838                }
839                (Some((ls, la)), Some((rs, _ra))) => {
840                    // Both non-None and same alias (guaranteed by the spanning check).
841                    let combined = format!("({ls} OR {rs})");
842                    if la == "s" {
843                        start_conditions.push(combined);
844                    } else {
845                        end_conditions.push(combined);
846                    }
847                }
848            }
849            Ok(None)
850        }
851    }
852}
853
854/// Compile a `WhereExpr` sub-tree to a SQL string plus the endpoint alias (`"s"` or `"r"`).
855fn compile_variable_length_where_to_sql(
856    expr: &WhereExpr,
857    start_var: Option<&str>,
858    end_var: Option<&str>,
859    params: &mut Vec<QueryValue>,
860) -> Result<Option<(String, &'static str)>, QueryError> {
861    match expr {
862        WhereExpr::True => Ok(None),
863        WhereExpr::Condition(cond) => {
864            let (sql, alias) = compile_var_len_condition(cond, start_var, end_var, params)?;
865            Ok(Some((sql, alias)))
866        }
867        WhereExpr::And(l, r) => {
868            let ls = compile_variable_length_where_to_sql(l, start_var, end_var, params)?;
869            let rs = compile_variable_length_where_to_sql(r, start_var, end_var, params)?;
870            Ok(match (ls, rs) {
871                (None, None) => None,
872                (Some(s), None) | (None, Some(s)) => Some(s),
873                (Some((lsql, la)), Some((rsql, _))) => Some((format!("{lsql} AND {rsql}"), la)),
874            })
875        }
876        WhereExpr::Or(l, r) => {
877            let ls = compile_variable_length_where_to_sql(l, start_var, end_var, params)?;
878            let rs = compile_variable_length_where_to_sql(r, start_var, end_var, params)?;
879            Ok(match (ls, rs) {
880                (None, None) => None,
881                (Some(s), None) | (None, Some(s)) => Some(s),
882                (Some((lsql, la)), Some((rsql, _))) => Some((format!("({lsql} OR {rsql})"), la)),
883            })
884        }
885    }
886}
887
888/// Compile variable-length patterns to a recursive CTE.
889fn compile_variable_length(
890    query: &GqlQuery,
891    opts: &CompileOptions,
892) -> Result<CompiledQuery, QueryError> {
893    let mut params: Vec<QueryValue> = Vec::new();
894    let mut var_to_alias: std::collections::HashMap<String, (String, VarKind)> =
895        std::collections::HashMap::new();
896
897    // For variable-length, we expect exactly: start_node -[*N..M]-> end_node.
898    // Mixed fixed+variable chains and additional trailing pattern elements are
899    // not yet supported — reject explicitly rather than silently dropping them.
900    let nodes: Vec<&NodePattern> = query.pattern.nodes().collect();
901    let edges: Vec<&EdgePattern> = query.pattern.edges().collect();
902
903    if nodes.len() != 2 || edges.len() != 1 || query.pattern.elements.len() != 3 {
904        return Err(QueryError::Unsupported(
905            "variable-length patterns must be a single start_node -[*N..M]-> end_node \
906             (mixed fixed/variable chains are not yet implemented)"
907                .into(),
908        ));
909    }
910
911    let start = &nodes[0];
912    let edge = &edges[0];
913    let end = &nodes[1];
914
915    // Synthetic observed_as_* edges join event_observations, which has no
916    // recursive path structure — reject them in variable-length patterns before
917    // attempting CTE compilation (would produce a CTE over graph_edges with an
918    // invalid relation string).
919    if edge.relations.iter().any(|r| is_synthetic(r)) {
920        return Err(QueryError::Unsupported(
921            "synthetic observed_as_* edges cannot be variable-length; \
922             use a fixed-length edge pattern instead"
923                .into(),
924        ));
925    }
926
927    // MAJ-2: depth cap — always parameterized, never injected as literal
928    let max_depth = edge.max_hops.min(MAX_DEPTH);
929    let min_depth = edge.min_hops;
930
931    // Build start-node conditions
932    let mut start_conditions: Vec<String> = vec!["s.deleted_at IS NULL".to_string()];
933    let ns_filter = namespace_filter("s", opts, &mut params);
934    if !ns_filter.is_empty() {
935        start_conditions.push(ns_filter.trim_start_matches(" AND ").to_string());
936    }
937
938    if let Some(ref kind) = start.kind {
939        params.push(QueryValue::Text(kind.clone()));
940        start_conditions.push(format!("s.kind = ?{}", params.len()));
941    }
942    if let Some(ref et) = start.entity_type {
943        params.push(QueryValue::Text(et.clone()));
944        start_conditions.push(format!("s.entity_type = ?{}", params.len()));
945    }
946    let mut start_props: Vec<_> = start.properties.iter().collect();
947    start_props.sort_by_key(|(k, _)| k.as_str());
948    for (key, val) in start_props {
949        params.push(QueryValue::Text(val.clone()));
950        if key == "name" {
951            start_conditions.push(format!("s.name = ?{} COLLATE NOCASE", params.len()));
952        } else {
953            start_conditions.push(format!(
954                "json_extract(s.properties, '$.{}') = ?{} COLLATE NOCASE",
955                key.replace('\'', "''"),
956                params.len()
957            ));
958        }
959    }
960
961    // Relation filter
962    let mut relation_condition = String::new();
963    if !edge.relations.is_empty() {
964        if edge.relations.len() == 1 {
965            params.push(QueryValue::Text(edge.relations[0].clone()));
966            relation_condition = format!(" AND e.relation = ?{}", params.len());
967        } else {
968            let placeholders: Vec<String> = edge
969                .relations
970                .iter()
971                .map(|r| {
972                    params.push(QueryValue::Text(r.clone()));
973                    format!("?{}", params.len())
974                })
975                .collect();
976            relation_condition = format!(" AND e.relation IN ({})", placeholders.join(", "));
977        }
978    }
979
980    // Edge namespace filter
981    let e_ns_filter = namespace_filter("e", opts, &mut params);
982
983    // Direction-dependent JOIN
984    let (seed_join, seed_next, recurse_join, recurse_next) = match edge.direction {
985        EdgeDirection::Out => (
986            "e.source_id = s.id",
987            "e.target_id",
988            "e.source_id = t.current_id",
989            "e.target_id",
990        ),
991        EdgeDirection::In => (
992            "e.target_id = s.id",
993            "e.source_id",
994            "e.target_id = t.current_id",
995            "e.source_id",
996        ),
997        EdgeDirection::Both => (
998            "(e.source_id = s.id OR e.target_id = s.id)",
999            "CASE WHEN e.source_id = s.id THEN e.target_id ELSE e.source_id END",
1000            "(e.source_id = t.current_id OR e.target_id = t.current_id)",
1001            "CASE WHEN e.source_id = t.current_id THEN e.target_id ELSE e.source_id END",
1002        ),
1003    };
1004
1005    // Build the next-intermediate-node namespace filter.
1006    // This is applied in the recursive CTE member to prevent traversal through
1007    // deleted or out-of-scope intermediate nodes.  Without it, a path like
1008    // A -> B_deleted -> C would be returned even when B is soft-deleted.
1009    let next_node_ns_filter = namespace_filter("next_node", opts, &mut params);
1010
1011    let max_depth_i64 = i64::try_from(max_depth)
1012        .map_err(|_| QueryError::InvalidInput("max_depth exceeds i64::MAX".into()))?;
1013    params.push(QueryValue::Integer(max_depth_i64));
1014    let depth_param = params.len();
1015
1016    // End-node conditions (applied in outer WHERE). `r` is always joined
1017    // unconditionally below so these references resolve regardless of whether
1018    // the end variable is projected.
1019    let mut end_conditions: Vec<String> = vec!["r.deleted_at IS NULL".to_string()];
1020    let r_ns_filter = namespace_filter("r", opts, &mut params);
1021    if !r_ns_filter.is_empty() {
1022        end_conditions.push(r_ns_filter.trim_start_matches(" AND ").to_string());
1023    }
1024    if let Some(ref kind) = end.kind {
1025        params.push(QueryValue::Text(kind.clone()));
1026        end_conditions.push(format!("r.kind = ?{}", params.len()));
1027    }
1028    if let Some(ref et) = end.entity_type {
1029        params.push(QueryValue::Text(et.clone()));
1030        end_conditions.push(format!("r.entity_type = ?{}", params.len()));
1031    }
1032    let mut end_props: Vec<_> = end.properties.iter().collect();
1033    end_props.sort_by_key(|(k, _)| k.as_str());
1034    for (key, val) in end_props {
1035        params.push(QueryValue::Text(val.clone()));
1036        if key == "name" {
1037            end_conditions.push(format!("r.name = ?{} COLLATE NOCASE", params.len()));
1038        } else {
1039            end_conditions.push(format!(
1040                "json_extract(r.properties, '$.{}') = ?{} COLLATE NOCASE",
1041                key.replace('\'', "''"),
1042                params.len()
1043            ));
1044        }
1045    }
1046
1047    // WHERE clause conditions for variable-length patterns.
1048    // OR expressions that span both start and end nodes are not supported — reject
1049    // explicitly with an actionable error message rather than silently converting OR to AND.
1050    reject_or_spanning_endpoints(&query.where_clause, start, end)?;
1051
1052    // Compile the WHERE tree preserving Or/And connectives.  After the spanning
1053    // check above we know every Or node touches at most one endpoint, so we can
1054    // safely route whole sub-trees to start_conditions or end_conditions.
1055    if let Some(where_sql) = compile_variable_length_where(
1056        &query.where_clause,
1057        start.variable.as_deref(),
1058        end.variable.as_deref(),
1059        &mut params,
1060        &mut start_conditions,
1061        &mut end_conditions,
1062    )? {
1063        // A non-None return means the expression spans no variable (WhereExpr::True
1064        // is the only such case and returns None).  This branch is unreachable given
1065        // the reject_or_spanning_endpoints guard above, but handle it safely.
1066        start_conditions.push(where_sql);
1067    }
1068
1069    // MAJ-2: min_depth is always a bound parameter, never a literal
1070    if min_depth > 0 {
1071        let min_depth_i64 = i64::try_from(min_depth)
1072            .map_err(|_| QueryError::InvalidInput("min_depth exceeds i64::MAX".into()))?;
1073        params.push(QueryValue::Integer(min_depth_i64));
1074        end_conditions.push(format!("t.depth >= ?{}", params.len()));
1075    }
1076
1077    let limit = query.limit.unwrap_or(opts.max_limit).min(opts.max_limit);
1078    let limit_i64 = i64::try_from(limit)
1079        .map_err(|_| QueryError::InvalidInput("limit exceeds i64::MAX".into()))?;
1080    params.push(QueryValue::Integer(limit_i64));
1081    let limit_param = params.len();
1082
1083    // Register variables
1084    if let Some(ref var) = start.variable {
1085        var_to_alias.insert(var.clone(), ("s".to_string(), VarKind::Node));
1086    }
1087    if let Some(ref var) = end.variable {
1088        var_to_alias.insert(var.clone(), ("r".to_string(), VarKind::Node));
1089    }
1090    if let Some(ref var) = edge.variable {
1091        var_to_alias.insert(var.clone(), ("e".to_string(), VarKind::Edge));
1092    }
1093
1094    // Build SELECT based on RETURN items
1095    let mut select_parts: Vec<String> = Vec::new();
1096    let mut has_start = false;
1097
1098    for item in &query.return_items {
1099        let var = item.variable();
1100        if let Some((_, kind)) = var_to_alias.get(var) {
1101            match item {
1102                ReturnItem::Property(_, prop) => {
1103                    let is_start = start.variable.as_deref() == Some(var);
1104                    if matches!(kind, VarKind::EventNode | VarKind::NoteNode) {
1105                        return Err(QueryError::Unsupported(
1106                            "synthetic observed_as_* edges cannot be used in variable-length \
1107                             patterns; use a fixed-length edge pattern instead"
1108                                .into(),
1109                        ));
1110                    }
1111                    if *kind == VarKind::Node {
1112                        let tbl = if is_start { "s" } else { "r" };
1113                        if is_start {
1114                            has_start = true;
1115                        }
1116                        let col = property_to_column(prop, kind)?;
1117                        select_parts.push(format!("{tbl}.{col} AS {var}_{prop}"));
1118                    } else {
1119                        let col = match prop.as_str() {
1120                            "id" => "via_edge",
1121                            "relation" => "via_relation",
1122                            "weight" => "via_weight",
1123                            _ => {
1124                                return Err(QueryError::Compile(format!(
1125                                    "unknown edge property '{prop}' in RETURN projection. \
1126                                     Valid: id, source_id, target_id, relation, weight"
1127                                )));
1128                            }
1129                        };
1130                        select_parts.push(format!("t.{col} AS {var}_{prop}"));
1131                    }
1132                }
1133                ReturnItem::Variable(_) => match kind {
1134                    VarKind::Node => {
1135                        if start.variable.as_deref() == Some(var) {
1136                            has_start = true;
1137                            select_parts.push(format!(
1138                                "s.id AS {var}_id, s.namespace AS {var}_namespace, \
1139                                 s.kind AS {var}_kind, s.entity_type AS {var}_entity_type, \
1140                                 s.name AS {var}_name, \
1141                                 s.properties AS {var}_properties, \
1142                                 s.created_at AS {var}_created_at, \
1143                                 s.updated_at AS {var}_updated_at"
1144                            ));
1145                        } else {
1146                            select_parts.push(format!(
1147                                "r.id AS {var}_id, r.namespace AS {var}_namespace, \
1148                                 r.kind AS {var}_kind, r.entity_type AS {var}_entity_type, \
1149                                 r.name AS {var}_name, \
1150                                 r.properties AS {var}_properties, \
1151                                 r.created_at AS {var}_created_at, \
1152                                 r.updated_at AS {var}_updated_at"
1153                            ));
1154                        }
1155                    }
1156                    VarKind::EventNode | VarKind::NoteNode => {
1157                        // Synthetic observed_as_* edges require a fixed-length pattern;
1158                        // variable-length recursion over the events/notes tables is not supported.
1159                        return Err(QueryError::Unsupported(
1160                            "synthetic observed_as_* edges cannot be used in variable-length \
1161                             patterns; use a fixed-length edge pattern instead"
1162                                .into(),
1163                        ));
1164                    }
1165                    VarKind::Edge => {
1166                        select_parts.push(format!(
1167                            "t.via_edge AS {var}_id, t.via_relation AS {var}_relation, \
1168                             t.via_weight AS {var}_weight"
1169                        ));
1170                    }
1171                },
1172            }
1173        } else {
1174            return Err(QueryError::Compile(format!(
1175                "unknown variable '{var}' in RETURN clause"
1176            )));
1177        }
1178    }
1179
1180    // Always include traversal metadata
1181    select_parts.push("t.depth AS _depth".to_string());
1182    select_parts.push("t.total_weight AS _total_weight".to_string());
1183
1184    // `s` is optional (only joined if the start variable is projected); `r` is
1185    // always joined because the outer WHERE always references `r.deleted_at`,
1186    // `r.namespace` (and possibly r.kind / r.properties) regardless of whether
1187    // it appears in RETURN.
1188    let join_start = if has_start {
1189        "JOIN entities s ON s.id = t.start_id"
1190    } else {
1191        ""
1192    };
1193    let join_end = "JOIN entities r ON r.id = t.current_id";
1194
1195    // Build the next-node namespace filter clause (may be empty).
1196    // Already pushed into params by namespace_filter above.
1197    let next_node_ns_and = if next_node_ns_filter.is_empty() {
1198        String::new()
1199    } else {
1200        format!(" AND {}", next_node_ns_filter.trim_start_matches(" AND "))
1201    };
1202
1203    let sql = format!(
1204        "WITH RECURSIVE traverse(start_id, current_id, depth, path, total_weight, via_edge, via_relation, via_weight) AS (\
1205             SELECT s.id, {seed_next}, 1, s.id || ',' || {seed_next}, e.weight, \
1206                    e.id, e.relation, e.weight \
1207             FROM entities s \
1208             JOIN graph_edges e ON {seed_join} AND e.deleted_at IS NULL{e_ns_filter}{relation_condition} \
1209             WHERE {start_where} \
1210             UNION ALL \
1211             SELECT t.start_id, {recurse_next}, t.depth + 1, \
1212                    t.path || ',' || {recurse_next}, \
1213                    t.total_weight + e.weight, \
1214                    e.id, e.relation, e.weight \
1215             FROM traverse t CROSS JOIN graph_edges e \
1216                 ON {recurse_join} AND e.deleted_at IS NULL{e_ns_filter}{relation_condition} \
1217             JOIN entities next_node ON next_node.id = ({recurse_next}) \
1218                    AND next_node.deleted_at IS NULL{next_node_ns_and} \
1219             WHERE t.depth < ?{depth_param} \
1220               AND (',' || t.path || ',') NOT LIKE '%,' || {recurse_next} || ',%' \
1221         ) \
1222         SELECT DISTINCT {select_cols} \
1223         FROM traverse t \
1224         {join_start} {join_end} \
1225         WHERE {end_where} \
1226         ORDER BY t.depth, t.total_weight DESC, t.start_id, t.current_id \
1227         LIMIT ?{limit_param}",
1228        seed_next = seed_next,
1229        seed_join = seed_join,
1230        e_ns_filter = e_ns_filter,
1231        relation_condition = relation_condition,
1232        start_where = start_conditions.join(" AND "),
1233        recurse_next = recurse_next,
1234        recurse_join = recurse_join,
1235        next_node_ns_and = next_node_ns_and,
1236        depth_param = depth_param,
1237        select_cols = select_parts.join(", "),
1238        join_start = join_start,
1239        join_end = join_end,
1240        end_where = end_conditions.join(" AND "),
1241        limit_param = limit_param,
1242    );
1243
1244    Ok(CompiledQuery {
1245        sql,
1246        params,
1247        return_vars: query.return_items.clone(),
1248        warnings: Vec::new(),
1249    })
1250}
1251
1252#[derive(Clone, Copy, PartialEq, Eq)]
1253enum VarKind {
1254    Node,
1255    /// Node that maps to the `events` table (synthetic `observed_as_*` edge source).
1256    EventNode,
1257    /// Node that maps to the `notes` table (synthetic `observed_as_*` edge target).
1258    NoteNode,
1259    Edge,
1260}
1261
1262const NODE_COLUMNS: &[&str] = &[
1263    "id",
1264    "name",
1265    "kind",
1266    "entity_type",
1267    "namespace",
1268    "description",
1269    "properties",
1270    "created_at",
1271    "updated_at",
1272];
1273/// Columns available for projection on `notes` table nodes (synthetic edge targets).
1274const NOTE_COLUMNS: &[&str] = &[
1275    "id",
1276    "namespace",
1277    "kind",
1278    "status",
1279    "name",
1280    "content",
1281    "salience",
1282    "decay_factor",
1283    "properties",
1284    "created_at",
1285    "updated_at",
1286];
1287/// Columns available for projection on `events` table nodes (synthetic edge sources).
1288const EVENT_COLUMNS: &[&str] = &[
1289    "id",
1290    "namespace",
1291    "verb",
1292    "substrate",
1293    "actor",
1294    "kind",
1295    "outcome",
1296    "payload",
1297    "duration_us",
1298    "target_id",
1299    "session_id",
1300    "created_at",
1301];
1302const EDGE_COLUMNS: &[&str] = &["id", "source_id", "target_id", "relation", "weight"];
1303
1304fn property_to_column<'a>(prop: &'a str, kind: &VarKind) -> Result<&'a str, QueryError> {
1305    let (valid, kind_name) = match kind {
1306        VarKind::Node => (NODE_COLUMNS, "node"),
1307        VarKind::NoteNode => (NOTE_COLUMNS, "note"),
1308        VarKind::EventNode => (EVENT_COLUMNS, "event"),
1309        VarKind::Edge => (EDGE_COLUMNS, "edge"),
1310    };
1311    if valid.contains(&prop) {
1312        Ok(prop)
1313    } else {
1314        Err(QueryError::Compile(format!(
1315            "unknown {kind_name} property '{prop}' in RETURN projection. \
1316             Valid: {}",
1317            valid.join(", ")
1318        )))
1319    }
1320}
1321
1322// INLINE TEST JUSTIFICATION: Tests access private helpers (compile_fixed_length,
1323// compile_variable_length, compile_single_condition, compile_var_len_condition) and
1324// internal types (VarKind) via pub(crate) visibility; moving to crates/khive-query/tests/
1325// would require making those items pub, which would widen the public API surface.
1326#[cfg(test)]
1327mod tests {
1328    use super::*;
1329    use crate::parsers::gql;
1330
1331    fn opts() -> CompileOptions {
1332        CompileOptions::default()
1333    }
1334
1335    fn scoped(namespace: &str) -> CompileOptions {
1336        CompileOptions {
1337            scopes: vec![namespace.to_string()],
1338            max_limit: 500,
1339        }
1340    }
1341
1342    #[test]
1343    fn fixed_length_basic() {
1344        let q =
1345            gql::parse("MATCH (a:concept)-[e:introduced_by]->(b:paper) RETURN a, e, b LIMIT 10")
1346                .unwrap();
1347        let compiled = compile(&q, &opts()).unwrap();
1348        assert!(compiled.sql.contains("JOIN graph_edges"));
1349        assert!(compiled.sql.contains("LIMIT"));
1350        assert_eq!(
1351            compiled.return_vars,
1352            vec![
1353                ReturnItem::Variable("a".into()),
1354                ReturnItem::Variable("e".into()),
1355                ReturnItem::Variable("b".into()),
1356            ]
1357        );
1358        // No recursive CTE for fixed-length
1359        assert!(!compiled.sql.contains("WITH RECURSIVE"));
1360    }
1361
1362    #[test]
1363    fn namespace_scoping_injected() {
1364        // Namespace must come from opts, never from the query
1365        let q =
1366            gql::parse("MATCH (a:concept)-[e:introduced_by]->(b:paper) RETURN a LIMIT 5").unwrap();
1367        let compiled = compile(&q, &scoped("research")).unwrap();
1368        assert!(compiled.sql.contains("namespace"));
1369        // The namespace value must appear as a parameter, not a literal in SQL
1370        let has_ns_param = compiled
1371            .params
1372            .iter()
1373            .any(|p| matches!(p, QueryValue::Text(s) if s == "research"));
1374        assert!(has_ns_param, "namespace must be a bound parameter");
1375    }
1376
1377    #[test]
1378    fn edge_property_whitelist_rejects_unknown() {
1379        // MAJ-1: only 'relation' and 'weight' are queryable edge properties
1380        let q = gql::parse("MATCH (a)-[e:introduced_by]->(b) WHERE e.source_id = 'x' RETURN a")
1381            .unwrap();
1382        let result = compile(&q, &opts());
1383        assert!(result.is_err());
1384        let err = result.unwrap_err().to_string();
1385        assert!(
1386            err.contains("source_id") || err.contains("not queryable"),
1387            "error: {err}"
1388        );
1389    }
1390
1391    #[test]
1392    fn edge_property_relation_allowed() {
1393        let q = gql::parse("MATCH (a)-[e]->(b) WHERE e.relation = 'extends' RETURN a").unwrap();
1394        let result = compile(&q, &opts());
1395        assert!(
1396            result.is_ok(),
1397            "relation should be allowed: {:?}",
1398            result.err()
1399        );
1400    }
1401
1402    #[test]
1403    fn edge_property_weight_allowed() {
1404        let q = gql::parse("MATCH (a)-[e]->(b) WHERE e.weight > 0.5 RETURN a").unwrap();
1405        let result = compile(&q, &opts());
1406        assert!(
1407            result.is_ok(),
1408            "weight should be allowed: {:?}",
1409            result.err()
1410        );
1411    }
1412
1413    #[test]
1414    fn variable_length_uses_cte() {
1415        let q =
1416            gql::parse("MATCH (a {name: 'LoRA'})-[:extends*1..3]->(b) RETURN b LIMIT 20").unwrap();
1417        let compiled = compile(&q, &opts()).unwrap();
1418        assert!(compiled.sql.contains("WITH RECURSIVE"));
1419        assert!(compiled.sql.contains("traverse"));
1420    }
1421
1422    #[test]
1423    fn depth_cap_at_ten_rejects_above_max() {
1424        // Exceeding MAX_DEPTH is an InvalidInput error at validation time —
1425        // the compiler never sees a query with depth > 10.
1426        let q = gql::parse("MATCH (a)-[:extends*1..50]->(b) RETURN b").unwrap();
1427        let err = compile(&q, &opts()).unwrap_err();
1428        assert!(
1429            matches!(err, QueryError::InvalidInput(_)),
1430            "expected InvalidInput for depth > 10, got {err:?}"
1431        );
1432    }
1433
1434    #[test]
1435    fn depth_within_cap_compiles() {
1436        // depth *1..10 is at the cap — must compile successfully.
1437        let q = gql::parse("MATCH (a)-[:extends*1..10]->(b) RETURN b").unwrap();
1438        let compiled = compile(&q, &opts()).unwrap();
1439        assert!(compiled.sql.contains("WITH RECURSIVE"));
1440        // The depth parameter must equal 10
1441        let depth_val = compiled.params.iter().find_map(|p| {
1442            if let QueryValue::Integer(n) = p {
1443                Some(*n)
1444            } else {
1445                None
1446            }
1447        });
1448        assert_eq!(depth_val, Some(10), "depth param should be 10");
1449    }
1450
1451    #[test]
1452    fn limit_capped_by_max_limit() {
1453        // Query requests 1000, max_limit is 500 — result should be 500
1454        let q = gql::parse("MATCH (a:concept)-[e]->(b) RETURN a LIMIT 1000").unwrap();
1455        let compiled = compile(&q, &opts()).unwrap();
1456        let limit_param = compiled.params.last().unwrap();
1457        assert!(
1458            matches!(limit_param, QueryValue::Integer(500)),
1459            "expected Integer(500), got {limit_param:?}"
1460        );
1461    }
1462
1463    #[test]
1464    fn compile_rejects_unknown_relation() {
1465        let q = gql::parse("MATCH (a)-[:not_a_relation]->(b) RETURN a").unwrap();
1466        let err = compile(&q, &opts()).unwrap_err();
1467        let msg = err.to_string();
1468        assert!(msg.contains("not_a_relation"), "msg: {msg}");
1469    }
1470
1471    #[test]
1472    fn compile_unknown_kind_passes_through() {
1473        // Pack-agnostic: any string is accepted as an entity kind at the query layer.
1474        // Validation is a pack-handler concern.
1475        let q = gql::parse("MATCH (a:gizmo)-[:extends]->(b) RETURN a").unwrap();
1476        let compiled = compile(&q, &opts()).unwrap();
1477        let has_gizmo = compiled
1478            .params
1479            .iter()
1480            .any(|p| matches!(p, QueryValue::Text(s) if s == "gizmo"));
1481        assert!(
1482            has_gizmo,
1483            "pack-agnostic: unknown kind must pass through into SQL params"
1484        );
1485    }
1486
1487    #[test]
1488    fn compile_kind_passes_through_unchanged() {
1489        // Pack-agnostic: 'paper' is no longer normalized to 'document' at the query layer.
1490        // The string passes through as-is.
1491        let q =
1492            gql::parse("MATCH (a:paper)-[:introduced_by]->(b:concept) RETURN a LIMIT 1").unwrap();
1493        let compiled = compile(&q, &opts()).unwrap();
1494        let has_paper = compiled
1495            .params
1496            .iter()
1497            .any(|p| matches!(p, QueryValue::Text(s) if s == "paper"));
1498        assert!(
1499            has_paper,
1500            "kind 'paper' must pass through unchanged into SQL params"
1501        );
1502    }
1503
1504    #[test]
1505    fn compile_rejects_namespace_in_where() {
1506        let q =
1507            gql::parse("MATCH (a:concept)-[:extends]->(b) WHERE a.namespace = 'other' RETURN a")
1508                .unwrap();
1509        let err = compile(&q, &opts()).unwrap_err();
1510        assert!(err.to_string().contains("namespace"), "msg: {err}");
1511    }
1512
1513    #[test]
1514    fn compile_rejects_unknown_relation_in_where() {
1515        let q = gql::parse("MATCH (a)-[e:extends]->(b) WHERE e.relation = 'related_to' RETURN a")
1516            .unwrap();
1517        let err = compile(&q, &opts()).unwrap_err();
1518        assert!(err.to_string().contains("related_to"), "msg: {err}");
1519    }
1520
1521    #[test]
1522    fn compile_kind_in_where_passes_through_unchanged() {
1523        // Pack-agnostic: kind strings in WHERE conditions pass through as-is.
1524        let q = gql::parse("MATCH (a)-[:extends]->(b) WHERE a.kind = 'paper' RETURN a").unwrap();
1525        let compiled = compile(&q, &opts()).unwrap();
1526        let has_paper = compiled
1527            .params
1528            .iter()
1529            .any(|p| matches!(p, QueryValue::Text(s) if s == "paper"));
1530        assert!(
1531            has_paper,
1532            "kind 'paper' must pass through unchanged into SQL params"
1533        );
1534    }
1535
1536    #[test]
1537    fn variable_length_return_start_only_joins_end_entity() {
1538        // Even when only the start variable is projected, the outer query
1539        // references `r.deleted_at` / `r.namespace`, so entities r must be
1540        // joined unconditionally.
1541        let q = gql::parse("MATCH (a:concept)-[:extends*1..3]->(b) RETURN a LIMIT 10").unwrap();
1542        let compiled = compile(&q, &opts()).unwrap();
1543        assert!(
1544            compiled.sql.contains("JOIN entities r"),
1545            "entities r must always be joined when r.* conditions are emitted; sql: {}",
1546            compiled.sql
1547        );
1548    }
1549
1550    #[test]
1551    fn variable_length_trailing_pattern_unsupported() {
1552        let q = gql::parse("MATCH (a)-[:extends*1..3]->(b)-[:implements]->(c) RETURN b").unwrap();
1553        let err = compile(&q, &opts()).unwrap_err();
1554        assert!(
1555            matches!(err, QueryError::Unsupported(_)),
1556            "expected Unsupported, got {err:?}"
1557        );
1558    }
1559
1560    #[test]
1561    fn variable_length_mixed_chain_unsupported() {
1562        // Mixed fixed + variable in one chain — has_variable_length() triggers
1563        // the variable-length path, which must reject because edges.len() > 1.
1564        let q = gql::parse("MATCH (a)-[:extends]->(b)-[:implements*1..2]->(c) RETURN c").unwrap();
1565        let err = compile(&q, &opts()).unwrap_err();
1566        assert!(matches!(err, QueryError::Unsupported(_)), "got {err:?}");
1567    }
1568
1569    #[test]
1570    fn sparql_star_rejected_as_unsupported() {
1571        use crate::parsers::sparql;
1572        let err = sparql::parse("SELECT ?a ?b WHERE { ?a :extends* ?b . }").unwrap_err();
1573        assert!(matches!(err, QueryError::Unsupported(_)), "got {err:?}");
1574    }
1575
1576    /// Regression guard for ISSUE #231: SPARQL subject→predicate→object direction.
1577    /// `?a :extends ?b` must bind ?a to source_id and ?b to target_id, not swapped.
1578    #[test]
1579    fn sparql_subject_object_direction_compiles_outbound() {
1580        use crate::parsers::sparql;
1581
1582        let q = sparql::parse("SELECT ?a ?b WHERE { ?a :extends ?b . }").unwrap();
1583        let compiled = compile(&q, &opts()).unwrap();
1584
1585        assert!(
1586            compiled
1587                .sql
1588                .contains("JOIN graph_edges e0 ON e0.source_id = n0.id"),
1589            "SPARQL subject must bind graph_edges.source_id; sql: {}",
1590            compiled.sql
1591        );
1592        assert!(
1593            compiled
1594                .sql
1595                .contains("JOIN entities n1 ON n1.id = e0.target_id"),
1596            "SPARQL object must bind graph_edges.target_id; sql: {}",
1597            compiled.sql
1598        );
1599        assert!(
1600            compiled.sql.contains("e0.relation = ?1"),
1601            "SPARQL predicate must bind graph_edges.relation; sql: {}",
1602            compiled.sql
1603        );
1604    }
1605
1606    #[test]
1607    fn return_property_projection_compiles() {
1608        let q =
1609            gql::parse("MATCH (a:concept)-[e:extends]->(b:concept) RETURN a.name, b.name LIMIT 5")
1610                .unwrap();
1611        let compiled = compile(&q, &opts()).unwrap();
1612        // Node aliases are n0, n1; the SQL uses `alias.col AS var_prop`
1613        assert!(
1614            compiled.sql.contains(".name AS a_name"),
1615            "sql: {}",
1616            compiled.sql
1617        );
1618        assert!(
1619            compiled.sql.contains(".name AS b_name"),
1620            "sql: {}",
1621            compiled.sql
1622        );
1623        assert!(
1624            !compiled.sql.contains("a_kind"),
1625            "should not emit full node columns"
1626        );
1627    }
1628
1629    #[test]
1630    fn return_unknown_node_property_rejected() {
1631        let q = gql::parse("MATCH (a:concept)-[:extends]->(b) RETURN a.domain LIMIT 5").unwrap();
1632        let err = compile(&q, &opts()).unwrap_err();
1633        assert!(
1634            matches!(err, QueryError::Compile(ref msg) if msg.contains("unknown node property 'domain'")),
1635            "got {err:?}"
1636        );
1637    }
1638
1639    #[test]
1640    fn return_unknown_edge_property_rejected() {
1641        let q = gql::parse("MATCH (a)-[e:extends]->(b) RETURN e.label LIMIT 5").unwrap();
1642        let err = compile(&q, &opts()).unwrap_err();
1643        assert!(
1644            matches!(err, QueryError::Compile(ref msg) if msg.contains("unknown edge property 'label'")),
1645            "got {err:?}"
1646        );
1647    }
1648
1649    #[test]
1650    fn return_valid_edge_property_compiles() {
1651        let q =
1652            gql::parse("MATCH (a)-[e:extends]->(b) RETURN e.relation, e.weight LIMIT 5").unwrap();
1653        let compiled = compile(&q, &opts()).unwrap();
1654        // Edge alias is e0; SQL: `e0.relation AS e_relation`
1655        assert!(
1656            compiled.sql.contains(".relation AS e_relation"),
1657            "sql: {}",
1658            compiled.sql
1659        );
1660        assert!(
1661            compiled.sql.contains(".weight AS e_weight"),
1662            "sql: {}",
1663            compiled.sql
1664        );
1665    }
1666
1667    #[test]
1668    fn entity_type_compiles_as_direct_column_not_json_extract() {
1669        // entity_type in a NodePattern must become `alias.entity_type = ?N` in the WHERE
1670        // clause — a direct column reference, not json_extract from the properties blob.
1671        let q = gql::parse("MATCH (n:document {entity_type: 'paper'})-[:extends]->(m) RETURN n")
1672            .unwrap();
1673        let compiled = compile(&q, &opts()).unwrap();
1674        assert!(
1675            compiled.sql.contains(".entity_type = ?"),
1676            "entity_type must compile to a direct column comparison; sql: {}",
1677            compiled.sql
1678        );
1679        assert!(
1680            !compiled.sql.contains("json_extract"),
1681            "entity_type must NOT use json_extract; sql: {}",
1682            compiled.sql
1683        );
1684        let has_paper_param = compiled
1685            .params
1686            .iter()
1687            .any(|p| matches!(p, QueryValue::Text(s) if s == "paper"));
1688        assert!(
1689            has_paper_param,
1690            "entity_type value 'paper' must appear as a bound parameter"
1691        );
1692    }
1693
1694    // --- OR support in WHERE clause ---
1695
1696    #[test]
1697    fn where_or_compiles_to_sql_or() {
1698        let q = gql::parse(
1699            "MATCH (a:concept)-[e:extends]->(b) WHERE a.name = 'LoRA' OR a.name = 'QLoRA' RETURN a",
1700        )
1701        .unwrap();
1702        let compiled = compile(&q, &opts()).unwrap();
1703        assert!(
1704            compiled.sql.contains(" OR "),
1705            "WHERE OR must produce SQL OR; sql: {}",
1706            compiled.sql
1707        );
1708        let has_lora = compiled
1709            .params
1710            .iter()
1711            .any(|p| matches!(p, QueryValue::Text(s) if s == "LoRA"));
1712        let has_qlora = compiled
1713            .params
1714            .iter()
1715            .any(|p| matches!(p, QueryValue::Text(s) if s == "QLoRA"));
1716        assert!(has_lora && has_qlora, "both OR values must be bound params");
1717    }
1718
1719    #[test]
1720    fn where_and_or_precedence() {
1721        // `a AND b OR c` should compile as `(a AND b) OR c`
1722        let q = gql::parse(
1723            "MATCH (a:concept)-[e:extends]->(b) WHERE a.name = 'X' AND a.kind = 'concept' OR b.kind = 'project' RETURN a"
1724        ).unwrap();
1725        let compiled = compile(&q, &opts()).unwrap();
1726        // The SQL should contain an OR at the outer level wrapping the AND group
1727        assert!(
1728            compiled.sql.contains(" OR "),
1729            "expected OR in sql; sql: {}",
1730            compiled.sql
1731        );
1732    }
1733
1734    // --- event_observations synthetic edge support ---
1735
1736    #[test]
1737    fn synthetic_edge_joins_event_observations() {
1738        let q = gql::parse("MATCH (ev)-[:observed_as_selected]->(m:memory) RETURN ev, m").unwrap();
1739        let compiled = compile(&q, &opts()).unwrap();
1740        assert!(
1741            compiled.sql.contains("event_observations"),
1742            "synthetic edge must join event_observations; sql: {}",
1743            compiled.sql
1744        );
1745        assert!(
1746            !compiled.sql.contains("graph_edges"),
1747            "synthetic edge must NOT join graph_edges; sql: {}",
1748            compiled.sql
1749        );
1750        let has_role_param = compiled
1751            .params
1752            .iter()
1753            .any(|p| matches!(p, QueryValue::Text(s) if s == "selected"));
1754        assert!(has_role_param, "role 'selected' must be a bound parameter");
1755    }
1756
1757    // CRIT-1 regression: event source node must bind to `events` table, not `entities`.
1758    // Previously `FROM entities n0 JOIN event_observations e0 ON e0.event_id = n0.id`
1759    // was emitted — IDs are disjoint so every query returned zero rows.
1760    #[test]
1761    fn synthetic_edge_event_source_binds_events_table() {
1762        let q = gql::parse("MATCH (ev)-[:observed_as_selected]->(m:memory) RETURN ev, m").unwrap();
1763        let compiled = compile(&q, &opts()).unwrap();
1764        assert!(
1765            compiled.sql.contains("FROM events "),
1766            "CRIT-1: event source must come FROM events table, not entities; sql: {}",
1767            compiled.sql
1768        );
1769        assert!(
1770            !compiled
1771                .sql
1772                .starts_with("SELECT * FROM entities n0 JOIN event_observations"),
1773            "CRIT-1: must not join events via entities table; sql: {}",
1774            compiled.sql
1775        );
1776    }
1777
1778    #[test]
1779    fn synthetic_edge_event_observation_join_uses_events_id() {
1780        // The JOIN must be `event_observations.event_id = events_alias.id`,
1781        // not `event_observations.event_id = entities_alias.id`.
1782        let q = gql::parse("MATCH (ev)-[:observed_as_selected]->(m) RETURN m").unwrap();
1783        let compiled = compile(&q, &opts()).unwrap();
1784        // The event alias is n0; the join must reference n0 against `events` table.
1785        assert!(
1786            compiled
1787                .sql
1788                .contains("JOIN event_observations e0 ON e0.event_id = n0.id"),
1789            "CRIT-1: event_observations must join on events.id (n0 is now events); sql: {}",
1790            compiled.sql
1791        );
1792    }
1793
1794    #[test]
1795    fn synthetic_edge_event_node_projects_event_columns() {
1796        // The event variable in RETURN must select event-table columns (verb, outcome, …),
1797        // not entity columns (name, entity_type, properties, …).
1798        let q = gql::parse("MATCH (ev)-[:observed_as_selected]->(m) RETURN ev").unwrap();
1799        let compiled = compile(&q, &opts()).unwrap();
1800        assert!(
1801            compiled.sql.contains("ev_verb"),
1802            "CRIT-1: event variable must project verb column; sql: {}",
1803            compiled.sql
1804        );
1805        assert!(
1806            compiled.sql.contains("ev_outcome"),
1807            "CRIT-1: event variable must project outcome column; sql: {}",
1808            compiled.sql
1809        );
1810        assert!(
1811            !compiled.sql.contains("ev_name,") && !compiled.sql.contains("ev_name "),
1812            "CRIT-1: event variable must NOT project entity name column; sql: {}",
1813            compiled.sql
1814        );
1815        assert!(
1816            !compiled.sql.contains("ev_properties"),
1817            "CRIT-1: event variable must NOT project entity properties column; sql: {}",
1818            compiled.sql
1819        );
1820    }
1821
1822    #[test]
1823    fn synthetic_edge_namespace_filter_on_events_table() {
1824        // MIN-2: when scoped, the namespace filter must target the events table
1825        // (which has a namespace column) — not rely on entities indirection.
1826        let q = gql::parse("MATCH (ev)-[:observed_as_selected]->(m) RETURN m").unwrap();
1827        let compiled = compile(&q, &scoped("test-ns")).unwrap();
1828        // Both the event alias (n0, now from `events`) and the target alias (n1, from `entities`)
1829        // must have namespace filters.
1830        let ns_count = compiled
1831            .params
1832            .iter()
1833            .filter(|p| matches!(p, QueryValue::Text(s) if s == "test-ns"))
1834            .count();
1835        assert!(
1836            ns_count >= 2,
1837            "MIN-2: namespace must be filtered on both events and target; params: {:?}",
1838            compiled.params
1839        );
1840    }
1841
1842    #[test]
1843    fn synthetic_edge_candidate_role() {
1844        let q = gql::parse("MATCH (ev)-[:observed_as_candidate]->(m) RETURN ev, m").unwrap();
1845        let compiled = compile(&q, &opts()).unwrap();
1846        assert!(
1847            compiled.sql.contains("event_observations"),
1848            "sql: {}",
1849            compiled.sql
1850        );
1851        let has_candidate = compiled
1852            .params
1853            .iter()
1854            .any(|p| matches!(p, QueryValue::Text(s) if s == "candidate"));
1855        assert!(has_candidate, "role 'candidate' must be bound");
1856    }
1857
1858    #[test]
1859    fn synthetic_edge_multi_role() {
1860        // Multiple observed_as_* relations compile to a role IN (...) predicate.
1861        let q =
1862            gql::parse("MATCH (ev)-[:observed_as_candidate|observed_as_selected]->(m) RETURN m")
1863                .unwrap();
1864        let compiled = compile(&q, &opts()).unwrap();
1865        assert!(
1866            compiled.sql.contains("event_observations"),
1867            "sql: {}",
1868            compiled.sql
1869        );
1870        assert!(
1871            compiled.sql.contains("IN"),
1872            "multi-role must use IN; sql: {}",
1873            compiled.sql
1874        );
1875    }
1876
1877    #[test]
1878    fn mixed_synthetic_and_canonical_rejected() {
1879        let q = gql::parse("MATCH (ev)-[:observed_as_selected|extends]->(m) RETURN m").unwrap();
1880        let err = compile(&q, &opts()).unwrap_err();
1881        assert!(
1882            matches!(err, QueryError::Compile(_)),
1883            "mixed synthetic+canonical must be rejected; got {err:?}"
1884        );
1885    }
1886
1887    #[test]
1888    fn synthetic_edge_inbound_rejected() {
1889        let q = gql::parse("MATCH (m)<-[:observed_as_selected]-(ev) RETURN m").unwrap();
1890        let err = compile(&q, &opts()).unwrap_err();
1891        assert!(
1892            matches!(err, QueryError::Compile(_)),
1893            "inbound synthetic edge must be rejected; got {err:?}"
1894        );
1895    }
1896
1897    // --- MAJ-1: OR spanning both endpoints in variable-length patterns must be rejected ---
1898
1899    #[test]
1900    fn variable_length_or_across_endpoints_rejected() {
1901        // MAJ-1: `WHERE a.name='X' OR b.name='Y'` in a variable-length pattern must be
1902        // rejected with Unsupported — not silently compiled to AND.
1903        let q = gql::parse(
1904            "MATCH (a)-[:extends*1..3]->(b) WHERE a.name = 'X' OR b.name = 'Y' RETURN a",
1905        )
1906        .unwrap();
1907        let result = compile(&q, &opts());
1908        assert!(
1909            matches!(result, Err(QueryError::Unsupported(_))),
1910            "MAJ-1: OR spanning both endpoints must return Unsupported; got {result:?}"
1911        );
1912        let err_msg = result.unwrap_err().to_string();
1913        assert!(
1914            err_msg.contains("separate queries") || err_msg.contains("one endpoint"),
1915            "error must be actionable; got: {err_msg}"
1916        );
1917    }
1918
1919    #[test]
1920    fn variable_length_or_single_endpoint_still_works() {
1921        // OR within a single endpoint (same alias) must still compile successfully.
1922        let q = gql::parse(
1923            "MATCH (a)-[:extends*1..3]->(b) WHERE a.name = 'X' OR a.name = 'Y' RETURN a",
1924        )
1925        .unwrap();
1926        let result = compile(&q, &opts());
1927        assert!(
1928            result.is_ok(),
1929            "single-endpoint OR must compile; got {result:?}"
1930        );
1931    }
1932
1933    #[test]
1934    fn variable_length_and_across_endpoints_still_works() {
1935        // AND across endpoints must still compile (the existing behavior is correct for AND).
1936        let q = gql::parse(
1937            "MATCH (a)-[:extends*1..3]->(b) WHERE a.name = 'X' AND b.name = 'Y' RETURN a",
1938        )
1939        .unwrap();
1940        let result = compile(&q, &opts());
1941        assert!(
1942            result.is_ok(),
1943            "AND across endpoints must compile; got {result:?}"
1944        );
1945    }
1946
1947    // --- Regression tests for #379: variable-length WHERE OR must not flatten to AND ---
1948
1949    #[test]
1950    fn test_variable_length_or_compiles_to_or() {
1951        // #379: MATCH (a)-[*1..3 WHERE p1 OR p2]-> in GQL surface maps to a single-endpoint
1952        // OR in the WHERE clause.  The compiled SQL must contain OR, not AND.
1953        let q = gql::parse(
1954            "MATCH (a)-[:extends*1..3]->(b) WHERE a.name = 'LoRA' OR a.name = 'QLoRA' RETURN b",
1955        )
1956        .unwrap();
1957        let compiled = compile(&q, &opts()).unwrap();
1958        // The start_conditions list must contain an OR fragment, not two AND-joined conditions.
1959        assert!(
1960            compiled.sql.contains(" OR "),
1961            "#379: variable-length single-endpoint OR must produce SQL OR; sql: {}",
1962            compiled.sql
1963        );
1964        // Both values must appear as bound parameters.
1965        let has_lora = compiled
1966            .params
1967            .iter()
1968            .any(|p| matches!(p, QueryValue::Text(s) if s == "LoRA"));
1969        let has_qlora = compiled
1970            .params
1971            .iter()
1972            .any(|p| matches!(p, QueryValue::Text(s) if s == "QLoRA"));
1973        assert!(has_lora && has_qlora, "both OR values must be bound params");
1974    }
1975
1976    #[test]
1977    fn test_single_endpoint_or_at_depth_1() {
1978        // #379: single-hop pattern with single-endpoint OR in WHERE.
1979        // The OR must appear in the compiled SQL (not silently become AND).
1980        let q = gql::parse(
1981            "MATCH (a)-[r:extends]->(b) WHERE r.weight > 0.5 OR r.relation = 'extends' RETURN a",
1982        )
1983        .unwrap();
1984        let compiled = compile(&q, &opts()).unwrap();
1985        assert!(
1986            compiled.sql.contains(" OR "),
1987            "#379: fixed-length single-endpoint OR must produce SQL OR; sql: {}",
1988            compiled.sql
1989        );
1990        let has_extends = compiled
1991            .params
1992            .iter()
1993            .any(|p| matches!(p, QueryValue::Text(s) if s == "extends"));
1994        assert!(
1995            has_extends,
1996            "relation value 'extends' must be a bound param"
1997        );
1998    }
1999
2000    #[test]
2001    fn test_and_still_works() {
2002        // #379: regression guard — simple WHERE p1 AND p2 must still emit AND.
2003        let q = gql::parse(
2004            "MATCH (a)-[:extends*1..3]->(b) WHERE a.name = 'LoRA' AND a.kind = 'concept' RETURN b",
2005        )
2006        .unwrap();
2007        let compiled = compile(&q, &opts()).unwrap();
2008        // The SQL must not contain a bare " OR " from the AND expression.
2009        assert!(
2010            !compiled.sql.contains(" OR "),
2011            "#379: AND must not produce OR; sql: {}",
2012            compiled.sql
2013        );
2014        let has_lora = compiled
2015            .params
2016            .iter()
2017            .any(|p| matches!(p, QueryValue::Text(s) if s == "LoRA"));
2018        let has_concept = compiled
2019            .params
2020            .iter()
2021            .any(|p| matches!(p, QueryValue::Text(s) if s == "concept"));
2022        assert!(
2023            has_lora && has_concept,
2024            "both AND values must be bound params"
2025        );
2026    }
2027
2028    // --- Regression tests for P0/P1 correctness fixes ---
2029
2030    /// max_limit overflow: usize::MAX as i64 == -1 on 64-bit, defeating the cap.
2031    #[test]
2032    fn max_limit_overflow_returns_error() {
2033        let q = gql::parse("MATCH (a)-[:extends]->(b) RETURN a").unwrap();
2034        let opts = CompileOptions {
2035            scopes: vec![],
2036            max_limit: usize::MAX,
2037        };
2038        // On 64-bit: usize::MAX > i64::MAX, so try_from must return Err.
2039        // On 32-bit: usize::MAX == u32::MAX which fits in i64, so this may succeed —
2040        // either way we must not produce a negative limit.
2041        let result = compile(&q, &opts);
2042        match result {
2043            Err(QueryError::InvalidInput(_)) => {
2044                // Expected on 64-bit: overflow detected, error returned.
2045            }
2046            Ok(compiled) => {
2047                // On 32-bit: limit fits in i64 — verify it is non-negative.
2048                let limit_param = compiled.params.last().unwrap();
2049                assert!(
2050                    matches!(limit_param, QueryValue::Integer(n) if *n >= 0),
2051                    "limit must never be negative; got {limit_param:?}"
2052                );
2053            }
2054            Err(e) => panic!("unexpected error: {e:?}"),
2055        }
2056    }
2057
2058    /// max_limit=0 with no query limit: query limit defaults to 0, no crash.
2059    #[test]
2060    fn max_limit_zero_compiles() {
2061        let q = gql::parse("MATCH (a)-[:extends]->(b) RETURN a").unwrap();
2062        let opts = CompileOptions {
2063            scopes: vec![],
2064            max_limit: 0,
2065        };
2066        let compiled = compile(&q, &opts).unwrap();
2067        let limit_param = compiled.params.last().unwrap();
2068        assert!(
2069            matches!(limit_param, QueryValue::Integer(0)),
2070            "max_limit=0 should produce LIMIT 0; got {limit_param:?}"
2071        );
2072    }
2073
2074    /// Variable-length synthetic edges must be rejected.
2075    #[test]
2076    fn variable_length_synthetic_edge_rejected() {
2077        // observed_as_selected*1..3 must be rejected — the recursive CTE targets
2078        // graph_edges, which has no event_observations data.
2079        let q = gql::parse("MATCH (ev)-[:observed_as_selected*1..3]->(m) RETURN m").unwrap();
2080        let err = compile(&q, &opts()).unwrap_err();
2081        assert!(
2082            matches!(err, QueryError::Unsupported(_)),
2083            "variable-length synthetic edge must return Unsupported; got {err:?}"
2084        );
2085        assert!(
2086            err.to_string().contains("synthetic") || err.to_string().contains("observed_as"),
2087            "error should mention synthetic edges: {err}"
2088        );
2089    }
2090
2091    /// Variable-length traversal must not pass through deleted intermediate nodes.
2092    /// The compiled SQL must join entities for the next node in the recursive member.
2093    #[test]
2094    fn variable_length_recursive_member_joins_next_node_for_deleted_filter() {
2095        let q = gql::parse("MATCH (a)-[:extends*1..3]->(b) RETURN b").unwrap();
2096        let compiled = compile(&q, &opts()).unwrap();
2097        // The recursive CTE member must join next_node to filter deleted intermediates.
2098        assert!(
2099            compiled.sql.contains("JOIN entities next_node"),
2100            "recursive CTE must join entities next_node for deleted-intermediate filtering; sql: {}",
2101            compiled.sql
2102        );
2103        assert!(
2104            compiled.sql.contains("next_node.deleted_at IS NULL"),
2105            "recursive CTE must filter next_node.deleted_at IS NULL; sql: {}",
2106            compiled.sql
2107        );
2108    }
2109
2110    /// Variable-length traversal with namespace scope: the next_node join must
2111    /// also apply the namespace filter to prevent namespace-crossing intermediates.
2112    #[test]
2113    fn variable_length_recursive_member_namespace_scopes_intermediates() {
2114        let q = gql::parse("MATCH (a)-[:extends*1..3]->(b) RETURN b").unwrap();
2115        let compiled = compile(&q, &scoped("test-ns")).unwrap();
2116        // The next_node join must include a namespace condition.
2117        assert!(
2118            compiled.sql.contains("next_node.namespace"),
2119            "recursive CTE next_node join must filter namespace; sql: {}",
2120            compiled.sql
2121        );
2122    }
2123
2124    /// Public AST panic: compile must return an error for a malformed AST instead
2125    /// of panicking with an out-of-bounds index.
2126    #[test]
2127    fn compile_malformed_ast_returns_error_not_panic() {
2128        use crate::ast::{EdgeDirection, EdgePattern, GqlQuery, MatchPattern, PatternElement};
2129        // An AST that starts with an Edge (no leading Node) is malformed.
2130        let q = GqlQuery {
2131            pattern: MatchPattern {
2132                elements: vec![PatternElement::Edge(EdgePattern {
2133                    variable: None,
2134                    relations: vec!["extends".to_string()],
2135                    direction: EdgeDirection::Out,
2136                    min_hops: 1,
2137                    max_hops: 1,
2138                })],
2139            },
2140            where_clause: WhereExpr::True,
2141            return_items: vec![],
2142            limit: None,
2143        };
2144        let result = compile(&q, &opts());
2145        assert!(
2146            result.is_err(),
2147            "malformed AST (starts with Edge) must return error, not panic"
2148        );
2149    }
2150
2151    /// GQL edge pattern suffix fix: `(a)-[e:extends](b)` must be rejected because
2152    /// the `-` suffix after `]` is required.
2153    #[test]
2154    fn edge_pattern_without_suffix_dash_rejected() {
2155        let result = gql::parse("MATCH (a)-[e:extends](b) RETURN a");
2156        assert!(
2157            result.is_err(),
2158            "edge pattern without suffix '-' must be rejected as a parse error"
2159        );
2160    }
2161
2162    // --- Read-only invariant regression tests (#16) ---
2163
2164    /// assert_select_only accepts SELECT and WITH (recursive CTE).
2165    #[test]
2166    fn assert_select_only_accepts_select_and_with() {
2167        assert!(
2168            assert_select_only("SELECT a FROM entities WHERE 1=1").is_ok(),
2169            "SELECT must be accepted"
2170        );
2171        assert!(
2172            assert_select_only("WITH RECURSIVE traverse AS (...) SELECT ...").is_ok(),
2173            "WITH must be accepted (recursive CTE)"
2174        );
2175    }
2176
2177    /// assert_select_only rejects write SQL with the canonical read-only message.
2178    #[test]
2179    fn assert_select_only_rejects_write_sql_with_readonly_message() {
2180        for stmt in &[
2181            "INSERT INTO entities VALUES (?)",
2182            "UPDATE entities SET name = ?",
2183            "DELETE FROM entities WHERE id = ?",
2184            "DROP TABLE entities",
2185        ] {
2186            let err = assert_select_only(stmt).unwrap_err();
2187            assert!(
2188                matches!(err, QueryError::Compile(_)),
2189                "write SQL must return Compile error for '{stmt}'; got {err:?}"
2190            );
2191            let msg = err.to_string();
2192            assert!(
2193                msg.contains("read-only"),
2194                "error must mention 'read-only' for '{stmt}'; got: {msg}"
2195            );
2196            assert!(
2197                msg.contains("create") && msg.contains("delete"),
2198                "error must name the mutation verbs for '{stmt}'; got: {msg}"
2199            );
2200        }
2201    }
2202
2203    /// Regression: compile() for a valid GQL query must still succeed end-to-end.
2204    /// This guards against assert_select_only incorrectly rejecting compiler output.
2205    #[test]
2206    fn readonly_guard_does_not_break_valid_gql_compile() {
2207        let q = gql::parse("MATCH (a:concept)-[:extends]->(b) RETURN a LIMIT 10").unwrap();
2208        let compiled = compile(&q, &opts()).unwrap();
2209        assert!(
2210            compiled.sql.starts_with("SELECT"),
2211            "valid GQL must compile to SELECT; sql: {}",
2212            compiled.sql
2213        );
2214    }
2215
2216    /// Regression: compile() for a variable-length GQL query must still succeed.
2217    #[test]
2218    fn readonly_guard_does_not_break_valid_cte_compile() {
2219        let q = gql::parse("MATCH (a)-[:extends*1..3]->(b) RETURN b LIMIT 10").unwrap();
2220        let compiled = compile(&q, &opts()).unwrap();
2221        assert!(
2222            compiled.sql.starts_with("WITH RECURSIVE"),
2223            "variable-length GQL must compile to WITH RECURSIVE; sql: {}",
2224            compiled.sql
2225        );
2226    }
2227
2228    /// GQL write forms are rejected at the parse layer before reaching the compiler.
2229    #[test]
2230    fn gql_write_form_rejected_before_compile() {
2231        use crate::parsers::gql;
2232        let err = gql::parse("CREATE (n:concept) RETURN n").unwrap_err();
2233        assert!(
2234            matches!(err, QueryError::Unsupported(_)),
2235            "GQL CREATE must be Unsupported; got {err:?}"
2236        );
2237        assert!(
2238            err.to_string().contains("read-only"),
2239            "error must mention read-only; got: {err}"
2240        );
2241    }
2242
2243    /// SPARQL write forms are rejected at the parse layer before reaching the compiler.
2244    #[test]
2245    fn sparql_write_form_rejected_before_compile() {
2246        use crate::parsers::sparql;
2247        let err = sparql::parse("INSERT DATA { ?a :extends ?b }").unwrap_err();
2248        assert!(
2249            matches!(err, QueryError::Unsupported(_)),
2250            "SPARQL INSERT must be Unsupported; got {err:?}"
2251        );
2252        assert!(
2253            err.to_string().contains("read-only"),
2254            "error must mention read-only; got: {err}"
2255        );
2256    }
2257
2258    /// Duplicate inline property rejection.
2259    #[test]
2260    fn duplicate_inline_property_rejected() {
2261        let result = gql::parse("MATCH (n {name: 'A', name: 'B'}) RETURN n");
2262        assert!(
2263            result.is_err(),
2264            "duplicate property 'name' in node props must be rejected"
2265        );
2266        let err = result.unwrap_err().to_string();
2267        assert!(
2268            err.contains("duplicate") || err.contains("name"),
2269            "error should mention duplicate or key name: {err}"
2270        );
2271    }
2272
2273    /// Unknown synthetic relation must be rejected at validation.
2274    #[test]
2275    fn unknown_synthetic_relation_rejected_at_compile() {
2276        let q = gql::parse("MATCH (a)-[:observed_as_bogus]->(b) RETURN a").unwrap();
2277        let err = compile(&q, &opts()).unwrap_err();
2278        assert!(
2279            matches!(err, QueryError::Validation(_)),
2280            "unknown synthetic relation must return Validation error; got {err:?}"
2281        );
2282    }
2283}