1use crate::ast::*;
4use crate::error::QueryError;
5use crate::language::read_only_error;
6use crate::validate::{validate_with_warnings, MAX_DEPTH, SYNTHETIC_RELATIONS};
7
8fn is_synthetic(rel: &str) -> bool {
9 SYNTHETIC_RELATIONS.contains(&rel)
10}
11
12fn synthetic_role(rel: &str) -> Option<&'static str> {
13 match rel {
14 "observed_as_candidate" => Some("candidate"),
15 "observed_as_selected" => Some("selected"),
16 "observed_as_target" => Some("target"),
17 "observed_as_signal" => Some("signal"),
18 _ => None,
19 }
20}
21
22const PRIMARY_NODE_SQL: &str = "\
24 SELECT id, namespace, kind, entity_type, name, description, NULL AS content, \
25 NULL AS status, NULL AS salience, NULL AS decay_factor, properties, \
26 created_at, updated_at, deleted_at, 'entity' AS substrate_kind \
27 FROM entities \
28 UNION ALL \
29 SELECT id, namespace, kind, NULL AS entity_type, name, NULL AS description, \
30 content, status, salience, decay_factor, properties, \
31 created_at, updated_at, deleted_at, 'note' AS substrate_kind \
32 FROM notes \
33 UNION ALL \
34 SELECT id, namespace, kind, NULL AS entity_type, verb AS name, \
35 NULL AS description, NULL AS content, NULL AS status, \
36 NULL AS salience, NULL AS decay_factor, payload AS properties, \
37 created_at, created_at AS updated_at, NULL AS deleted_at, \
38 'event' AS substrate_kind \
39 FROM events \
40 UNION ALL \
41 SELECT id, namespace, relation AS kind, NULL AS entity_type, NULL AS name, \
42 NULL AS description, NULL AS content, NULL AS status, \
43 NULL AS salience, NULL AS decay_factor, metadata AS properties, \
44 created_at, updated_at, deleted_at, 'edge' AS substrate_kind \
45 FROM graph_edges";
46
47fn primary_node_source(alias: &str) -> String {
48 format!("({PRIMARY_NODE_SQL}) {alias}")
49}
50
51const OBSERVATION_TARGET_SQL: &str = "\
53 SELECT id, namespace, kind, entity_type, name, description, \
54 NULL AS content, NULL AS status, NULL AS salience, \
55 NULL AS decay_factor, properties, created_at, updated_at, \
56 deleted_at, 'entity' AS referent_kind \
57 FROM entities \
58 UNION ALL \
59 SELECT id, namespace, kind, NULL AS entity_type, name, NULL AS description, \
60 content, status, salience, decay_factor, properties, \
61 created_at, updated_at, deleted_at, 'note' AS referent_kind \
62 FROM notes";
63
64fn observation_target_source(alias: &str) -> String {
65 format!("({OBSERVATION_TARGET_SQL}) {alias}")
66}
67
68fn quote_identifier(identifier: &str) -> String {
70 format!("\"{}\"", identifier.replace('"', "\"\""))
71}
72
73#[derive(Debug)]
77pub struct CompiledQuery {
78 pub sql: String,
80 pub params: Vec<QueryValue>,
82 pub return_vars: Vec<ReturnItem>,
84 pub warnings: Vec<String>,
86 pub truncation_check: Option<TruncationCheck>,
88}
89
90#[derive(Clone, Copy, Debug, PartialEq, Eq)]
92pub struct TruncationCheck {
93 pub max_limit: usize,
95 pub requested_limit: Option<usize>,
97}
98
99pub struct CompileOptions {
101 pub scopes: Vec<String>,
103 pub max_limit: usize,
108}
109
110impl Default for CompileOptions {
111 fn default() -> Self {
112 Self {
113 scopes: Vec::new(),
114 max_limit: 500,
115 }
116 }
117}
118
119fn effective_limit(
129 requested_limit: Option<usize>,
130 offset: usize,
131 max_limit: usize,
132) -> (usize, Option<TruncationCheck>) {
133 match requested_limit {
134 Some(limit) => {
135 let remaining = limit.saturating_sub(offset);
136 if remaining <= max_limit {
137 (remaining, None)
138 } else {
139 (
140 max_limit.saturating_add(1),
141 Some(TruncationCheck {
142 max_limit,
143 requested_limit: Some(limit),
144 }),
145 )
146 }
147 }
148 None => (
149 max_limit.saturating_add(1),
150 Some(TruncationCheck {
151 max_limit,
152 requested_limit: None,
153 }),
154 ),
155 }
156}
157
158pub fn compile(query: &GqlQuery, opts: &CompileOptions) -> Result<CompiledQuery, QueryError> {
168 if query.pattern.elements.is_empty() {
169 return Err(QueryError::Compile("empty pattern".into()));
170 }
171
172 let mut query = query.clone();
173 let warnings = validate_with_warnings(&mut query)?;
174
175 let mut compiled = if query.pattern.has_variable_length() {
176 compile_variable_length(&query, opts)?
177 } else {
178 compile_fixed_length(&query, opts)?
179 };
180 compiled.warnings.extend(warnings);
181
182 assert_select_only(&compiled.sql)?;
184
185 Ok(compiled)
186}
187
188fn assert_select_only(sql: &str) -> Result<(), QueryError> {
190 let first = sql.split_whitespace().next().unwrap_or("").to_uppercase();
191 if first == "SELECT" || first == "WITH" {
192 return Ok(());
193 }
194 Err(read_only_error(QueryError::Compile))
195}
196
197fn in_placeholders(indices: impl IntoIterator<Item = usize>) -> String {
198 indices
199 .into_iter()
200 .map(|index| format!("?{index}"))
201 .collect::<Vec<_>>()
202 .join(", ")
203}
204
205fn namespace_filter(alias: &str, opts: &CompileOptions, params: &mut Vec<QueryValue>) -> String {
206 if opts.scopes.is_empty() {
207 String::new()
208 } else if opts.scopes.len() == 1 {
209 params.push(QueryValue::Text(opts.scopes[0].clone()));
210 format!(" AND {alias}.namespace = ?{}", params.len())
211 } else {
212 let placeholders = in_placeholders(opts.scopes.iter().map(|s| {
213 params.push(QueryValue::Text(s.clone()));
214 params.len()
215 }));
216 format!(" AND {alias}.namespace IN ({})", placeholders)
217 }
218}
219
220fn kind_filter_predicate(alias: &str, kind: &str, params: &mut Vec<QueryValue>) -> String {
222 match kind {
223 "entity" | "note" | "event" | "edge" => {
224 params.push(QueryValue::Text(kind.to_string()));
225 format!("{alias}.substrate_kind = ?{}", params.len())
226 }
227 _ => {
228 params.push(QueryValue::Text(kind.to_string()));
229 format!("{alias}.kind = ?{}", params.len())
230 }
231 }
232}
233
234fn observation_kind_filter_predicate(
236 alias: &str,
237 kind: &str,
238 params: &mut Vec<QueryValue>,
239) -> String {
240 match kind {
241 "entity" | "note" => {
242 params.push(QueryValue::Text(kind.to_string()));
243 format!("{alias}.referent_kind = ?{}", params.len())
244 }
245 _ => {
246 params.push(QueryValue::Text(kind.to_string()));
247 format!("{alias}.kind = ?{}", params.len())
248 }
249 }
250}
251
252fn compile_property_equality(
255 alias: &str,
256 key: &str,
257 value: &ConditionValue,
258 text_column: Option<&str>,
259 params: &mut Vec<QueryValue>,
260) -> Result<String, QueryError> {
261 let is_string = matches!(value, ConditionValue::String(_));
262 match value {
263 ConditionValue::String(s) => params.push(QueryValue::Text(s.clone())),
264 ConditionValue::Integer(n) => params.push(QueryValue::Integer(*n)),
265 ConditionValue::Number(n) => {
266 if !n.is_finite() {
267 return Err(QueryError::InvalidInput(
268 "non-finite float (NaN or Infinity) is not a valid query parameter".into(),
269 ));
270 }
271 params.push(QueryValue::Float(*n));
272 }
273 ConditionValue::Bool(b) => params.push(QueryValue::Integer(if *b { 1 } else { 0 })),
274 ConditionValue::List(_) | ConditionValue::Null => {
275 return Err(QueryError::Validation(
276 "list and null operands are not valid in inline property maps".into(),
277 ));
278 }
279 }
280 let collate = if is_string { " COLLATE NOCASE" } else { "" };
281 Ok(match text_column {
282 Some(col) => format!("{alias}.{col} = ?{}{collate}", params.len()),
283 None => format!(
284 "json_extract({alias}.properties, '$.{}') = ?{}{collate}",
285 key.replace('\'', "''"),
286 params.len()
287 ),
288 })
289}
290
291fn synthetic_endpoint_node_indices(
293 elements: &[PatternElement],
294) -> (
295 std::collections::HashSet<usize>,
296 std::collections::HashSet<usize>,
297) {
298 let mut source_set = std::collections::HashSet::new();
299 let mut target_set = std::collections::HashSet::new();
300 let mut node_idx = 0usize;
301 let mut prev_node_idx: Option<usize> = None;
302 for element in elements {
303 match element {
304 PatternElement::Node(_) => {
305 prev_node_idx = Some(node_idx);
306 node_idx += 1;
307 }
308 PatternElement::Edge(ep) => {
309 let has_synthetic = ep.relations.iter().any(|r| is_synthetic(r));
310 if has_synthetic {
311 if let Some(src_idx) = prev_node_idx {
312 source_set.insert(src_idx);
313 target_set.insert(node_idx);
315 }
316 }
317 }
318 }
319 }
320 (source_set, target_set)
321}
322
323fn compile_fixed_length(
325 query: &GqlQuery,
326 opts: &CompileOptions,
327) -> Result<CompiledQuery, QueryError> {
328 let mut params: Vec<QueryValue> = Vec::new();
329 let mut from_parts: Vec<String> = Vec::new();
330 let mut join_parts: Vec<String> = Vec::new();
331 let mut where_parts: Vec<String> = Vec::new();
332 let mut select_parts: Vec<String> = Vec::new();
333 let mut order_parts: Vec<String> = Vec::new();
334
335 let mut node_aliases: Vec<String> = Vec::new();
336 let mut var_to_alias: std::collections::HashMap<String, (String, VarKind)> =
337 std::collections::HashMap::new();
338
339 let (event_source_indices, observation_target_indices) =
341 synthetic_endpoint_node_indices(&query.pattern.elements);
342
343 let mut node_idx = 0usize;
344 let mut edge_idx = 0usize;
345
346 for element in &query.pattern.elements {
347 match element {
348 PatternElement::Node(np) => {
349 let alias = format!("n{node_idx}");
350 node_aliases.push(alias.clone());
351
352 let is_event_source = event_source_indices.contains(&node_idx);
353 let is_observation_target = observation_target_indices.contains(&node_idx);
354
355 if is_event_source {
359 order_parts.push(format!("{alias}.id"));
360 } else if is_observation_target {
361 order_parts.push(format!("{alias}.referent_kind"));
362 order_parts.push(format!("{alias}.id"));
363 } else {
364 order_parts.push(format!("{alias}.substrate_kind"));
365 order_parts.push(format!("{alias}.id"));
366 }
367
368 if node_idx == 0 {
369 if is_event_source {
370 from_parts.push(format!("events {alias}"));
371 } else if !is_observation_target {
372 from_parts.push(primary_node_source(&alias));
373 }
374 }
375
376 if is_event_source {
377 let ns_filter = namespace_filter(&alias, opts, &mut params);
379 if !ns_filter.is_empty() {
380 where_parts.push(ns_filter.trim_start_matches(" AND ").to_string());
381 }
382 if let Some(ref kind) = np.kind {
384 if kind != "event" {
385 params.push(QueryValue::Text(kind.clone()));
386 where_parts.push(format!("{alias}.kind = ?{}", params.len()));
387 }
388 }
389 if np.entity_type.is_some() {
390 return Err(QueryError::Compile(
391 "event nodes do not have an entity_type column".into(),
392 ));
393 }
394 if !np.properties.is_empty() {
395 return Err(QueryError::Compile(
396 "event nodes do not support inline property filters; \
397 use a WHERE clause on verb, outcome, or payload fields"
398 .into(),
399 ));
400 }
401 } else if is_observation_target {
402 where_parts.push(format!("{alias}.deleted_at IS NULL"));
403
404 let ns_filter = namespace_filter(&alias, opts, &mut params);
405 if !ns_filter.is_empty() {
406 where_parts.push(ns_filter.trim_start_matches(" AND ").to_string());
407 }
408
409 if let Some(ref kind) = np.kind {
410 where_parts.push(observation_kind_filter_predicate(
411 &alias,
412 kind,
413 &mut params,
414 ));
415 }
416
417 if let Some(ref et) = np.entity_type {
418 params.push(QueryValue::Text(et.clone()));
419 where_parts.push(format!("{alias}.entity_type = ?{}", params.len()));
420 }
421
422 let mut props: Vec<_> = np.properties.iter().collect();
423 props.sort_by_key(|(k, _)| k.as_str());
424 for (key, val) in props {
425 let text_column = if key == "name" || key == "content" {
426 Some(key.as_str())
427 } else {
428 None
429 };
430 where_parts.push(compile_property_equality(
431 &alias,
432 key,
433 val,
434 text_column,
435 &mut params,
436 )?);
437 }
438 } else {
439 where_parts.push(format!("{alias}.deleted_at IS NULL"));
440
441 let ns_filter = namespace_filter(&alias, opts, &mut params);
442 if !ns_filter.is_empty() {
443 where_parts.push(ns_filter.trim_start_matches(" AND ").to_string());
444 }
445
446 if let Some(ref kind) = np.kind {
447 where_parts.push(kind_filter_predicate(&alias, kind, &mut params));
448 }
449
450 if let Some(ref et) = np.entity_type {
451 params.push(QueryValue::Text(et.clone()));
452 where_parts.push(format!("{alias}.entity_type = ?{}", params.len()));
453 }
454
455 let mut props: Vec<_> = np.properties.iter().collect();
456 props.sort_by_key(|(k, _)| k.as_str());
457 for (key, val) in props {
458 let text_column = if key == "name" { Some("name") } else { None };
459 where_parts.push(compile_property_equality(
460 &alias,
461 key,
462 val,
463 text_column,
464 &mut params,
465 )?);
466 }
467 }
468
469 if let Some(ref var) = np.variable {
470 let kind = if is_event_source {
471 VarKind::EventNode
472 } else if is_observation_target {
473 VarKind::ObservationTargetNode
474 } else {
475 VarKind::Node
476 };
477 var_to_alias.insert(var.clone(), (alias.clone(), kind));
478 }
479
480 node_idx += 1;
481 }
482 PatternElement::Edge(ep) => {
483 let e_alias = format!("e{edge_idx}");
484 let prev_node = &node_aliases[node_aliases.len() - 1];
485 let next_alias = format!("n{}", node_idx);
486
487 let has_synthetic = ep.relations.iter().any(|r| is_synthetic(r));
489 let has_canonical = ep.relations.iter().any(|r| !is_synthetic(r));
490 if has_synthetic && has_canonical {
491 return Err(QueryError::Compile(
492 "cannot mix synthetic observed_as_* relations with canonical edge relations \
493 in a single edge pattern"
494 .into(),
495 ));
496 }
497
498 if has_synthetic {
499 order_parts.push(format!("{e_alias}.event_id"));
501 order_parts.push(format!("{e_alias}.role"));
502 order_parts.push(format!("{e_alias}.position"));
503 if !matches!(ep.direction, EdgeDirection::Out) {
505 return Err(QueryError::Compile(
506 "synthetic observed_as_* edges are always event → entity (outbound only)".into(),
507 ));
508 }
509 join_parts.push(format!(
510 "JOIN event_observations {e_alias} ON {e_alias}.event_id = {prev_node}.id"
511 ));
512 let roles: Vec<&'static str> = ep
513 .relations
514 .iter()
515 .filter_map(|r| synthetic_role(r))
516 .collect();
517 if roles.len() == 1 {
518 params.push(QueryValue::Text(roles[0].to_string()));
519 where_parts.push(format!("{e_alias}.role = ?{}", params.len()));
520 } else if roles.len() > 1 {
521 let placeholders = in_placeholders(roles.iter().map(|r| {
522 params.push(QueryValue::Text(r.to_string()));
523 params.len()
524 }));
525 where_parts.push(format!("{e_alias}.role IN ({})", placeholders));
526 }
527 join_parts.push(format!(
529 "JOIN {} ON {next_alias}.id = {e_alias}.entity_id \
530 AND {next_alias}.referent_kind = {e_alias}.referent_kind",
531 observation_target_source(&next_alias)
532 ));
533 where_parts.push(format!(
535 "(({e_alias}.role IN ('candidate', 'selected') AND {e_alias}.referent_kind IN ('entity', 'note')) \
536 OR ({e_alias}.role = 'target' AND {e_alias}.referent_kind IN ('entity', 'note')) \
537 OR ({e_alias}.role = 'signal' AND {e_alias}.referent_kind IN ('entity', 'note')))"
538 ));
539 } else {
540 order_parts.push(format!("{e_alias}.id"));
541 let (source_join, target_join) = match ep.direction {
542 EdgeDirection::Out => (
543 format!("{e_alias}.source_id = {prev_node}.id"),
544 "target_id",
545 ),
546 EdgeDirection::In => (
547 format!("{e_alias}.target_id = {prev_node}.id"),
548 "source_id",
549 ),
550 EdgeDirection::Both => (
551 format!(
552 "({e_alias}.source_id = {prev_node}.id OR {e_alias}.target_id = {prev_node}.id)"
553 ),
554 "CASE_BOTH",
555 ),
556 };
557
558 let next_join_col = if target_join == "CASE_BOTH" {
559 format!(
560 "CASE WHEN {e_alias}.source_id = {prev_node}.id THEN {e_alias}.target_id ELSE {e_alias}.source_id END"
561 )
562 } else {
563 format!("{e_alias}.{target_join}")
564 };
565
566 join_parts.push(format!(
567 "JOIN graph_edges {e_alias} ON {source_join} AND {e_alias}.deleted_at IS NULL"
568 ));
569
570 let ens_filter = namespace_filter(&e_alias, opts, &mut params);
571 if !ens_filter.is_empty() {
572 where_parts.push(ens_filter.trim_start_matches(" AND ").to_string());
573 }
574
575 join_parts.push(format!(
576 "JOIN {} ON {next_alias}.id = {next_join_col}",
577 primary_node_source(&next_alias)
578 ));
579
580 if !ep.relations.is_empty() {
581 if ep.relations.len() == 1 {
582 params.push(QueryValue::Text(ep.relations[0].clone()));
583 where_parts.push(format!("{e_alias}.relation = ?{}", params.len()));
584 } else {
585 let placeholders = in_placeholders(ep.relations.iter().map(|r| {
586 params.push(QueryValue::Text(r.clone()));
587 params.len()
588 }));
589 where_parts.push(format!("{e_alias}.relation IN ({})", placeholders));
590 }
591 }
592 }
593
594 if let Some(ref var) = ep.variable {
595 var_to_alias.insert(var.clone(), (e_alias.clone(), VarKind::Edge));
596 }
597
598 edge_idx += 1;
599 }
600 }
601 }
602
603 if let Some(where_sql) = compile_where_expr(&query.where_clause, &var_to_alias, &mut params)? {
604 where_parts.push(where_sql);
605 }
606
607 for item in &query.return_items {
608 let var = item.variable();
609 if let Some((alias, kind)) = var_to_alias.get(var) {
610 match item {
611 ReturnItem::Property(_, prop) => {
612 let col = property_to_column(prop, kind)?;
613 let output_alias = quote_identifier(&format!("{var}_{prop}"));
614 select_parts.push(format!("{alias}.{col} AS {output_alias}"));
615 }
616 ReturnItem::Variable(_) => {
617 let columns: &[(&str, &str)] = match kind {
618 VarKind::Node => &[
619 ("id", "id"),
620 ("namespace", "namespace"),
621 ("kind", "kind"),
622 ("entity_type", "entity_type"),
623 ("name", "name"),
624 ("properties", "properties"),
625 ("created_at", "created_at"),
626 ("updated_at", "updated_at"),
627 ],
628 VarKind::ObservationTargetNode => &[
629 ("id", "id"),
630 ("namespace", "namespace"),
631 ("kind", "kind"),
632 ("entity_type", "entity_type"),
633 ("status", "status"),
634 ("content", "content"),
635 ("salience", "salience"),
636 ("properties", "properties"),
637 ("created_at", "created_at"),
638 ("updated_at", "updated_at"),
639 ("referent_kind", "referent_kind"),
640 ],
641 VarKind::EventNode => &[
642 ("id", "id"),
643 ("namespace", "namespace"),
644 ("verb", "verb"),
645 ("substrate", "substrate"),
646 ("actor", "actor"),
647 ("kind", "kind"),
648 ("outcome", "outcome"),
649 ("payload", "payload"),
650 ("created_at", "created_at"),
651 ],
652 VarKind::Edge => &[
653 ("id", "id"),
654 ("source_id", "source"),
655 ("target_id", "target"),
656 ("relation", "relation"),
657 ("weight", "weight"),
658 ],
659 };
660 for (column, suffix) in columns {
661 let output_alias = quote_identifier(&format!("{var}_{suffix}"));
662 select_parts.push(format!("{alias}.{column} AS {output_alias}"));
663 }
664 }
665 }
666 } else {
667 return Err(QueryError::Compile(format!(
668 "unknown variable '{var}' in RETURN clause"
669 )));
670 }
671 }
672
673 let offset_i64 = i64::try_from(query.offset)
674 .map_err(|_| QueryError::InvalidInput("SKIP exceeds i64::MAX".into()))?;
675 params.push(QueryValue::Integer(offset_i64));
676 let offset_param = params.len();
677
678 let (limit, truncation_check) = effective_limit(query.limit, query.offset, opts.max_limit);
679 let limit_i64 = i64::try_from(limit)
680 .map_err(|_| QueryError::InvalidInput("limit exceeds i64::MAX".into()))?;
681 params.push(QueryValue::Integer(limit_i64));
682 let limit_param = params.len();
683
684 let sql = format!(
685 "SELECT {} FROM {} {} WHERE {} ORDER BY {} LIMIT ?{} OFFSET ?{}",
686 select_parts.join(", "),
687 from_parts.join(", "),
688 join_parts.join(" "),
689 where_parts.join(" AND "),
690 order_parts.join(", "),
691 limit_param,
692 offset_param,
693 );
694
695 Ok(CompiledQuery {
696 sql,
697 params,
698 return_vars: query.return_items.clone(),
699 warnings: Vec::new(),
700 truncation_check,
701 })
702}
703
704fn compile_where_expr(
706 expr: &WhereExpr,
707 var_to_alias: &std::collections::HashMap<String, (String, VarKind)>,
708 params: &mut Vec<QueryValue>,
709) -> Result<Option<String>, QueryError> {
710 match expr {
711 WhereExpr::True => Ok(None),
712 WhereExpr::Condition(cond) => {
713 let sql = compile_single_condition(cond, var_to_alias, params)?;
714 Ok(Some(sql))
715 }
716 WhereExpr::And(l, r) => {
717 let ls = compile_where_expr(l, var_to_alias, params)?;
718 let rs = compile_where_expr(r, var_to_alias, params)?;
719 Ok(match (ls, rs) {
720 (None, None) => None,
721 (Some(s), None) | (None, Some(s)) => Some(s),
722 (Some(l), Some(r)) => Some(format!("{l} AND {r}")),
723 })
724 }
725 WhereExpr::Or(l, r) => {
726 let ls = compile_where_expr(l, var_to_alias, params)?;
727 let rs = compile_where_expr(r, var_to_alias, params)?;
728 Ok(match (ls, rs) {
729 (None, None) => None,
730 (Some(s), None) | (None, Some(s)) => Some(s),
731 (Some(l), Some(r)) => Some(format!("({l} OR {r})")),
732 })
733 }
734 }
735}
736
737fn compile_single_condition(
738 cond: &Condition,
739 var_to_alias: &std::collections::HashMap<String, (String, VarKind)>,
740 params: &mut Vec<QueryValue>,
741) -> Result<String, QueryError> {
742 let (alias, kind) = var_to_alias.get(&cond.variable).ok_or_else(|| {
743 QueryError::Compile(format!(
744 "unknown variable '{}' in WHERE clause",
745 cond.variable
746 ))
747 })?;
748
749 let col_expr = where_property_expression(&cond.property, kind, alias)?;
750
751 compile_condition_predicate(&col_expr, cond, params)
752}
753
754fn bind_condition_value(
755 value: &ConditionValue,
756 params: &mut Vec<QueryValue>,
757) -> Result<usize, QueryError> {
758 match value {
759 ConditionValue::String(s) => params.push(QueryValue::Text(s.clone())),
760 ConditionValue::Integer(n) => params.push(QueryValue::Integer(*n)),
761 ConditionValue::Number(n) => {
762 if !n.is_finite() {
763 return Err(QueryError::InvalidInput(
764 "non-finite float (NaN or Infinity) is not a valid query parameter".into(),
765 ));
766 }
767 params.push(QueryValue::Float(*n));
768 }
769 ConditionValue::Bool(b) => {
770 params.push(QueryValue::Integer(if *b { 1 } else { 0 }));
771 }
772 ConditionValue::List(_) | ConditionValue::Null => {
773 return Err(QueryError::Validation(
774 "operator requires a scalar value".into(),
775 ));
776 }
777 }
778 Ok(params.len())
779}
780
781fn compile_condition_predicate(
782 col_expr: &str,
783 cond: &Condition,
784 params: &mut Vec<QueryValue>,
785) -> Result<String, QueryError> {
786 match cond.op {
787 CompareOp::Contains | CompareOp::StartsWith => {
788 let ConditionValue::String(value) = &cond.value else {
789 return Err(QueryError::Validation(
790 "CONTAINS and STARTS WITH require a string literal".into(),
791 ));
792 };
793 let escaped = khive_types::escape_like_literal(value);
794 let pattern = if cond.op == CompareOp::Contains {
795 format!("%{escaped}%")
796 } else {
797 format!("{escaped}%")
798 };
799 params.push(QueryValue::Text(pattern));
800 Ok(format!(
801 "{col_expr} LIKE ?{} COLLATE NOCASE ESCAPE '\\'",
802 params.len()
803 ))
804 }
805 CompareOp::In => {
806 let ConditionValue::List(values) = &cond.value else {
807 return Err(QueryError::Validation("IN requires a list literal".into()));
808 };
809 if values.is_empty() {
810 return Ok("0".into());
811 }
812 let has_string = values
813 .iter()
814 .any(|value| matches!(value, ConditionValue::String(_)));
815 let placeholders = values
816 .iter()
817 .map(|value| bind_condition_value(value, params))
818 .collect::<Result<Vec<_>, _>>()?;
819 let collate = if has_string { " COLLATE NOCASE" } else { "" };
820 Ok(format!(
821 "{col_expr}{collate} IN ({})",
822 in_placeholders(placeholders)
823 ))
824 }
825 CompareOp::IsNotNull => {
826 if !matches!(cond.value, ConditionValue::Null) {
827 return Err(QueryError::Validation(
828 "IS NOT NULL does not accept a value".into(),
829 ));
830 }
831 Ok(format!("{col_expr} IS NOT NULL"))
832 }
833 CompareOp::IsNull => {
834 if !matches!(cond.value, ConditionValue::Null) {
835 return Err(QueryError::Validation(
836 "IS NULL does not accept a value".into(),
837 ));
838 }
839 Ok(format!("{col_expr} IS NULL"))
840 }
841 op => {
842 let op_str = match op {
843 CompareOp::Eq => "=",
844 CompareOp::Neq => "!=",
845 CompareOp::Gt => ">",
846 CompareOp::Lt => "<",
847 CompareOp::Gte => ">=",
848 CompareOp::Lte => "<=",
849 CompareOp::Like => "LIKE",
850 CompareOp::Contains
851 | CompareOp::StartsWith
852 | CompareOp::In
853 | CompareOp::IsNotNull
854 | CompareOp::IsNull => unreachable!(),
855 };
856 let is_string = matches!(cond.value, ConditionValue::String(_));
857 let param_index = bind_condition_value(&cond.value, params)?;
858 let collate = if is_string && matches!(op, CompareOp::Eq | CompareOp::Like) {
859 " COLLATE NOCASE"
860 } else {
861 ""
862 };
863 Ok(format!("{col_expr} {op_str} ?{param_index}{collate}"))
864 }
865 }
866}
867
868fn expr_endpoint_set(
869 expr: &WhereExpr,
870 start_var: Option<&str>,
871 end_var: Option<&str>,
872) -> (bool, bool) {
873 match expr {
874 WhereExpr::True => (false, false),
875 WhereExpr::Condition(c) => {
876 let is_start = start_var == Some(c.variable.as_str());
877 let is_end = end_var == Some(c.variable.as_str());
878 (is_start, is_end)
879 }
880 WhereExpr::And(l, r) | WhereExpr::Or(l, r) => {
881 let (ls, le) = expr_endpoint_set(l, start_var, end_var);
882 let (rs, re) = expr_endpoint_set(r, start_var, end_var);
883 (ls || rs, le || re)
884 }
885 }
886}
887
888fn reject_or_spanning_endpoints(
890 expr: &WhereExpr,
891 start: &NodePattern,
892 end: &NodePattern,
893) -> Result<(), QueryError> {
894 let start_var = start.variable.as_deref();
895 let end_var = end.variable.as_deref();
896 reject_or_spanning_impl(expr, start_var, end_var)
897}
898
899fn reject_or_spanning_impl(
900 expr: &WhereExpr,
901 start_var: Option<&str>,
902 end_var: Option<&str>,
903) -> Result<(), QueryError> {
904 match expr {
905 WhereExpr::True | WhereExpr::Condition(_) => Ok(()),
906 WhereExpr::And(l, r) => {
907 reject_or_spanning_impl(l, start_var, end_var)?;
908 reject_or_spanning_impl(r, start_var, end_var)
909 }
910 WhereExpr::Or(l, r) => {
911 let (l_start, l_end) = expr_endpoint_set(l, start_var, end_var);
912 let (r_start, r_end) = expr_endpoint_set(r, start_var, end_var);
913 let spans_start = l_start || r_start;
914 let spans_end = l_end || r_end;
915 if spans_start && spans_end {
916 return Err(QueryError::Unsupported(
917 "WHERE clauses that span both endpoints in a variable-length pattern \
918 are not yet supported; rewrite as separate queries or restrict each \
919 OR branch to one endpoint"
920 .into(),
921 ));
922 }
923 reject_or_spanning_impl(l, start_var, end_var)?;
924 reject_or_spanning_impl(r, start_var, end_var)
925 }
926 }
927}
928
929fn compile_var_len_condition(
930 cond: &Condition,
931 start_var: Option<&str>,
932 end_var: Option<&str>,
933 params: &mut Vec<QueryValue>,
934) -> Result<(String, &'static str), QueryError> {
935 let col_alias = if start_var == Some(cond.variable.as_str()) {
936 "s"
937 } else if end_var == Some(cond.variable.as_str()) {
938 "r"
939 } else {
940 return Err(QueryError::Compile(format!(
941 "variable '{}' in WHERE not supported in variable-length pattern \
942 (only start/end node variables)",
943 cond.variable
944 )));
945 };
946
947 let col_expr = where_property_expression(&cond.property, &VarKind::Node, col_alias)?;
948
949 let sql = compile_condition_predicate(&col_expr, cond, params)?;
950 Ok((sql, col_alias))
951}
952
953fn compile_variable_length_where(
955 expr: &WhereExpr,
956 start_var: Option<&str>,
957 end_var: Option<&str>,
958 params: &mut Vec<QueryValue>,
959 start_conditions: &mut Vec<String>,
960 end_conditions: &mut Vec<String>,
961) -> Result<Option<String>, QueryError> {
962 match expr {
963 WhereExpr::True => Ok(None),
964 WhereExpr::Condition(cond) => {
965 let (sql, alias) = compile_var_len_condition(cond, start_var, end_var, params)?;
966 if alias == "s" {
967 start_conditions.push(sql);
968 } else {
969 end_conditions.push(sql);
970 }
971 Ok(None)
972 }
973 WhereExpr::And(l, r) => {
974 compile_variable_length_where(
975 l,
976 start_var,
977 end_var,
978 params,
979 start_conditions,
980 end_conditions,
981 )?;
982 compile_variable_length_where(
983 r,
984 start_var,
985 end_var,
986 params,
987 start_conditions,
988 end_conditions,
989 )?;
990 Ok(None)
991 }
992 WhereExpr::Or(l, r) => {
993 let l_sql = compile_variable_length_where_to_sql(l, start_var, end_var, params)?;
995 let r_sql = compile_variable_length_where_to_sql(r, start_var, end_var, params)?;
996 match (l_sql, r_sql) {
997 (None, None) => {}
998 (Some((ls, la)), None) => {
999 if la == "s" {
1000 start_conditions.push(ls);
1001 } else {
1002 end_conditions.push(ls);
1003 }
1004 }
1005 (None, Some((rs, ra))) => {
1006 if ra == "s" {
1007 start_conditions.push(rs);
1008 } else {
1009 end_conditions.push(rs);
1010 }
1011 }
1012 (Some((ls, la)), Some((rs, _ra))) => {
1013 let combined = format!("({ls} OR {rs})");
1014 if la == "s" {
1015 start_conditions.push(combined);
1016 } else {
1017 end_conditions.push(combined);
1018 }
1019 }
1020 }
1021 Ok(None)
1022 }
1023 }
1024}
1025
1026fn compile_variable_length_where_to_sql(
1028 expr: &WhereExpr,
1029 start_var: Option<&str>,
1030 end_var: Option<&str>,
1031 params: &mut Vec<QueryValue>,
1032) -> Result<Option<(String, &'static str)>, QueryError> {
1033 match expr {
1034 WhereExpr::True => Ok(None),
1035 WhereExpr::Condition(cond) => {
1036 let (sql, alias) = compile_var_len_condition(cond, start_var, end_var, params)?;
1037 Ok(Some((sql, alias)))
1038 }
1039 WhereExpr::And(l, r) => {
1040 let ls = compile_variable_length_where_to_sql(l, start_var, end_var, params)?;
1041 let rs = compile_variable_length_where_to_sql(r, start_var, end_var, params)?;
1042 Ok(match (ls, rs) {
1043 (None, None) => None,
1044 (Some(s), None) | (None, Some(s)) => Some(s),
1045 (Some((lsql, la)), Some((rsql, _))) => Some((format!("{lsql} AND {rsql}"), la)),
1046 })
1047 }
1048 WhereExpr::Or(l, r) => {
1049 let ls = compile_variable_length_where_to_sql(l, start_var, end_var, params)?;
1050 let rs = compile_variable_length_where_to_sql(r, start_var, end_var, params)?;
1051 Ok(match (ls, rs) {
1052 (None, None) => None,
1053 (Some(s), None) | (None, Some(s)) => Some(s),
1054 (Some((lsql, la)), Some((rsql, _))) => Some((format!("({lsql} OR {rsql})"), la)),
1055 })
1056 }
1057 }
1058}
1059
1060fn compile_variable_length(
1062 query: &GqlQuery,
1063 opts: &CompileOptions,
1064) -> Result<CompiledQuery, QueryError> {
1065 let mut params: Vec<QueryValue> = Vec::new();
1066 let mut var_to_alias: std::collections::HashMap<String, (String, VarKind)> =
1067 std::collections::HashMap::new();
1068
1069 let nodes: Vec<&NodePattern> = query.pattern.nodes().collect();
1071 let edges: Vec<&EdgePattern> = query.pattern.edges().collect();
1072
1073 if nodes.len() != 2 || edges.len() != 1 || query.pattern.elements.len() != 3 {
1074 return Err(QueryError::Unsupported(
1075 "variable-length patterns must be a single start_node -[*N..M]-> end_node \
1076 (mixed fixed/variable chains are not yet implemented)"
1077 .into(),
1078 ));
1079 }
1080
1081 let start = &nodes[0];
1082 let edge = &edges[0];
1083 let end = &nodes[1];
1084
1085 if edge.relations.iter().any(|r| is_synthetic(r)) {
1087 return Err(QueryError::Unsupported(
1088 "synthetic observed_as_* edges cannot be variable-length; \
1089 use a fixed-length edge pattern instead"
1090 .into(),
1091 ));
1092 }
1093
1094 let max_depth = edge.max_hops.min(MAX_DEPTH);
1095 let min_depth = edge.min_hops;
1096
1097 let mut start_conditions: Vec<String> = vec!["s.deleted_at IS NULL".to_string()];
1098 let ns_filter = namespace_filter("s", opts, &mut params);
1099 if !ns_filter.is_empty() {
1100 start_conditions.push(ns_filter.trim_start_matches(" AND ").to_string());
1101 }
1102
1103 if let Some(ref kind) = start.kind {
1104 start_conditions.push(kind_filter_predicate("s", kind, &mut params));
1105 }
1106 if let Some(ref et) = start.entity_type {
1107 params.push(QueryValue::Text(et.clone()));
1108 start_conditions.push(format!("s.entity_type = ?{}", params.len()));
1109 }
1110 let mut start_props: Vec<_> = start.properties.iter().collect();
1111 start_props.sort_by_key(|(k, _)| k.as_str());
1112 for (key, val) in start_props {
1113 let text_column = if key == "name" { Some("name") } else { None };
1114 start_conditions.push(compile_property_equality(
1115 "s",
1116 key,
1117 val,
1118 text_column,
1119 &mut params,
1120 )?);
1121 }
1122
1123 let mut relation_condition = String::new();
1124 if !edge.relations.is_empty() {
1125 if edge.relations.len() == 1 {
1126 params.push(QueryValue::Text(edge.relations[0].clone()));
1127 relation_condition = format!(" AND e.relation = ?{}", params.len());
1128 } else {
1129 let placeholders = in_placeholders(edge.relations.iter().map(|r| {
1130 params.push(QueryValue::Text(r.clone()));
1131 params.len()
1132 }));
1133 relation_condition = format!(" AND e.relation IN ({})", placeholders);
1134 }
1135 }
1136
1137 let e_ns_filter = namespace_filter("e", opts, &mut params);
1138
1139 let (seed_join, seed_next, recurse_join, recurse_next) = match edge.direction {
1140 EdgeDirection::Out => (
1141 "e.source_id = s.id",
1142 "e.target_id",
1143 "e.source_id = t.current_id",
1144 "e.target_id",
1145 ),
1146 EdgeDirection::In => (
1147 "e.target_id = s.id",
1148 "e.source_id",
1149 "e.target_id = t.current_id",
1150 "e.source_id",
1151 ),
1152 EdgeDirection::Both => (
1153 "(e.source_id = s.id OR e.target_id = s.id)",
1154 "CASE WHEN e.source_id = s.id THEN e.target_id ELSE e.source_id END",
1155 "(e.source_id = t.current_id OR e.target_id = t.current_id)",
1156 "CASE WHEN e.source_id = t.current_id THEN e.target_id ELSE e.source_id END",
1157 ),
1158 };
1159
1160 let next_node_ns_filter = namespace_filter("next_node", opts, &mut params);
1162
1163 let max_depth_i64 = i64::try_from(max_depth)
1164 .map_err(|_| QueryError::InvalidInput("max_depth exceeds i64::MAX".into()))?;
1165 params.push(QueryValue::Integer(max_depth_i64));
1166 let depth_param = params.len();
1167
1168 let mut end_conditions: Vec<String> = vec!["r.deleted_at IS NULL".to_string()];
1170 let r_ns_filter = namespace_filter("r", opts, &mut params);
1171 if !r_ns_filter.is_empty() {
1172 end_conditions.push(r_ns_filter.trim_start_matches(" AND ").to_string());
1173 }
1174 if let Some(ref kind) = end.kind {
1175 end_conditions.push(kind_filter_predicate("r", kind, &mut params));
1176 }
1177 if let Some(ref et) = end.entity_type {
1178 params.push(QueryValue::Text(et.clone()));
1179 end_conditions.push(format!("r.entity_type = ?{}", params.len()));
1180 }
1181 let mut end_props: Vec<_> = end.properties.iter().collect();
1182 end_props.sort_by_key(|(k, _)| k.as_str());
1183 for (key, val) in end_props {
1184 let text_column = if key == "name" { Some("name") } else { None };
1185 end_conditions.push(compile_property_equality(
1186 "r",
1187 key,
1188 val,
1189 text_column,
1190 &mut params,
1191 )?);
1192 }
1193
1194 reject_or_spanning_endpoints(&query.where_clause, start, end)?;
1195
1196 if let Some(where_sql) = compile_variable_length_where(
1197 &query.where_clause,
1198 start.variable.as_deref(),
1199 end.variable.as_deref(),
1200 &mut params,
1201 &mut start_conditions,
1202 &mut end_conditions,
1203 )? {
1204 start_conditions.push(where_sql);
1206 }
1207
1208 if min_depth > 0 {
1209 let min_depth_i64 = i64::try_from(min_depth)
1210 .map_err(|_| QueryError::InvalidInput("min_depth exceeds i64::MAX".into()))?;
1211 params.push(QueryValue::Integer(min_depth_i64));
1212 end_conditions.push(format!("t.depth >= ?{}", params.len()));
1213 }
1214
1215 let offset_i64 = i64::try_from(query.offset)
1216 .map_err(|_| QueryError::InvalidInput("SKIP exceeds i64::MAX".into()))?;
1217 params.push(QueryValue::Integer(offset_i64));
1218 let offset_param = params.len();
1219
1220 let (limit, truncation_check) = effective_limit(query.limit, query.offset, opts.max_limit);
1221 let limit_i64 = i64::try_from(limit)
1222 .map_err(|_| QueryError::InvalidInput("limit exceeds i64::MAX".into()))?;
1223 params.push(QueryValue::Integer(limit_i64));
1224 let limit_param = params.len();
1225
1226 if let Some(ref var) = start.variable {
1227 var_to_alias.insert(var.clone(), ("s".to_string(), VarKind::Node));
1228 }
1229 if let Some(ref var) = end.variable {
1230 var_to_alias.insert(var.clone(), ("r".to_string(), VarKind::Node));
1231 }
1232 if let Some(ref var) = edge.variable {
1233 var_to_alias.insert(var.clone(), ("e".to_string(), VarKind::Edge));
1234 }
1235
1236 let mut select_parts: Vec<String> = Vec::new();
1237 let mut projection_aliases: Vec<String> = Vec::new();
1238 let mut has_start = false;
1239
1240 for item in &query.return_items {
1241 let var = item.variable();
1242 if let Some((_, kind)) = var_to_alias.get(var) {
1243 match item {
1244 ReturnItem::Property(_, prop) => {
1245 let is_start = start.variable.as_deref() == Some(var);
1246 if matches!(kind, VarKind::EventNode | VarKind::ObservationTargetNode) {
1247 return Err(QueryError::Unsupported(
1248 "synthetic observed_as_* edges cannot be used in variable-length \
1249 patterns; use a fixed-length edge pattern instead"
1250 .into(),
1251 ));
1252 }
1253 if *kind == VarKind::Node {
1254 let tbl = if is_start { "s" } else { "r" };
1255 if is_start {
1256 has_start = true;
1257 }
1258 let col = property_to_column(prop, kind)?;
1259 let output_alias = quote_identifier(&format!("{var}_{prop}"));
1260 select_parts.push(format!("{tbl}.{col} AS {output_alias}"));
1261 projection_aliases.push(output_alias);
1262 } else {
1263 let col = match prop.as_str() {
1264 "id" => "via_edge",
1265 "relation" => "via_relation",
1266 "weight" => "via_weight",
1267 _ => {
1268 return Err(QueryError::Compile(format!(
1269 "unknown edge property '{prop}' in RETURN projection. \
1270 Valid: id, source_id, target_id, relation, weight"
1271 )));
1272 }
1273 };
1274 let output_alias = quote_identifier(&format!("{var}_{prop}"));
1275 select_parts.push(format!("t.{col} AS {output_alias}"));
1276 projection_aliases.push(output_alias);
1277 }
1278 }
1279 ReturnItem::Variable(_) => match kind {
1280 VarKind::Node => {
1281 let tbl = if start.variable.as_deref() == Some(var) {
1282 has_start = true;
1283 "s"
1284 } else {
1285 "r"
1286 };
1287 for column in [
1288 "id",
1289 "namespace",
1290 "kind",
1291 "entity_type",
1292 "name",
1293 "properties",
1294 "created_at",
1295 "updated_at",
1296 ] {
1297 let output_alias = quote_identifier(&format!("{var}_{column}"));
1298 select_parts.push(format!("{tbl}.{column} AS {output_alias}"));
1299 projection_aliases.push(output_alias);
1300 }
1301 }
1302 VarKind::EventNode | VarKind::ObservationTargetNode => {
1303 return Err(QueryError::Unsupported(
1304 "synthetic observed_as_* edges cannot be used in variable-length \
1305 patterns; use a fixed-length edge pattern instead"
1306 .into(),
1307 ));
1308 }
1309 VarKind::Edge => {
1310 for (column, suffix) in [
1311 ("via_edge", "id"),
1312 ("via_relation", "relation"),
1313 ("via_weight", "weight"),
1314 ] {
1315 let output_alias = quote_identifier(&format!("{var}_{suffix}"));
1316 select_parts.push(format!("t.{column} AS {output_alias}"));
1317 projection_aliases.push(output_alias);
1318 }
1319 }
1320 },
1321 }
1322 } else {
1323 return Err(QueryError::Compile(format!(
1324 "unknown variable '{var}' in RETURN clause"
1325 )));
1326 }
1327 }
1328
1329 let depth_alias = quote_identifier("_depth");
1330 let total_weight_alias = quote_identifier("_total_weight");
1331 select_parts.push(format!("t.depth AS {depth_alias}"));
1332 select_parts.push(format!("t.total_weight AS {total_weight_alias}"));
1333
1334 projection_aliases.retain(|alias| alias != &depth_alias && alias != &total_weight_alias);
1340 let projection_order = projection_aliases.join(", ");
1341 let projection_order_suffix = if projection_order.is_empty() {
1342 String::new()
1343 } else {
1344 format!(", {projection_order}")
1345 };
1346
1347 let join_start = if has_start {
1349 "JOIN primary_nodes s ON s.id = t.start_id"
1350 } else {
1351 ""
1352 };
1353 let join_end = "JOIN primary_nodes r ON r.id = t.current_id";
1354
1355 let next_node_ns_and = if next_node_ns_filter.is_empty() {
1356 String::new()
1357 } else {
1358 format!(" AND {}", next_node_ns_filter.trim_start_matches(" AND "))
1359 };
1360
1361 let sql = format!(
1362 "WITH RECURSIVE primary_nodes AS ({primary_nodes}), \
1363 traverse(start_id, current_id, depth, path, total_weight, via_edge, via_relation, via_weight) AS (\
1364 SELECT s.id, {seed_next}, 1, s.id || ',' || {seed_next}, e.weight, \
1365 e.id, e.relation, e.weight \
1366 FROM primary_nodes s \
1367 JOIN graph_edges e ON {seed_join} AND e.deleted_at IS NULL{e_ns_filter}{relation_condition} \
1368 JOIN primary_nodes next_node ON next_node.id = ({seed_next}) \
1369 AND next_node.deleted_at IS NULL{next_node_ns_and} \
1370 WHERE {start_where} \
1371 UNION ALL \
1372 SELECT t.start_id, {recurse_next}, t.depth + 1, \
1373 t.path || ',' || {recurse_next}, \
1374 t.total_weight + e.weight, \
1375 e.id, e.relation, e.weight \
1376 FROM traverse t CROSS JOIN graph_edges e \
1377 ON {recurse_join} AND e.deleted_at IS NULL{e_ns_filter}{relation_condition} \
1378 JOIN primary_nodes next_node ON next_node.id = ({recurse_next}) \
1379 AND next_node.deleted_at IS NULL{next_node_ns_and} \
1380 WHERE t.depth < ?{depth_param} \
1381 AND (',' || t.path || ',') NOT LIKE '%,' || {recurse_next} || ',%' \
1382 ) \
1383 SELECT DISTINCT {select_cols} \
1384 FROM traverse t \
1385 {join_start} {join_end} \
1386 WHERE {end_where} \
1387 ORDER BY {depth_alias}, {total_weight_alias} DESC{projection_order_suffix} \
1388 LIMIT ?{limit_param} OFFSET ?{offset_param}",
1389 primary_nodes = PRIMARY_NODE_SQL,
1390 seed_next = seed_next,
1391 seed_join = seed_join,
1392 e_ns_filter = e_ns_filter,
1393 relation_condition = relation_condition,
1394 start_where = start_conditions.join(" AND "),
1395 recurse_next = recurse_next,
1396 recurse_join = recurse_join,
1397 next_node_ns_and = next_node_ns_and,
1398 depth_param = depth_param,
1399 select_cols = select_parts.join(", "),
1400 join_start = join_start,
1401 join_end = join_end,
1402 end_where = end_conditions.join(" AND "),
1403 projection_order_suffix = projection_order_suffix,
1404 limit_param = limit_param,
1405 offset_param = offset_param,
1406 );
1407
1408 Ok(CompiledQuery {
1409 sql,
1410 params,
1411 return_vars: query.return_items.clone(),
1412 warnings: Vec::new(),
1413 truncation_check,
1414 })
1415}
1416
1417#[derive(Clone, Copy, PartialEq, Eq)]
1418enum VarKind {
1419 Node,
1420 EventNode,
1422 ObservationTargetNode,
1424 Edge,
1425}
1426
1427const NODE_COLUMNS: &[&str] = &[
1428 "id",
1429 "name",
1430 "kind",
1431 "entity_type",
1432 "namespace",
1433 "description",
1434 "properties",
1435 "created_at",
1436 "updated_at",
1437];
1438const OBSERVATION_TARGET_COLUMNS: &[&str] = &[
1440 "id",
1441 "namespace",
1442 "kind",
1443 "entity_type",
1444 "status",
1445 "name",
1446 "content",
1447 "salience",
1448 "decay_factor",
1449 "properties",
1450 "created_at",
1451 "updated_at",
1452 "referent_kind",
1453];
1454const EVENT_COLUMNS: &[&str] = &[
1456 "id",
1457 "namespace",
1458 "verb",
1459 "substrate",
1460 "actor",
1461 "kind",
1462 "outcome",
1463 "payload",
1464 "duration_us",
1465 "target_id",
1466 "session_id",
1467 "created_at",
1468];
1469const EDGE_COLUMNS: &[&str] = &["id", "source_id", "target_id", "relation", "weight"];
1470const NODE_WHERE_COLUMNS: &[&str] = &[
1471 "id",
1472 "name",
1473 "kind",
1474 "entity_type",
1475 "description",
1476 "created_at",
1477 "updated_at",
1478];
1479const OBSERVATION_TARGET_WHERE_COLUMNS: &[&str] = &[
1480 "id",
1481 "kind",
1482 "entity_type",
1483 "status",
1484 "name",
1485 "content",
1486 "salience",
1487 "decay_factor",
1488 "created_at",
1489 "updated_at",
1490 "referent_kind",
1491];
1492const EVENT_WHERE_COLUMNS: &[&str] = &[
1493 "id",
1494 "verb",
1495 "substrate",
1496 "actor",
1497 "kind",
1498 "outcome",
1499 "payload",
1500 "duration_us",
1501 "target_id",
1502 "session_id",
1503 "created_at",
1504];
1505const EDGE_WHERE_COLUMNS: &[&str] = &["relation", "weight"];
1506
1507fn property_columns(kind: &VarKind) -> (&'static [&'static str], &'static str) {
1508 match kind {
1509 VarKind::Node => (NODE_COLUMNS, "node"),
1510 VarKind::ObservationTargetNode => (OBSERVATION_TARGET_COLUMNS, "observation target"),
1511 VarKind::EventNode => (EVENT_COLUMNS, "event"),
1512 VarKind::Edge => (EDGE_COLUMNS, "edge"),
1513 }
1514}
1515
1516fn property_to_column<'a>(prop: &'a str, kind: &VarKind) -> Result<&'a str, QueryError> {
1517 let (valid, kind_name) = property_columns(kind);
1518 if valid.contains(&prop) {
1519 Ok(prop)
1520 } else {
1521 Err(QueryError::Compile(format!(
1522 "unknown {kind_name} property '{prop}' in RETURN projection. \
1523 Valid: {}",
1524 valid.join(", ")
1525 )))
1526 }
1527}
1528
1529fn where_property_columns(kind: &VarKind) -> (&'static [&'static str], &'static str) {
1530 match kind {
1531 VarKind::Node => (NODE_WHERE_COLUMNS, "node"),
1532 VarKind::ObservationTargetNode => (OBSERVATION_TARGET_WHERE_COLUMNS, "observation target"),
1533 VarKind::EventNode => (EVENT_WHERE_COLUMNS, "event"),
1534 VarKind::Edge => (EDGE_WHERE_COLUMNS, "edge"),
1535 }
1536}
1537
1538fn where_property_expression(
1539 property: &PropertyRef,
1540 kind: &VarKind,
1541 alias: &str,
1542) -> Result<String, QueryError> {
1543 let (valid, kind_name) = where_property_columns(kind);
1544 match property {
1545 PropertyRef::Field(field) if valid.contains(&field.as_str()) => {
1546 Ok(format!("{alias}.{field}"))
1547 }
1548 PropertyRef::Field(field) if field == "properties" => Err(QueryError::Compile(
1549 "'properties' is reserved as the JSON path root in WHERE clauses; \
1550 use 'properties.<path>'"
1551 .into(),
1552 )),
1553 PropertyRef::Field(field) => Err(QueryError::Compile(format!(
1554 "unknown {kind_name} field '{field}' in WHERE clause. Valid fields: {}; \
1555 JSON properties must use 'properties.<path>'",
1556 valid.join(", ")
1557 ))),
1558 PropertyRef::JsonPath(path) => {
1559 let (projection_columns, _) = property_columns(kind);
1560 if !projection_columns.contains(&"properties") {
1561 return Err(QueryError::Compile(format!(
1562 "{kind_name} variables do not expose JSON properties in WHERE clauses. \
1563 Valid fields: {}",
1564 valid.join(", ")
1565 )));
1566 }
1567 if path.is_empty() {
1568 return Err(QueryError::Compile(
1569 "JSON property path cannot be empty; use 'properties.<path>'".into(),
1570 ));
1571 }
1572 if let Some(segment) = path.iter().find(|segment| {
1573 segment.is_empty() || !segment.chars().all(|ch| ch.is_alphanumeric() || ch == '_')
1574 }) {
1575 return Err(QueryError::Compile(format!(
1576 "invalid JSON property path segment '{segment}'; \
1577 path segments must be identifiers"
1578 )));
1579 }
1580 Ok(format!(
1581 "json_extract({alias}.properties, '$.{}')",
1582 path.join(".")
1583 ))
1584 }
1585 }
1586}
1587
1588#[cfg(test)]
1590#[path = "sql_tests.rs"]
1591mod tests;