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