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 = match kind {
708 VarKind::Node => {
709 if cond.property == "name"
710 || cond.property == "kind"
711 || cond.property == "entity_type"
712 || cond.property == "namespace"
713 {
714 format!("{alias}.{}", cond.property)
715 } else {
716 format!(
717 "json_extract({alias}.properties, '$.{}')",
718 cond.property.replace('\'', "''")
719 )
720 }
721 }
722 VarKind::ObservationTargetNode => {
723 if OBSERVATION_TARGET_COLUMNS.contains(&cond.property.as_str()) {
724 format!("{alias}.{}", cond.property)
725 } else {
726 format!(
727 "json_extract({alias}.properties, '$.{}')",
728 cond.property.replace('\'', "''")
729 )
730 }
731 }
732 VarKind::EventNode => {
733 if EVENT_COLUMNS.contains(&cond.property.as_str()) {
734 format!("{alias}.{}", cond.property)
735 } else {
736 return Err(QueryError::Validation(format!(
737 "event property '{}' not queryable; valid columns: {}",
738 cond.property,
739 EVENT_COLUMNS.join(", ")
740 )));
741 }
742 }
743 VarKind::Edge => match cond.property.as_str() {
744 "relation" | "weight" => format!("{alias}.{}", cond.property),
745 other => {
746 return Err(QueryError::Validation(format!(
747 "edge property '{other}' not queryable; use 'relation' or 'weight'"
748 )))
749 }
750 },
751 };
752
753 compile_condition_predicate(&col_expr, cond, params)
754}
755
756fn bind_condition_value(
757 value: &ConditionValue,
758 params: &mut Vec<QueryValue>,
759) -> Result<usize, QueryError> {
760 match value {
761 ConditionValue::String(s) => params.push(QueryValue::Text(s.clone())),
762 ConditionValue::Integer(n) => params.push(QueryValue::Integer(*n)),
763 ConditionValue::Number(n) => {
764 if !n.is_finite() {
765 return Err(QueryError::InvalidInput(
766 "non-finite float (NaN or Infinity) is not a valid query parameter".into(),
767 ));
768 }
769 params.push(QueryValue::Float(*n));
770 }
771 ConditionValue::Bool(b) => {
772 params.push(QueryValue::Integer(if *b { 1 } else { 0 }));
773 }
774 ConditionValue::List(_) | ConditionValue::Null => {
775 return Err(QueryError::Validation(
776 "operator requires a scalar value".into(),
777 ));
778 }
779 }
780 Ok(params.len())
781}
782
783fn escape_like_literal(value: &str) -> String {
784 let mut escaped = String::with_capacity(value.len());
785 for ch in value.chars() {
786 if matches!(ch, '\\' | '%' | '_') {
787 escaped.push('\\');
788 }
789 escaped.push(ch);
790 }
791 escaped
792}
793
794fn compile_condition_predicate(
795 col_expr: &str,
796 cond: &Condition,
797 params: &mut Vec<QueryValue>,
798) -> Result<String, QueryError> {
799 match cond.op {
800 CompareOp::Contains | CompareOp::StartsWith => {
801 let ConditionValue::String(value) = &cond.value else {
802 return Err(QueryError::Validation(
803 "CONTAINS and STARTS WITH require a string literal".into(),
804 ));
805 };
806 let escaped = escape_like_literal(value);
807 let pattern = if cond.op == CompareOp::Contains {
808 format!("%{escaped}%")
809 } else {
810 format!("{escaped}%")
811 };
812 params.push(QueryValue::Text(pattern));
813 Ok(format!(
814 "{col_expr} LIKE ?{} COLLATE NOCASE ESCAPE '\\'",
815 params.len()
816 ))
817 }
818 CompareOp::In => {
819 let ConditionValue::List(values) = &cond.value else {
820 return Err(QueryError::Validation("IN requires a list literal".into()));
821 };
822 if values.is_empty() {
823 return Ok("0".into());
824 }
825 let has_string = values
826 .iter()
827 .any(|value| matches!(value, ConditionValue::String(_)));
828 let placeholders = values
829 .iter()
830 .map(|value| bind_condition_value(value, params).map(|index| format!("?{index}")))
831 .collect::<Result<Vec<_>, _>>()?;
832 let collate = if has_string { " COLLATE NOCASE" } else { "" };
833 Ok(format!(
834 "{col_expr}{collate} IN ({})",
835 placeholders.join(", ")
836 ))
837 }
838 CompareOp::IsNotNull => {
839 if !matches!(cond.value, ConditionValue::Null) {
840 return Err(QueryError::Validation(
841 "IS NOT NULL does not accept a value".into(),
842 ));
843 }
844 Ok(format!("{col_expr} IS NOT NULL"))
845 }
846 op => {
847 let op_str = match op {
848 CompareOp::Eq => "=",
849 CompareOp::Neq => "!=",
850 CompareOp::Gt => ">",
851 CompareOp::Lt => "<",
852 CompareOp::Gte => ">=",
853 CompareOp::Lte => "<=",
854 CompareOp::Like => "LIKE",
855 CompareOp::Contains
856 | CompareOp::StartsWith
857 | CompareOp::In
858 | CompareOp::IsNotNull => unreachable!(),
859 };
860 let is_string = matches!(cond.value, ConditionValue::String(_));
861 let param_index = bind_condition_value(&cond.value, params)?;
862 let collate = if is_string && matches!(op, CompareOp::Eq | CompareOp::Like) {
863 " COLLATE NOCASE"
864 } else {
865 ""
866 };
867 Ok(format!("{col_expr} {op_str} ?{param_index}{collate}"))
868 }
869 }
870}
871
872fn expr_endpoint_set(
873 expr: &WhereExpr,
874 start_var: Option<&str>,
875 end_var: Option<&str>,
876) -> (bool, bool) {
877 match expr {
878 WhereExpr::True => (false, false),
879 WhereExpr::Condition(c) => {
880 let is_start = start_var == Some(c.variable.as_str());
881 let is_end = end_var == Some(c.variable.as_str());
882 (is_start, is_end)
883 }
884 WhereExpr::And(l, r) | WhereExpr::Or(l, r) => {
885 let (ls, le) = expr_endpoint_set(l, start_var, end_var);
886 let (rs, re) = expr_endpoint_set(r, start_var, end_var);
887 (ls || rs, le || re)
888 }
889 }
890}
891
892fn reject_or_spanning_endpoints(
894 expr: &WhereExpr,
895 start: &NodePattern,
896 end: &NodePattern,
897) -> Result<(), QueryError> {
898 let start_var = start.variable.as_deref();
899 let end_var = end.variable.as_deref();
900 reject_or_spanning_impl(expr, start_var, end_var)
901}
902
903fn reject_or_spanning_impl(
904 expr: &WhereExpr,
905 start_var: Option<&str>,
906 end_var: Option<&str>,
907) -> Result<(), QueryError> {
908 match expr {
909 WhereExpr::True | WhereExpr::Condition(_) => Ok(()),
910 WhereExpr::And(l, r) => {
911 reject_or_spanning_impl(l, start_var, end_var)?;
912 reject_or_spanning_impl(r, start_var, end_var)
913 }
914 WhereExpr::Or(l, r) => {
915 let (l_start, l_end) = expr_endpoint_set(l, start_var, end_var);
916 let (r_start, r_end) = expr_endpoint_set(r, start_var, end_var);
917 let spans_start = l_start || r_start;
918 let spans_end = l_end || r_end;
919 if spans_start && spans_end {
920 return Err(QueryError::Unsupported(
921 "WHERE clauses that span both endpoints in a variable-length pattern \
922 are not yet supported; rewrite as separate queries or restrict each \
923 OR branch to one endpoint"
924 .into(),
925 ));
926 }
927 reject_or_spanning_impl(l, start_var, end_var)?;
928 reject_or_spanning_impl(r, start_var, end_var)
929 }
930 }
931}
932
933fn compile_var_len_condition(
934 cond: &Condition,
935 start_var: Option<&str>,
936 end_var: Option<&str>,
937 params: &mut Vec<QueryValue>,
938) -> Result<(String, &'static str), QueryError> {
939 let col_alias = if start_var == Some(cond.variable.as_str()) {
940 "s"
941 } else if end_var == Some(cond.variable.as_str()) {
942 "r"
943 } else {
944 return Err(QueryError::Compile(format!(
945 "variable '{}' in WHERE not supported in variable-length pattern \
946 (only start/end node variables)",
947 cond.variable
948 )));
949 };
950
951 let col_expr =
952 if cond.property == "name" || cond.property == "kind" || cond.property == "entity_type" {
953 format!("{col_alias}.{}", cond.property)
954 } else {
955 format!(
956 "json_extract({col_alias}.properties, '$.{}')",
957 cond.property.replace('\'', "''")
958 )
959 };
960
961 let sql = compile_condition_predicate(&col_expr, cond, params)?;
962 Ok((sql, col_alias))
963}
964
965fn compile_variable_length_where(
967 expr: &WhereExpr,
968 start_var: Option<&str>,
969 end_var: Option<&str>,
970 params: &mut Vec<QueryValue>,
971 start_conditions: &mut Vec<String>,
972 end_conditions: &mut Vec<String>,
973) -> Result<Option<String>, QueryError> {
974 match expr {
975 WhereExpr::True => Ok(None),
976 WhereExpr::Condition(cond) => {
977 let (sql, alias) = compile_var_len_condition(cond, start_var, end_var, params)?;
978 if alias == "s" {
979 start_conditions.push(sql);
980 } else {
981 end_conditions.push(sql);
982 }
983 Ok(None)
984 }
985 WhereExpr::And(l, r) => {
986 compile_variable_length_where(
987 l,
988 start_var,
989 end_var,
990 params,
991 start_conditions,
992 end_conditions,
993 )?;
994 compile_variable_length_where(
995 r,
996 start_var,
997 end_var,
998 params,
999 start_conditions,
1000 end_conditions,
1001 )?;
1002 Ok(None)
1003 }
1004 WhereExpr::Or(l, r) => {
1005 let l_sql = compile_variable_length_where_to_sql(l, start_var, end_var, params)?;
1007 let r_sql = compile_variable_length_where_to_sql(r, start_var, end_var, params)?;
1008 match (l_sql, r_sql) {
1009 (None, None) => {}
1010 (Some((ls, la)), None) => {
1011 if la == "s" {
1012 start_conditions.push(ls);
1013 } else {
1014 end_conditions.push(ls);
1015 }
1016 }
1017 (None, Some((rs, ra))) => {
1018 if ra == "s" {
1019 start_conditions.push(rs);
1020 } else {
1021 end_conditions.push(rs);
1022 }
1023 }
1024 (Some((ls, la)), Some((rs, _ra))) => {
1025 let combined = format!("({ls} OR {rs})");
1026 if la == "s" {
1027 start_conditions.push(combined);
1028 } else {
1029 end_conditions.push(combined);
1030 }
1031 }
1032 }
1033 Ok(None)
1034 }
1035 }
1036}
1037
1038fn compile_variable_length_where_to_sql(
1040 expr: &WhereExpr,
1041 start_var: Option<&str>,
1042 end_var: Option<&str>,
1043 params: &mut Vec<QueryValue>,
1044) -> Result<Option<(String, &'static str)>, QueryError> {
1045 match expr {
1046 WhereExpr::True => Ok(None),
1047 WhereExpr::Condition(cond) => {
1048 let (sql, alias) = compile_var_len_condition(cond, start_var, end_var, params)?;
1049 Ok(Some((sql, alias)))
1050 }
1051 WhereExpr::And(l, r) => {
1052 let ls = compile_variable_length_where_to_sql(l, start_var, end_var, params)?;
1053 let rs = compile_variable_length_where_to_sql(r, start_var, end_var, params)?;
1054 Ok(match (ls, rs) {
1055 (None, None) => None,
1056 (Some(s), None) | (None, Some(s)) => Some(s),
1057 (Some((lsql, la)), Some((rsql, _))) => Some((format!("{lsql} AND {rsql}"), la)),
1058 })
1059 }
1060 WhereExpr::Or(l, r) => {
1061 let ls = compile_variable_length_where_to_sql(l, start_var, end_var, params)?;
1062 let rs = compile_variable_length_where_to_sql(r, start_var, end_var, params)?;
1063 Ok(match (ls, rs) {
1064 (None, None) => None,
1065 (Some(s), None) | (None, Some(s)) => Some(s),
1066 (Some((lsql, la)), Some((rsql, _))) => Some((format!("({lsql} OR {rsql})"), la)),
1067 })
1068 }
1069 }
1070}
1071
1072fn compile_variable_length(
1074 query: &GqlQuery,
1075 opts: &CompileOptions,
1076) -> Result<CompiledQuery, QueryError> {
1077 let mut params: Vec<QueryValue> = Vec::new();
1078 let mut var_to_alias: std::collections::HashMap<String, (String, VarKind)> =
1079 std::collections::HashMap::new();
1080
1081 let nodes: Vec<&NodePattern> = query.pattern.nodes().collect();
1083 let edges: Vec<&EdgePattern> = query.pattern.edges().collect();
1084
1085 if nodes.len() != 2 || edges.len() != 1 || query.pattern.elements.len() != 3 {
1086 return Err(QueryError::Unsupported(
1087 "variable-length patterns must be a single start_node -[*N..M]-> end_node \
1088 (mixed fixed/variable chains are not yet implemented)"
1089 .into(),
1090 ));
1091 }
1092
1093 let start = &nodes[0];
1094 let edge = &edges[0];
1095 let end = &nodes[1];
1096
1097 if edge.relations.iter().any(|r| is_synthetic(r)) {
1099 return Err(QueryError::Unsupported(
1100 "synthetic observed_as_* edges cannot be variable-length; \
1101 use a fixed-length edge pattern instead"
1102 .into(),
1103 ));
1104 }
1105
1106 let max_depth = edge.max_hops.min(MAX_DEPTH);
1107 let min_depth = edge.min_hops;
1108
1109 let mut start_conditions: Vec<String> = vec!["s.deleted_at IS NULL".to_string()];
1110 let ns_filter = namespace_filter("s", opts, &mut params);
1111 if !ns_filter.is_empty() {
1112 start_conditions.push(ns_filter.trim_start_matches(" AND ").to_string());
1113 }
1114
1115 if let Some(ref kind) = start.kind {
1116 start_conditions.push(kind_filter_predicate("s", kind, &mut params));
1117 }
1118 if let Some(ref et) = start.entity_type {
1119 params.push(QueryValue::Text(et.clone()));
1120 start_conditions.push(format!("s.entity_type = ?{}", params.len()));
1121 }
1122 let mut start_props: Vec<_> = start.properties.iter().collect();
1123 start_props.sort_by_key(|(k, _)| k.as_str());
1124 for (key, val) in start_props {
1125 let text_column = if key == "name" { Some("name") } else { None };
1126 start_conditions.push(compile_property_equality(
1127 "s",
1128 key,
1129 val,
1130 text_column,
1131 &mut params,
1132 )?);
1133 }
1134
1135 let mut relation_condition = String::new();
1136 if !edge.relations.is_empty() {
1137 if edge.relations.len() == 1 {
1138 params.push(QueryValue::Text(edge.relations[0].clone()));
1139 relation_condition = format!(" AND e.relation = ?{}", params.len());
1140 } else {
1141 let placeholders: Vec<String> = edge
1142 .relations
1143 .iter()
1144 .map(|r| {
1145 params.push(QueryValue::Text(r.clone()));
1146 format!("?{}", params.len())
1147 })
1148 .collect();
1149 relation_condition = format!(" AND e.relation IN ({})", placeholders.join(", "));
1150 }
1151 }
1152
1153 let e_ns_filter = namespace_filter("e", opts, &mut params);
1154
1155 let (seed_join, seed_next, recurse_join, recurse_next) = match edge.direction {
1156 EdgeDirection::Out => (
1157 "e.source_id = s.id",
1158 "e.target_id",
1159 "e.source_id = t.current_id",
1160 "e.target_id",
1161 ),
1162 EdgeDirection::In => (
1163 "e.target_id = s.id",
1164 "e.source_id",
1165 "e.target_id = t.current_id",
1166 "e.source_id",
1167 ),
1168 EdgeDirection::Both => (
1169 "(e.source_id = s.id OR e.target_id = s.id)",
1170 "CASE WHEN e.source_id = s.id THEN e.target_id ELSE e.source_id END",
1171 "(e.source_id = t.current_id OR e.target_id = t.current_id)",
1172 "CASE WHEN e.source_id = t.current_id THEN e.target_id ELSE e.source_id END",
1173 ),
1174 };
1175
1176 let next_node_ns_filter = namespace_filter("next_node", opts, &mut params);
1178
1179 let max_depth_i64 = i64::try_from(max_depth)
1180 .map_err(|_| QueryError::InvalidInput("max_depth exceeds i64::MAX".into()))?;
1181 params.push(QueryValue::Integer(max_depth_i64));
1182 let depth_param = params.len();
1183
1184 let mut end_conditions: Vec<String> = vec!["r.deleted_at IS NULL".to_string()];
1186 let r_ns_filter = namespace_filter("r", opts, &mut params);
1187 if !r_ns_filter.is_empty() {
1188 end_conditions.push(r_ns_filter.trim_start_matches(" AND ").to_string());
1189 }
1190 if let Some(ref kind) = end.kind {
1191 end_conditions.push(kind_filter_predicate("r", kind, &mut params));
1192 }
1193 if let Some(ref et) = end.entity_type {
1194 params.push(QueryValue::Text(et.clone()));
1195 end_conditions.push(format!("r.entity_type = ?{}", params.len()));
1196 }
1197 let mut end_props: Vec<_> = end.properties.iter().collect();
1198 end_props.sort_by_key(|(k, _)| k.as_str());
1199 for (key, val) in end_props {
1200 let text_column = if key == "name" { Some("name") } else { None };
1201 end_conditions.push(compile_property_equality(
1202 "r",
1203 key,
1204 val,
1205 text_column,
1206 &mut params,
1207 )?);
1208 }
1209
1210 reject_or_spanning_endpoints(&query.where_clause, start, end)?;
1211
1212 if let Some(where_sql) = compile_variable_length_where(
1213 &query.where_clause,
1214 start.variable.as_deref(),
1215 end.variable.as_deref(),
1216 &mut params,
1217 &mut start_conditions,
1218 &mut end_conditions,
1219 )? {
1220 start_conditions.push(where_sql);
1222 }
1223
1224 if min_depth > 0 {
1225 let min_depth_i64 = i64::try_from(min_depth)
1226 .map_err(|_| QueryError::InvalidInput("min_depth exceeds i64::MAX".into()))?;
1227 params.push(QueryValue::Integer(min_depth_i64));
1228 end_conditions.push(format!("t.depth >= ?{}", params.len()));
1229 }
1230
1231 let (limit, truncation_check) = effective_limit(query.limit, opts.max_limit);
1232 let limit_i64 = i64::try_from(limit)
1233 .map_err(|_| QueryError::InvalidInput("limit exceeds i64::MAX".into()))?;
1234 params.push(QueryValue::Integer(limit_i64));
1235 let limit_param = params.len();
1236
1237 if let Some(ref var) = start.variable {
1238 var_to_alias.insert(var.clone(), ("s".to_string(), VarKind::Node));
1239 }
1240 if let Some(ref var) = end.variable {
1241 var_to_alias.insert(var.clone(), ("r".to_string(), VarKind::Node));
1242 }
1243 if let Some(ref var) = edge.variable {
1244 var_to_alias.insert(var.clone(), ("e".to_string(), VarKind::Edge));
1245 }
1246
1247 let mut select_parts: Vec<String> = Vec::new();
1248 let mut has_start = false;
1249
1250 for item in &query.return_items {
1251 let var = item.variable();
1252 if let Some((_, kind)) = var_to_alias.get(var) {
1253 match item {
1254 ReturnItem::Property(_, prop) => {
1255 let is_start = start.variable.as_deref() == Some(var);
1256 if matches!(kind, 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 if *kind == VarKind::Node {
1264 let tbl = if is_start { "s" } else { "r" };
1265 if is_start {
1266 has_start = true;
1267 }
1268 let col = property_to_column(prop, kind)?;
1269 select_parts.push(format!("{tbl}.{col} AS {var}_{prop}"));
1270 } else {
1271 let col = match prop.as_str() {
1272 "id" => "via_edge",
1273 "relation" => "via_relation",
1274 "weight" => "via_weight",
1275 _ => {
1276 return Err(QueryError::Compile(format!(
1277 "unknown edge property '{prop}' in RETURN projection. \
1278 Valid: id, source_id, target_id, relation, weight"
1279 )));
1280 }
1281 };
1282 select_parts.push(format!("t.{col} AS {var}_{prop}"));
1283 }
1284 }
1285 ReturnItem::Variable(_) => match kind {
1286 VarKind::Node => {
1287 if start.variable.as_deref() == Some(var) {
1288 has_start = true;
1289 select_parts.push(format!(
1290 "s.id AS {var}_id, s.namespace AS {var}_namespace, \
1291 s.kind AS {var}_kind, s.entity_type AS {var}_entity_type, \
1292 s.name AS {var}_name, \
1293 s.properties AS {var}_properties, \
1294 s.created_at AS {var}_created_at, \
1295 s.updated_at AS {var}_updated_at"
1296 ));
1297 } else {
1298 select_parts.push(format!(
1299 "r.id AS {var}_id, r.namespace AS {var}_namespace, \
1300 r.kind AS {var}_kind, r.entity_type AS {var}_entity_type, \
1301 r.name AS {var}_name, \
1302 r.properties AS {var}_properties, \
1303 r.created_at AS {var}_created_at, \
1304 r.updated_at AS {var}_updated_at"
1305 ));
1306 }
1307 }
1308 VarKind::EventNode | VarKind::ObservationTargetNode => {
1309 return Err(QueryError::Unsupported(
1310 "synthetic observed_as_* edges cannot be used in variable-length \
1311 patterns; use a fixed-length edge pattern instead"
1312 .into(),
1313 ));
1314 }
1315 VarKind::Edge => {
1316 select_parts.push(format!(
1317 "t.via_edge AS {var}_id, t.via_relation AS {var}_relation, \
1318 t.via_weight AS {var}_weight"
1319 ));
1320 }
1321 },
1322 }
1323 } else {
1324 return Err(QueryError::Compile(format!(
1325 "unknown variable '{var}' in RETURN clause"
1326 )));
1327 }
1328 }
1329
1330 select_parts.push("t.depth AS _depth".to_string());
1331 select_parts.push("t.total_weight AS _total_weight".to_string());
1332
1333 let join_start = if has_start {
1335 "JOIN primary_nodes s ON s.id = t.start_id"
1336 } else {
1337 ""
1338 };
1339 let join_end = "JOIN primary_nodes r ON r.id = t.current_id";
1340
1341 let next_node_ns_and = if next_node_ns_filter.is_empty() {
1342 String::new()
1343 } else {
1344 format!(" AND {}", next_node_ns_filter.trim_start_matches(" AND "))
1345 };
1346
1347 let sql = format!(
1348 "WITH RECURSIVE primary_nodes AS ({primary_nodes}), \
1349 traverse(start_id, current_id, depth, path, total_weight, via_edge, via_relation, via_weight) AS (\
1350 SELECT s.id, {seed_next}, 1, s.id || ',' || {seed_next}, e.weight, \
1351 e.id, e.relation, e.weight \
1352 FROM primary_nodes s \
1353 JOIN graph_edges e ON {seed_join} AND e.deleted_at IS NULL{e_ns_filter}{relation_condition} \
1354 WHERE {start_where} \
1355 UNION ALL \
1356 SELECT t.start_id, {recurse_next}, t.depth + 1, \
1357 t.path || ',' || {recurse_next}, \
1358 t.total_weight + e.weight, \
1359 e.id, e.relation, e.weight \
1360 FROM traverse t CROSS JOIN graph_edges e \
1361 ON {recurse_join} AND e.deleted_at IS NULL{e_ns_filter}{relation_condition} \
1362 JOIN primary_nodes next_node ON next_node.id = ({recurse_next}) \
1363 AND next_node.deleted_at IS NULL{next_node_ns_and} \
1364 WHERE t.depth < ?{depth_param} \
1365 AND (',' || t.path || ',') NOT LIKE '%,' || {recurse_next} || ',%' \
1366 ) \
1367 SELECT DISTINCT {select_cols} \
1368 FROM traverse t \
1369 {join_start} {join_end} \
1370 WHERE {end_where} \
1371 ORDER BY t.depth, t.total_weight DESC, t.start_id, t.current_id \
1372 LIMIT ?{limit_param}",
1373 primary_nodes = PRIMARY_NODE_SQL,
1374 seed_next = seed_next,
1375 seed_join = seed_join,
1376 e_ns_filter = e_ns_filter,
1377 relation_condition = relation_condition,
1378 start_where = start_conditions.join(" AND "),
1379 recurse_next = recurse_next,
1380 recurse_join = recurse_join,
1381 next_node_ns_and = next_node_ns_and,
1382 depth_param = depth_param,
1383 select_cols = select_parts.join(", "),
1384 join_start = join_start,
1385 join_end = join_end,
1386 end_where = end_conditions.join(" AND "),
1387 limit_param = limit_param,
1388 );
1389
1390 Ok(CompiledQuery {
1391 sql,
1392 params,
1393 return_vars: query.return_items.clone(),
1394 warnings: Vec::new(),
1395 truncation_check,
1396 })
1397}
1398
1399#[derive(Clone, Copy, PartialEq, Eq)]
1400enum VarKind {
1401 Node,
1402 EventNode,
1404 ObservationTargetNode,
1406 Edge,
1407}
1408
1409const NODE_COLUMNS: &[&str] = &[
1410 "id",
1411 "name",
1412 "kind",
1413 "entity_type",
1414 "namespace",
1415 "description",
1416 "properties",
1417 "created_at",
1418 "updated_at",
1419];
1420const OBSERVATION_TARGET_COLUMNS: &[&str] = &[
1422 "id",
1423 "namespace",
1424 "kind",
1425 "entity_type",
1426 "status",
1427 "name",
1428 "content",
1429 "salience",
1430 "decay_factor",
1431 "properties",
1432 "created_at",
1433 "updated_at",
1434 "referent_kind",
1435];
1436const EVENT_COLUMNS: &[&str] = &[
1438 "id",
1439 "namespace",
1440 "verb",
1441 "substrate",
1442 "actor",
1443 "kind",
1444 "outcome",
1445 "payload",
1446 "duration_us",
1447 "target_id",
1448 "session_id",
1449 "created_at",
1450];
1451const EDGE_COLUMNS: &[&str] = &["id", "source_id", "target_id", "relation", "weight"];
1452
1453fn property_to_column<'a>(prop: &'a str, kind: &VarKind) -> Result<&'a str, QueryError> {
1454 let (valid, kind_name) = match kind {
1455 VarKind::Node => (NODE_COLUMNS, "node"),
1456 VarKind::ObservationTargetNode => (OBSERVATION_TARGET_COLUMNS, "observation target"),
1457 VarKind::EventNode => (EVENT_COLUMNS, "event"),
1458 VarKind::Edge => (EDGE_COLUMNS, "edge"),
1459 };
1460 if valid.contains(&prop) {
1461 Ok(prop)
1462 } else {
1463 Err(QueryError::Compile(format!(
1464 "unknown {kind_name} property '{prop}' in RETURN projection. \
1465 Valid: {}",
1466 valid.join(", ")
1467 )))
1468 }
1469}
1470
1471#[cfg(test)]
1473mod tests {
1474 use super::*;
1475 use crate::parsers::gql;
1476
1477 fn opts() -> CompileOptions {
1478 CompileOptions::default()
1479 }
1480
1481 fn scoped(namespace: &str) -> CompileOptions {
1482 CompileOptions {
1483 scopes: vec![namespace.to_string()],
1484 max_limit: 500,
1485 }
1486 }
1487
1488 #[test]
1489 fn fixed_length_basic() {
1490 let q =
1491 gql::parse("MATCH (a:concept)-[e:introduced_by]->(b:paper) RETURN a, e, b LIMIT 10")
1492 .unwrap();
1493 let compiled = compile(&q, &opts()).unwrap();
1494 assert!(compiled.sql.contains("JOIN graph_edges"));
1495 assert!(compiled.sql.contains("LIMIT"));
1496 assert_eq!(
1497 compiled.return_vars,
1498 vec![
1499 ReturnItem::Variable("a".into()),
1500 ReturnItem::Variable("e".into()),
1501 ReturnItem::Variable("b".into()),
1502 ]
1503 );
1504 assert!(!compiled.sql.contains("WITH RECURSIVE"));
1505 }
1506
1507 #[test]
1508 fn namespace_scoping_injected() {
1509 let q =
1510 gql::parse("MATCH (a:concept)-[e:introduced_by]->(b:paper) RETURN a LIMIT 5").unwrap();
1511 let compiled = compile(&q, &scoped("research")).unwrap();
1512 assert!(compiled.sql.contains("namespace"));
1513 let has_ns_param = compiled
1514 .params
1515 .iter()
1516 .any(|p| matches!(p, QueryValue::Text(s) if s == "research"));
1517 assert!(has_ns_param, "namespace must be a bound parameter");
1518 }
1519
1520 #[test]
1521 fn edge_property_whitelist_rejects_unknown() {
1522 let q = gql::parse("MATCH (a)-[e:introduced_by]->(b) WHERE e.source_id = 'x' RETURN a")
1523 .unwrap();
1524 let result = compile(&q, &opts());
1525 assert!(result.is_err());
1526 let err = result.unwrap_err().to_string();
1527 assert!(
1528 err.contains("source_id") || err.contains("not queryable"),
1529 "error: {err}"
1530 );
1531 }
1532
1533 #[test]
1534 fn edge_property_relation_allowed() {
1535 let q = gql::parse("MATCH (a)-[e]->(b) WHERE e.relation = 'extends' RETURN a").unwrap();
1536 let result = compile(&q, &opts());
1537 assert!(
1538 result.is_ok(),
1539 "relation should be allowed: {:?}",
1540 result.err()
1541 );
1542 }
1543
1544 #[test]
1545 fn edge_property_weight_allowed() {
1546 let q = gql::parse("MATCH (a)-[e]->(b) WHERE e.weight > 0.5 RETURN a").unwrap();
1547 let result = compile(&q, &opts());
1548 assert!(
1549 result.is_ok(),
1550 "weight should be allowed: {:?}",
1551 result.err()
1552 );
1553 }
1554
1555 #[test]
1556 fn variable_length_uses_cte() {
1557 let q =
1558 gql::parse("MATCH (a {name: 'LoRA'})-[:extends*1..3]->(b) RETURN b LIMIT 20").unwrap();
1559 let compiled = compile(&q, &opts()).unwrap();
1560 assert!(compiled.sql.contains("WITH RECURSIVE"));
1561 assert!(compiled.sql.contains("traverse"));
1562 }
1563
1564 #[test]
1565 fn depth_cap_at_ten_rejects_above_max() {
1566 let q = gql::parse("MATCH (a)-[:extends*1..50]->(b) RETURN b").unwrap();
1567 let err = compile(&q, &opts()).unwrap_err();
1568 assert!(
1569 matches!(err, QueryError::InvalidInput(_)),
1570 "expected InvalidInput for depth > 10, got {err:?}"
1571 );
1572 }
1573
1574 #[test]
1575 fn depth_within_cap_compiles() {
1576 let q = gql::parse("MATCH (a)-[:extends*1..10]->(b) RETURN b").unwrap();
1577 let compiled = compile(&q, &opts()).unwrap();
1578 assert!(compiled.sql.contains("WITH RECURSIVE"));
1579 let depth_val = compiled.params.iter().find_map(|p| {
1580 if let QueryValue::Integer(n) = p {
1581 Some(*n)
1582 } else {
1583 None
1584 }
1585 });
1586 assert_eq!(depth_val, Some(10), "depth param should be 10");
1587 }
1588
1589 #[test]
1590 fn limit_capped_by_max_limit() {
1591 let q = gql::parse("MATCH (a:concept)-[e]->(b) RETURN a LIMIT 1000").unwrap();
1592 let compiled = compile(&q, &opts()).unwrap();
1593 let limit_param = compiled.params.last().unwrap();
1594 assert!(
1595 matches!(limit_param, QueryValue::Integer(501)),
1596 "expected Integer(501), got {limit_param:?}"
1597 );
1598 }
1599
1600 #[test]
1601 fn limit_over_cap_requests_sentinel_row() {
1602 let q = gql::parse("MATCH (a:concept)-[e]->(b) RETURN a LIMIT 1000").unwrap();
1603 let compiled = compile(&q, &opts()).unwrap();
1604 assert_eq!(
1605 compiled.truncation_check,
1606 Some(TruncationCheck {
1607 max_limit: 500,
1608 requested_limit: Some(1000),
1609 })
1610 );
1611 let limit_param = compiled.params.last().unwrap();
1612 assert!(
1613 matches!(limit_param, QueryValue::Integer(501)),
1614 "expected sentinel LIMIT 501, got {limit_param:?}"
1615 );
1616 }
1617
1618 #[test]
1619 fn limit_exactly_at_cap_no_sentinel() {
1620 let q = gql::parse("MATCH (a:concept)-[e]->(b) RETURN a LIMIT 500").unwrap();
1621 let compiled = compile(&q, &opts()).unwrap();
1622 assert_eq!(compiled.truncation_check, None);
1623 let limit_param = compiled.params.last().unwrap();
1624 assert!(matches!(limit_param, QueryValue::Integer(500)));
1625 }
1626
1627 #[test]
1628 fn limit_below_cap_no_sentinel() {
1629 let q = gql::parse("MATCH (a:concept)-[e]->(b) RETURN a LIMIT 100").unwrap();
1630 let compiled = compile(&q, &opts()).unwrap();
1631 assert_eq!(compiled.truncation_check, None);
1632 let limit_param = compiled.params.last().unwrap();
1633 assert!(matches!(limit_param, QueryValue::Integer(100)));
1634 }
1635
1636 #[test]
1637 fn no_explicit_limit_requests_sentinel_row() {
1638 let q = gql::parse("MATCH (a:concept)-[e]->(b) RETURN a").unwrap();
1639 let compiled = compile(&q, &opts()).unwrap();
1640 assert_eq!(
1641 compiled.truncation_check,
1642 Some(TruncationCheck {
1643 max_limit: 500,
1644 requested_limit: None,
1645 })
1646 );
1647 let limit_param = compiled.params.last().unwrap();
1648 assert!(
1649 matches!(limit_param, QueryValue::Integer(501)),
1650 "expected sentinel LIMIT 501, got {limit_param:?}"
1651 );
1652 }
1653
1654 #[test]
1655 fn variable_length_limit_over_cap_requests_sentinel_row() {
1656 let q = gql::parse("MATCH (a)-[:extends*1..3]->(b) RETURN b LIMIT 5000").unwrap();
1657 let compiled = compile(&q, &opts()).unwrap();
1658 assert_eq!(
1659 compiled.truncation_check,
1660 Some(TruncationCheck {
1661 max_limit: 500,
1662 requested_limit: Some(5000),
1663 })
1664 );
1665 let limit_param = compiled.params.last().unwrap();
1666 assert!(
1667 matches!(limit_param, QueryValue::Integer(501)),
1668 "expected sentinel LIMIT 501, got {limit_param:?}"
1669 );
1670 }
1671
1672 #[test]
1673 fn compile_rejects_unknown_relation() {
1674 let q = gql::parse("MATCH (a)-[:not_a_relation]->(b) RETURN a").unwrap();
1675 let err = compile(&q, &opts()).unwrap_err();
1676 let msg = err.to_string();
1677 assert!(msg.contains("not_a_relation"), "msg: {msg}");
1678 }
1679
1680 #[test]
1681 fn compile_unknown_kind_passes_through() {
1682 let q = gql::parse("MATCH (a:gizmo)-[:extends]->(b) RETURN a").unwrap();
1683 let compiled = compile(&q, &opts()).unwrap();
1684 let has_gizmo = compiled
1685 .params
1686 .iter()
1687 .any(|p| matches!(p, QueryValue::Text(s) if s == "gizmo"));
1688 assert!(
1689 has_gizmo,
1690 "pack-agnostic: unknown kind must pass through into SQL params"
1691 );
1692 }
1693
1694 #[test]
1695 fn compile_kind_passes_through_unchanged() {
1696 let q =
1697 gql::parse("MATCH (a:paper)-[:introduced_by]->(b:concept) RETURN a LIMIT 1").unwrap();
1698 let compiled = compile(&q, &opts()).unwrap();
1699 let has_paper = compiled
1700 .params
1701 .iter()
1702 .any(|p| matches!(p, QueryValue::Text(s) if s == "paper"));
1703 assert!(
1704 has_paper,
1705 "kind 'paper' must pass through unchanged into SQL params"
1706 );
1707 }
1708
1709 #[test]
1710 fn compile_rejects_namespace_in_where() {
1711 let q =
1712 gql::parse("MATCH (a:concept)-[:extends]->(b) WHERE a.namespace = 'other' RETURN a")
1713 .unwrap();
1714 let err = compile(&q, &opts()).unwrap_err();
1715 assert!(err.to_string().contains("namespace"), "msg: {err}");
1716 }
1717
1718 #[test]
1719 fn compile_rejects_unknown_relation_in_where() {
1720 let q = gql::parse("MATCH (a)-[e:extends]->(b) WHERE e.relation = 'related_to' RETURN a")
1721 .unwrap();
1722 let err = compile(&q, &opts()).unwrap_err();
1723 assert!(err.to_string().contains("related_to"), "msg: {err}");
1724 }
1725
1726 #[test]
1727 fn compile_kind_in_where_passes_through_unchanged() {
1728 let q = gql::parse("MATCH (a)-[:extends]->(b) WHERE a.kind = 'paper' RETURN a").unwrap();
1729 let compiled = compile(&q, &opts()).unwrap();
1730 let has_paper = compiled
1731 .params
1732 .iter()
1733 .any(|p| matches!(p, QueryValue::Text(s) if s == "paper"));
1734 assert!(
1735 has_paper,
1736 "kind 'paper' must pass through unchanged into SQL params"
1737 );
1738 }
1739
1740 #[test]
1741 fn variable_length_return_start_only_joins_end_entity() {
1742 let q = gql::parse("MATCH (a:concept)-[:extends*1..3]->(b) RETURN a LIMIT 10").unwrap();
1743 let compiled = compile(&q, &opts()).unwrap();
1744 assert!(
1745 compiled.sql.contains("JOIN primary_nodes r"),
1746 "primary_nodes r must always be joined when r.* conditions are emitted; sql: {}",
1747 compiled.sql
1748 );
1749 }
1750
1751 #[test]
1752 fn variable_length_trailing_pattern_unsupported() {
1753 let q = gql::parse("MATCH (a)-[:extends*1..3]->(b)-[:implements]->(c) RETURN b").unwrap();
1754 let err = compile(&q, &opts()).unwrap_err();
1755 assert!(
1756 matches!(err, QueryError::Unsupported(_)),
1757 "expected Unsupported, got {err:?}"
1758 );
1759 }
1760
1761 #[test]
1762 fn variable_length_mixed_chain_unsupported() {
1763 let q = gql::parse("MATCH (a)-[:extends]->(b)-[:implements*1..2]->(c) RETURN c").unwrap();
1764 let err = compile(&q, &opts()).unwrap_err();
1765 assert!(matches!(err, QueryError::Unsupported(_)), "got {err:?}");
1766 }
1767
1768 #[test]
1769 fn sparql_star_rejected_as_unsupported() {
1770 use crate::parsers::sparql;
1771 let err = sparql::parse("SELECT ?a ?b WHERE { ?a :extends* ?b . }").unwrap_err();
1772 assert!(matches!(err, QueryError::Unsupported(_)), "got {err:?}");
1773 }
1774
1775 #[test]
1777 fn sparql_subject_object_direction_compiles_outbound() {
1778 use crate::parsers::sparql;
1779
1780 let q = sparql::parse("SELECT ?a ?b WHERE { ?a :extends ?b . }").unwrap();
1781 let compiled = compile(&q, &opts()).unwrap();
1782
1783 assert!(
1784 compiled
1785 .sql
1786 .contains("JOIN graph_edges e0 ON e0.source_id = n0.id"),
1787 "SPARQL subject must bind graph_edges.source_id; sql: {}",
1788 compiled.sql
1789 );
1790 assert!(
1791 compiled.sql.contains("ON n1.id = e0.target_id"),
1792 "SPARQL object must bind graph_edges.target_id; sql: {}",
1793 compiled.sql
1794 );
1795 assert!(
1796 compiled.sql.contains("e0.relation = ?1"),
1797 "SPARQL predicate must bind graph_edges.relation; sql: {}",
1798 compiled.sql
1799 );
1800 }
1801
1802 #[test]
1803 fn return_property_projection_compiles() {
1804 let q =
1805 gql::parse("MATCH (a:concept)-[e:extends]->(b:concept) RETURN a.name, b.name LIMIT 5")
1806 .unwrap();
1807 let compiled = compile(&q, &opts()).unwrap();
1808 assert!(
1809 compiled.sql.contains(".name AS a_name"),
1810 "sql: {}",
1811 compiled.sql
1812 );
1813 assert!(
1814 compiled.sql.contains(".name AS b_name"),
1815 "sql: {}",
1816 compiled.sql
1817 );
1818 assert!(
1819 !compiled.sql.contains("a_kind"),
1820 "should not emit full node columns"
1821 );
1822 }
1823
1824 #[test]
1825 fn return_unknown_node_property_rejected() {
1826 let q = gql::parse("MATCH (a:concept)-[:extends]->(b) RETURN a.domain LIMIT 5").unwrap();
1827 let err = compile(&q, &opts()).unwrap_err();
1828 assert!(
1829 matches!(err, QueryError::Compile(ref msg) if msg.contains("unknown node property 'domain'")),
1830 "got {err:?}"
1831 );
1832 }
1833
1834 #[test]
1835 fn return_unknown_edge_property_rejected() {
1836 let q = gql::parse("MATCH (a)-[e:extends]->(b) RETURN e.label LIMIT 5").unwrap();
1837 let err = compile(&q, &opts()).unwrap_err();
1838 assert!(
1839 matches!(err, QueryError::Compile(ref msg) if msg.contains("unknown edge property 'label'")),
1840 "got {err:?}"
1841 );
1842 }
1843
1844 #[test]
1845 fn return_valid_edge_property_compiles() {
1846 let q =
1847 gql::parse("MATCH (a)-[e:extends]->(b) RETURN e.relation, e.weight LIMIT 5").unwrap();
1848 let compiled = compile(&q, &opts()).unwrap();
1849 assert!(
1850 compiled.sql.contains(".relation AS e_relation"),
1851 "sql: {}",
1852 compiled.sql
1853 );
1854 assert!(
1855 compiled.sql.contains(".weight AS e_weight"),
1856 "sql: {}",
1857 compiled.sql
1858 );
1859 }
1860
1861 #[test]
1862 fn entity_type_compiles_as_direct_column_not_json_extract() {
1863 let q = gql::parse("MATCH (n:document {entity_type: 'paper'})-[:extends]->(m) RETURN n")
1864 .unwrap();
1865 let compiled = compile(&q, &opts()).unwrap();
1866 assert!(
1867 compiled.sql.contains(".entity_type = ?"),
1868 "entity_type must compile to a direct column comparison; sql: {}",
1869 compiled.sql
1870 );
1871 assert!(
1872 !compiled.sql.contains("json_extract"),
1873 "entity_type must NOT use json_extract; sql: {}",
1874 compiled.sql
1875 );
1876 let has_paper_param = compiled
1877 .params
1878 .iter()
1879 .any(|p| matches!(p, QueryValue::Text(s) if s == "paper"));
1880 assert!(
1881 has_paper_param,
1882 "entity_type value 'paper' must appear as a bound parameter"
1883 );
1884 }
1885
1886 #[test]
1887 fn where_or_compiles_to_sql_or() {
1888 let q = gql::parse(
1889 "MATCH (a:concept)-[e:extends]->(b) WHERE a.name = 'LoRA' OR a.name = 'QLoRA' RETURN a",
1890 )
1891 .unwrap();
1892 let compiled = compile(&q, &opts()).unwrap();
1893 assert!(
1894 compiled.sql.contains(" OR "),
1895 "WHERE OR must produce SQL OR; sql: {}",
1896 compiled.sql
1897 );
1898 let has_lora = compiled
1899 .params
1900 .iter()
1901 .any(|p| matches!(p, QueryValue::Text(s) if s == "LoRA"));
1902 let has_qlora = compiled
1903 .params
1904 .iter()
1905 .any(|p| matches!(p, QueryValue::Text(s) if s == "QLoRA"));
1906 assert!(has_lora && has_qlora, "both OR values must be bound params");
1907 }
1908
1909 #[test]
1910 fn where_and_or_precedence() {
1911 let q = gql::parse(
1912 "MATCH (a:concept)-[e:extends]->(b) WHERE a.name = 'X' AND a.kind = 'concept' OR b.kind = 'project' RETURN a"
1913 ).unwrap();
1914 let compiled = compile(&q, &opts()).unwrap();
1915 assert!(
1916 compiled.sql.contains(" OR "),
1917 "expected OR in sql; sql: {}",
1918 compiled.sql
1919 );
1920 }
1921
1922 #[test]
1923 fn synthetic_edge_joins_event_observations() {
1924 let q = gql::parse("MATCH (ev)-[:observed_as_selected]->(m:memory) RETURN ev, m").unwrap();
1925 let compiled = compile(&q, &opts()).unwrap();
1926 assert!(
1927 compiled.sql.contains("event_observations"),
1928 "synthetic edge must join event_observations; sql: {}",
1929 compiled.sql
1930 );
1931 assert!(
1932 !compiled.sql.contains("graph_edges"),
1933 "synthetic edge must NOT join graph_edges; sql: {}",
1934 compiled.sql
1935 );
1936 let has_role_param = compiled
1937 .params
1938 .iter()
1939 .any(|p| matches!(p, QueryValue::Text(s) if s == "selected"));
1940 assert!(has_role_param, "role 'selected' must be a bound parameter");
1941 }
1942
1943 #[test]
1944 fn synthetic_edge_event_source_binds_events_table() {
1945 let q = gql::parse("MATCH (ev)-[:observed_as_selected]->(m:memory) RETURN ev, m").unwrap();
1946 let compiled = compile(&q, &opts()).unwrap();
1947 assert!(
1948 compiled.sql.contains("FROM events "),
1949 "CRIT-1: event source must come FROM events table, not entities; sql: {}",
1950 compiled.sql
1951 );
1952 assert!(
1953 !compiled
1954 .sql
1955 .starts_with("SELECT * FROM entities n0 JOIN event_observations"),
1956 "CRIT-1: must not join events via entities table; sql: {}",
1957 compiled.sql
1958 );
1959 }
1960
1961 #[test]
1962 fn synthetic_edge_event_observation_join_uses_events_id() {
1963 let q = gql::parse("MATCH (ev)-[:observed_as_selected]->(m) RETURN m").unwrap();
1964 let compiled = compile(&q, &opts()).unwrap();
1965 assert!(
1966 compiled
1967 .sql
1968 .contains("JOIN event_observations e0 ON e0.event_id = n0.id"),
1969 "CRIT-1: event_observations must join on events.id (n0 is now events); sql: {}",
1970 compiled.sql
1971 );
1972 }
1973
1974 #[test]
1975 fn synthetic_edge_event_node_projects_event_columns() {
1976 let q = gql::parse("MATCH (ev)-[:observed_as_selected]->(m) RETURN ev").unwrap();
1977 let compiled = compile(&q, &opts()).unwrap();
1978 assert!(
1979 compiled.sql.contains("ev_verb"),
1980 "CRIT-1: event variable must project verb column; sql: {}",
1981 compiled.sql
1982 );
1983 assert!(
1984 compiled.sql.contains("ev_outcome"),
1985 "CRIT-1: event variable must project outcome column; sql: {}",
1986 compiled.sql
1987 );
1988 assert!(
1989 !compiled.sql.contains("ev_name,") && !compiled.sql.contains("ev_name "),
1990 "CRIT-1: event variable must NOT project entity name column; sql: {}",
1991 compiled.sql
1992 );
1993 assert!(
1994 !compiled.sql.contains("ev_properties"),
1995 "CRIT-1: event variable must NOT project entity properties column; sql: {}",
1996 compiled.sql
1997 );
1998 }
1999
2000 #[test]
2001 fn synthetic_edge_namespace_filter_on_events_table() {
2002 let q = gql::parse("MATCH (ev)-[:observed_as_selected]->(m) RETURN m").unwrap();
2003 let compiled = compile(&q, &scoped("test-ns")).unwrap();
2004 let ns_count = compiled
2005 .params
2006 .iter()
2007 .filter(|p| matches!(p, QueryValue::Text(s) if s == "test-ns"))
2008 .count();
2009 assert!(
2010 ns_count >= 2,
2011 "MIN-2: namespace must be filtered on both events and target; params: {:?}",
2012 compiled.params
2013 );
2014 }
2015
2016 #[test]
2017 fn synthetic_edge_candidate_role() {
2018 let q = gql::parse("MATCH (ev)-[:observed_as_candidate]->(m) RETURN ev, m").unwrap();
2019 let compiled = compile(&q, &opts()).unwrap();
2020 assert!(
2021 compiled.sql.contains("event_observations"),
2022 "sql: {}",
2023 compiled.sql
2024 );
2025 let has_candidate = compiled
2026 .params
2027 .iter()
2028 .any(|p| matches!(p, QueryValue::Text(s) if s == "candidate"));
2029 assert!(has_candidate, "role 'candidate' must be bound");
2030 }
2031
2032 #[test]
2033 fn synthetic_edge_multi_role() {
2034 let q =
2035 gql::parse("MATCH (ev)-[:observed_as_candidate|observed_as_selected]->(m) RETURN m")
2036 .unwrap();
2037 let compiled = compile(&q, &opts()).unwrap();
2038 assert!(
2039 compiled.sql.contains("event_observations"),
2040 "sql: {}",
2041 compiled.sql
2042 );
2043 assert!(
2044 compiled.sql.contains("IN"),
2045 "multi-role must use IN; sql: {}",
2046 compiled.sql
2047 );
2048 }
2049
2050 #[test]
2051 fn mixed_synthetic_and_canonical_rejected() {
2052 let q = gql::parse("MATCH (ev)-[:observed_as_selected|extends]->(m) RETURN m").unwrap();
2053 let err = compile(&q, &opts()).unwrap_err();
2054 assert!(
2055 matches!(err, QueryError::Compile(_)),
2056 "mixed synthetic+canonical must be rejected; got {err:?}"
2057 );
2058 }
2059
2060 #[test]
2061 fn synthetic_edge_inbound_rejected() {
2062 let q = gql::parse("MATCH (m)<-[:observed_as_selected]-(ev) RETURN m").unwrap();
2063 let err = compile(&q, &opts()).unwrap_err();
2064 assert!(
2065 matches!(err, QueryError::Compile(_)),
2066 "inbound synthetic edge must be rejected; got {err:?}"
2067 );
2068 }
2069
2070 #[test]
2071 fn variable_length_or_across_endpoints_rejected() {
2072 let q = gql::parse(
2073 "MATCH (a)-[:extends*1..3]->(b) WHERE a.name = 'X' OR b.name = 'Y' RETURN a",
2074 )
2075 .unwrap();
2076 let result = compile(&q, &opts());
2077 assert!(
2078 matches!(result, Err(QueryError::Unsupported(_))),
2079 "MAJ-1: OR spanning both endpoints must return Unsupported; got {result:?}"
2080 );
2081 let err_msg = result.unwrap_err().to_string();
2082 assert!(
2083 err_msg.contains("separate queries") || err_msg.contains("one endpoint"),
2084 "error must be actionable; got: {err_msg}"
2085 );
2086 }
2087
2088 #[test]
2089 fn variable_length_or_single_endpoint_still_works() {
2090 let q = gql::parse(
2091 "MATCH (a)-[:extends*1..3]->(b) WHERE a.name = 'X' OR a.name = 'Y' RETURN a",
2092 )
2093 .unwrap();
2094 let result = compile(&q, &opts());
2095 assert!(
2096 result.is_ok(),
2097 "single-endpoint OR must compile; got {result:?}"
2098 );
2099 }
2100
2101 #[test]
2102 fn variable_length_and_across_endpoints_still_works() {
2103 let q = gql::parse(
2104 "MATCH (a)-[:extends*1..3]->(b) WHERE a.name = 'X' AND b.name = 'Y' RETURN a",
2105 )
2106 .unwrap();
2107 let result = compile(&q, &opts());
2108 assert!(
2109 result.is_ok(),
2110 "AND across endpoints must compile; got {result:?}"
2111 );
2112 }
2113
2114 #[test]
2115 fn test_variable_length_or_compiles_to_or() {
2116 let q = gql::parse(
2117 "MATCH (a)-[:extends*1..3]->(b) WHERE a.name = 'LoRA' OR a.name = 'QLoRA' RETURN b",
2118 )
2119 .unwrap();
2120 let compiled = compile(&q, &opts()).unwrap();
2121 assert!(
2122 compiled.sql.contains(" OR "),
2123 "#379: variable-length single-endpoint OR must produce SQL OR; sql: {}",
2124 compiled.sql
2125 );
2126 let has_lora = compiled
2127 .params
2128 .iter()
2129 .any(|p| matches!(p, QueryValue::Text(s) if s == "LoRA"));
2130 let has_qlora = compiled
2131 .params
2132 .iter()
2133 .any(|p| matches!(p, QueryValue::Text(s) if s == "QLoRA"));
2134 assert!(has_lora && has_qlora, "both OR values must be bound params");
2135 }
2136
2137 #[test]
2138 fn test_single_endpoint_or_at_depth_1() {
2139 let q = gql::parse(
2140 "MATCH (a)-[r:extends]->(b) WHERE r.weight > 0.5 OR r.relation = 'extends' RETURN a",
2141 )
2142 .unwrap();
2143 let compiled = compile(&q, &opts()).unwrap();
2144 assert!(
2145 compiled.sql.contains(" OR "),
2146 "#379: fixed-length single-endpoint OR must produce SQL OR; sql: {}",
2147 compiled.sql
2148 );
2149 let has_extends = compiled
2150 .params
2151 .iter()
2152 .any(|p| matches!(p, QueryValue::Text(s) if s == "extends"));
2153 assert!(
2154 has_extends,
2155 "relation value 'extends' must be a bound param"
2156 );
2157 }
2158
2159 #[test]
2160 fn test_and_still_works() {
2161 let q = gql::parse(
2162 "MATCH (a)-[:extends*1..3]->(b) WHERE a.name = 'LoRA' AND a.kind = 'concept' RETURN b",
2163 )
2164 .unwrap();
2165 let compiled = compile(&q, &opts()).unwrap();
2166 assert!(
2167 !compiled.sql.contains(" OR "),
2168 "#379: AND must not produce OR; sql: {}",
2169 compiled.sql
2170 );
2171 let has_lora = compiled
2172 .params
2173 .iter()
2174 .any(|p| matches!(p, QueryValue::Text(s) if s == "LoRA"));
2175 let has_concept = compiled
2176 .params
2177 .iter()
2178 .any(|p| matches!(p, QueryValue::Text(s) if s == "concept"));
2179 assert!(
2180 has_lora && has_concept,
2181 "both AND values must be bound params"
2182 );
2183 }
2184
2185 #[test]
2187 fn max_limit_overflow_returns_error() {
2188 let q = gql::parse("MATCH (a)-[:extends]->(b) RETURN a").unwrap();
2189 let opts = CompileOptions {
2190 scopes: vec![],
2191 max_limit: usize::MAX,
2192 };
2193 let result = compile(&q, &opts);
2194 match result {
2195 Err(QueryError::InvalidInput(_)) => {}
2196 Ok(compiled) => {
2197 let limit_param = compiled.params.last().unwrap();
2198 assert!(
2199 matches!(limit_param, QueryValue::Integer(n) if *n >= 0),
2200 "limit must never be negative; got {limit_param:?}"
2201 );
2202 }
2203 Err(e) => panic!("unexpected error: {e:?}"),
2204 }
2205 }
2206
2207 #[test]
2209 fn max_limit_zero_compiles() {
2210 let q = gql::parse("MATCH (a)-[:extends]->(b) RETURN a").unwrap();
2211 let opts = CompileOptions {
2212 scopes: vec![],
2213 max_limit: 0,
2214 };
2215 let compiled = compile(&q, &opts).unwrap();
2216 assert_eq!(
2217 compiled.truncation_check,
2218 Some(TruncationCheck {
2219 max_limit: 0,
2220 requested_limit: None,
2221 })
2222 );
2223 let limit_param = compiled.params.last().unwrap();
2224 assert!(
2225 matches!(limit_param, QueryValue::Integer(1)),
2226 "max_limit=0 should produce sentinel LIMIT 1; got {limit_param:?}"
2227 );
2228 }
2229
2230 #[test]
2231 fn variable_length_synthetic_edge_rejected() {
2232 let q = gql::parse("MATCH (ev)-[:observed_as_selected*1..3]->(m) RETURN m").unwrap();
2233 let err = compile(&q, &opts()).unwrap_err();
2234 assert!(
2235 matches!(err, QueryError::Unsupported(_)),
2236 "variable-length synthetic edge must return Unsupported; got {err:?}"
2237 );
2238 assert!(
2239 err.to_string().contains("synthetic") || err.to_string().contains("observed_as"),
2240 "error should mention synthetic edges: {err}"
2241 );
2242 }
2243
2244 #[test]
2246 fn variable_length_recursive_member_joins_next_node_for_deleted_filter() {
2247 let q = gql::parse("MATCH (a)-[:extends*1..3]->(b) RETURN b").unwrap();
2248 let compiled = compile(&q, &opts()).unwrap();
2249 assert!(
2250 compiled.sql.contains("JOIN primary_nodes next_node"),
2251 "recursive CTE must join primary_nodes next_node for deleted-intermediate filtering; sql: {}",
2252 compiled.sql
2253 );
2254 assert!(
2255 compiled.sql.contains("next_node.deleted_at IS NULL"),
2256 "recursive CTE must filter next_node.deleted_at IS NULL; sql: {}",
2257 compiled.sql
2258 );
2259 }
2260
2261 #[test]
2263 fn variable_length_recursive_member_namespace_scopes_intermediates() {
2264 let q = gql::parse("MATCH (a)-[:extends*1..3]->(b) RETURN b").unwrap();
2265 let compiled = compile(&q, &scoped("test-ns")).unwrap();
2266 assert!(
2267 compiled.sql.contains("next_node.namespace"),
2268 "recursive CTE next_node join must filter namespace; sql: {}",
2269 compiled.sql
2270 );
2271 }
2272
2273 #[test]
2275 fn compile_malformed_ast_returns_error_not_panic() {
2276 use crate::ast::{EdgeDirection, EdgePattern, GqlQuery, MatchPattern, PatternElement};
2277 let q = GqlQuery {
2278 pattern: MatchPattern {
2279 elements: vec![PatternElement::Edge(EdgePattern {
2280 variable: None,
2281 relations: vec!["extends".to_string()],
2282 direction: EdgeDirection::Out,
2283 min_hops: 1,
2284 max_hops: 1,
2285 })],
2286 },
2287 where_clause: WhereExpr::True,
2288 return_items: vec![],
2289 limit: None,
2290 };
2291 let result = compile(&q, &opts());
2292 assert!(
2293 result.is_err(),
2294 "malformed AST (starts with Edge) must return error, not panic"
2295 );
2296 }
2297
2298 #[test]
2300 fn edge_pattern_without_suffix_dash_rejected() {
2301 let result = gql::parse("MATCH (a)-[e:extends](b) RETURN a");
2302 assert!(
2303 result.is_err(),
2304 "edge pattern without suffix '-' must be rejected as a parse error"
2305 );
2306 }
2307
2308 #[test]
2309 fn assert_select_only_accepts_select_and_with() {
2310 assert!(
2311 assert_select_only("SELECT a FROM entities WHERE 1=1").is_ok(),
2312 "SELECT must be accepted"
2313 );
2314 assert!(
2315 assert_select_only("WITH RECURSIVE traverse AS (...) SELECT ...").is_ok(),
2316 "WITH must be accepted (recursive CTE)"
2317 );
2318 }
2319
2320 #[test]
2321 fn assert_select_only_rejects_write_sql_with_readonly_message() {
2322 for stmt in &[
2323 "INSERT INTO entities VALUES (?)",
2324 "UPDATE entities SET name = ?",
2325 "DELETE FROM entities WHERE id = ?",
2326 "DROP TABLE entities",
2327 ] {
2328 let err = assert_select_only(stmt).unwrap_err();
2329 assert!(
2330 matches!(err, QueryError::Compile(_)),
2331 "write SQL must return Compile error for '{stmt}'; got {err:?}"
2332 );
2333 let msg = err.to_string();
2334 assert!(
2335 msg.contains("read-only"),
2336 "error must mention 'read-only' for '{stmt}'; got: {msg}"
2337 );
2338 assert!(
2339 msg.contains("create") && msg.contains("delete"),
2340 "error must name the mutation verbs for '{stmt}'; got: {msg}"
2341 );
2342 }
2343 }
2344
2345 #[test]
2346 fn readonly_guard_does_not_break_valid_gql_compile() {
2347 let q = gql::parse("MATCH (a:concept)-[:extends]->(b) RETURN a LIMIT 10").unwrap();
2348 let compiled = compile(&q, &opts()).unwrap();
2349 assert!(
2350 compiled.sql.starts_with("SELECT"),
2351 "valid GQL must compile to SELECT; sql: {}",
2352 compiled.sql
2353 );
2354 }
2355
2356 #[test]
2357 fn readonly_guard_does_not_break_valid_cte_compile() {
2358 let q = gql::parse("MATCH (a)-[:extends*1..3]->(b) RETURN b LIMIT 10").unwrap();
2359 let compiled = compile(&q, &opts()).unwrap();
2360 assert!(
2361 compiled.sql.starts_with("WITH RECURSIVE"),
2362 "variable-length GQL must compile to WITH RECURSIVE; sql: {}",
2363 compiled.sql
2364 );
2365 }
2366
2367 #[test]
2368 fn gql_write_form_rejected_before_compile() {
2369 use crate::parsers::gql;
2370 let err = gql::parse("CREATE (n:concept) RETURN n").unwrap_err();
2371 assert!(
2372 matches!(err, QueryError::Unsupported(_)),
2373 "GQL CREATE must be Unsupported; got {err:?}"
2374 );
2375 assert!(
2376 err.to_string().contains("read-only"),
2377 "error must mention read-only; got: {err}"
2378 );
2379 }
2380
2381 #[test]
2382 fn sparql_write_form_rejected_before_compile() {
2383 use crate::parsers::sparql;
2384 let err = sparql::parse("INSERT DATA { ?a :extends ?b }").unwrap_err();
2385 assert!(
2386 matches!(err, QueryError::Unsupported(_)),
2387 "SPARQL INSERT must be Unsupported; got {err:?}"
2388 );
2389 assert!(
2390 err.to_string().contains("read-only"),
2391 "error must mention read-only; got: {err}"
2392 );
2393 }
2394
2395 #[test]
2396 fn duplicate_inline_property_rejected() {
2397 let result = gql::parse("MATCH (n {name: 'A', name: 'B'}) RETURN n");
2398 assert!(
2399 result.is_err(),
2400 "duplicate property 'name' in node props must be rejected"
2401 );
2402 let err = result.unwrap_err().to_string();
2403 assert!(
2404 err.contains("duplicate") || err.contains("name"),
2405 "error should mention duplicate or key name: {err}"
2406 );
2407 }
2408
2409 #[test]
2410 fn unknown_synthetic_relation_rejected_at_compile() {
2411 let q = gql::parse("MATCH (a)-[:observed_as_bogus]->(b) RETURN a").unwrap();
2412 let err = compile(&q, &opts()).unwrap_err();
2413 assert!(
2414 matches!(err, QueryError::Validation(_)),
2415 "unknown synthetic relation must return Validation error; got {err:?}"
2416 );
2417 }
2418
2419 #[test]
2421 fn canonical_edge_annotates_compiles_note_source_and_any_target_substrate() {
2422 let q = gql::parse("MATCH (n)-[:annotates]->(x) RETURN n.id, x.id LIMIT 10").unwrap();
2423 let compiled = compile(&q, &opts()).unwrap();
2424 assert!(
2425 !compiled.sql.contains("FROM entities n0"),
2426 "endpoint must not be hard-bound to entities only; sql: {}",
2427 compiled.sql
2428 );
2429 for table in ["FROM notes", "FROM events", "FROM graph_edges"] {
2430 assert!(
2431 compiled.sql.contains(table),
2432 "endpoint source must admit {table} rows for annotates; sql: {}",
2433 compiled.sql
2434 );
2435 }
2436 }
2437
2438 #[test]
2440 fn canonical_edge_supports_compiles_note_note_endpoints() {
2441 let q = gql::parse("MATCH (a)-[:supports]->(b) RETURN a.id, b.id LIMIT 10").unwrap();
2442 let compiled = compile(&q, &opts()).unwrap();
2443 assert_eq!(
2444 compiled.sql.matches("FROM notes").count(),
2445 2,
2446 "both supports endpoints must admit note rows; sql: {}",
2447 compiled.sql
2448 );
2449 }
2450
2451 #[test]
2453 fn canonical_edge_depends_on_compiles_pack_task_note_endpoints() {
2454 let q = gql::parse("MATCH (a:task)-[:depends_on]->(b:task) RETURN a.id, b.id LIMIT 10")
2455 .unwrap();
2456 let compiled = compile(&q, &opts()).unwrap();
2457 assert_eq!(
2458 compiled.sql.matches("FROM notes").count(),
2459 2,
2460 "task-kind depends_on endpoints must admit note rows; sql: {}",
2461 compiled.sql
2462 );
2463 let task_params = compiled
2464 .params
2465 .iter()
2466 .filter(|p| matches!(p, QueryValue::Text(s) if s == "task"))
2467 .count();
2468 assert_eq!(
2469 task_params, 2,
2470 "kind='task' must be bound for both endpoints"
2471 );
2472 }
2473
2474 #[test]
2476 fn variable_length_canonical_compiles_primary_substrate_nodes() {
2477 let q = gql::parse("MATCH (a)-[:supports*1..2]->(b) RETURN b.id LIMIT 10").unwrap();
2478 let compiled = compile(&q, &opts()).unwrap();
2479 assert!(
2480 !compiled.sql.contains("FROM entities s"),
2481 "seed must not be hard-bound to entities only; sql: {}",
2482 compiled.sql
2483 );
2484 assert!(
2485 compiled.sql.contains("JOIN primary_nodes next_node"),
2486 "intermediate must join primary_nodes; sql: {}",
2487 compiled.sql
2488 );
2489 assert!(
2490 compiled.sql.contains("JOIN primary_nodes r"),
2491 "final endpoint must join primary_nodes; sql: {}",
2492 compiled.sql
2493 );
2494 }
2495
2496 #[test]
2498 fn synthetic_edge_observed_as_target_compiles_entity_referents() {
2499 let q = gql::parse("MATCH (ev)-[:observed_as_target]->(t) RETURN t.id LIMIT 10").unwrap();
2500 let compiled = compile(&q, &opts()).unwrap();
2501 assert!(
2502 !compiled
2503 .sql
2504 .contains("JOIN notes n1 ON n1.id = e0.entity_id AND e0.referent_kind = 'note'"),
2505 "target join must not be hard-bound to notes; sql: {}",
2506 compiled.sql
2507 );
2508 assert!(
2509 compiled.sql.contains("FROM entities"),
2510 "observation target source must admit entity referents; sql: {}",
2511 compiled.sql
2512 );
2513 assert!(
2514 compiled.sql.contains("n1.referent_kind = e0.referent_kind"),
2515 "target join must discriminate by referent_kind instead of hard-coding 'note'; sql: {}",
2516 compiled.sql
2517 );
2518 }
2519
2520 #[test]
2522 fn synthetic_edge_observed_as_signal_compiles_entity_and_note_referents() {
2523 let q = gql::parse("MATCH (ev)-[:observed_as_signal]->(t) RETURN t.id LIMIT 10").unwrap();
2524 let compiled = compile(&q, &opts()).unwrap();
2525 assert!(
2526 compiled
2527 .sql
2528 .contains("e0.role = 'signal' AND e0.referent_kind IN ('entity', 'note')"),
2529 "signal role must be admitted against entity or note referents; sql: {}",
2530 compiled.sql
2531 );
2532 }
2533
2534 #[test]
2536 fn synthetic_edge_observed_as_selected_still_compiles_note_referents() {
2537 let q = gql::parse("MATCH (ev)-[:observed_as_selected]->(m:memory) RETURN m.id LIMIT 10")
2538 .unwrap();
2539 let compiled = compile(&q, &opts()).unwrap();
2540 assert!(
2541 compiled
2542 .sql
2543 .contains("e0.role IN ('candidate', 'selected') AND e0.referent_kind = 'note'"),
2544 "selected/candidate roles must still be guarded to note referents only; sql: {}",
2545 compiled.sql
2546 );
2547 }
2548}