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
29#[derive(Debug)]
31pub struct CompiledQuery {
32 pub sql: String,
33 pub params: Vec<QueryValue>,
34 pub return_vars: Vec<ReturnItem>,
35 pub warnings: Vec<String>,
36}
37
38pub struct CompileOptions {
40 pub scopes: Vec<String>,
42 pub max_limit: usize,
44}
45
46impl Default for CompileOptions {
47 fn default() -> Self {
48 Self {
49 scopes: Vec::new(),
50 max_limit: 500,
51 }
52 }
53}
54
55pub fn compile(query: &GqlQuery, opts: &CompileOptions) -> Result<CompiledQuery, QueryError> {
57 if query.pattern.elements.is_empty() {
58 return Err(QueryError::Compile("empty pattern".into()));
59 }
60
61 let mut query = query.clone();
63 let warnings = validate_with_warnings(&mut query)?;
64
65 let mut compiled = if query.pattern.has_variable_length() {
66 compile_variable_length(&query, opts)?
67 } else {
68 compile_fixed_length(&query, opts)?
69 };
70 compiled.warnings = warnings;
71
72 assert_select_only(&compiled.sql)?;
77
78 Ok(compiled)
79}
80
81fn assert_select_only(sql: &str) -> Result<(), QueryError> {
89 let first = sql.split_whitespace().next().unwrap_or("").to_uppercase();
90 if first == "SELECT" || first == "WITH" {
91 return Ok(());
92 }
93 Err(QueryError::Compile(
94 "the query verb is read-only; \
95 to mutate the graph use: create, update, link, merge, delete"
96 .into(),
97 ))
98}
99
100fn namespace_filter(alias: &str, opts: &CompileOptions, params: &mut Vec<QueryValue>) -> String {
101 if opts.scopes.is_empty() {
102 String::new()
103 } else if opts.scopes.len() == 1 {
104 params.push(QueryValue::Text(opts.scopes[0].clone()));
105 format!(" AND {alias}.namespace = ?{}", params.len())
106 } else {
107 let placeholders: Vec<String> = opts
108 .scopes
109 .iter()
110 .map(|s| {
111 params.push(QueryValue::Text(s.clone()));
112 format!("?{}", params.len())
113 })
114 .collect();
115 format!(" AND {alias}.namespace IN ({})", placeholders.join(", "))
116 }
117}
118
119fn synthetic_endpoint_node_indices(
121 elements: &[PatternElement],
122) -> (
123 std::collections::HashSet<usize>,
124 std::collections::HashSet<usize>,
125) {
126 let mut source_set = std::collections::HashSet::new();
127 let mut target_set = std::collections::HashSet::new();
128 let mut node_idx = 0usize;
129 let mut prev_node_idx: Option<usize> = None;
130 for element in elements {
131 match element {
132 PatternElement::Node(_) => {
133 prev_node_idx = Some(node_idx);
134 node_idx += 1;
135 }
136 PatternElement::Edge(ep) => {
137 let has_synthetic = ep.relations.iter().any(|r| is_synthetic(r));
138 if has_synthetic {
139 if let Some(src_idx) = prev_node_idx {
140 source_set.insert(src_idx);
141 target_set.insert(node_idx);
143 }
144 }
145 }
146 }
147 }
148 (source_set, target_set)
149}
150
151fn compile_fixed_length(
153 query: &GqlQuery,
154 opts: &CompileOptions,
155) -> Result<CompiledQuery, QueryError> {
156 let mut params: Vec<QueryValue> = Vec::new();
157 let mut from_parts: Vec<String> = Vec::new();
158 let mut join_parts: Vec<String> = Vec::new();
159 let mut where_parts: Vec<String> = Vec::new();
160 let mut select_parts: Vec<String> = Vec::new();
161
162 let mut node_aliases: Vec<String> = Vec::new();
163 let mut edge_aliases: Vec<String> = Vec::new();
164 let mut var_to_alias: std::collections::HashMap<String, (String, VarKind)> =
165 std::collections::HashMap::new();
166
167 let (event_source_indices, note_target_indices) =
170 synthetic_endpoint_node_indices(&query.pattern.elements);
171
172 let mut node_idx = 0usize;
173 let mut edge_idx = 0usize;
174
175 for element in &query.pattern.elements {
176 match element {
177 PatternElement::Node(np) => {
178 let alias = format!("n{node_idx}");
179 node_aliases.push(alias.clone());
180
181 let is_event_source = event_source_indices.contains(&node_idx);
182 let is_note_target = note_target_indices.contains(&node_idx);
183
184 if node_idx == 0 {
185 if is_event_source {
186 from_parts.push(format!("events {alias}"));
187 } else {
188 if !is_note_target {
190 from_parts.push(format!("entities {alias}"));
191 }
192 }
193 }
194
195 if is_event_source {
196 let ns_filter = namespace_filter(&alias, opts, &mut params);
199 if !ns_filter.is_empty() {
200 where_parts.push(ns_filter.trim_start_matches(" AND ").to_string());
201 }
202 if let Some(ref kind) = np.kind {
204 params.push(QueryValue::Text(kind.clone()));
205 where_parts.push(format!("{alias}.kind = ?{}", params.len()));
206 }
207 if np.entity_type.is_some() {
209 return Err(QueryError::Compile(
210 "event nodes do not have an entity_type column".into(),
211 ));
212 }
213 if !np.properties.is_empty() {
214 return Err(QueryError::Compile(
215 "event nodes do not support inline property filters; \
216 use a WHERE clause on verb, outcome, or payload fields"
217 .into(),
218 ));
219 }
220 } else if is_note_target {
221 where_parts.push(format!("{alias}.deleted_at IS NULL"));
223
224 let ns_filter = namespace_filter(&alias, opts, &mut params);
225 if !ns_filter.is_empty() {
226 where_parts.push(ns_filter.trim_start_matches(" AND ").to_string());
227 }
228
229 if let Some(ref kind) = np.kind {
230 params.push(QueryValue::Text(kind.clone()));
231 where_parts.push(format!("{alias}.kind = ?{}", params.len()));
232 }
233
234 if np.entity_type.is_some() {
236 return Err(QueryError::Compile(
237 "observed note targets do not have an entity_type column".into(),
238 ));
239 }
240
241 let mut props: Vec<_> = np.properties.iter().collect();
242 props.sort_by_key(|(k, _)| k.as_str());
243 for (key, val) in props {
244 params.push(QueryValue::Text(val.clone()));
245 if key == "name" || key == "content" {
246 where_parts
247 .push(format!("{alias}.{key} = ?{} COLLATE NOCASE", params.len()));
248 } else {
249 where_parts.push(format!(
250 "json_extract({alias}.properties, '$.{}') = ?{} COLLATE NOCASE",
251 key.replace('\'', "''"),
252 params.len()
253 ));
254 }
255 }
256 } else {
257 where_parts.push(format!("{alias}.deleted_at IS NULL"));
258
259 let ns_filter = namespace_filter(&alias, opts, &mut params);
260 if !ns_filter.is_empty() {
261 where_parts.push(ns_filter.trim_start_matches(" AND ").to_string());
262 }
263
264 if let Some(ref kind) = np.kind {
265 params.push(QueryValue::Text(kind.clone()));
266 where_parts.push(format!("{alias}.kind = ?{}", params.len()));
267 }
268
269 if let Some(ref et) = np.entity_type {
270 params.push(QueryValue::Text(et.clone()));
271 where_parts.push(format!("{alias}.entity_type = ?{}", params.len()));
272 }
273
274 let mut props: Vec<_> = np.properties.iter().collect();
275 props.sort_by_key(|(k, _)| k.as_str());
276 for (key, val) in props {
277 params.push(QueryValue::Text(val.clone()));
278 if key == "name" {
279 where_parts
280 .push(format!("{alias}.name = ?{} COLLATE NOCASE", params.len()));
281 } else {
282 where_parts.push(format!(
283 "json_extract({alias}.properties, '$.{}') = ?{} COLLATE NOCASE",
284 key.replace('\'', "''"),
285 params.len()
286 ));
287 }
288 }
289 }
290
291 if let Some(ref var) = np.variable {
292 let kind = if is_event_source {
293 VarKind::EventNode
294 } else if is_note_target {
295 VarKind::NoteNode
296 } else {
297 VarKind::Node
298 };
299 var_to_alias.insert(var.clone(), (alias.clone(), kind));
300 }
301
302 node_idx += 1;
303 }
304 PatternElement::Edge(ep) => {
305 let e_alias = format!("e{edge_idx}");
306 let prev_node = &node_aliases[node_aliases.len() - 1];
307 let next_alias = format!("n{}", node_idx);
308
309 edge_aliases.push(e_alias.clone());
310
311 let has_synthetic = ep.relations.iter().any(|r| is_synthetic(r));
316 let has_canonical = ep.relations.iter().any(|r| !is_synthetic(r));
317 if has_synthetic && has_canonical {
318 return Err(QueryError::Compile(
319 "cannot mix synthetic observed_as_* relations with canonical edge relations \
320 in a single edge pattern"
321 .into(),
322 ));
323 }
324
325 if has_synthetic {
326 if !matches!(ep.direction, EdgeDirection::Out) {
330 return Err(QueryError::Compile(
331 "synthetic observed_as_* edges are always event → entity (outbound only)".into(),
332 ));
333 }
334 join_parts.push(format!(
335 "JOIN event_observations {e_alias} ON {e_alias}.event_id = {prev_node}.id"
336 ));
337 let roles: Vec<&'static str> = ep
339 .relations
340 .iter()
341 .filter_map(|r| synthetic_role(r))
342 .collect();
343 if roles.len() == 1 {
344 params.push(QueryValue::Text(roles[0].to_string()));
345 where_parts.push(format!("{e_alias}.role = ?{}", params.len()));
346 } else if roles.len() > 1 {
347 let placeholders: Vec<String> = roles
348 .iter()
349 .map(|r| {
350 params.push(QueryValue::Text(r.to_string()));
351 format!("?{}", params.len())
352 })
353 .collect();
354 where_parts
355 .push(format!("{e_alias}.role IN ({})", placeholders.join(", ")));
356 }
357 join_parts.push(format!(
364 "JOIN notes {next_alias} ON {next_alias}.id = {e_alias}.entity_id \
365 AND {e_alias}.referent_kind = 'note'"
366 ));
367 } else {
368 let (source_join, target_join) = match ep.direction {
370 EdgeDirection::Out => (
371 format!("{e_alias}.source_id = {prev_node}.id"),
372 "target_id",
373 ),
374 EdgeDirection::In => (
375 format!("{e_alias}.target_id = {prev_node}.id"),
376 "source_id",
377 ),
378 EdgeDirection::Both => (
379 format!(
380 "({e_alias}.source_id = {prev_node}.id OR {e_alias}.target_id = {prev_node}.id)"
381 ),
382 "CASE_BOTH",
383 ),
384 };
385
386 let next_join_col = if target_join == "CASE_BOTH" {
387 format!(
388 "CASE WHEN {e_alias}.source_id = {prev_node}.id THEN {e_alias}.target_id ELSE {e_alias}.source_id END"
389 )
390 } else {
391 format!("{e_alias}.{target_join}")
392 };
393
394 join_parts.push(format!(
395 "JOIN graph_edges {e_alias} ON {source_join} AND {e_alias}.deleted_at IS NULL"
396 ));
397
398 let ens_filter = namespace_filter(&e_alias, opts, &mut params);
399 if !ens_filter.is_empty() {
400 where_parts.push(ens_filter.trim_start_matches(" AND ").to_string());
401 }
402
403 join_parts.push(format!(
404 "JOIN entities {next_alias} ON {next_alias}.id = {next_join_col}"
405 ));
406
407 if !ep.relations.is_empty() {
408 if ep.relations.len() == 1 {
409 params.push(QueryValue::Text(ep.relations[0].clone()));
410 where_parts.push(format!("{e_alias}.relation = ?{}", params.len()));
411 } else {
412 let placeholders: Vec<String> = ep
413 .relations
414 .iter()
415 .map(|r| {
416 params.push(QueryValue::Text(r.clone()));
417 format!("?{}", params.len())
418 })
419 .collect();
420 where_parts.push(format!(
421 "{e_alias}.relation IN ({})",
422 placeholders.join(", ")
423 ));
424 }
425 }
426 }
427
428 if let Some(ref var) = ep.variable {
429 var_to_alias.insert(var.clone(), (e_alias.clone(), VarKind::Edge));
430 }
431
432 edge_idx += 1;
433 }
434 }
435 }
436
437 if let Some(where_sql) = compile_where_expr(&query.where_clause, &var_to_alias, &mut params)? {
439 where_parts.push(where_sql);
440 }
441
442 for item in &query.return_items {
444 let var = item.variable();
445 if let Some((alias, kind)) = var_to_alias.get(var) {
446 match item {
447 ReturnItem::Property(_, prop) => {
448 let col = property_to_column(prop, kind)?;
449 select_parts.push(format!("{alias}.{col} AS {var}_{prop}"));
450 }
451 ReturnItem::Variable(_) => match kind {
452 VarKind::Node => {
453 select_parts.push(format!(
454 "{alias}.id AS {var}_id, {alias}.namespace AS {var}_namespace, \
455 {alias}.kind AS {var}_kind, {alias}.entity_type AS {var}_entity_type, \
456 {alias}.name AS {var}_name, \
457 {alias}.properties AS {var}_properties, \
458 {alias}.created_at AS {var}_created_at, \
459 {alias}.updated_at AS {var}_updated_at"
460 ));
461 }
462 VarKind::NoteNode => {
463 select_parts.push(format!(
464 "{alias}.id AS {var}_id, {alias}.namespace AS {var}_namespace, \
465 {alias}.kind AS {var}_kind, {alias}.status AS {var}_status, \
466 {alias}.content AS {var}_content, \
467 {alias}.salience AS {var}_salience, \
468 {alias}.properties AS {var}_properties, \
469 {alias}.created_at AS {var}_created_at, \
470 {alias}.updated_at AS {var}_updated_at"
471 ));
472 }
473 VarKind::EventNode => {
474 select_parts.push(format!(
475 "{alias}.id AS {var}_id, {alias}.namespace AS {var}_namespace, \
476 {alias}.verb AS {var}_verb, {alias}.substrate AS {var}_substrate, \
477 {alias}.actor AS {var}_actor, {alias}.kind AS {var}_kind, \
478 {alias}.outcome AS {var}_outcome, \
479 {alias}.payload AS {var}_payload, \
480 {alias}.created_at AS {var}_created_at"
481 ));
482 }
483 VarKind::Edge => {
484 select_parts.push(format!(
485 "{alias}.id AS {var}_id, {alias}.source_id AS {var}_source, \
486 {alias}.target_id AS {var}_target, \
487 {alias}.relation AS {var}_relation, \
488 {alias}.weight AS {var}_weight"
489 ));
490 }
491 },
492 }
493 } else {
494 return Err(QueryError::Compile(format!(
495 "unknown variable '{var}' in RETURN clause"
496 )));
497 }
498 }
499
500 let limit = query.limit.unwrap_or(opts.max_limit).min(opts.max_limit);
501 let limit_i64 = i64::try_from(limit)
502 .map_err(|_| QueryError::InvalidInput("limit exceeds i64::MAX".into()))?;
503 params.push(QueryValue::Integer(limit_i64));
504
505 let sql = format!(
506 "SELECT {} FROM {} {} WHERE {} LIMIT ?{}",
507 select_parts.join(", "),
508 from_parts.join(", "),
509 join_parts.join(" "),
510 where_parts.join(" AND "),
511 params.len(),
512 );
513
514 Ok(CompiledQuery {
515 sql,
516 params,
517 return_vars: query.return_items.clone(),
518 warnings: Vec::new(),
519 })
520}
521
522fn compile_where_expr(
524 expr: &WhereExpr,
525 var_to_alias: &std::collections::HashMap<String, (String, VarKind)>,
526 params: &mut Vec<QueryValue>,
527) -> Result<Option<String>, QueryError> {
528 match expr {
529 WhereExpr::True => Ok(None),
530 WhereExpr::Condition(cond) => {
531 let sql = compile_single_condition(cond, var_to_alias, params)?;
532 Ok(Some(sql))
533 }
534 WhereExpr::And(l, r) => {
535 let ls = compile_where_expr(l, var_to_alias, params)?;
536 let rs = compile_where_expr(r, var_to_alias, params)?;
537 Ok(match (ls, rs) {
538 (None, None) => None,
539 (Some(s), None) | (None, Some(s)) => Some(s),
540 (Some(l), Some(r)) => Some(format!("{l} AND {r}")),
541 })
542 }
543 WhereExpr::Or(l, r) => {
544 let ls = compile_where_expr(l, var_to_alias, params)?;
545 let rs = compile_where_expr(r, var_to_alias, params)?;
546 Ok(match (ls, rs) {
547 (None, None) => None,
548 (Some(s), None) | (None, Some(s)) => Some(s),
549 (Some(l), Some(r)) => Some(format!("({l} OR {r})")),
550 })
551 }
552 }
553}
554
555fn compile_single_condition(
556 cond: &Condition,
557 var_to_alias: &std::collections::HashMap<String, (String, VarKind)>,
558 params: &mut Vec<QueryValue>,
559) -> Result<String, QueryError> {
560 let (alias, kind) = var_to_alias.get(&cond.variable).ok_or_else(|| {
561 QueryError::Compile(format!(
562 "unknown variable '{}' in WHERE clause",
563 cond.variable
564 ))
565 })?;
566
567 let col_expr = match kind {
568 VarKind::Node => {
569 if cond.property == "name"
570 || cond.property == "kind"
571 || cond.property == "entity_type"
572 || cond.property == "namespace"
573 {
574 format!("{alias}.{}", cond.property)
575 } else {
576 format!(
577 "json_extract({alias}.properties, '$.{}')",
578 cond.property.replace('\'', "''")
579 )
580 }
581 }
582 VarKind::NoteNode => {
583 if NOTE_COLUMNS.contains(&cond.property.as_str()) {
584 format!("{alias}.{}", cond.property)
585 } else {
586 format!(
587 "json_extract({alias}.properties, '$.{}')",
588 cond.property.replace('\'', "''")
589 )
590 }
591 }
592 VarKind::EventNode => {
593 if EVENT_COLUMNS.contains(&cond.property.as_str()) {
595 format!("{alias}.{}", cond.property)
596 } else {
597 return Err(QueryError::Validation(format!(
598 "event property '{}' not queryable; valid columns: {}",
599 cond.property,
600 EVENT_COLUMNS.join(", ")
601 )));
602 }
603 }
604 VarKind::Edge => match cond.property.as_str() {
605 "relation" | "weight" => format!("{alias}.{}", cond.property),
606 other => {
607 return Err(QueryError::Validation(format!(
608 "edge property '{other}' not queryable; use 'relation' or 'weight'"
609 )))
610 }
611 },
612 };
613
614 let op_str = match cond.op {
615 CompareOp::Eq => "=",
616 CompareOp::Neq => "!=",
617 CompareOp::Gt => ">",
618 CompareOp::Lt => "<",
619 CompareOp::Gte => ">=",
620 CompareOp::Lte => "<=",
621 CompareOp::Like => "LIKE",
622 };
623
624 let sql = match &cond.value {
625 ConditionValue::String(s) => {
626 params.push(QueryValue::Text(s.clone()));
627 let collate = if matches!(cond.op, CompareOp::Eq | CompareOp::Like) {
628 " COLLATE NOCASE"
629 } else {
630 ""
631 };
632 format!("{col_expr} {op_str} ?{}{}", params.len(), collate)
633 }
634 ConditionValue::Number(n) => {
635 if !n.is_finite() {
636 return Err(QueryError::InvalidInput(
637 "non-finite float (NaN or Infinity) is not a valid query parameter".into(),
638 ));
639 }
640 params.push(QueryValue::Float(*n));
641 format!("{col_expr} {op_str} ?{}", params.len())
642 }
643 ConditionValue::Bool(b) => {
644 params.push(QueryValue::Integer(if *b { 1 } else { 0 }));
645 format!("{col_expr} {op_str} ?{}", params.len())
646 }
647 };
648 Ok(sql)
649}
650
651fn expr_endpoint_set(
652 expr: &WhereExpr,
653 start_var: Option<&str>,
654 end_var: Option<&str>,
655) -> (bool, bool) {
656 match expr {
657 WhereExpr::True => (false, false),
658 WhereExpr::Condition(c) => {
659 let is_start = start_var == Some(c.variable.as_str());
660 let is_end = end_var == Some(c.variable.as_str());
661 (is_start, is_end)
662 }
663 WhereExpr::And(l, r) | WhereExpr::Or(l, r) => {
664 let (ls, le) = expr_endpoint_set(l, start_var, end_var);
665 let (rs, re) = expr_endpoint_set(r, start_var, end_var);
666 (ls || rs, le || re)
667 }
668 }
669}
670
671fn reject_or_spanning_endpoints(
673 expr: &WhereExpr,
674 start: &NodePattern,
675 end: &NodePattern,
676) -> Result<(), QueryError> {
677 let start_var = start.variable.as_deref();
678 let end_var = end.variable.as_deref();
679 reject_or_spanning_impl(expr, start_var, end_var)
680}
681
682fn reject_or_spanning_impl(
683 expr: &WhereExpr,
684 start_var: Option<&str>,
685 end_var: Option<&str>,
686) -> Result<(), QueryError> {
687 match expr {
688 WhereExpr::True | WhereExpr::Condition(_) => Ok(()),
689 WhereExpr::And(l, r) => {
690 reject_or_spanning_impl(l, start_var, end_var)?;
691 reject_or_spanning_impl(r, start_var, end_var)
692 }
693 WhereExpr::Or(l, r) => {
694 let (l_start, l_end) = expr_endpoint_set(l, start_var, end_var);
695 let (r_start, r_end) = expr_endpoint_set(r, start_var, end_var);
696 let spans_start = l_start || r_start;
697 let spans_end = l_end || r_end;
698 if spans_start && spans_end {
699 return Err(QueryError::Unsupported(
700 "WHERE clauses that span both endpoints in a variable-length pattern \
701 are not yet supported; rewrite as separate queries or restrict each \
702 OR branch to one endpoint"
703 .into(),
704 ));
705 }
706 reject_or_spanning_impl(l, start_var, end_var)?;
708 reject_or_spanning_impl(r, start_var, end_var)
709 }
710 }
711}
712
713fn compile_var_len_condition(
714 cond: &Condition,
715 start_var: Option<&str>,
716 end_var: Option<&str>,
717 params: &mut Vec<QueryValue>,
718) -> Result<(String, &'static str), QueryError> {
719 let col_alias = if start_var == Some(cond.variable.as_str()) {
720 "s"
721 } else if end_var == Some(cond.variable.as_str()) {
722 "r"
723 } else {
724 return Err(QueryError::Compile(format!(
725 "variable '{}' in WHERE not supported in variable-length pattern \
726 (only start/end node variables)",
727 cond.variable
728 )));
729 };
730
731 let col_expr =
732 if cond.property == "name" || cond.property == "kind" || cond.property == "entity_type" {
733 format!("{col_alias}.{}", cond.property)
734 } else {
735 format!(
736 "json_extract({col_alias}.properties, '$.{}')",
737 cond.property.replace('\'', "''")
738 )
739 };
740
741 let op_str = match cond.op {
742 CompareOp::Eq => "=",
743 CompareOp::Neq => "!=",
744 CompareOp::Gt => ">",
745 CompareOp::Lt => "<",
746 CompareOp::Gte => ">=",
747 CompareOp::Lte => "<=",
748 CompareOp::Like => "LIKE",
749 };
750
751 let sql = match &cond.value {
752 ConditionValue::String(s) => {
753 params.push(QueryValue::Text(s.clone()));
754 let collate = if matches!(cond.op, CompareOp::Eq | CompareOp::Like) {
755 " COLLATE NOCASE"
756 } else {
757 ""
758 };
759 format!("{col_expr} {op_str} ?{}{collate}", params.len())
760 }
761 ConditionValue::Number(n) => {
762 if !n.is_finite() {
763 return Err(QueryError::InvalidInput(
764 "non-finite float (NaN or Infinity) is not a valid query parameter".into(),
765 ));
766 }
767 params.push(QueryValue::Float(*n));
768 format!("{col_expr} {op_str} ?{}", params.len())
769 }
770 ConditionValue::Bool(b) => {
771 params.push(QueryValue::Integer(if *b { 1 } else { 0 }));
772 format!("{col_expr} {op_str} ?{}", params.len())
773 }
774 };
775 Ok((sql, col_alias))
776}
777
778fn compile_variable_length_where(
780 expr: &WhereExpr,
781 start_var: Option<&str>,
782 end_var: Option<&str>,
783 params: &mut Vec<QueryValue>,
784 start_conditions: &mut Vec<String>,
785 end_conditions: &mut Vec<String>,
786) -> Result<Option<String>, QueryError> {
787 match expr {
788 WhereExpr::True => Ok(None),
789 WhereExpr::Condition(cond) => {
790 let (sql, alias) = compile_var_len_condition(cond, start_var, end_var, params)?;
791 if alias == "s" {
792 start_conditions.push(sql);
793 } else {
794 end_conditions.push(sql);
795 }
796 Ok(None)
797 }
798 WhereExpr::And(l, r) => {
799 compile_variable_length_where(
800 l,
801 start_var,
802 end_var,
803 params,
804 start_conditions,
805 end_conditions,
806 )?;
807 compile_variable_length_where(
808 r,
809 start_var,
810 end_var,
811 params,
812 start_conditions,
813 end_conditions,
814 )?;
815 Ok(None)
816 }
817 WhereExpr::Or(l, r) => {
818 let l_sql = compile_variable_length_where_to_sql(l, start_var, end_var, params)?;
822 let r_sql = compile_variable_length_where_to_sql(r, start_var, end_var, params)?;
823 match (l_sql, r_sql) {
824 (None, None) => {}
825 (Some((ls, la)), None) => {
826 if la == "s" {
827 start_conditions.push(ls);
828 } else {
829 end_conditions.push(ls);
830 }
831 }
832 (None, Some((rs, ra))) => {
833 if ra == "s" {
834 start_conditions.push(rs);
835 } else {
836 end_conditions.push(rs);
837 }
838 }
839 (Some((ls, la)), Some((rs, _ra))) => {
840 let combined = format!("({ls} OR {rs})");
842 if la == "s" {
843 start_conditions.push(combined);
844 } else {
845 end_conditions.push(combined);
846 }
847 }
848 }
849 Ok(None)
850 }
851 }
852}
853
854fn compile_variable_length_where_to_sql(
856 expr: &WhereExpr,
857 start_var: Option<&str>,
858 end_var: Option<&str>,
859 params: &mut Vec<QueryValue>,
860) -> Result<Option<(String, &'static str)>, QueryError> {
861 match expr {
862 WhereExpr::True => Ok(None),
863 WhereExpr::Condition(cond) => {
864 let (sql, alias) = compile_var_len_condition(cond, start_var, end_var, params)?;
865 Ok(Some((sql, alias)))
866 }
867 WhereExpr::And(l, r) => {
868 let ls = compile_variable_length_where_to_sql(l, start_var, end_var, params)?;
869 let rs = compile_variable_length_where_to_sql(r, start_var, end_var, params)?;
870 Ok(match (ls, rs) {
871 (None, None) => None,
872 (Some(s), None) | (None, Some(s)) => Some(s),
873 (Some((lsql, la)), Some((rsql, _))) => Some((format!("{lsql} AND {rsql}"), la)),
874 })
875 }
876 WhereExpr::Or(l, r) => {
877 let ls = compile_variable_length_where_to_sql(l, start_var, end_var, params)?;
878 let rs = compile_variable_length_where_to_sql(r, start_var, end_var, params)?;
879 Ok(match (ls, rs) {
880 (None, None) => None,
881 (Some(s), None) | (None, Some(s)) => Some(s),
882 (Some((lsql, la)), Some((rsql, _))) => Some((format!("({lsql} OR {rsql})"), la)),
883 })
884 }
885 }
886}
887
888fn compile_variable_length(
890 query: &GqlQuery,
891 opts: &CompileOptions,
892) -> Result<CompiledQuery, QueryError> {
893 let mut params: Vec<QueryValue> = Vec::new();
894 let mut var_to_alias: std::collections::HashMap<String, (String, VarKind)> =
895 std::collections::HashMap::new();
896
897 let nodes: Vec<&NodePattern> = query.pattern.nodes().collect();
901 let edges: Vec<&EdgePattern> = query.pattern.edges().collect();
902
903 if nodes.len() != 2 || edges.len() != 1 || query.pattern.elements.len() != 3 {
904 return Err(QueryError::Unsupported(
905 "variable-length patterns must be a single start_node -[*N..M]-> end_node \
906 (mixed fixed/variable chains are not yet implemented)"
907 .into(),
908 ));
909 }
910
911 let start = &nodes[0];
912 let edge = &edges[0];
913 let end = &nodes[1];
914
915 if edge.relations.iter().any(|r| is_synthetic(r)) {
920 return Err(QueryError::Unsupported(
921 "synthetic observed_as_* edges cannot be variable-length; \
922 use a fixed-length edge pattern instead"
923 .into(),
924 ));
925 }
926
927 let max_depth = edge.max_hops.min(MAX_DEPTH);
929 let min_depth = edge.min_hops;
930
931 let mut start_conditions: Vec<String> = vec!["s.deleted_at IS NULL".to_string()];
933 let ns_filter = namespace_filter("s", opts, &mut params);
934 if !ns_filter.is_empty() {
935 start_conditions.push(ns_filter.trim_start_matches(" AND ").to_string());
936 }
937
938 if let Some(ref kind) = start.kind {
939 params.push(QueryValue::Text(kind.clone()));
940 start_conditions.push(format!("s.kind = ?{}", params.len()));
941 }
942 if let Some(ref et) = start.entity_type {
943 params.push(QueryValue::Text(et.clone()));
944 start_conditions.push(format!("s.entity_type = ?{}", params.len()));
945 }
946 let mut start_props: Vec<_> = start.properties.iter().collect();
947 start_props.sort_by_key(|(k, _)| k.as_str());
948 for (key, val) in start_props {
949 params.push(QueryValue::Text(val.clone()));
950 if key == "name" {
951 start_conditions.push(format!("s.name = ?{} COLLATE NOCASE", params.len()));
952 } else {
953 start_conditions.push(format!(
954 "json_extract(s.properties, '$.{}') = ?{} COLLATE NOCASE",
955 key.replace('\'', "''"),
956 params.len()
957 ));
958 }
959 }
960
961 let mut relation_condition = String::new();
963 if !edge.relations.is_empty() {
964 if edge.relations.len() == 1 {
965 params.push(QueryValue::Text(edge.relations[0].clone()));
966 relation_condition = format!(" AND e.relation = ?{}", params.len());
967 } else {
968 let placeholders: Vec<String> = edge
969 .relations
970 .iter()
971 .map(|r| {
972 params.push(QueryValue::Text(r.clone()));
973 format!("?{}", params.len())
974 })
975 .collect();
976 relation_condition = format!(" AND e.relation IN ({})", placeholders.join(", "));
977 }
978 }
979
980 let e_ns_filter = namespace_filter("e", opts, &mut params);
982
983 let (seed_join, seed_next, recurse_join, recurse_next) = match edge.direction {
985 EdgeDirection::Out => (
986 "e.source_id = s.id",
987 "e.target_id",
988 "e.source_id = t.current_id",
989 "e.target_id",
990 ),
991 EdgeDirection::In => (
992 "e.target_id = s.id",
993 "e.source_id",
994 "e.target_id = t.current_id",
995 "e.source_id",
996 ),
997 EdgeDirection::Both => (
998 "(e.source_id = s.id OR e.target_id = s.id)",
999 "CASE WHEN e.source_id = s.id THEN e.target_id ELSE e.source_id END",
1000 "(e.source_id = t.current_id OR e.target_id = t.current_id)",
1001 "CASE WHEN e.source_id = t.current_id THEN e.target_id ELSE e.source_id END",
1002 ),
1003 };
1004
1005 let next_node_ns_filter = namespace_filter("next_node", opts, &mut params);
1010
1011 let max_depth_i64 = i64::try_from(max_depth)
1012 .map_err(|_| QueryError::InvalidInput("max_depth exceeds i64::MAX".into()))?;
1013 params.push(QueryValue::Integer(max_depth_i64));
1014 let depth_param = params.len();
1015
1016 let mut end_conditions: Vec<String> = vec!["r.deleted_at IS NULL".to_string()];
1020 let r_ns_filter = namespace_filter("r", opts, &mut params);
1021 if !r_ns_filter.is_empty() {
1022 end_conditions.push(r_ns_filter.trim_start_matches(" AND ").to_string());
1023 }
1024 if let Some(ref kind) = end.kind {
1025 params.push(QueryValue::Text(kind.clone()));
1026 end_conditions.push(format!("r.kind = ?{}", params.len()));
1027 }
1028 if let Some(ref et) = end.entity_type {
1029 params.push(QueryValue::Text(et.clone()));
1030 end_conditions.push(format!("r.entity_type = ?{}", params.len()));
1031 }
1032 let mut end_props: Vec<_> = end.properties.iter().collect();
1033 end_props.sort_by_key(|(k, _)| k.as_str());
1034 for (key, val) in end_props {
1035 params.push(QueryValue::Text(val.clone()));
1036 if key == "name" {
1037 end_conditions.push(format!("r.name = ?{} COLLATE NOCASE", params.len()));
1038 } else {
1039 end_conditions.push(format!(
1040 "json_extract(r.properties, '$.{}') = ?{} COLLATE NOCASE",
1041 key.replace('\'', "''"),
1042 params.len()
1043 ));
1044 }
1045 }
1046
1047 reject_or_spanning_endpoints(&query.where_clause, start, end)?;
1051
1052 if let Some(where_sql) = compile_variable_length_where(
1056 &query.where_clause,
1057 start.variable.as_deref(),
1058 end.variable.as_deref(),
1059 &mut params,
1060 &mut start_conditions,
1061 &mut end_conditions,
1062 )? {
1063 start_conditions.push(where_sql);
1067 }
1068
1069 if min_depth > 0 {
1071 let min_depth_i64 = i64::try_from(min_depth)
1072 .map_err(|_| QueryError::InvalidInput("min_depth exceeds i64::MAX".into()))?;
1073 params.push(QueryValue::Integer(min_depth_i64));
1074 end_conditions.push(format!("t.depth >= ?{}", params.len()));
1075 }
1076
1077 let limit = query.limit.unwrap_or(opts.max_limit).min(opts.max_limit);
1078 let limit_i64 = i64::try_from(limit)
1079 .map_err(|_| QueryError::InvalidInput("limit exceeds i64::MAX".into()))?;
1080 params.push(QueryValue::Integer(limit_i64));
1081 let limit_param = params.len();
1082
1083 if let Some(ref var) = start.variable {
1085 var_to_alias.insert(var.clone(), ("s".to_string(), VarKind::Node));
1086 }
1087 if let Some(ref var) = end.variable {
1088 var_to_alias.insert(var.clone(), ("r".to_string(), VarKind::Node));
1089 }
1090 if let Some(ref var) = edge.variable {
1091 var_to_alias.insert(var.clone(), ("e".to_string(), VarKind::Edge));
1092 }
1093
1094 let mut select_parts: Vec<String> = Vec::new();
1096 let mut has_start = false;
1097
1098 for item in &query.return_items {
1099 let var = item.variable();
1100 if let Some((_, kind)) = var_to_alias.get(var) {
1101 match item {
1102 ReturnItem::Property(_, prop) => {
1103 let is_start = start.variable.as_deref() == Some(var);
1104 if matches!(kind, VarKind::EventNode | VarKind::NoteNode) {
1105 return Err(QueryError::Unsupported(
1106 "synthetic observed_as_* edges cannot be used in variable-length \
1107 patterns; use a fixed-length edge pattern instead"
1108 .into(),
1109 ));
1110 }
1111 if *kind == VarKind::Node {
1112 let tbl = if is_start { "s" } else { "r" };
1113 if is_start {
1114 has_start = true;
1115 }
1116 let col = property_to_column(prop, kind)?;
1117 select_parts.push(format!("{tbl}.{col} AS {var}_{prop}"));
1118 } else {
1119 let col = match prop.as_str() {
1120 "id" => "via_edge",
1121 "relation" => "via_relation",
1122 "weight" => "via_weight",
1123 _ => {
1124 return Err(QueryError::Compile(format!(
1125 "unknown edge property '{prop}' in RETURN projection. \
1126 Valid: id, source_id, target_id, relation, weight"
1127 )));
1128 }
1129 };
1130 select_parts.push(format!("t.{col} AS {var}_{prop}"));
1131 }
1132 }
1133 ReturnItem::Variable(_) => match kind {
1134 VarKind::Node => {
1135 if start.variable.as_deref() == Some(var) {
1136 has_start = true;
1137 select_parts.push(format!(
1138 "s.id AS {var}_id, s.namespace AS {var}_namespace, \
1139 s.kind AS {var}_kind, s.entity_type AS {var}_entity_type, \
1140 s.name AS {var}_name, \
1141 s.properties AS {var}_properties, \
1142 s.created_at AS {var}_created_at, \
1143 s.updated_at AS {var}_updated_at"
1144 ));
1145 } else {
1146 select_parts.push(format!(
1147 "r.id AS {var}_id, r.namespace AS {var}_namespace, \
1148 r.kind AS {var}_kind, r.entity_type AS {var}_entity_type, \
1149 r.name AS {var}_name, \
1150 r.properties AS {var}_properties, \
1151 r.created_at AS {var}_created_at, \
1152 r.updated_at AS {var}_updated_at"
1153 ));
1154 }
1155 }
1156 VarKind::EventNode | VarKind::NoteNode => {
1157 return Err(QueryError::Unsupported(
1160 "synthetic observed_as_* edges cannot be used in variable-length \
1161 patterns; use a fixed-length edge pattern instead"
1162 .into(),
1163 ));
1164 }
1165 VarKind::Edge => {
1166 select_parts.push(format!(
1167 "t.via_edge AS {var}_id, t.via_relation AS {var}_relation, \
1168 t.via_weight AS {var}_weight"
1169 ));
1170 }
1171 },
1172 }
1173 } else {
1174 return Err(QueryError::Compile(format!(
1175 "unknown variable '{var}' in RETURN clause"
1176 )));
1177 }
1178 }
1179
1180 select_parts.push("t.depth AS _depth".to_string());
1182 select_parts.push("t.total_weight AS _total_weight".to_string());
1183
1184 let join_start = if has_start {
1189 "JOIN entities s ON s.id = t.start_id"
1190 } else {
1191 ""
1192 };
1193 let join_end = "JOIN entities r ON r.id = t.current_id";
1194
1195 let next_node_ns_and = if next_node_ns_filter.is_empty() {
1198 String::new()
1199 } else {
1200 format!(" AND {}", next_node_ns_filter.trim_start_matches(" AND "))
1201 };
1202
1203 let sql = format!(
1204 "WITH RECURSIVE traverse(start_id, current_id, depth, path, total_weight, via_edge, via_relation, via_weight) AS (\
1205 SELECT s.id, {seed_next}, 1, s.id || ',' || {seed_next}, e.weight, \
1206 e.id, e.relation, e.weight \
1207 FROM entities s \
1208 JOIN graph_edges e ON {seed_join} AND e.deleted_at IS NULL{e_ns_filter}{relation_condition} \
1209 WHERE {start_where} \
1210 UNION ALL \
1211 SELECT t.start_id, {recurse_next}, t.depth + 1, \
1212 t.path || ',' || {recurse_next}, \
1213 t.total_weight + e.weight, \
1214 e.id, e.relation, e.weight \
1215 FROM traverse t CROSS JOIN graph_edges e \
1216 ON {recurse_join} AND e.deleted_at IS NULL{e_ns_filter}{relation_condition} \
1217 JOIN entities next_node ON next_node.id = ({recurse_next}) \
1218 AND next_node.deleted_at IS NULL{next_node_ns_and} \
1219 WHERE t.depth < ?{depth_param} \
1220 AND (',' || t.path || ',') NOT LIKE '%,' || {recurse_next} || ',%' \
1221 ) \
1222 SELECT DISTINCT {select_cols} \
1223 FROM traverse t \
1224 {join_start} {join_end} \
1225 WHERE {end_where} \
1226 ORDER BY t.depth, t.total_weight DESC, t.start_id, t.current_id \
1227 LIMIT ?{limit_param}",
1228 seed_next = seed_next,
1229 seed_join = seed_join,
1230 e_ns_filter = e_ns_filter,
1231 relation_condition = relation_condition,
1232 start_where = start_conditions.join(" AND "),
1233 recurse_next = recurse_next,
1234 recurse_join = recurse_join,
1235 next_node_ns_and = next_node_ns_and,
1236 depth_param = depth_param,
1237 select_cols = select_parts.join(", "),
1238 join_start = join_start,
1239 join_end = join_end,
1240 end_where = end_conditions.join(" AND "),
1241 limit_param = limit_param,
1242 );
1243
1244 Ok(CompiledQuery {
1245 sql,
1246 params,
1247 return_vars: query.return_items.clone(),
1248 warnings: Vec::new(),
1249 })
1250}
1251
1252#[derive(Clone, Copy, PartialEq, Eq)]
1253enum VarKind {
1254 Node,
1255 EventNode,
1257 NoteNode,
1259 Edge,
1260}
1261
1262const NODE_COLUMNS: &[&str] = &[
1263 "id",
1264 "name",
1265 "kind",
1266 "entity_type",
1267 "namespace",
1268 "description",
1269 "properties",
1270 "created_at",
1271 "updated_at",
1272];
1273const NOTE_COLUMNS: &[&str] = &[
1275 "id",
1276 "namespace",
1277 "kind",
1278 "status",
1279 "name",
1280 "content",
1281 "salience",
1282 "decay_factor",
1283 "properties",
1284 "created_at",
1285 "updated_at",
1286];
1287const EVENT_COLUMNS: &[&str] = &[
1289 "id",
1290 "namespace",
1291 "verb",
1292 "substrate",
1293 "actor",
1294 "kind",
1295 "outcome",
1296 "payload",
1297 "duration_us",
1298 "target_id",
1299 "session_id",
1300 "created_at",
1301];
1302const EDGE_COLUMNS: &[&str] = &["id", "source_id", "target_id", "relation", "weight"];
1303
1304fn property_to_column<'a>(prop: &'a str, kind: &VarKind) -> Result<&'a str, QueryError> {
1305 let (valid, kind_name) = match kind {
1306 VarKind::Node => (NODE_COLUMNS, "node"),
1307 VarKind::NoteNode => (NOTE_COLUMNS, "note"),
1308 VarKind::EventNode => (EVENT_COLUMNS, "event"),
1309 VarKind::Edge => (EDGE_COLUMNS, "edge"),
1310 };
1311 if valid.contains(&prop) {
1312 Ok(prop)
1313 } else {
1314 Err(QueryError::Compile(format!(
1315 "unknown {kind_name} property '{prop}' in RETURN projection. \
1316 Valid: {}",
1317 valid.join(", ")
1318 )))
1319 }
1320}
1321
1322#[cfg(test)]
1327mod tests {
1328 use super::*;
1329 use crate::parsers::gql;
1330
1331 fn opts() -> CompileOptions {
1332 CompileOptions::default()
1333 }
1334
1335 fn scoped(namespace: &str) -> CompileOptions {
1336 CompileOptions {
1337 scopes: vec![namespace.to_string()],
1338 max_limit: 500,
1339 }
1340 }
1341
1342 #[test]
1343 fn fixed_length_basic() {
1344 let q =
1345 gql::parse("MATCH (a:concept)-[e:introduced_by]->(b:paper) RETURN a, e, b LIMIT 10")
1346 .unwrap();
1347 let compiled = compile(&q, &opts()).unwrap();
1348 assert!(compiled.sql.contains("JOIN graph_edges"));
1349 assert!(compiled.sql.contains("LIMIT"));
1350 assert_eq!(
1351 compiled.return_vars,
1352 vec![
1353 ReturnItem::Variable("a".into()),
1354 ReturnItem::Variable("e".into()),
1355 ReturnItem::Variable("b".into()),
1356 ]
1357 );
1358 assert!(!compiled.sql.contains("WITH RECURSIVE"));
1360 }
1361
1362 #[test]
1363 fn namespace_scoping_injected() {
1364 let q =
1366 gql::parse("MATCH (a:concept)-[e:introduced_by]->(b:paper) RETURN a LIMIT 5").unwrap();
1367 let compiled = compile(&q, &scoped("research")).unwrap();
1368 assert!(compiled.sql.contains("namespace"));
1369 let has_ns_param = compiled
1371 .params
1372 .iter()
1373 .any(|p| matches!(p, QueryValue::Text(s) if s == "research"));
1374 assert!(has_ns_param, "namespace must be a bound parameter");
1375 }
1376
1377 #[test]
1378 fn edge_property_whitelist_rejects_unknown() {
1379 let q = gql::parse("MATCH (a)-[e:introduced_by]->(b) WHERE e.source_id = 'x' RETURN a")
1381 .unwrap();
1382 let result = compile(&q, &opts());
1383 assert!(result.is_err());
1384 let err = result.unwrap_err().to_string();
1385 assert!(
1386 err.contains("source_id") || err.contains("not queryable"),
1387 "error: {err}"
1388 );
1389 }
1390
1391 #[test]
1392 fn edge_property_relation_allowed() {
1393 let q = gql::parse("MATCH (a)-[e]->(b) WHERE e.relation = 'extends' RETURN a").unwrap();
1394 let result = compile(&q, &opts());
1395 assert!(
1396 result.is_ok(),
1397 "relation should be allowed: {:?}",
1398 result.err()
1399 );
1400 }
1401
1402 #[test]
1403 fn edge_property_weight_allowed() {
1404 let q = gql::parse("MATCH (a)-[e]->(b) WHERE e.weight > 0.5 RETURN a").unwrap();
1405 let result = compile(&q, &opts());
1406 assert!(
1407 result.is_ok(),
1408 "weight should be allowed: {:?}",
1409 result.err()
1410 );
1411 }
1412
1413 #[test]
1414 fn variable_length_uses_cte() {
1415 let q =
1416 gql::parse("MATCH (a {name: 'LoRA'})-[:extends*1..3]->(b) RETURN b LIMIT 20").unwrap();
1417 let compiled = compile(&q, &opts()).unwrap();
1418 assert!(compiled.sql.contains("WITH RECURSIVE"));
1419 assert!(compiled.sql.contains("traverse"));
1420 }
1421
1422 #[test]
1423 fn depth_cap_at_ten_rejects_above_max() {
1424 let q = gql::parse("MATCH (a)-[:extends*1..50]->(b) RETURN b").unwrap();
1427 let err = compile(&q, &opts()).unwrap_err();
1428 assert!(
1429 matches!(err, QueryError::InvalidInput(_)),
1430 "expected InvalidInput for depth > 10, got {err:?}"
1431 );
1432 }
1433
1434 #[test]
1435 fn depth_within_cap_compiles() {
1436 let q = gql::parse("MATCH (a)-[:extends*1..10]->(b) RETURN b").unwrap();
1438 let compiled = compile(&q, &opts()).unwrap();
1439 assert!(compiled.sql.contains("WITH RECURSIVE"));
1440 let depth_val = compiled.params.iter().find_map(|p| {
1442 if let QueryValue::Integer(n) = p {
1443 Some(*n)
1444 } else {
1445 None
1446 }
1447 });
1448 assert_eq!(depth_val, Some(10), "depth param should be 10");
1449 }
1450
1451 #[test]
1452 fn limit_capped_by_max_limit() {
1453 let q = gql::parse("MATCH (a:concept)-[e]->(b) RETURN a LIMIT 1000").unwrap();
1455 let compiled = compile(&q, &opts()).unwrap();
1456 let limit_param = compiled.params.last().unwrap();
1457 assert!(
1458 matches!(limit_param, QueryValue::Integer(500)),
1459 "expected Integer(500), got {limit_param:?}"
1460 );
1461 }
1462
1463 #[test]
1464 fn compile_rejects_unknown_relation() {
1465 let q = gql::parse("MATCH (a)-[:not_a_relation]->(b) RETURN a").unwrap();
1466 let err = compile(&q, &opts()).unwrap_err();
1467 let msg = err.to_string();
1468 assert!(msg.contains("not_a_relation"), "msg: {msg}");
1469 }
1470
1471 #[test]
1472 fn compile_unknown_kind_passes_through() {
1473 let q = gql::parse("MATCH (a:gizmo)-[:extends]->(b) RETURN a").unwrap();
1476 let compiled = compile(&q, &opts()).unwrap();
1477 let has_gizmo = compiled
1478 .params
1479 .iter()
1480 .any(|p| matches!(p, QueryValue::Text(s) if s == "gizmo"));
1481 assert!(
1482 has_gizmo,
1483 "pack-agnostic: unknown kind must pass through into SQL params"
1484 );
1485 }
1486
1487 #[test]
1488 fn compile_kind_passes_through_unchanged() {
1489 let q =
1492 gql::parse("MATCH (a:paper)-[:introduced_by]->(b:concept) RETURN a LIMIT 1").unwrap();
1493 let compiled = compile(&q, &opts()).unwrap();
1494 let has_paper = compiled
1495 .params
1496 .iter()
1497 .any(|p| matches!(p, QueryValue::Text(s) if s == "paper"));
1498 assert!(
1499 has_paper,
1500 "kind 'paper' must pass through unchanged into SQL params"
1501 );
1502 }
1503
1504 #[test]
1505 fn compile_rejects_namespace_in_where() {
1506 let q =
1507 gql::parse("MATCH (a:concept)-[:extends]->(b) WHERE a.namespace = 'other' RETURN a")
1508 .unwrap();
1509 let err = compile(&q, &opts()).unwrap_err();
1510 assert!(err.to_string().contains("namespace"), "msg: {err}");
1511 }
1512
1513 #[test]
1514 fn compile_rejects_unknown_relation_in_where() {
1515 let q = gql::parse("MATCH (a)-[e:extends]->(b) WHERE e.relation = 'related_to' RETURN a")
1516 .unwrap();
1517 let err = compile(&q, &opts()).unwrap_err();
1518 assert!(err.to_string().contains("related_to"), "msg: {err}");
1519 }
1520
1521 #[test]
1522 fn compile_kind_in_where_passes_through_unchanged() {
1523 let q = gql::parse("MATCH (a)-[:extends]->(b) WHERE a.kind = 'paper' RETURN a").unwrap();
1525 let compiled = compile(&q, &opts()).unwrap();
1526 let has_paper = compiled
1527 .params
1528 .iter()
1529 .any(|p| matches!(p, QueryValue::Text(s) if s == "paper"));
1530 assert!(
1531 has_paper,
1532 "kind 'paper' must pass through unchanged into SQL params"
1533 );
1534 }
1535
1536 #[test]
1537 fn variable_length_return_start_only_joins_end_entity() {
1538 let q = gql::parse("MATCH (a:concept)-[:extends*1..3]->(b) RETURN a LIMIT 10").unwrap();
1542 let compiled = compile(&q, &opts()).unwrap();
1543 assert!(
1544 compiled.sql.contains("JOIN entities r"),
1545 "entities r must always be joined when r.* conditions are emitted; sql: {}",
1546 compiled.sql
1547 );
1548 }
1549
1550 #[test]
1551 fn variable_length_trailing_pattern_unsupported() {
1552 let q = gql::parse("MATCH (a)-[:extends*1..3]->(b)-[:implements]->(c) RETURN b").unwrap();
1553 let err = compile(&q, &opts()).unwrap_err();
1554 assert!(
1555 matches!(err, QueryError::Unsupported(_)),
1556 "expected Unsupported, got {err:?}"
1557 );
1558 }
1559
1560 #[test]
1561 fn variable_length_mixed_chain_unsupported() {
1562 let q = gql::parse("MATCH (a)-[:extends]->(b)-[:implements*1..2]->(c) RETURN c").unwrap();
1565 let err = compile(&q, &opts()).unwrap_err();
1566 assert!(matches!(err, QueryError::Unsupported(_)), "got {err:?}");
1567 }
1568
1569 #[test]
1570 fn sparql_star_rejected_as_unsupported() {
1571 use crate::parsers::sparql;
1572 let err = sparql::parse("SELECT ?a ?b WHERE { ?a :extends* ?b . }").unwrap_err();
1573 assert!(matches!(err, QueryError::Unsupported(_)), "got {err:?}");
1574 }
1575
1576 #[test]
1579 fn sparql_subject_object_direction_compiles_outbound() {
1580 use crate::parsers::sparql;
1581
1582 let q = sparql::parse("SELECT ?a ?b WHERE { ?a :extends ?b . }").unwrap();
1583 let compiled = compile(&q, &opts()).unwrap();
1584
1585 assert!(
1586 compiled
1587 .sql
1588 .contains("JOIN graph_edges e0 ON e0.source_id = n0.id"),
1589 "SPARQL subject must bind graph_edges.source_id; sql: {}",
1590 compiled.sql
1591 );
1592 assert!(
1593 compiled
1594 .sql
1595 .contains("JOIN entities n1 ON n1.id = e0.target_id"),
1596 "SPARQL object must bind graph_edges.target_id; sql: {}",
1597 compiled.sql
1598 );
1599 assert!(
1600 compiled.sql.contains("e0.relation = ?1"),
1601 "SPARQL predicate must bind graph_edges.relation; sql: {}",
1602 compiled.sql
1603 );
1604 }
1605
1606 #[test]
1607 fn return_property_projection_compiles() {
1608 let q =
1609 gql::parse("MATCH (a:concept)-[e:extends]->(b:concept) RETURN a.name, b.name LIMIT 5")
1610 .unwrap();
1611 let compiled = compile(&q, &opts()).unwrap();
1612 assert!(
1614 compiled.sql.contains(".name AS a_name"),
1615 "sql: {}",
1616 compiled.sql
1617 );
1618 assert!(
1619 compiled.sql.contains(".name AS b_name"),
1620 "sql: {}",
1621 compiled.sql
1622 );
1623 assert!(
1624 !compiled.sql.contains("a_kind"),
1625 "should not emit full node columns"
1626 );
1627 }
1628
1629 #[test]
1630 fn return_unknown_node_property_rejected() {
1631 let q = gql::parse("MATCH (a:concept)-[:extends]->(b) RETURN a.domain LIMIT 5").unwrap();
1632 let err = compile(&q, &opts()).unwrap_err();
1633 assert!(
1634 matches!(err, QueryError::Compile(ref msg) if msg.contains("unknown node property 'domain'")),
1635 "got {err:?}"
1636 );
1637 }
1638
1639 #[test]
1640 fn return_unknown_edge_property_rejected() {
1641 let q = gql::parse("MATCH (a)-[e:extends]->(b) RETURN e.label LIMIT 5").unwrap();
1642 let err = compile(&q, &opts()).unwrap_err();
1643 assert!(
1644 matches!(err, QueryError::Compile(ref msg) if msg.contains("unknown edge property 'label'")),
1645 "got {err:?}"
1646 );
1647 }
1648
1649 #[test]
1650 fn return_valid_edge_property_compiles() {
1651 let q =
1652 gql::parse("MATCH (a)-[e:extends]->(b) RETURN e.relation, e.weight LIMIT 5").unwrap();
1653 let compiled = compile(&q, &opts()).unwrap();
1654 assert!(
1656 compiled.sql.contains(".relation AS e_relation"),
1657 "sql: {}",
1658 compiled.sql
1659 );
1660 assert!(
1661 compiled.sql.contains(".weight AS e_weight"),
1662 "sql: {}",
1663 compiled.sql
1664 );
1665 }
1666
1667 #[test]
1668 fn entity_type_compiles_as_direct_column_not_json_extract() {
1669 let q = gql::parse("MATCH (n:document {entity_type: 'paper'})-[:extends]->(m) RETURN n")
1672 .unwrap();
1673 let compiled = compile(&q, &opts()).unwrap();
1674 assert!(
1675 compiled.sql.contains(".entity_type = ?"),
1676 "entity_type must compile to a direct column comparison; sql: {}",
1677 compiled.sql
1678 );
1679 assert!(
1680 !compiled.sql.contains("json_extract"),
1681 "entity_type must NOT use json_extract; sql: {}",
1682 compiled.sql
1683 );
1684 let has_paper_param = compiled
1685 .params
1686 .iter()
1687 .any(|p| matches!(p, QueryValue::Text(s) if s == "paper"));
1688 assert!(
1689 has_paper_param,
1690 "entity_type value 'paper' must appear as a bound parameter"
1691 );
1692 }
1693
1694 #[test]
1697 fn where_or_compiles_to_sql_or() {
1698 let q = gql::parse(
1699 "MATCH (a:concept)-[e:extends]->(b) WHERE a.name = 'LoRA' OR a.name = 'QLoRA' RETURN a",
1700 )
1701 .unwrap();
1702 let compiled = compile(&q, &opts()).unwrap();
1703 assert!(
1704 compiled.sql.contains(" OR "),
1705 "WHERE OR must produce SQL OR; sql: {}",
1706 compiled.sql
1707 );
1708 let has_lora = compiled
1709 .params
1710 .iter()
1711 .any(|p| matches!(p, QueryValue::Text(s) if s == "LoRA"));
1712 let has_qlora = compiled
1713 .params
1714 .iter()
1715 .any(|p| matches!(p, QueryValue::Text(s) if s == "QLoRA"));
1716 assert!(has_lora && has_qlora, "both OR values must be bound params");
1717 }
1718
1719 #[test]
1720 fn where_and_or_precedence() {
1721 let q = gql::parse(
1723 "MATCH (a:concept)-[e:extends]->(b) WHERE a.name = 'X' AND a.kind = 'concept' OR b.kind = 'project' RETURN a"
1724 ).unwrap();
1725 let compiled = compile(&q, &opts()).unwrap();
1726 assert!(
1728 compiled.sql.contains(" OR "),
1729 "expected OR in sql; sql: {}",
1730 compiled.sql
1731 );
1732 }
1733
1734 #[test]
1737 fn synthetic_edge_joins_event_observations() {
1738 let q = gql::parse("MATCH (ev)-[:observed_as_selected]->(m:memory) RETURN ev, m").unwrap();
1739 let compiled = compile(&q, &opts()).unwrap();
1740 assert!(
1741 compiled.sql.contains("event_observations"),
1742 "synthetic edge must join event_observations; sql: {}",
1743 compiled.sql
1744 );
1745 assert!(
1746 !compiled.sql.contains("graph_edges"),
1747 "synthetic edge must NOT join graph_edges; sql: {}",
1748 compiled.sql
1749 );
1750 let has_role_param = compiled
1751 .params
1752 .iter()
1753 .any(|p| matches!(p, QueryValue::Text(s) if s == "selected"));
1754 assert!(has_role_param, "role 'selected' must be a bound parameter");
1755 }
1756
1757 #[test]
1761 fn synthetic_edge_event_source_binds_events_table() {
1762 let q = gql::parse("MATCH (ev)-[:observed_as_selected]->(m:memory) RETURN ev, m").unwrap();
1763 let compiled = compile(&q, &opts()).unwrap();
1764 assert!(
1765 compiled.sql.contains("FROM events "),
1766 "CRIT-1: event source must come FROM events table, not entities; sql: {}",
1767 compiled.sql
1768 );
1769 assert!(
1770 !compiled
1771 .sql
1772 .starts_with("SELECT * FROM entities n0 JOIN event_observations"),
1773 "CRIT-1: must not join events via entities table; sql: {}",
1774 compiled.sql
1775 );
1776 }
1777
1778 #[test]
1779 fn synthetic_edge_event_observation_join_uses_events_id() {
1780 let q = gql::parse("MATCH (ev)-[:observed_as_selected]->(m) RETURN m").unwrap();
1783 let compiled = compile(&q, &opts()).unwrap();
1784 assert!(
1786 compiled
1787 .sql
1788 .contains("JOIN event_observations e0 ON e0.event_id = n0.id"),
1789 "CRIT-1: event_observations must join on events.id (n0 is now events); sql: {}",
1790 compiled.sql
1791 );
1792 }
1793
1794 #[test]
1795 fn synthetic_edge_event_node_projects_event_columns() {
1796 let q = gql::parse("MATCH (ev)-[:observed_as_selected]->(m) RETURN ev").unwrap();
1799 let compiled = compile(&q, &opts()).unwrap();
1800 assert!(
1801 compiled.sql.contains("ev_verb"),
1802 "CRIT-1: event variable must project verb column; sql: {}",
1803 compiled.sql
1804 );
1805 assert!(
1806 compiled.sql.contains("ev_outcome"),
1807 "CRIT-1: event variable must project outcome column; sql: {}",
1808 compiled.sql
1809 );
1810 assert!(
1811 !compiled.sql.contains("ev_name,") && !compiled.sql.contains("ev_name "),
1812 "CRIT-1: event variable must NOT project entity name column; sql: {}",
1813 compiled.sql
1814 );
1815 assert!(
1816 !compiled.sql.contains("ev_properties"),
1817 "CRIT-1: event variable must NOT project entity properties column; sql: {}",
1818 compiled.sql
1819 );
1820 }
1821
1822 #[test]
1823 fn synthetic_edge_namespace_filter_on_events_table() {
1824 let q = gql::parse("MATCH (ev)-[:observed_as_selected]->(m) RETURN m").unwrap();
1827 let compiled = compile(&q, &scoped("test-ns")).unwrap();
1828 let ns_count = compiled
1831 .params
1832 .iter()
1833 .filter(|p| matches!(p, QueryValue::Text(s) if s == "test-ns"))
1834 .count();
1835 assert!(
1836 ns_count >= 2,
1837 "MIN-2: namespace must be filtered on both events and target; params: {:?}",
1838 compiled.params
1839 );
1840 }
1841
1842 #[test]
1843 fn synthetic_edge_candidate_role() {
1844 let q = gql::parse("MATCH (ev)-[:observed_as_candidate]->(m) RETURN ev, m").unwrap();
1845 let compiled = compile(&q, &opts()).unwrap();
1846 assert!(
1847 compiled.sql.contains("event_observations"),
1848 "sql: {}",
1849 compiled.sql
1850 );
1851 let has_candidate = compiled
1852 .params
1853 .iter()
1854 .any(|p| matches!(p, QueryValue::Text(s) if s == "candidate"));
1855 assert!(has_candidate, "role 'candidate' must be bound");
1856 }
1857
1858 #[test]
1859 fn synthetic_edge_multi_role() {
1860 let q =
1862 gql::parse("MATCH (ev)-[:observed_as_candidate|observed_as_selected]->(m) RETURN m")
1863 .unwrap();
1864 let compiled = compile(&q, &opts()).unwrap();
1865 assert!(
1866 compiled.sql.contains("event_observations"),
1867 "sql: {}",
1868 compiled.sql
1869 );
1870 assert!(
1871 compiled.sql.contains("IN"),
1872 "multi-role must use IN; sql: {}",
1873 compiled.sql
1874 );
1875 }
1876
1877 #[test]
1878 fn mixed_synthetic_and_canonical_rejected() {
1879 let q = gql::parse("MATCH (ev)-[:observed_as_selected|extends]->(m) RETURN m").unwrap();
1880 let err = compile(&q, &opts()).unwrap_err();
1881 assert!(
1882 matches!(err, QueryError::Compile(_)),
1883 "mixed synthetic+canonical must be rejected; got {err:?}"
1884 );
1885 }
1886
1887 #[test]
1888 fn synthetic_edge_inbound_rejected() {
1889 let q = gql::parse("MATCH (m)<-[:observed_as_selected]-(ev) RETURN m").unwrap();
1890 let err = compile(&q, &opts()).unwrap_err();
1891 assert!(
1892 matches!(err, QueryError::Compile(_)),
1893 "inbound synthetic edge must be rejected; got {err:?}"
1894 );
1895 }
1896
1897 #[test]
1900 fn variable_length_or_across_endpoints_rejected() {
1901 let q = gql::parse(
1904 "MATCH (a)-[:extends*1..3]->(b) WHERE a.name = 'X' OR b.name = 'Y' RETURN a",
1905 )
1906 .unwrap();
1907 let result = compile(&q, &opts());
1908 assert!(
1909 matches!(result, Err(QueryError::Unsupported(_))),
1910 "MAJ-1: OR spanning both endpoints must return Unsupported; got {result:?}"
1911 );
1912 let err_msg = result.unwrap_err().to_string();
1913 assert!(
1914 err_msg.contains("separate queries") || err_msg.contains("one endpoint"),
1915 "error must be actionable; got: {err_msg}"
1916 );
1917 }
1918
1919 #[test]
1920 fn variable_length_or_single_endpoint_still_works() {
1921 let q = gql::parse(
1923 "MATCH (a)-[:extends*1..3]->(b) WHERE a.name = 'X' OR a.name = 'Y' RETURN a",
1924 )
1925 .unwrap();
1926 let result = compile(&q, &opts());
1927 assert!(
1928 result.is_ok(),
1929 "single-endpoint OR must compile; got {result:?}"
1930 );
1931 }
1932
1933 #[test]
1934 fn variable_length_and_across_endpoints_still_works() {
1935 let q = gql::parse(
1937 "MATCH (a)-[:extends*1..3]->(b) WHERE a.name = 'X' AND b.name = 'Y' RETURN a",
1938 )
1939 .unwrap();
1940 let result = compile(&q, &opts());
1941 assert!(
1942 result.is_ok(),
1943 "AND across endpoints must compile; got {result:?}"
1944 );
1945 }
1946
1947 #[test]
1950 fn test_variable_length_or_compiles_to_or() {
1951 let q = gql::parse(
1954 "MATCH (a)-[:extends*1..3]->(b) WHERE a.name = 'LoRA' OR a.name = 'QLoRA' RETURN b",
1955 )
1956 .unwrap();
1957 let compiled = compile(&q, &opts()).unwrap();
1958 assert!(
1960 compiled.sql.contains(" OR "),
1961 "#379: variable-length single-endpoint OR must produce SQL OR; sql: {}",
1962 compiled.sql
1963 );
1964 let has_lora = compiled
1966 .params
1967 .iter()
1968 .any(|p| matches!(p, QueryValue::Text(s) if s == "LoRA"));
1969 let has_qlora = compiled
1970 .params
1971 .iter()
1972 .any(|p| matches!(p, QueryValue::Text(s) if s == "QLoRA"));
1973 assert!(has_lora && has_qlora, "both OR values must be bound params");
1974 }
1975
1976 #[test]
1977 fn test_single_endpoint_or_at_depth_1() {
1978 let q = gql::parse(
1981 "MATCH (a)-[r:extends]->(b) WHERE r.weight > 0.5 OR r.relation = 'extends' RETURN a",
1982 )
1983 .unwrap();
1984 let compiled = compile(&q, &opts()).unwrap();
1985 assert!(
1986 compiled.sql.contains(" OR "),
1987 "#379: fixed-length single-endpoint OR must produce SQL OR; sql: {}",
1988 compiled.sql
1989 );
1990 let has_extends = compiled
1991 .params
1992 .iter()
1993 .any(|p| matches!(p, QueryValue::Text(s) if s == "extends"));
1994 assert!(
1995 has_extends,
1996 "relation value 'extends' must be a bound param"
1997 );
1998 }
1999
2000 #[test]
2001 fn test_and_still_works() {
2002 let q = gql::parse(
2004 "MATCH (a)-[:extends*1..3]->(b) WHERE a.name = 'LoRA' AND a.kind = 'concept' RETURN b",
2005 )
2006 .unwrap();
2007 let compiled = compile(&q, &opts()).unwrap();
2008 assert!(
2010 !compiled.sql.contains(" OR "),
2011 "#379: AND must not produce OR; sql: {}",
2012 compiled.sql
2013 );
2014 let has_lora = compiled
2015 .params
2016 .iter()
2017 .any(|p| matches!(p, QueryValue::Text(s) if s == "LoRA"));
2018 let has_concept = compiled
2019 .params
2020 .iter()
2021 .any(|p| matches!(p, QueryValue::Text(s) if s == "concept"));
2022 assert!(
2023 has_lora && has_concept,
2024 "both AND values must be bound params"
2025 );
2026 }
2027
2028 #[test]
2032 fn max_limit_overflow_returns_error() {
2033 let q = gql::parse("MATCH (a)-[:extends]->(b) RETURN a").unwrap();
2034 let opts = CompileOptions {
2035 scopes: vec![],
2036 max_limit: usize::MAX,
2037 };
2038 let result = compile(&q, &opts);
2042 match result {
2043 Err(QueryError::InvalidInput(_)) => {
2044 }
2046 Ok(compiled) => {
2047 let limit_param = compiled.params.last().unwrap();
2049 assert!(
2050 matches!(limit_param, QueryValue::Integer(n) if *n >= 0),
2051 "limit must never be negative; got {limit_param:?}"
2052 );
2053 }
2054 Err(e) => panic!("unexpected error: {e:?}"),
2055 }
2056 }
2057
2058 #[test]
2060 fn max_limit_zero_compiles() {
2061 let q = gql::parse("MATCH (a)-[:extends]->(b) RETURN a").unwrap();
2062 let opts = CompileOptions {
2063 scopes: vec![],
2064 max_limit: 0,
2065 };
2066 let compiled = compile(&q, &opts).unwrap();
2067 let limit_param = compiled.params.last().unwrap();
2068 assert!(
2069 matches!(limit_param, QueryValue::Integer(0)),
2070 "max_limit=0 should produce LIMIT 0; got {limit_param:?}"
2071 );
2072 }
2073
2074 #[test]
2076 fn variable_length_synthetic_edge_rejected() {
2077 let q = gql::parse("MATCH (ev)-[:observed_as_selected*1..3]->(m) RETURN m").unwrap();
2080 let err = compile(&q, &opts()).unwrap_err();
2081 assert!(
2082 matches!(err, QueryError::Unsupported(_)),
2083 "variable-length synthetic edge must return Unsupported; got {err:?}"
2084 );
2085 assert!(
2086 err.to_string().contains("synthetic") || err.to_string().contains("observed_as"),
2087 "error should mention synthetic edges: {err}"
2088 );
2089 }
2090
2091 #[test]
2094 fn variable_length_recursive_member_joins_next_node_for_deleted_filter() {
2095 let q = gql::parse("MATCH (a)-[:extends*1..3]->(b) RETURN b").unwrap();
2096 let compiled = compile(&q, &opts()).unwrap();
2097 assert!(
2099 compiled.sql.contains("JOIN entities next_node"),
2100 "recursive CTE must join entities next_node for deleted-intermediate filtering; sql: {}",
2101 compiled.sql
2102 );
2103 assert!(
2104 compiled.sql.contains("next_node.deleted_at IS NULL"),
2105 "recursive CTE must filter next_node.deleted_at IS NULL; sql: {}",
2106 compiled.sql
2107 );
2108 }
2109
2110 #[test]
2113 fn variable_length_recursive_member_namespace_scopes_intermediates() {
2114 let q = gql::parse("MATCH (a)-[:extends*1..3]->(b) RETURN b").unwrap();
2115 let compiled = compile(&q, &scoped("test-ns")).unwrap();
2116 assert!(
2118 compiled.sql.contains("next_node.namespace"),
2119 "recursive CTE next_node join must filter namespace; sql: {}",
2120 compiled.sql
2121 );
2122 }
2123
2124 #[test]
2127 fn compile_malformed_ast_returns_error_not_panic() {
2128 use crate::ast::{EdgeDirection, EdgePattern, GqlQuery, MatchPattern, PatternElement};
2129 let q = GqlQuery {
2131 pattern: MatchPattern {
2132 elements: vec![PatternElement::Edge(EdgePattern {
2133 variable: None,
2134 relations: vec!["extends".to_string()],
2135 direction: EdgeDirection::Out,
2136 min_hops: 1,
2137 max_hops: 1,
2138 })],
2139 },
2140 where_clause: WhereExpr::True,
2141 return_items: vec![],
2142 limit: None,
2143 };
2144 let result = compile(&q, &opts());
2145 assert!(
2146 result.is_err(),
2147 "malformed AST (starts with Edge) must return error, not panic"
2148 );
2149 }
2150
2151 #[test]
2154 fn edge_pattern_without_suffix_dash_rejected() {
2155 let result = gql::parse("MATCH (a)-[e:extends](b) RETURN a");
2156 assert!(
2157 result.is_err(),
2158 "edge pattern without suffix '-' must be rejected as a parse error"
2159 );
2160 }
2161
2162 #[test]
2166 fn assert_select_only_accepts_select_and_with() {
2167 assert!(
2168 assert_select_only("SELECT a FROM entities WHERE 1=1").is_ok(),
2169 "SELECT must be accepted"
2170 );
2171 assert!(
2172 assert_select_only("WITH RECURSIVE traverse AS (...) SELECT ...").is_ok(),
2173 "WITH must be accepted (recursive CTE)"
2174 );
2175 }
2176
2177 #[test]
2179 fn assert_select_only_rejects_write_sql_with_readonly_message() {
2180 for stmt in &[
2181 "INSERT INTO entities VALUES (?)",
2182 "UPDATE entities SET name = ?",
2183 "DELETE FROM entities WHERE id = ?",
2184 "DROP TABLE entities",
2185 ] {
2186 let err = assert_select_only(stmt).unwrap_err();
2187 assert!(
2188 matches!(err, QueryError::Compile(_)),
2189 "write SQL must return Compile error for '{stmt}'; got {err:?}"
2190 );
2191 let msg = err.to_string();
2192 assert!(
2193 msg.contains("read-only"),
2194 "error must mention 'read-only' for '{stmt}'; got: {msg}"
2195 );
2196 assert!(
2197 msg.contains("create") && msg.contains("delete"),
2198 "error must name the mutation verbs for '{stmt}'; got: {msg}"
2199 );
2200 }
2201 }
2202
2203 #[test]
2206 fn readonly_guard_does_not_break_valid_gql_compile() {
2207 let q = gql::parse("MATCH (a:concept)-[:extends]->(b) RETURN a LIMIT 10").unwrap();
2208 let compiled = compile(&q, &opts()).unwrap();
2209 assert!(
2210 compiled.sql.starts_with("SELECT"),
2211 "valid GQL must compile to SELECT; sql: {}",
2212 compiled.sql
2213 );
2214 }
2215
2216 #[test]
2218 fn readonly_guard_does_not_break_valid_cte_compile() {
2219 let q = gql::parse("MATCH (a)-[:extends*1..3]->(b) RETURN b LIMIT 10").unwrap();
2220 let compiled = compile(&q, &opts()).unwrap();
2221 assert!(
2222 compiled.sql.starts_with("WITH RECURSIVE"),
2223 "variable-length GQL must compile to WITH RECURSIVE; sql: {}",
2224 compiled.sql
2225 );
2226 }
2227
2228 #[test]
2230 fn gql_write_form_rejected_before_compile() {
2231 use crate::parsers::gql;
2232 let err = gql::parse("CREATE (n:concept) RETURN n").unwrap_err();
2233 assert!(
2234 matches!(err, QueryError::Unsupported(_)),
2235 "GQL CREATE must be Unsupported; got {err:?}"
2236 );
2237 assert!(
2238 err.to_string().contains("read-only"),
2239 "error must mention read-only; got: {err}"
2240 );
2241 }
2242
2243 #[test]
2245 fn sparql_write_form_rejected_before_compile() {
2246 use crate::parsers::sparql;
2247 let err = sparql::parse("INSERT DATA { ?a :extends ?b }").unwrap_err();
2248 assert!(
2249 matches!(err, QueryError::Unsupported(_)),
2250 "SPARQL INSERT must be Unsupported; got {err:?}"
2251 );
2252 assert!(
2253 err.to_string().contains("read-only"),
2254 "error must mention read-only; got: {err}"
2255 );
2256 }
2257
2258 #[test]
2260 fn duplicate_inline_property_rejected() {
2261 let result = gql::parse("MATCH (n {name: 'A', name: 'B'}) RETURN n");
2262 assert!(
2263 result.is_err(),
2264 "duplicate property 'name' in node props must be rejected"
2265 );
2266 let err = result.unwrap_err().to_string();
2267 assert!(
2268 err.contains("duplicate") || err.contains("name"),
2269 "error should mention duplicate or key name: {err}"
2270 );
2271 }
2272
2273 #[test]
2275 fn unknown_synthetic_relation_rejected_at_compile() {
2276 let q = gql::parse("MATCH (a)-[:observed_as_bogus]->(b) RETURN a").unwrap();
2277 let err = compile(&q, &opts()).unwrap_err();
2278 assert!(
2279 matches!(err, QueryError::Validation(_)),
2280 "unknown synthetic relation must return Validation error; got {err:?}"
2281 );
2282 }
2283}