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