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