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