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