Skip to main content

khive_query/compilers/
sql.rs

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