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