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