Skip to main content

khive_query/compilers/
sql.rs

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