Skip to main content

khive_query/compilers/
sql.rs

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