Skip to main content

khive_query/compilers/
sql.rs

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