Skip to main content

khive_query/compilers/
sql.rs

1//! Parameterized SQL compilation for fixed and variable-length query patterns.
2
3use crate::ast::*;
4use crate::error::QueryError;
5use crate::language::read_only_error;
6use crate::validate::{validate_with_warnings, MAX_DEPTH, SYNTHETIC_RELATIONS};
7
8fn is_synthetic(rel: &str) -> bool {
9    SYNTHETIC_RELATIONS.contains(&rel)
10}
11
12fn synthetic_role(rel: &str) -> Option<&'static str> {
13    match rel {
14        "observed_as_candidate" => Some("candidate"),
15        "observed_as_selected" => Some("selected"),
16        "observed_as_target" => Some("target"),
17        "observed_as_signal" => Some("signal"),
18        _ => None,
19    }
20}
21
22/// Substrate-agnostic source for canonical edge endpoints (issue #467).
23const PRIMARY_NODE_SQL: &str = "\
24    SELECT id, namespace, kind, entity_type, name, description, NULL AS content, \
25           NULL AS status, NULL AS salience, NULL AS decay_factor, properties, \
26           created_at, updated_at, deleted_at, 'entity' AS substrate_kind \
27      FROM entities \
28    UNION ALL \
29    SELECT id, namespace, kind, NULL AS entity_type, name, NULL AS description, \
30           content, status, salience, decay_factor, properties, \
31           created_at, updated_at, deleted_at, 'note' AS substrate_kind \
32      FROM notes \
33    UNION ALL \
34    SELECT id, namespace, kind, NULL AS entity_type, verb AS name, \
35           NULL AS description, NULL AS content, NULL AS status, \
36           NULL AS salience, NULL AS decay_factor, payload AS properties, \
37           created_at, created_at AS updated_at, NULL AS deleted_at, \
38           'event' AS substrate_kind \
39      FROM events \
40    UNION ALL \
41    SELECT id, namespace, relation AS kind, NULL AS entity_type, NULL AS name, \
42           NULL AS description, NULL AS content, NULL AS status, \
43           NULL AS salience, NULL AS decay_factor, metadata AS properties, \
44           created_at, updated_at, deleted_at, 'edge' AS substrate_kind \
45      FROM graph_edges";
46
47fn primary_node_source(alias: &str) -> String {
48    format!("({PRIMARY_NODE_SQL}) {alias}")
49}
50
51/// Entity/note referent source for synthetic observation targets (issue #468).
52const OBSERVATION_TARGET_SQL: &str = "\
53    SELECT id, namespace, kind, entity_type, name, description, \
54           NULL AS content, NULL AS status, NULL AS salience, \
55           NULL AS decay_factor, properties, created_at, updated_at, \
56           deleted_at, 'entity' AS referent_kind \
57      FROM entities \
58    UNION ALL \
59    SELECT id, namespace, kind, NULL AS entity_type, name, NULL AS description, \
60           content, status, salience, decay_factor, properties, \
61           created_at, updated_at, deleted_at, 'note' AS referent_kind \
62      FROM notes";
63
64fn observation_target_source(alias: &str) -> String {
65    format!("({OBSERVATION_TARGET_SQL}) {alias}")
66}
67
68/// Quotes one SQL identifier while preserving the returned column name.
69fn quote_identifier(identifier: &str) -> String {
70    format!("\"{}\"", identifier.replace('"', "\"\""))
71}
72
73/// Parameterized read-only SQL plus the metadata required to decode its result.
74///
75/// See `crates/khive-query/docs/api/sql-compilation.md` for lowering rules.
76#[derive(Debug)]
77pub struct CompiledQuery {
78    /// SQL statement using positional `?N` placeholders.
79    pub sql: String,
80    /// Parameters in placeholder order.
81    pub params: Vec<QueryValue>,
82    /// Caller-requested projections in result-column order.
83    pub return_vars: Vec<ReturnItem>,
84    /// Non-fatal validation or compilation diagnostics.
85    pub warnings: Vec<String>,
86    /// Sentinel metadata when SQL fetches `max_limit + 1` to detect another page.
87    pub truncation_check: Option<TruncationCheck>,
88}
89
90/// Instructions for stripping a row-cap sentinel after execution.
91#[derive(Clone, Copy, Debug, PartialEq, Eq)]
92pub struct TruncationCheck {
93    /// Effective page size to enforce after detecting and removing the sentinel.
94    pub max_limit: usize,
95    /// Explicit caller limit, retained for diagnostics.
96    pub requested_limit: Option<usize>,
97}
98
99/// Runtime options injected by the caller to scope and cap query execution.
100pub struct CompileOptions {
101    /// Namespace scope; empty means all namespaces.
102    pub scopes: Vec<String>,
103    /// Effective server page size applied against any explicit query limit.
104    ///
105    /// The public handler clamps its requested `page_size` to the hard cap before
106    /// constructing these compiler options.
107    pub max_limit: usize,
108}
109
110impl Default for CompileOptions {
111    fn default() -> Self {
112        Self {
113            scopes: Vec::new(),
114            max_limit: 500,
115        }
116    }
117}
118
119/// Chooses the SQL limit and optional cap-sentinel check (issue #777).
120///
121/// An explicit query-text `LIMIT` is a TOTAL bound across every `SKIP` page, not a
122/// page-local one (ADR-008 §"query LIMIT and page_size composition", docs/guide/api-reference.md).
123/// `offset` is the page's `SKIP` value; the SQL bound is `min(limit - offset, max_limit)`,
124/// and the cap sentinel (`max_limit + 1`) is requested only when another page could still
125/// exist within the query's own limit. An offset at or beyond the limit yields SQL `LIMIT 0`
126/// — a terminal empty page, never an error.
127/// See `crates/khive-query/docs/api/sql-compilation.md` for lifecycle details.
128fn effective_limit(
129    requested_limit: Option<usize>,
130    offset: usize,
131    max_limit: usize,
132) -> (usize, Option<TruncationCheck>) {
133    match requested_limit {
134        Some(limit) => {
135            let remaining = limit.saturating_sub(offset);
136            if remaining <= max_limit {
137                (remaining, None)
138            } else {
139                (
140                    max_limit.saturating_add(1),
141                    Some(TruncationCheck {
142                        max_limit,
143                        requested_limit: Some(limit),
144                    }),
145                )
146            }
147        }
148        None => (
149            max_limit.saturating_add(1),
150            Some(TruncationCheck {
151                max_limit,
152                requested_limit: None,
153            }),
154        ),
155    }
156}
157
158/// Compiles `query` to parameterized, read-only SQL under `opts`.
159///
160/// The returned parameter vector is ordered to match the statement's `?N` placeholders.
161///
162/// # Errors
163///
164/// Returns [`QueryError`] when validation fails, a construct is unsupported,
165/// a projection cannot be lowered, or a bound limit/value is invalid.
166/// See `crates/khive-query/docs/api/sql-compilation.md` for compilation paths.
167pub fn compile(query: &GqlQuery, opts: &CompileOptions) -> Result<CompiledQuery, QueryError> {
168    if query.pattern.elements.is_empty() {
169        return Err(QueryError::Compile("empty pattern".into()));
170    }
171
172    let mut query = query.clone();
173    let warnings = validate_with_warnings(&mut query)?;
174
175    let mut compiled = if query.pattern.has_variable_length() {
176        compile_variable_length(&query, opts)?
177    } else {
178        compile_fixed_length(&query, opts)?
179    };
180    compiled.warnings.extend(warnings);
181
182    // Fail closed if a future lowering path accidentally emits a mutation.
183    assert_select_only(&compiled.sql)?;
184
185    Ok(compiled)
186}
187
188/// Enforces the compiler's SELECT/WITH-only invariant; the reader is the security boundary.
189fn assert_select_only(sql: &str) -> Result<(), QueryError> {
190    let first = sql.split_whitespace().next().unwrap_or("").to_uppercase();
191    if first == "SELECT" || first == "WITH" {
192        return Ok(());
193    }
194    Err(read_only_error(QueryError::Compile))
195}
196
197fn in_placeholders(indices: impl IntoIterator<Item = usize>) -> String {
198    indices
199        .into_iter()
200        .map(|index| format!("?{index}"))
201        .collect::<Vec<_>>()
202        .join(", ")
203}
204
205fn namespace_filter(alias: &str, opts: &CompileOptions, params: &mut Vec<QueryValue>) -> String {
206    if opts.scopes.is_empty() {
207        String::new()
208    } else if opts.scopes.len() == 1 {
209        params.push(QueryValue::Text(opts.scopes[0].clone()));
210        format!(" AND {alias}.namespace = ?{}", params.len())
211    } else {
212        let placeholders = in_placeholders(opts.scopes.iter().map(|s| {
213            params.push(QueryValue::Text(s.clone()));
214            params.len()
215        }));
216        format!(" AND {alias}.namespace IN ({})", placeholders)
217    }
218}
219
220/// Maps substrate labels to the union discriminator and granular labels to `kind`.
221fn kind_filter_predicate(alias: &str, kind: &str, params: &mut Vec<QueryValue>) -> String {
222    match kind {
223        "entity" | "note" | "event" | "edge" => {
224            params.push(QueryValue::Text(kind.to_string()));
225            format!("{alias}.substrate_kind = ?{}", params.len())
226        }
227        _ => {
228            params.push(QueryValue::Text(kind.to_string()));
229            format!("{alias}.kind = ?{}", params.len())
230        }
231    }
232}
233
234/// Applies kind filtering to the entity/note observation-target union.
235fn observation_kind_filter_predicate(
236    alias: &str,
237    kind: &str,
238    params: &mut Vec<QueryValue>,
239) -> String {
240    match kind {
241        "entity" | "note" => {
242            params.push(QueryValue::Text(kind.to_string()));
243            format!("{alias}.referent_kind = ?{}", params.len())
244        }
245        _ => {
246            params.push(QueryValue::Text(kind.to_string()));
247            format!("{alias}.kind = ?{}", params.len())
248        }
249    }
250}
251
252/// Compiles typed inline-map equality against a direct or JSON property column.
253/// See `crates/khive-query/docs/api/sql-compilation.md` for storage-class rules.
254fn compile_property_equality(
255    alias: &str,
256    key: &str,
257    value: &ConditionValue,
258    text_column: Option<&str>,
259    params: &mut Vec<QueryValue>,
260) -> Result<String, QueryError> {
261    let is_string = matches!(value, ConditionValue::String(_));
262    match value {
263        ConditionValue::String(s) => params.push(QueryValue::Text(s.clone())),
264        ConditionValue::Integer(n) => params.push(QueryValue::Integer(*n)),
265        ConditionValue::Number(n) => {
266            if !n.is_finite() {
267                return Err(QueryError::InvalidInput(
268                    "non-finite float (NaN or Infinity) is not a valid query parameter".into(),
269                ));
270            }
271            params.push(QueryValue::Float(*n));
272        }
273        ConditionValue::Bool(b) => params.push(QueryValue::Integer(if *b { 1 } else { 0 })),
274        ConditionValue::List(_) | ConditionValue::Null => {
275            return Err(QueryError::Validation(
276                "list and null operands are not valid in inline property maps".into(),
277            ));
278        }
279    }
280    let collate = if is_string { " COLLATE NOCASE" } else { "" };
281    Ok(match text_column {
282        Some(col) => format!("{alias}.{col} = ?{}{collate}", params.len()),
283        None => format!(
284            "json_extract({alias}.properties, '$.{}') = ?{}{collate}",
285            key.replace('\'', "''"),
286            params.len()
287        ),
288    })
289}
290
291/// Returns `(source_indices, target_indices)` for synthetic `observed_as_*` edge endpoints.
292fn synthetic_endpoint_node_indices(
293    elements: &[PatternElement],
294) -> (
295    std::collections::HashSet<usize>,
296    std::collections::HashSet<usize>,
297) {
298    let mut source_set = std::collections::HashSet::new();
299    let mut target_set = std::collections::HashSet::new();
300    let mut node_idx = 0usize;
301    let mut prev_node_idx: Option<usize> = None;
302    for element in elements {
303        match element {
304            PatternElement::Node(_) => {
305                prev_node_idx = Some(node_idx);
306                node_idx += 1;
307            }
308            PatternElement::Edge(ep) => {
309                let has_synthetic = ep.relations.iter().any(|r| is_synthetic(r));
310                if has_synthetic {
311                    if let Some(src_idx) = prev_node_idx {
312                        source_set.insert(src_idx);
313                        // The target is the next node (current node_idx).
314                        target_set.insert(node_idx);
315                    }
316                }
317            }
318        }
319    }
320    (source_set, target_set)
321}
322
323/// Compiles fixed-length patterns to a JOIN chain.
324fn compile_fixed_length(
325    query: &GqlQuery,
326    opts: &CompileOptions,
327) -> Result<CompiledQuery, QueryError> {
328    let mut params: Vec<QueryValue> = Vec::new();
329    let mut from_parts: Vec<String> = Vec::new();
330    let mut join_parts: Vec<String> = Vec::new();
331    let mut where_parts: Vec<String> = Vec::new();
332    let mut select_parts: Vec<String> = Vec::new();
333    let mut order_parts: Vec<String> = Vec::new();
334
335    let mut node_aliases: Vec<String> = Vec::new();
336    let mut var_to_alias: std::collections::HashMap<String, (String, VarKind)> =
337        std::collections::HashMap::new();
338
339    // Synthetic endpoints bind different substrate sources (issue #468).
340    let (event_source_indices, observation_target_indices) =
341        synthetic_endpoint_node_indices(&query.pattern.elements);
342
343    let mut node_idx = 0usize;
344    let mut edge_idx = 0usize;
345
346    for element in &query.pattern.elements {
347        match element {
348            PatternElement::Node(np) => {
349                let alias = format!("n{node_idx}");
350                node_aliases.push(alias.clone());
351
352                let is_event_source = event_source_indices.contains(&node_idx);
353                let is_observation_target = observation_target_indices.contains(&node_idx);
354
355                // Paging requires a total order over match identities, not caller
356                // projections. UUIDs can overlap across unioned substrates, so keep
357                // each union's discriminator ahead of its ID.
358                if is_event_source {
359                    order_parts.push(format!("{alias}.id"));
360                } else if is_observation_target {
361                    order_parts.push(format!("{alias}.referent_kind"));
362                    order_parts.push(format!("{alias}.id"));
363                } else {
364                    order_parts.push(format!("{alias}.substrate_kind"));
365                    order_parts.push(format!("{alias}.id"));
366                }
367
368                if node_idx == 0 {
369                    if is_event_source {
370                        from_parts.push(format!("events {alias}"));
371                    } else if !is_observation_target {
372                        from_parts.push(primary_node_source(&alias));
373                    }
374                }
375
376                if is_event_source {
377                    // Events have no `deleted_at`; namespace is filtered directly.
378                    let ns_filter = namespace_filter(&alias, opts, &mut params);
379                    if !ns_filter.is_empty() {
380                        where_parts.push(ns_filter.trim_start_matches(" AND ").to_string());
381                    }
382                    // Bare `event` needs no kind predicate on an event-only source.
383                    if let Some(ref kind) = np.kind {
384                        if kind != "event" {
385                            params.push(QueryValue::Text(kind.clone()));
386                            where_parts.push(format!("{alias}.kind = ?{}", params.len()));
387                        }
388                    }
389                    if np.entity_type.is_some() {
390                        return Err(QueryError::Compile(
391                            "event nodes do not have an entity_type column".into(),
392                        ));
393                    }
394                    if !np.properties.is_empty() {
395                        return Err(QueryError::Compile(
396                            "event nodes do not support inline property filters; \
397                             use a WHERE clause on verb, outcome, or payload fields"
398                                .into(),
399                        ));
400                    }
401                } else if is_observation_target {
402                    where_parts.push(format!("{alias}.deleted_at IS NULL"));
403
404                    let ns_filter = namespace_filter(&alias, opts, &mut params);
405                    if !ns_filter.is_empty() {
406                        where_parts.push(ns_filter.trim_start_matches(" AND ").to_string());
407                    }
408
409                    if let Some(ref kind) = np.kind {
410                        where_parts.push(observation_kind_filter_predicate(
411                            &alias,
412                            kind,
413                            &mut params,
414                        ));
415                    }
416
417                    if let Some(ref et) = np.entity_type {
418                        params.push(QueryValue::Text(et.clone()));
419                        where_parts.push(format!("{alias}.entity_type = ?{}", params.len()));
420                    }
421
422                    let mut props: Vec<_> = np.properties.iter().collect();
423                    props.sort_by_key(|(k, _)| k.as_str());
424                    for (key, val) in props {
425                        let text_column = if key == "name" || key == "content" {
426                            Some(key.as_str())
427                        } else {
428                            None
429                        };
430                        where_parts.push(compile_property_equality(
431                            &alias,
432                            key,
433                            val,
434                            text_column,
435                            &mut params,
436                        )?);
437                    }
438                } else {
439                    where_parts.push(format!("{alias}.deleted_at IS NULL"));
440
441                    let ns_filter = namespace_filter(&alias, opts, &mut params);
442                    if !ns_filter.is_empty() {
443                        where_parts.push(ns_filter.trim_start_matches(" AND ").to_string());
444                    }
445
446                    if let Some(ref kind) = np.kind {
447                        where_parts.push(kind_filter_predicate(&alias, kind, &mut params));
448                    }
449
450                    if let Some(ref et) = np.entity_type {
451                        params.push(QueryValue::Text(et.clone()));
452                        where_parts.push(format!("{alias}.entity_type = ?{}", params.len()));
453                    }
454
455                    let mut props: Vec<_> = np.properties.iter().collect();
456                    props.sort_by_key(|(k, _)| k.as_str());
457                    for (key, val) in props {
458                        let text_column = if key == "name" { Some("name") } else { None };
459                        where_parts.push(compile_property_equality(
460                            &alias,
461                            key,
462                            val,
463                            text_column,
464                            &mut params,
465                        )?);
466                    }
467                }
468
469                if let Some(ref var) = np.variable {
470                    let kind = if is_event_source {
471                        VarKind::EventNode
472                    } else if is_observation_target {
473                        VarKind::ObservationTargetNode
474                    } else {
475                        VarKind::Node
476                    };
477                    var_to_alias.insert(var.clone(), (alias.clone(), kind));
478                }
479
480                node_idx += 1;
481            }
482            PatternElement::Edge(ep) => {
483                let e_alias = format!("e{edge_idx}");
484                let prev_node = &node_aliases[node_aliases.len() - 1];
485                let next_alias = format!("n{}", node_idx);
486
487                // The synthetic and canonical backing tables have no meaningful shared join key.
488                let has_synthetic = ep.relations.iter().any(|r| is_synthetic(r));
489                let has_canonical = ep.relations.iter().any(|r| !is_synthetic(r));
490                if has_synthetic && has_canonical {
491                    return Err(QueryError::Compile(
492                        "cannot mix synthetic observed_as_* relations with canonical edge relations \
493                         in a single edge pattern"
494                            .into(),
495                    ));
496                }
497
498                if has_synthetic {
499                    // `(event_id, role, position)` is the observation primary key.
500                    order_parts.push(format!("{e_alias}.event_id"));
501                    order_parts.push(format!("{e_alias}.role"));
502                    order_parts.push(format!("{e_alias}.position"));
503                    // Synthetic projections are always event-to-referent.
504                    if !matches!(ep.direction, EdgeDirection::Out) {
505                        return Err(QueryError::Compile(
506                            "synthetic observed_as_* edges are always event → entity (outbound only)".into(),
507                        ));
508                    }
509                    join_parts.push(format!(
510                        "JOIN event_observations {e_alias} ON {e_alias}.event_id = {prev_node}.id"
511                    ));
512                    let roles: Vec<&'static str> = ep
513                        .relations
514                        .iter()
515                        .filter_map(|r| synthetic_role(r))
516                        .collect();
517                    if roles.len() == 1 {
518                        params.push(QueryValue::Text(roles[0].to_string()));
519                        where_parts.push(format!("{e_alias}.role = ?{}", params.len()));
520                    } else if roles.len() > 1 {
521                        let placeholders = in_placeholders(roles.iter().map(|r| {
522                            params.push(QueryValue::Text(r.to_string()));
523                            params.len()
524                        }));
525                        where_parts.push(format!("{e_alias}.role IN ({})", placeholders));
526                    }
527                    // `referent_kind` prevents equal IDs on different substrates from colliding.
528                    join_parts.push(format!(
529                        "JOIN {} ON {next_alias}.id = {e_alias}.entity_id \
530                         AND {next_alias}.referent_kind = {e_alias}.referent_kind",
531                        observation_target_source(&next_alias)
532                    ));
533                    // Enforce the ADR-041 role/substrate matrix.
534                    where_parts.push(format!(
535                        "(({e_alias}.role IN ('candidate', 'selected') AND {e_alias}.referent_kind IN ('entity', 'note')) \
536                          OR ({e_alias}.role = 'target' AND {e_alias}.referent_kind IN ('entity', 'note')) \
537                          OR ({e_alias}.role = 'signal' AND {e_alias}.referent_kind IN ('entity', 'note')))"
538                    ));
539                } else {
540                    order_parts.push(format!("{e_alias}.id"));
541                    let (source_join, target_join) = match ep.direction {
542                        EdgeDirection::Out => (
543                            format!("{e_alias}.source_id = {prev_node}.id"),
544                            "target_id",
545                        ),
546                        EdgeDirection::In => (
547                            format!("{e_alias}.target_id = {prev_node}.id"),
548                            "source_id",
549                        ),
550                        EdgeDirection::Both => (
551                            format!(
552                                "({e_alias}.source_id = {prev_node}.id OR {e_alias}.target_id = {prev_node}.id)"
553                            ),
554                            "CASE_BOTH",
555                        ),
556                    };
557
558                    let next_join_col = if target_join == "CASE_BOTH" {
559                        format!(
560                            "CASE WHEN {e_alias}.source_id = {prev_node}.id THEN {e_alias}.target_id ELSE {e_alias}.source_id END"
561                        )
562                    } else {
563                        format!("{e_alias}.{target_join}")
564                    };
565
566                    join_parts.push(format!(
567                        "JOIN graph_edges {e_alias} ON {source_join} AND {e_alias}.deleted_at IS NULL"
568                    ));
569
570                    let ens_filter = namespace_filter(&e_alias, opts, &mut params);
571                    if !ens_filter.is_empty() {
572                        where_parts.push(ens_filter.trim_start_matches(" AND ").to_string());
573                    }
574
575                    join_parts.push(format!(
576                        "JOIN {} ON {next_alias}.id = {next_join_col}",
577                        primary_node_source(&next_alias)
578                    ));
579
580                    if !ep.relations.is_empty() {
581                        if ep.relations.len() == 1 {
582                            params.push(QueryValue::Text(ep.relations[0].clone()));
583                            where_parts.push(format!("{e_alias}.relation = ?{}", params.len()));
584                        } else {
585                            let placeholders = in_placeholders(ep.relations.iter().map(|r| {
586                                params.push(QueryValue::Text(r.clone()));
587                                params.len()
588                            }));
589                            where_parts.push(format!("{e_alias}.relation IN ({})", placeholders));
590                        }
591                    }
592                }
593
594                if let Some(ref var) = ep.variable {
595                    var_to_alias.insert(var.clone(), (e_alias.clone(), VarKind::Edge));
596                }
597
598                edge_idx += 1;
599            }
600        }
601    }
602
603    if let Some(where_sql) = compile_where_expr(&query.where_clause, &var_to_alias, &mut params)? {
604        where_parts.push(where_sql);
605    }
606
607    for item in &query.return_items {
608        let var = item.variable();
609        if let Some((alias, kind)) = var_to_alias.get(var) {
610            match item {
611                ReturnItem::Property(_, prop) => {
612                    let col = property_to_column(prop, kind)?;
613                    let output_alias = quote_identifier(&format!("{var}_{prop}"));
614                    select_parts.push(format!("{alias}.{col} AS {output_alias}"));
615                }
616                ReturnItem::Variable(_) => {
617                    let columns: &[(&str, &str)] = match kind {
618                        VarKind::Node => &[
619                            ("id", "id"),
620                            ("namespace", "namespace"),
621                            ("kind", "kind"),
622                            ("entity_type", "entity_type"),
623                            ("name", "name"),
624                            ("properties", "properties"),
625                            ("created_at", "created_at"),
626                            ("updated_at", "updated_at"),
627                        ],
628                        VarKind::ObservationTargetNode => &[
629                            ("id", "id"),
630                            ("namespace", "namespace"),
631                            ("kind", "kind"),
632                            ("entity_type", "entity_type"),
633                            ("status", "status"),
634                            ("content", "content"),
635                            ("salience", "salience"),
636                            ("properties", "properties"),
637                            ("created_at", "created_at"),
638                            ("updated_at", "updated_at"),
639                            ("referent_kind", "referent_kind"),
640                        ],
641                        VarKind::EventNode => &[
642                            ("id", "id"),
643                            ("namespace", "namespace"),
644                            ("verb", "verb"),
645                            ("substrate", "substrate"),
646                            ("actor", "actor"),
647                            ("kind", "kind"),
648                            ("outcome", "outcome"),
649                            ("payload", "payload"),
650                            ("created_at", "created_at"),
651                        ],
652                        VarKind::Edge => &[
653                            ("id", "id"),
654                            ("source_id", "source"),
655                            ("target_id", "target"),
656                            ("relation", "relation"),
657                            ("weight", "weight"),
658                        ],
659                    };
660                    for (column, suffix) in columns {
661                        let output_alias = quote_identifier(&format!("{var}_{suffix}"));
662                        select_parts.push(format!("{alias}.{column} AS {output_alias}"));
663                    }
664                }
665            }
666        } else {
667            return Err(QueryError::Compile(format!(
668                "unknown variable '{var}' in RETURN clause"
669            )));
670        }
671    }
672
673    let offset_i64 = i64::try_from(query.offset)
674        .map_err(|_| QueryError::InvalidInput("SKIP exceeds i64::MAX".into()))?;
675    params.push(QueryValue::Integer(offset_i64));
676    let offset_param = params.len();
677
678    let (limit, truncation_check) = effective_limit(query.limit, query.offset, opts.max_limit);
679    let limit_i64 = i64::try_from(limit)
680        .map_err(|_| QueryError::InvalidInput("limit exceeds i64::MAX".into()))?;
681    params.push(QueryValue::Integer(limit_i64));
682    let limit_param = params.len();
683
684    let sql = format!(
685        "SELECT {} FROM {} {} WHERE {} ORDER BY {} LIMIT ?{} OFFSET ?{}",
686        select_parts.join(", "),
687        from_parts.join(", "),
688        join_parts.join(" "),
689        where_parts.join(" AND "),
690        order_parts.join(", "),
691        limit_param,
692        offset_param,
693    );
694
695    Ok(CompiledQuery {
696        sql,
697        params,
698        return_vars: query.return_items.clone(),
699        warnings: Vec::new(),
700        truncation_check,
701    })
702}
703
704/// Compiles a `WhereExpr` while preserving its boolean grouping.
705fn compile_where_expr(
706    expr: &WhereExpr,
707    var_to_alias: &std::collections::HashMap<String, (String, VarKind)>,
708    params: &mut Vec<QueryValue>,
709) -> Result<Option<String>, QueryError> {
710    match expr {
711        WhereExpr::True => Ok(None),
712        WhereExpr::Condition(cond) => {
713            let sql = compile_single_condition(cond, var_to_alias, params)?;
714            Ok(Some(sql))
715        }
716        WhereExpr::And(l, r) => {
717            let ls = compile_where_expr(l, var_to_alias, params)?;
718            let rs = compile_where_expr(r, var_to_alias, params)?;
719            Ok(match (ls, rs) {
720                (None, None) => None,
721                (Some(s), None) | (None, Some(s)) => Some(s),
722                (Some(l), Some(r)) => Some(format!("{l} AND {r}")),
723            })
724        }
725        WhereExpr::Or(l, r) => {
726            let ls = compile_where_expr(l, var_to_alias, params)?;
727            let rs = compile_where_expr(r, var_to_alias, params)?;
728            Ok(match (ls, rs) {
729                (None, None) => None,
730                (Some(s), None) | (None, Some(s)) => Some(s),
731                (Some(l), Some(r)) => Some(format!("({l} OR {r})")),
732            })
733        }
734    }
735}
736
737fn compile_single_condition(
738    cond: &Condition,
739    var_to_alias: &std::collections::HashMap<String, (String, VarKind)>,
740    params: &mut Vec<QueryValue>,
741) -> Result<String, QueryError> {
742    let (alias, kind) = var_to_alias.get(&cond.variable).ok_or_else(|| {
743        QueryError::Compile(format!(
744            "unknown variable '{}' in WHERE clause",
745            cond.variable
746        ))
747    })?;
748
749    let col_expr = where_property_expression(&cond.property, kind, alias)?;
750
751    compile_condition_predicate(&col_expr, cond, params)
752}
753
754fn bind_condition_value(
755    value: &ConditionValue,
756    params: &mut Vec<QueryValue>,
757) -> Result<usize, QueryError> {
758    match value {
759        ConditionValue::String(s) => params.push(QueryValue::Text(s.clone())),
760        ConditionValue::Integer(n) => params.push(QueryValue::Integer(*n)),
761        ConditionValue::Number(n) => {
762            if !n.is_finite() {
763                return Err(QueryError::InvalidInput(
764                    "non-finite float (NaN or Infinity) is not a valid query parameter".into(),
765                ));
766            }
767            params.push(QueryValue::Float(*n));
768        }
769        ConditionValue::Bool(b) => {
770            params.push(QueryValue::Integer(if *b { 1 } else { 0 }));
771        }
772        ConditionValue::List(_) | ConditionValue::Null => {
773            return Err(QueryError::Validation(
774                "operator requires a scalar value".into(),
775            ));
776        }
777    }
778    Ok(params.len())
779}
780
781fn compile_condition_predicate(
782    col_expr: &str,
783    cond: &Condition,
784    params: &mut Vec<QueryValue>,
785) -> Result<String, QueryError> {
786    match cond.op {
787        CompareOp::Contains | CompareOp::StartsWith => {
788            let ConditionValue::String(value) = &cond.value else {
789                return Err(QueryError::Validation(
790                    "CONTAINS and STARTS WITH require a string literal".into(),
791                ));
792            };
793            let escaped = khive_types::escape_like_literal(value);
794            let pattern = if cond.op == CompareOp::Contains {
795                format!("%{escaped}%")
796            } else {
797                format!("{escaped}%")
798            };
799            params.push(QueryValue::Text(pattern));
800            Ok(format!(
801                "{col_expr} LIKE ?{} COLLATE NOCASE ESCAPE '\\'",
802                params.len()
803            ))
804        }
805        CompareOp::In => {
806            let ConditionValue::List(values) = &cond.value else {
807                return Err(QueryError::Validation("IN requires a list literal".into()));
808            };
809            if values.is_empty() {
810                return Ok("0".into());
811            }
812            let has_string = values
813                .iter()
814                .any(|value| matches!(value, ConditionValue::String(_)));
815            let placeholders = values
816                .iter()
817                .map(|value| bind_condition_value(value, params))
818                .collect::<Result<Vec<_>, _>>()?;
819            let collate = if has_string { " COLLATE NOCASE" } else { "" };
820            Ok(format!(
821                "{col_expr}{collate} IN ({})",
822                in_placeholders(placeholders)
823            ))
824        }
825        CompareOp::IsNotNull => {
826            if !matches!(cond.value, ConditionValue::Null) {
827                return Err(QueryError::Validation(
828                    "IS NOT NULL does not accept a value".into(),
829                ));
830            }
831            Ok(format!("{col_expr} IS NOT NULL"))
832        }
833        CompareOp::IsNull => {
834            if !matches!(cond.value, ConditionValue::Null) {
835                return Err(QueryError::Validation(
836                    "IS NULL does not accept a value".into(),
837                ));
838            }
839            Ok(format!("{col_expr} IS NULL"))
840        }
841        op => {
842            let op_str = match op {
843                CompareOp::Eq => "=",
844                CompareOp::Neq => "!=",
845                CompareOp::Gt => ">",
846                CompareOp::Lt => "<",
847                CompareOp::Gte => ">=",
848                CompareOp::Lte => "<=",
849                CompareOp::Like => "LIKE",
850                CompareOp::Contains
851                | CompareOp::StartsWith
852                | CompareOp::In
853                | CompareOp::IsNotNull
854                | CompareOp::IsNull => unreachable!(),
855            };
856            let is_string = matches!(cond.value, ConditionValue::String(_));
857            let param_index = bind_condition_value(&cond.value, params)?;
858            let collate = if is_string && matches!(op, CompareOp::Eq | CompareOp::Like) {
859                " COLLATE NOCASE"
860            } else {
861                ""
862            };
863            Ok(format!("{col_expr} {op_str} ?{param_index}{collate}"))
864        }
865    }
866}
867
868fn expr_endpoint_set(
869    expr: &WhereExpr,
870    start_var: Option<&str>,
871    end_var: Option<&str>,
872) -> (bool, bool) {
873    match expr {
874        WhereExpr::True => (false, false),
875        WhereExpr::Condition(c) => {
876            let is_start = start_var == Some(c.variable.as_str());
877            let is_end = end_var == Some(c.variable.as_str());
878            (is_start, is_end)
879        }
880        WhereExpr::And(l, r) | WhereExpr::Or(l, r) => {
881            let (ls, le) = expr_endpoint_set(l, start_var, end_var);
882            let (rs, re) = expr_endpoint_set(r, start_var, end_var);
883            (ls || rs, le || re)
884        }
885    }
886}
887
888/// Rejects OR subtrees that cannot be preserved across CTE endpoint phases.
889fn reject_or_spanning_endpoints(
890    expr: &WhereExpr,
891    start: &NodePattern,
892    end: &NodePattern,
893) -> Result<(), QueryError> {
894    let start_var = start.variable.as_deref();
895    let end_var = end.variable.as_deref();
896    reject_or_spanning_impl(expr, start_var, end_var)
897}
898
899fn reject_or_spanning_impl(
900    expr: &WhereExpr,
901    start_var: Option<&str>,
902    end_var: Option<&str>,
903) -> Result<(), QueryError> {
904    match expr {
905        WhereExpr::True | WhereExpr::Condition(_) => Ok(()),
906        WhereExpr::And(l, r) => {
907            reject_or_spanning_impl(l, start_var, end_var)?;
908            reject_or_spanning_impl(r, start_var, end_var)
909        }
910        WhereExpr::Or(l, r) => {
911            let (l_start, l_end) = expr_endpoint_set(l, start_var, end_var);
912            let (r_start, r_end) = expr_endpoint_set(r, start_var, end_var);
913            let spans_start = l_start || r_start;
914            let spans_end = l_end || r_end;
915            if spans_start && spans_end {
916                return Err(QueryError::Unsupported(
917                    "WHERE clauses that span both endpoints in a variable-length pattern \
918                     are not yet supported; rewrite as separate queries or restrict each \
919                     OR branch to one endpoint"
920                        .into(),
921                ));
922            }
923            reject_or_spanning_impl(l, start_var, end_var)?;
924            reject_or_spanning_impl(r, start_var, end_var)
925        }
926    }
927}
928
929fn compile_var_len_condition(
930    cond: &Condition,
931    start_var: Option<&str>,
932    end_var: Option<&str>,
933    params: &mut Vec<QueryValue>,
934) -> Result<(String, &'static str), QueryError> {
935    let col_alias = if start_var == Some(cond.variable.as_str()) {
936        "s"
937    } else if end_var == Some(cond.variable.as_str()) {
938        "r"
939    } else {
940        return Err(QueryError::Compile(format!(
941            "variable '{}' in WHERE not supported in variable-length pattern \
942             (only start/end node variables)",
943            cond.variable
944        )));
945    };
946
947    let col_expr = where_property_expression(&cond.property, &VarKind::Node, col_alias)?;
948
949    let sql = compile_condition_predicate(&col_expr, cond, params)?;
950    Ok((sql, col_alias))
951}
952
953/// Routes variable-length predicates to the start or end CTE phase.
954fn compile_variable_length_where(
955    expr: &WhereExpr,
956    start_var: Option<&str>,
957    end_var: Option<&str>,
958    params: &mut Vec<QueryValue>,
959    start_conditions: &mut Vec<String>,
960    end_conditions: &mut Vec<String>,
961) -> Result<Option<String>, QueryError> {
962    match expr {
963        WhereExpr::True => Ok(None),
964        WhereExpr::Condition(cond) => {
965            let (sql, alias) = compile_var_len_condition(cond, start_var, end_var, params)?;
966            if alias == "s" {
967                start_conditions.push(sql);
968            } else {
969                end_conditions.push(sql);
970            }
971            Ok(None)
972        }
973        WhereExpr::And(l, r) => {
974            compile_variable_length_where(
975                l,
976                start_var,
977                end_var,
978                params,
979                start_conditions,
980                end_conditions,
981            )?;
982            compile_variable_length_where(
983                r,
984                start_var,
985                end_var,
986                params,
987                start_conditions,
988                end_conditions,
989            )?;
990            Ok(None)
991        }
992        WhereExpr::Or(l, r) => {
993            // The prior spanning check guarantees both branches use one endpoint.
994            let l_sql = compile_variable_length_where_to_sql(l, start_var, end_var, params)?;
995            let r_sql = compile_variable_length_where_to_sql(r, start_var, end_var, params)?;
996            match (l_sql, r_sql) {
997                (None, None) => {}
998                (Some((ls, la)), None) => {
999                    if la == "s" {
1000                        start_conditions.push(ls);
1001                    } else {
1002                        end_conditions.push(ls);
1003                    }
1004                }
1005                (None, Some((rs, ra))) => {
1006                    if ra == "s" {
1007                        start_conditions.push(rs);
1008                    } else {
1009                        end_conditions.push(rs);
1010                    }
1011                }
1012                (Some((ls, la)), Some((rs, _ra))) => {
1013                    let combined = format!("({ls} OR {rs})");
1014                    if la == "s" {
1015                        start_conditions.push(combined);
1016                    } else {
1017                        end_conditions.push(combined);
1018                    }
1019                }
1020            }
1021            Ok(None)
1022        }
1023    }
1024}
1025
1026/// Compiles one endpoint-local expression and returns its CTE alias.
1027fn compile_variable_length_where_to_sql(
1028    expr: &WhereExpr,
1029    start_var: Option<&str>,
1030    end_var: Option<&str>,
1031    params: &mut Vec<QueryValue>,
1032) -> Result<Option<(String, &'static str)>, QueryError> {
1033    match expr {
1034        WhereExpr::True => Ok(None),
1035        WhereExpr::Condition(cond) => {
1036            let (sql, alias) = compile_var_len_condition(cond, start_var, end_var, params)?;
1037            Ok(Some((sql, alias)))
1038        }
1039        WhereExpr::And(l, r) => {
1040            let ls = compile_variable_length_where_to_sql(l, start_var, end_var, params)?;
1041            let rs = compile_variable_length_where_to_sql(r, start_var, end_var, params)?;
1042            Ok(match (ls, rs) {
1043                (None, None) => None,
1044                (Some(s), None) | (None, Some(s)) => Some(s),
1045                (Some((lsql, la)), Some((rsql, _))) => Some((format!("{lsql} AND {rsql}"), la)),
1046            })
1047        }
1048        WhereExpr::Or(l, r) => {
1049            let ls = compile_variable_length_where_to_sql(l, start_var, end_var, params)?;
1050            let rs = compile_variable_length_where_to_sql(r, start_var, end_var, params)?;
1051            Ok(match (ls, rs) {
1052                (None, None) => None,
1053                (Some(s), None) | (None, Some(s)) => Some(s),
1054                (Some((lsql, la)), Some((rsql, _))) => Some((format!("({lsql} OR {rsql})"), la)),
1055            })
1056        }
1057    }
1058}
1059
1060/// Compiles one variable-length edge to a recursive CTE.
1061fn compile_variable_length(
1062    query: &GqlQuery,
1063    opts: &CompileOptions,
1064) -> Result<CompiledQuery, QueryError> {
1065    let mut params: Vec<QueryValue> = Vec::new();
1066    let mut var_to_alias: std::collections::HashMap<String, (String, VarKind)> =
1067        std::collections::HashMap::new();
1068
1069    // Reject extra elements rather than silently dropping part of the pattern.
1070    let nodes: Vec<&NodePattern> = query.pattern.nodes().collect();
1071    let edges: Vec<&EdgePattern> = query.pattern.edges().collect();
1072
1073    if nodes.len() != 2 || edges.len() != 1 || query.pattern.elements.len() != 3 {
1074        return Err(QueryError::Unsupported(
1075            "variable-length patterns must be a single start_node -[*N..M]-> end_node \
1076             (mixed fixed/variable chains are not yet implemented)"
1077                .into(),
1078        ));
1079    }
1080
1081    let start = &nodes[0];
1082    let edge = &edges[0];
1083    let end = &nodes[1];
1084
1085    // event_observations has no recursive path structure.
1086    if edge.relations.iter().any(|r| is_synthetic(r)) {
1087        return Err(QueryError::Unsupported(
1088            "synthetic observed_as_* edges cannot be variable-length; \
1089             use a fixed-length edge pattern instead"
1090                .into(),
1091        ));
1092    }
1093
1094    let max_depth = edge.max_hops.min(MAX_DEPTH);
1095    let min_depth = edge.min_hops;
1096
1097    let mut start_conditions: Vec<String> = vec!["s.deleted_at IS NULL".to_string()];
1098    let ns_filter = namespace_filter("s", opts, &mut params);
1099    if !ns_filter.is_empty() {
1100        start_conditions.push(ns_filter.trim_start_matches(" AND ").to_string());
1101    }
1102
1103    if let Some(ref kind) = start.kind {
1104        start_conditions.push(kind_filter_predicate("s", kind, &mut params));
1105    }
1106    if let Some(ref et) = start.entity_type {
1107        params.push(QueryValue::Text(et.clone()));
1108        start_conditions.push(format!("s.entity_type = ?{}", params.len()));
1109    }
1110    let mut start_props: Vec<_> = start.properties.iter().collect();
1111    start_props.sort_by_key(|(k, _)| k.as_str());
1112    for (key, val) in start_props {
1113        let text_column = if key == "name" { Some("name") } else { None };
1114        start_conditions.push(compile_property_equality(
1115            "s",
1116            key,
1117            val,
1118            text_column,
1119            &mut params,
1120        )?);
1121    }
1122
1123    let mut relation_condition = String::new();
1124    if !edge.relations.is_empty() {
1125        if edge.relations.len() == 1 {
1126            params.push(QueryValue::Text(edge.relations[0].clone()));
1127            relation_condition = format!(" AND e.relation = ?{}", params.len());
1128        } else {
1129            let placeholders = in_placeholders(edge.relations.iter().map(|r| {
1130                params.push(QueryValue::Text(r.clone()));
1131                params.len()
1132            }));
1133            relation_condition = format!(" AND e.relation IN ({})", placeholders);
1134        }
1135    }
1136
1137    let e_ns_filter = namespace_filter("e", opts, &mut params);
1138
1139    let (seed_join, seed_next, recurse_join, recurse_next) = match edge.direction {
1140        EdgeDirection::Out => (
1141            "e.source_id = s.id",
1142            "e.target_id",
1143            "e.source_id = t.current_id",
1144            "e.target_id",
1145        ),
1146        EdgeDirection::In => (
1147            "e.target_id = s.id",
1148            "e.source_id",
1149            "e.target_id = t.current_id",
1150            "e.source_id",
1151        ),
1152        EdgeDirection::Both => (
1153            "(e.source_id = s.id OR e.target_id = s.id)",
1154            "CASE WHEN e.source_id = s.id THEN e.target_id ELSE e.source_id END",
1155            "(e.source_id = t.current_id OR e.target_id = t.current_id)",
1156            "CASE WHEN e.source_id = t.current_id THEN e.target_id ELSE e.source_id END",
1157        ),
1158    };
1159
1160    // Filter every intermediate node so paths cannot cross deleted or out-of-scope rows.
1161    let next_node_ns_filter = namespace_filter("next_node", opts, &mut params);
1162
1163    let max_depth_i64 = i64::try_from(max_depth)
1164        .map_err(|_| QueryError::InvalidInput("max_depth exceeds i64::MAX".into()))?;
1165    params.push(QueryValue::Integer(max_depth_i64));
1166    let depth_param = params.len();
1167
1168    // End filters require `r` even when the end variable is not projected.
1169    let mut end_conditions: Vec<String> = vec!["r.deleted_at IS NULL".to_string()];
1170    let r_ns_filter = namespace_filter("r", opts, &mut params);
1171    if !r_ns_filter.is_empty() {
1172        end_conditions.push(r_ns_filter.trim_start_matches(" AND ").to_string());
1173    }
1174    if let Some(ref kind) = end.kind {
1175        end_conditions.push(kind_filter_predicate("r", kind, &mut params));
1176    }
1177    if let Some(ref et) = end.entity_type {
1178        params.push(QueryValue::Text(et.clone()));
1179        end_conditions.push(format!("r.entity_type = ?{}", params.len()));
1180    }
1181    let mut end_props: Vec<_> = end.properties.iter().collect();
1182    end_props.sort_by_key(|(k, _)| k.as_str());
1183    for (key, val) in end_props {
1184        let text_column = if key == "name" { Some("name") } else { None };
1185        end_conditions.push(compile_property_equality(
1186            "r",
1187            key,
1188            val,
1189            text_column,
1190            &mut params,
1191        )?);
1192    }
1193
1194    reject_or_spanning_endpoints(&query.where_clause, start, end)?;
1195
1196    if let Some(where_sql) = compile_variable_length_where(
1197        &query.where_clause,
1198        start.variable.as_deref(),
1199        end.variable.as_deref(),
1200        &mut params,
1201        &mut start_conditions,
1202        &mut end_conditions,
1203    )? {
1204        // Keep the defensive fallback if a future expression has no endpoint binding.
1205        start_conditions.push(where_sql);
1206    }
1207
1208    if min_depth > 0 {
1209        let min_depth_i64 = i64::try_from(min_depth)
1210            .map_err(|_| QueryError::InvalidInput("min_depth exceeds i64::MAX".into()))?;
1211        params.push(QueryValue::Integer(min_depth_i64));
1212        end_conditions.push(format!("t.depth >= ?{}", params.len()));
1213    }
1214
1215    let offset_i64 = i64::try_from(query.offset)
1216        .map_err(|_| QueryError::InvalidInput("SKIP exceeds i64::MAX".into()))?;
1217    params.push(QueryValue::Integer(offset_i64));
1218    let offset_param = params.len();
1219
1220    let (limit, truncation_check) = effective_limit(query.limit, query.offset, opts.max_limit);
1221    let limit_i64 = i64::try_from(limit)
1222        .map_err(|_| QueryError::InvalidInput("limit exceeds i64::MAX".into()))?;
1223    params.push(QueryValue::Integer(limit_i64));
1224    let limit_param = params.len();
1225
1226    if let Some(ref var) = start.variable {
1227        var_to_alias.insert(var.clone(), ("s".to_string(), VarKind::Node));
1228    }
1229    if let Some(ref var) = end.variable {
1230        var_to_alias.insert(var.clone(), ("r".to_string(), VarKind::Node));
1231    }
1232    if let Some(ref var) = edge.variable {
1233        var_to_alias.insert(var.clone(), ("e".to_string(), VarKind::Edge));
1234    }
1235
1236    let mut select_parts: Vec<String> = Vec::new();
1237    let mut projection_aliases: Vec<String> = Vec::new();
1238    let mut has_start = false;
1239
1240    for item in &query.return_items {
1241        let var = item.variable();
1242        if let Some((_, kind)) = var_to_alias.get(var) {
1243            match item {
1244                ReturnItem::Property(_, prop) => {
1245                    let is_start = start.variable.as_deref() == Some(var);
1246                    if matches!(kind, VarKind::EventNode | VarKind::ObservationTargetNode) {
1247                        return Err(QueryError::Unsupported(
1248                            "synthetic observed_as_* edges cannot be used in variable-length \
1249                             patterns; use a fixed-length edge pattern instead"
1250                                .into(),
1251                        ));
1252                    }
1253                    if *kind == VarKind::Node {
1254                        let tbl = if is_start { "s" } else { "r" };
1255                        if is_start {
1256                            has_start = true;
1257                        }
1258                        let col = property_to_column(prop, kind)?;
1259                        let output_alias = quote_identifier(&format!("{var}_{prop}"));
1260                        select_parts.push(format!("{tbl}.{col} AS {output_alias}"));
1261                        projection_aliases.push(output_alias);
1262                    } else {
1263                        let col = match prop.as_str() {
1264                            "id" => "via_edge",
1265                            "relation" => "via_relation",
1266                            "weight" => "via_weight",
1267                            _ => {
1268                                return Err(QueryError::Compile(format!(
1269                                    "unknown edge property '{prop}' in RETURN projection. \
1270                                     Valid: id, source_id, target_id, relation, weight"
1271                                )));
1272                            }
1273                        };
1274                        let output_alias = quote_identifier(&format!("{var}_{prop}"));
1275                        select_parts.push(format!("t.{col} AS {output_alias}"));
1276                        projection_aliases.push(output_alias);
1277                    }
1278                }
1279                ReturnItem::Variable(_) => match kind {
1280                    VarKind::Node => {
1281                        let tbl = if start.variable.as_deref() == Some(var) {
1282                            has_start = true;
1283                            "s"
1284                        } else {
1285                            "r"
1286                        };
1287                        for column in [
1288                            "id",
1289                            "namespace",
1290                            "kind",
1291                            "entity_type",
1292                            "name",
1293                            "properties",
1294                            "created_at",
1295                            "updated_at",
1296                        ] {
1297                            let output_alias = quote_identifier(&format!("{var}_{column}"));
1298                            select_parts.push(format!("{tbl}.{column} AS {output_alias}"));
1299                            projection_aliases.push(output_alias);
1300                        }
1301                    }
1302                    VarKind::EventNode | VarKind::ObservationTargetNode => {
1303                        return Err(QueryError::Unsupported(
1304                            "synthetic observed_as_* edges cannot be used in variable-length \
1305                             patterns; use a fixed-length edge pattern instead"
1306                                .into(),
1307                        ));
1308                    }
1309                    VarKind::Edge => {
1310                        for (column, suffix) in [
1311                            ("via_edge", "id"),
1312                            ("via_relation", "relation"),
1313                            ("via_weight", "weight"),
1314                        ] {
1315                            let output_alias = quote_identifier(&format!("{var}_{suffix}"));
1316                            select_parts.push(format!("t.{column} AS {output_alias}"));
1317                            projection_aliases.push(output_alias);
1318                        }
1319                    }
1320                },
1321            }
1322        } else {
1323            return Err(QueryError::Compile(format!(
1324                "unknown variable '{var}' in RETURN clause"
1325            )));
1326        }
1327    }
1328
1329    let depth_alias = quote_identifier("_depth");
1330    let total_weight_alias = quote_identifier("_total_weight");
1331    select_parts.push(format!("t.depth AS {depth_alias}"));
1332    select_parts.push(format!("t.total_weight AS {total_weight_alias}"));
1333
1334    // This path uses SELECT DISTINCT, so ordering by hidden path identities can
1335    // pick an arbitrary representative for collapsed rows. Ordering by every
1336    // projected alias instead gives a total order over the rows that survive
1337    // DISTINCT while retaining depth/weight as the leading traversal order.
1338    // Keep aliases as data: public AST names may contain SQL delimiters.
1339    projection_aliases.retain(|alias| alias != &depth_alias && alias != &total_weight_alias);
1340    let projection_order = projection_aliases.join(", ");
1341    let projection_order_suffix = if projection_order.is_empty() {
1342        String::new()
1343    } else {
1344        format!(", {projection_order}")
1345    };
1346
1347    // `r` is required by end filters; `s` is needed only for a start projection.
1348    let join_start = if has_start {
1349        "JOIN primary_nodes s ON s.id = t.start_id"
1350    } else {
1351        ""
1352    };
1353    let join_end = "JOIN primary_nodes r ON r.id = t.current_id";
1354
1355    let next_node_ns_and = if next_node_ns_filter.is_empty() {
1356        String::new()
1357    } else {
1358        format!(" AND {}", next_node_ns_filter.trim_start_matches(" AND "))
1359    };
1360
1361    let sql = format!(
1362        "WITH RECURSIVE primary_nodes AS ({primary_nodes}), \
1363         traverse(start_id, current_id, depth, path, total_weight, via_edge, via_relation, via_weight) AS (\
1364             SELECT s.id, {seed_next}, 1, s.id || ',' || {seed_next}, e.weight, \
1365                    e.id, e.relation, e.weight \
1366             FROM primary_nodes s \
1367             JOIN graph_edges e ON {seed_join} AND e.deleted_at IS NULL{e_ns_filter}{relation_condition} \
1368             JOIN primary_nodes next_node ON next_node.id = ({seed_next}) \
1369                    AND next_node.deleted_at IS NULL{next_node_ns_and} \
1370             WHERE {start_where} \
1371             UNION ALL \
1372             SELECT t.start_id, {recurse_next}, t.depth + 1, \
1373                    t.path || ',' || {recurse_next}, \
1374                    t.total_weight + e.weight, \
1375                    e.id, e.relation, e.weight \
1376             FROM traverse t CROSS JOIN graph_edges e \
1377                 ON {recurse_join} AND e.deleted_at IS NULL{e_ns_filter}{relation_condition} \
1378             JOIN primary_nodes next_node ON next_node.id = ({recurse_next}) \
1379                    AND next_node.deleted_at IS NULL{next_node_ns_and} \
1380             WHERE t.depth < ?{depth_param} \
1381               AND (',' || t.path || ',') NOT LIKE '%,' || {recurse_next} || ',%' \
1382         ) \
1383         SELECT DISTINCT {select_cols} \
1384         FROM traverse t \
1385         {join_start} {join_end} \
1386         WHERE {end_where} \
1387         ORDER BY {depth_alias}, {total_weight_alias} DESC{projection_order_suffix} \
1388         LIMIT ?{limit_param} OFFSET ?{offset_param}",
1389        primary_nodes = PRIMARY_NODE_SQL,
1390        seed_next = seed_next,
1391        seed_join = seed_join,
1392        e_ns_filter = e_ns_filter,
1393        relation_condition = relation_condition,
1394        start_where = start_conditions.join(" AND "),
1395        recurse_next = recurse_next,
1396        recurse_join = recurse_join,
1397        next_node_ns_and = next_node_ns_and,
1398        depth_param = depth_param,
1399        select_cols = select_parts.join(", "),
1400        join_start = join_start,
1401        join_end = join_end,
1402        end_where = end_conditions.join(" AND "),
1403        projection_order_suffix = projection_order_suffix,
1404        limit_param = limit_param,
1405        offset_param = offset_param,
1406    );
1407
1408    Ok(CompiledQuery {
1409        sql,
1410        params,
1411        return_vars: query.return_items.clone(),
1412        warnings: Vec::new(),
1413        truncation_check,
1414    })
1415}
1416
1417#[derive(Clone, Copy, PartialEq, Eq)]
1418enum VarKind {
1419    Node,
1420    /// Synthetic observation source backed by `events`.
1421    EventNode,
1422    /// Synthetic observation target backed by an entity/note referent.
1423    ObservationTargetNode,
1424    Edge,
1425}
1426
1427const NODE_COLUMNS: &[&str] = &[
1428    "id",
1429    "name",
1430    "kind",
1431    "entity_type",
1432    "namespace",
1433    "description",
1434    "properties",
1435    "created_at",
1436    "updated_at",
1437];
1438/// Projection columns for entity/note observation targets.
1439const OBSERVATION_TARGET_COLUMNS: &[&str] = &[
1440    "id",
1441    "namespace",
1442    "kind",
1443    "entity_type",
1444    "status",
1445    "name",
1446    "content",
1447    "salience",
1448    "decay_factor",
1449    "properties",
1450    "created_at",
1451    "updated_at",
1452    "referent_kind",
1453];
1454/// Projection columns for synthetic event sources.
1455const EVENT_COLUMNS: &[&str] = &[
1456    "id",
1457    "namespace",
1458    "verb",
1459    "substrate",
1460    "actor",
1461    "kind",
1462    "outcome",
1463    "payload",
1464    "duration_us",
1465    "target_id",
1466    "session_id",
1467    "created_at",
1468];
1469const EDGE_COLUMNS: &[&str] = &["id", "source_id", "target_id", "relation", "weight"];
1470const NODE_WHERE_COLUMNS: &[&str] = &[
1471    "id",
1472    "name",
1473    "kind",
1474    "entity_type",
1475    "description",
1476    "created_at",
1477    "updated_at",
1478];
1479const OBSERVATION_TARGET_WHERE_COLUMNS: &[&str] = &[
1480    "id",
1481    "kind",
1482    "entity_type",
1483    "status",
1484    "name",
1485    "content",
1486    "salience",
1487    "decay_factor",
1488    "created_at",
1489    "updated_at",
1490    "referent_kind",
1491];
1492const EVENT_WHERE_COLUMNS: &[&str] = &[
1493    "id",
1494    "verb",
1495    "substrate",
1496    "actor",
1497    "kind",
1498    "outcome",
1499    "payload",
1500    "duration_us",
1501    "target_id",
1502    "session_id",
1503    "created_at",
1504];
1505const EDGE_WHERE_COLUMNS: &[&str] = &["relation", "weight"];
1506
1507fn property_columns(kind: &VarKind) -> (&'static [&'static str], &'static str) {
1508    match kind {
1509        VarKind::Node => (NODE_COLUMNS, "node"),
1510        VarKind::ObservationTargetNode => (OBSERVATION_TARGET_COLUMNS, "observation target"),
1511        VarKind::EventNode => (EVENT_COLUMNS, "event"),
1512        VarKind::Edge => (EDGE_COLUMNS, "edge"),
1513    }
1514}
1515
1516fn property_to_column<'a>(prop: &'a str, kind: &VarKind) -> Result<&'a str, QueryError> {
1517    let (valid, kind_name) = property_columns(kind);
1518    if valid.contains(&prop) {
1519        Ok(prop)
1520    } else {
1521        Err(QueryError::Compile(format!(
1522            "unknown {kind_name} property '{prop}' in RETURN projection. \
1523             Valid: {}",
1524            valid.join(", ")
1525        )))
1526    }
1527}
1528
1529fn where_property_columns(kind: &VarKind) -> (&'static [&'static str], &'static str) {
1530    match kind {
1531        VarKind::Node => (NODE_WHERE_COLUMNS, "node"),
1532        VarKind::ObservationTargetNode => (OBSERVATION_TARGET_WHERE_COLUMNS, "observation target"),
1533        VarKind::EventNode => (EVENT_WHERE_COLUMNS, "event"),
1534        VarKind::Edge => (EDGE_WHERE_COLUMNS, "edge"),
1535    }
1536}
1537
1538fn where_property_expression(
1539    property: &PropertyRef,
1540    kind: &VarKind,
1541    alias: &str,
1542) -> Result<String, QueryError> {
1543    let (valid, kind_name) = where_property_columns(kind);
1544    match property {
1545        PropertyRef::Field(field) if valid.contains(&field.as_str()) => {
1546            Ok(format!("{alias}.{field}"))
1547        }
1548        PropertyRef::Field(field) if field == "properties" => Err(QueryError::Compile(
1549            "'properties' is reserved as the JSON path root in WHERE clauses; \
1550             use 'properties.<path>'"
1551                .into(),
1552        )),
1553        PropertyRef::Field(field) => Err(QueryError::Compile(format!(
1554            "unknown {kind_name} field '{field}' in WHERE clause. Valid fields: {}; \
1555             JSON properties must use 'properties.<path>'",
1556            valid.join(", ")
1557        ))),
1558        PropertyRef::JsonPath(path) => {
1559            let (projection_columns, _) = property_columns(kind);
1560            if !projection_columns.contains(&"properties") {
1561                return Err(QueryError::Compile(format!(
1562                    "{kind_name} variables do not expose JSON properties in WHERE clauses. \
1563                     Valid fields: {}",
1564                    valid.join(", ")
1565                )));
1566            }
1567            if path.is_empty() {
1568                return Err(QueryError::Compile(
1569                    "JSON property path cannot be empty; use 'properties.<path>'".into(),
1570                ));
1571            }
1572            if let Some(segment) = path.iter().find(|segment| {
1573                segment.is_empty() || !segment.chars().all(|ch| ch.is_alphanumeric() || ch == '_')
1574            }) {
1575                return Err(QueryError::Compile(format!(
1576                    "invalid JSON property path segment '{segment}'; \
1577                     path segments must be identifiers"
1578                )));
1579            }
1580            Ok(format!(
1581                "json_extract({alias}.properties, '$.{}')",
1582                path.join(".")
1583            ))
1584        }
1585    }
1586}
1587
1588// Inline tests exercise private compiler phases without widening the public API.
1589#[cfg(test)]
1590#[path = "sql_tests.rs"]
1591mod tests;