1use super::*;
2
3pub(super) fn materialize_graph(store: &UnifiedStore) -> RedDBResult<GraphStore> {
4 materialize_graph_with_projection(store, None)
5}
6
7pub(super) fn materialize_graph_with_projection(
8 store: &UnifiedStore,
9 projection: Option<&RuntimeGraphProjection>,
10) -> RedDBResult<GraphStore> {
11 let graph = GraphStore::new();
12 let snap_ctx = crate::runtime::impl_core::capture_current_snapshot();
17 let entities = store.query_all(move |e| {
18 crate::runtime::impl_core::entity_visible_with_context(snap_ctx.as_ref(), e)
19 });
20 let node_label_filters = projection
21 .and_then(|projection| normalize_token_filter_list(projection.node_labels.clone()));
22 let node_type_filters = projection
23 .and_then(|projection| normalize_token_filter_list(projection.node_types.clone()));
24 let edge_label_filters = projection
25 .and_then(|projection| normalize_token_filter_list(projection.edge_labels.clone()));
26 let mut allowed_nodes = HashSet::new();
27
28 for (_, entity) in &entities {
29 if let EntityKind::GraphNode(ref node) = &entity.kind {
30 if !matches_graph_node_projection(
31 &node.label,
32 &node.node_type,
33 node_label_filters.as_ref(),
34 node_type_filters.as_ref(),
35 ) {
36 continue;
37 }
38 graph
39 .add_node_with_label(
40 &entity.id.raw().to_string(),
41 &node.label,
42 &graph_node_label(&node.node_type),
43 )
44 .map_err(|err| RedDBError::Query(err.to_string()))?;
45 allowed_nodes.insert(entity.id.raw().to_string());
46 }
47 }
48
49 for (_, entity) in &entities {
50 if let EntityKind::GraphEdge(ref edge) = &entity.kind {
51 if !allowed_nodes.contains(&edge.from_node) || !allowed_nodes.contains(&edge.to_node) {
52 continue;
53 }
54 if !matches_graph_edge_projection(&edge.label, edge_label_filters.as_ref()) {
55 continue;
56 }
57 let resolved_weight = match &entity.data {
58 EntityData::Edge(e) => e.weight,
59 _ => edge.weight as f32 / 1000.0,
60 };
61
62 graph
63 .add_edge_with_label(
64 &edge.from_node,
65 &edge.to_node,
66 &graph_edge_label(&edge.label),
67 resolved_weight,
68 )
69 .map_err(|err| RedDBError::Query(err.to_string()))?;
70 }
71 }
72
73 Ok(graph)
74}
75
76pub(super) fn materialize_graph_lazy(
79 store: &UnifiedStore,
80 seed_entity_ids: &[u64],
81 max_depth: usize,
82) -> RedDBResult<GraphStore> {
83 let graph = GraphStore::new();
84 let mut visited_nodes: HashSet<String> = HashSet::new();
85 let mut queue: VecDeque<(String, usize)> = VecDeque::new();
86
87 for &id in seed_entity_ids {
89 let id_str = id.to_string();
90 if visited_nodes.contains(&id_str) {
91 continue;
92 }
93 if let Some((_, entity)) = store.get_any(EntityId::new(id)) {
94 if let EntityKind::GraphNode(ref node) = &entity.kind {
95 let _ = graph.add_node_with_label(
96 &id_str,
97 &node.label,
98 &graph_node_label(&node.node_type),
99 );
100 visited_nodes.insert(id_str.clone());
101 queue.push_back((id_str, 0));
102 }
103 }
104 }
105
106 let collections = store.list_collections();
109 let use_parallel = collections.len() > 1 && crate::runtime::SystemInfo::should_parallelize();
110 let all_edges: Vec<UnifiedEntity> = if use_parallel {
111 let store_ref = &store;
112 let edge_batches: Vec<Vec<UnifiedEntity>> = std::thread::scope(|s| {
113 collections
114 .iter()
115 .map(|col| {
116 s.spawn(move || {
117 store_ref
118 .get_collection(col)
119 .map(|m| m.query_all(|e| matches!(e.kind, EntityKind::GraphEdge(_))))
120 .unwrap_or_default()
121 })
122 })
123 .collect::<Vec<_>>()
124 .into_iter()
125 .map(|h| h.join().unwrap_or_default())
126 .collect()
127 });
128 edge_batches.into_iter().flatten().collect()
129 } else {
130 collections
131 .iter()
132 .flat_map(|col| {
133 store
134 .get_collection(col)
135 .map(|m| m.query_all(|e| matches!(e.kind, EntityKind::GraphEdge(_))))
136 .unwrap_or_default()
137 })
138 .collect()
139 };
140
141 let mut adjacency: HashMap<String, Vec<(String, String, String, f32)>> = HashMap::new();
143 for entity in &all_edges {
144 if let EntityKind::GraphEdge(ref edge) = &entity.kind {
145 let w = match &entity.data {
146 EntityData::Edge(e) => e.weight,
147 _ => edge.weight as f32 / 1000.0,
148 };
149 adjacency.entry(edge.from_node.clone()).or_default().push((
150 edge.to_node.clone(),
151 edge.label.clone(),
152 entity.id.raw().to_string(),
153 w,
154 ));
155 adjacency.entry(edge.to_node.clone()).or_default().push((
156 edge.from_node.clone(),
157 edge.label.clone(),
158 entity.id.raw().to_string(),
159 w,
160 ));
161 }
162 }
163
164 while let Some((node_id, depth)) = queue.pop_front() {
165 if depth >= max_depth {
166 continue;
167 }
168 if let Some(neighbors) = adjacency.get(&node_id) {
169 for (neighbor_id, label, _edge_id, weight) in neighbors {
170 if !visited_nodes.contains(neighbor_id) {
172 if let Ok(parsed) = neighbor_id.parse::<u64>() {
173 if let Some((_, entity)) = store.get_any(EntityId::new(parsed)) {
174 if let EntityKind::GraphNode(ref node) = &entity.kind {
175 let _ = graph.add_node_with_label(
176 neighbor_id,
177 &node.label,
178 &graph_node_label(&node.node_type),
179 );
180 visited_nodes.insert(neighbor_id.clone());
181 queue.push_back((neighbor_id.clone(), depth + 1));
182 }
183 }
184 }
185 }
186 if visited_nodes.contains(neighbor_id) {
188 let _ = graph.add_edge_with_label(
189 &node_id,
190 neighbor_id,
191 &graph_edge_label(label),
192 *weight,
193 );
194 }
195 }
196 }
197 }
198
199 Ok(graph)
200}
201
202pub(super) fn materialize_graph_node_properties(
203 store: &UnifiedStore,
204) -> RedDBResult<HashMap<String, HashMap<String, Value>>> {
205 let mut node_properties = HashMap::new();
206
207 for (_, entity) in store.query_all(|_| true) {
208 if let (EntityKind::GraphNode(_), EntityData::Node(node)) = (&entity.kind, &entity.data) {
209 node_properties.insert(entity.id.raw().to_string(), node.properties.clone());
210 }
211 }
212
213 Ok(node_properties)
214}
215
216pub(super) fn normalize_token_filter_list(values: Option<Vec<String>>) -> Option<BTreeSet<String>> {
217 values
218 .map(|values| {
219 values
220 .into_iter()
221 .map(|value| normalize_graph_token(&value))
222 .filter(|value| !value.is_empty())
223 .collect::<BTreeSet<_>>()
224 })
225 .filter(|set| !set.is_empty())
226}
227
228pub(super) fn matches_graph_node_projection(
229 label: &str,
230 node_type: &str,
231 label_filters: Option<&BTreeSet<String>>,
232 node_type_filters: Option<&BTreeSet<String>>,
233) -> bool {
234 let label_ok =
235 label_filters.is_none_or(|filters| filters.contains(&normalize_graph_token(label)));
236 let node_type_ok =
237 node_type_filters.is_none_or(|filters| filters.contains(&normalize_graph_token(node_type)));
238 label_ok && node_type_ok
239}
240
241pub(super) fn matches_graph_edge_projection(
242 label: &str,
243 edge_filters: Option<&BTreeSet<String>>,
244) -> bool {
245 edge_filters.is_none_or(|filters| filters.contains(&normalize_graph_token(label)))
246}
247
248pub(super) fn ensure_graph_node(graph: &GraphStore, id: &str) -> RedDBResult<()> {
249 if graph.has_node(id) {
250 Ok(())
251 } else {
252 Err(RedDBError::NotFound(id.to_string()))
253 }
254}
255
256pub(super) fn stored_node_to_runtime(node: StoredNode) -> RuntimeGraphNode {
257 RuntimeGraphNode {
258 id: node.id,
259 label: node.label,
260 node_type: node.node_type.as_str().to_string(),
261 out_edge_count: node.out_edge_count,
262 in_edge_count: node.in_edge_count,
263 }
264}
265
266pub(super) fn path_to_runtime(
267 graph: &GraphStore,
268 path: &crate::storage::engine::pathfinding::Path,
269) -> RuntimeGraphPath {
270 let nodes = path
271 .nodes
272 .iter()
273 .filter_map(|id| graph.get_node(id))
274 .map(stored_node_to_runtime)
275 .collect();
276
277 let mut edges = Vec::new();
278 for index in 0..path.edge_types.len() {
279 let Some(source) = path.nodes.get(index) else {
280 continue;
281 };
282 let Some(target) = path.nodes.get(index + 1) else {
283 continue;
284 };
285 let Some(edge_type) = path.edge_types.get(index) else {
286 continue;
287 };
288 let weight = graph
289 .outgoing_edges(source)
290 .into_iter()
291 .find(|(candidate_type, candidate_target, _)| {
292 candidate_type.as_str() == edge_type.as_str() && candidate_target == target
293 })
294 .map(|(_, _, weight)| weight)
295 .unwrap_or(0.0);
296 edges.push(RuntimeGraphEdge {
297 source: source.clone(),
298 target: target.clone(),
299 edge_type: edge_type.as_str().to_string(),
300 weight,
301 });
302 }
303
304 RuntimeGraphPath {
305 hop_count: path.len(),
306 total_weight: path.total_weight,
307 nodes,
308 edges,
309 }
310}
311
312pub(super) fn cycle_to_runtime(
313 graph: &GraphStore,
314 cycle: crate::storage::engine::Cycle,
315) -> RuntimeGraphPath {
316 let nodes = cycle
317 .nodes
318 .iter()
319 .filter_map(|id| graph.get_node(id))
320 .map(stored_node_to_runtime)
321 .collect::<Vec<_>>();
322 let mut edges = Vec::new();
323 let mut total_weight = 0.0;
324
325 for window in cycle.nodes.windows(2) {
326 let Some(source) = window.first() else {
327 continue;
328 };
329 let Some(target) = window.get(1) else {
330 continue;
331 };
332 if let Some((edge_type, _, weight)) = graph
333 .outgoing_edges(source)
334 .into_iter()
335 .find(|(_, candidate_target, _)| candidate_target == target)
336 {
337 total_weight += weight as f64;
338 edges.push(RuntimeGraphEdge {
339 source: source.clone(),
340 target: target.clone(),
341 edge_type: edge_type.as_str().to_string(),
342 weight,
343 });
344 }
345 }
346
347 RuntimeGraphPath {
348 hop_count: cycle.length,
349 total_weight,
350 nodes,
351 edges,
352 }
353}
354
355pub(super) fn normalize_edge_filters(edge_labels: Option<Vec<String>>) -> Option<BTreeSet<String>> {
356 edge_labels
357 .map(|labels| {
358 labels
359 .into_iter()
360 .map(|label| normalize_graph_token(&label))
361 .filter(|label| !label.is_empty())
362 .collect()
363 })
364 .filter(|set: &BTreeSet<String>| !set.is_empty())
365}
366
367pub(super) fn merge_edge_filters(
368 edge_labels: Option<Vec<String>>,
369 projection: Option<&RuntimeGraphProjection>,
370) -> Option<BTreeSet<String>> {
371 let mut merged = BTreeSet::new();
372
373 if let Some(filters) = normalize_edge_filters(edge_labels) {
374 merged.extend(filters);
375 }
376
377 if let Some(filters) = projection
378 .and_then(|projection| normalize_token_filter_list(projection.edge_labels.clone()))
379 {
380 merged.extend(filters);
381 }
382
383 if merged.is_empty() {
384 None
385 } else {
386 Some(merged)
387 }
388}
389
390pub(super) fn merge_runtime_projection(
391 base: Option<RuntimeGraphProjection>,
392 overlay: Option<RuntimeGraphProjection>,
393) -> Option<RuntimeGraphProjection> {
394 let merge_list =
395 |left: Option<Vec<String>>, right: Option<Vec<String>>| -> Option<Vec<String>> {
396 let mut values = BTreeSet::new();
397 if let Some(left) = left {
398 values.extend(left);
399 }
400 if let Some(right) = right {
401 values.extend(right);
402 }
403 if values.is_empty() {
404 None
405 } else {
406 Some(values.into_iter().collect())
407 }
408 };
409
410 let _ = base.clone().or(overlay.clone())?;
411
412 Some(RuntimeGraphProjection {
413 node_labels: merge_list(
414 base.as_ref()
415 .and_then(|projection| projection.node_labels.clone()),
416 overlay
417 .as_ref()
418 .and_then(|projection| projection.node_labels.clone()),
419 ),
420 node_types: merge_list(
421 base.as_ref()
422 .and_then(|projection| projection.node_types.clone()),
423 overlay
424 .as_ref()
425 .and_then(|projection| projection.node_types.clone()),
426 ),
427 edge_labels: merge_list(
428 base.as_ref()
429 .and_then(|projection| projection.edge_labels.clone()),
430 overlay
431 .as_ref()
432 .and_then(|projection| projection.edge_labels.clone()),
433 ),
434 })
435}
436
437pub(super) fn edge_allowed(edge_label: &str, filters: Option<&BTreeSet<String>>) -> bool {
438 filters.is_none_or(|filters| filters.contains(&normalize_graph_token(edge_label)))
439}
440
441pub(super) fn graph_adjacent_edges(
442 graph: &GraphStore,
443 node: &str,
444 direction: RuntimeGraphDirection,
445 edge_filters: Option<&BTreeSet<String>>,
446) -> Vec<(String, RuntimeGraphEdge)> {
447 let mut adjacent = Vec::new();
448
449 if matches!(
450 direction,
451 RuntimeGraphDirection::Outgoing | RuntimeGraphDirection::Both
452 ) {
453 for (edge_type, target, weight) in graph.outgoing_edges(node) {
454 if edge_allowed(edge_type.as_str(), edge_filters) {
455 adjacent.push((
456 target.clone(),
457 RuntimeGraphEdge {
458 source: node.to_string(),
459 target,
460 edge_type: edge_type.as_str().to_string(),
461 weight,
462 },
463 ));
464 }
465 }
466 }
467
468 if matches!(
469 direction,
470 RuntimeGraphDirection::Incoming | RuntimeGraphDirection::Both
471 ) {
472 for (edge_type, source, weight) in graph.incoming_edges(node) {
473 if edge_allowed(edge_type.as_str(), edge_filters) {
474 adjacent.push((
475 source.clone(),
476 RuntimeGraphEdge {
477 source,
478 target: node.to_string(),
479 edge_type: edge_type.as_str().to_string(),
480 weight,
481 },
482 ));
483 }
484 }
485 }
486
487 adjacent
488}
489
490pub(super) fn push_runtime_edge(
491 edges: &mut Vec<RuntimeGraphEdge>,
492 seen_edges: &mut HashSet<(String, String, String, u32)>,
493 edge: RuntimeGraphEdge,
494) {
495 let key = (
496 edge.source.clone(),
497 edge.target.clone(),
498 edge.edge_type.clone(),
499 edge.weight.to_bits(),
500 );
501 if seen_edges.insert(key) {
502 edges.push(edge);
503 }
504}
505
506#[derive(Clone)]
507pub(super) struct RuntimeDijkstraState {
508 node: String,
509 cost: f64,
510}
511
512impl PartialEq for RuntimeDijkstraState {
513 fn eq(&self, other: &Self) -> bool {
514 self.node == other.node && self.cost == other.cost
515 }
516}
517
518impl Eq for RuntimeDijkstraState {}
519
520impl Ord for RuntimeDijkstraState {
521 fn cmp(&self, other: &Self) -> Ordering {
522 other
523 .cost
524 .partial_cmp(&self.cost)
525 .unwrap_or(Ordering::Equal)
526 }
527}
528
529impl PartialOrd for RuntimeDijkstraState {
530 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
531 Some(self.cmp(other))
532 }
533}
534
535pub(super) fn shortest_path_runtime(
536 graph: &GraphStore,
537 source: &str,
538 target: &str,
539 direction: RuntimeGraphDirection,
540 algorithm: RuntimeGraphPathAlgorithm,
541 edge_filters: Option<&BTreeSet<String>>,
542) -> RedDBResult<RuntimeGraphPathResult> {
543 let mut nodes_visited = 0;
544 let (path, negative_cycle_detected) = match algorithm {
545 RuntimeGraphPathAlgorithm::Bfs => {
546 let mut queue = VecDeque::new();
547 let mut visited = HashSet::new();
548 let mut previous: HashMap<String, (String, RuntimeGraphEdge)> = HashMap::new();
549
550 queue.push_back(source.to_string());
551 visited.insert(source.to_string());
552
553 while let Some(current) = queue.pop_front() {
554 nodes_visited += 1;
555 if current == target {
556 break;
557 }
558 let mut adjacent = graph_adjacent_edges(graph, ¤t, direction, edge_filters);
559 adjacent.sort_by(|left, right| left.0.cmp(&right.0));
560 for (neighbor, edge) in adjacent {
561 if visited.insert(neighbor.clone()) {
562 previous.insert(neighbor.clone(), (current.clone(), edge));
563 queue.push_back(neighbor);
564 }
565 }
566 }
567
568 (rebuild_runtime_path(graph, source, target, &previous), None)
569 }
570 RuntimeGraphPathAlgorithm::Dijkstra | RuntimeGraphPathAlgorithm::AStar => {
571 let mut dist: HashMap<String, f64> = HashMap::new();
572 let mut previous: HashMap<String, (String, RuntimeGraphEdge)> = HashMap::new();
573 let mut heap = BinaryHeap::new();
574
575 dist.insert(source.to_string(), 0.0);
576 heap.push(RuntimeDijkstraState {
577 node: source.to_string(),
578 cost: 0.0,
579 });
580
581 while let Some(RuntimeDijkstraState { node, cost }) = heap.pop() {
582 nodes_visited += 1;
583 if node == target {
584 break;
585 }
586 if let Some(best) = dist.get(&node) {
587 if cost > *best {
588 continue;
589 }
590 }
591
592 let mut adjacent = graph_adjacent_edges(graph, &node, direction, edge_filters);
593 adjacent.sort_by(|left, right| left.0.cmp(&right.0));
594 for (neighbor, edge) in adjacent {
595 let next_cost = cost + edge.weight as f64;
596 if dist.get(&neighbor).is_none_or(|best| next_cost < *best) {
597 dist.insert(neighbor.clone(), next_cost);
598 previous.insert(neighbor.clone(), (node.clone(), edge));
599 heap.push(RuntimeDijkstraState {
600 node: neighbor,
601 cost: next_cost,
602 });
603 }
604 }
605 }
606
607 (rebuild_runtime_path(graph, source, target, &previous), None)
608 }
609 RuntimeGraphPathAlgorithm::BellmanFord => {
610 let nodes: Vec<String> = graph.iter_nodes().map(|node| node.id.clone()).collect();
611 let mut dist: HashMap<String, f64> = nodes
612 .iter()
613 .map(|node| (node.clone(), f64::INFINITY))
614 .collect();
615 let mut previous: HashMap<String, (String, RuntimeGraphEdge)> = HashMap::new();
616
617 dist.insert(source.to_string(), 0.0);
618
619 for _ in 0..nodes.len().saturating_sub(1) {
620 let mut changed = false;
621
622 for node in &nodes {
623 nodes_visited += 1;
624 let Some(current_dist) = dist.get(node).copied() else {
625 continue;
626 };
627 if !current_dist.is_finite() {
628 continue;
629 }
630
631 let mut adjacent = graph_adjacent_edges(graph, node, direction, edge_filters);
632 adjacent.sort_by(|left, right| left.0.cmp(&right.0));
633 for (neighbor, edge) in adjacent {
634 let next_cost = current_dist + edge.weight as f64;
635 if dist.get(&neighbor).is_none_or(|best| next_cost < *best) {
636 dist.insert(neighbor.clone(), next_cost);
637 previous.insert(neighbor, (node.clone(), edge));
638 changed = true;
639 }
640 }
641 }
642
643 if !changed {
644 break;
645 }
646 }
647
648 let mut has_negative_cycle = false;
649 for node in &nodes {
650 let Some(current_dist) = dist.get(node).copied() else {
651 continue;
652 };
653 if !current_dist.is_finite() {
654 continue;
655 }
656
657 let adjacent = graph_adjacent_edges(graph, node, direction, edge_filters);
658 for (neighbor, edge) in adjacent {
659 let next_cost = current_dist + edge.weight as f64;
660 if dist.get(&neighbor).is_none_or(|best| next_cost < *best) {
661 has_negative_cycle = true;
662 break;
663 }
664 }
665
666 if has_negative_cycle {
667 break;
668 }
669 }
670
671 let path = if has_negative_cycle {
672 None
673 } else {
674 rebuild_runtime_path(graph, source, target, &previous)
675 };
676 (path, Some(has_negative_cycle))
677 }
678 };
679
680 Ok(RuntimeGraphPathResult {
681 source: source.to_string(),
682 target: target.to_string(),
683 direction,
684 algorithm,
685 nodes_visited,
686 negative_cycle_detected,
687 path,
688 })
689}
690
691pub(super) fn rebuild_runtime_path(
692 graph: &GraphStore,
693 source: &str,
694 target: &str,
695 previous: &HashMap<String, (String, RuntimeGraphEdge)>,
696) -> Option<RuntimeGraphPath> {
697 if source != target && !previous.contains_key(target) {
698 return None;
699 }
700
701 let mut node_ids = vec![target.to_string()];
702 let mut edges = Vec::new();
703 let mut current = target.to_string();
704
705 while current != source {
706 let (parent, edge) = previous.get(¤t)?.clone();
707 edges.push(edge);
708 node_ids.push(parent.clone());
709 current = parent;
710 }
711
712 node_ids.reverse();
713 edges.reverse();
714
715 let total_weight = edges.iter().map(|edge| edge.weight as f64).sum();
716 let nodes = node_ids
717 .iter()
718 .filter_map(|id| graph.get_node(id))
719 .map(stored_node_to_runtime)
720 .collect();
721
722 Some(RuntimeGraphPath {
723 hop_count: node_ids.len().saturating_sub(1),
724 total_weight,
725 nodes,
726 edges,
727 })
728}
729
730pub(super) fn top_runtime_scores(
731 graph: &GraphStore,
732 scores: HashMap<String, f64>,
733 top_k: usize,
734) -> Vec<RuntimeGraphCentralityScore> {
735 let mut pairs: Vec<_> = scores.into_iter().collect();
736 pairs.sort_by(|left, right| {
737 right
738 .1
739 .partial_cmp(&left.1)
740 .unwrap_or(Ordering::Equal)
741 .then_with(|| left.0.cmp(&right.0))
742 });
743 pairs.truncate(top_k.max(1));
744 pairs
745 .into_iter()
746 .filter_map(|(node_id, score)| {
747 graph
748 .get_node(&node_id)
749 .map(|node| RuntimeGraphCentralityScore {
750 node: stored_node_to_runtime(node),
751 score,
752 })
753 })
754 .collect()
755}
756
757pub(super) fn graph_node_label(input: &str) -> String {
762 let token = normalize_graph_token(input);
763 match token.as_str() {
764 "host" | "service" | "credential" | "vulnerability" | "endpoint" | "technology"
765 | "user" | "domain" | "certificate" => token,
766 "tech" => "technology".to_string(),
767 "cert" => "certificate".to_string(),
768 _ if !token.is_empty() => token,
770 _ => "endpoint".to_string(),
771 }
772}
773
774pub(super) fn graph_edge_label(input: &str) -> String {
776 let token = normalize_graph_token(input);
777 match token.as_str() {
778 "hasservice" => "has_service".to_string(),
779 "hasendpoint" => "has_endpoint".to_string(),
780 "usestech" | "usestechnology" => "uses_tech".to_string(),
781 "authaccess" | "hascredential" => "auth_access".to_string(),
782 "affectedby" => "affected_by".to_string(),
783 "contains" => "contains".to_string(),
784 "connectsto" | "connects" => "connects_to".to_string(),
785 "relatedto" | "related" => "related_to".to_string(),
786 "hasuser" => "has_user".to_string(),
787 "hascert" | "hascertificate" => "has_cert".to_string(),
788 _ if !token.is_empty() => token,
789 _ => "related_to".to_string(),
790 }
791}
792
793pub(super) fn normalize_graph_token(input: &str) -> String {
794 input
795 .chars()
796 .filter(|ch| ch.is_ascii_alphanumeric())
797 .flat_map(|ch| ch.to_lowercase())
798 .collect()
799}
800
801#[derive(Debug, Clone)]
802pub struct RuntimeGraphPattern {
803 pub node_label: Option<String>,
804 pub node_type: Option<String>,
805 pub edge_labels: Vec<String>,
806}
807
808#[derive(Debug, Clone, Default)]
809pub struct RuntimeGraphProjection {
810 pub node_labels: Option<Vec<String>>,
811 pub node_types: Option<Vec<String>>,
812 pub edge_labels: Option<Vec<String>>,
813}
814
815#[derive(Debug, Clone, Copy)]
816pub struct RuntimeQueryWeights {
817 pub vector: f32,
818 pub graph: f32,
819 pub filter: f32,
820}
821
822#[derive(Debug, Clone)]
823pub struct RuntimeFilter {
824 pub field: String,
825 pub op: String,
826 pub value: Option<RuntimeFilterValue>,
827}
828
829#[derive(Debug, Clone)]
830pub enum RuntimeFilterValue {
831 String(String),
832 Int(i64),
833 Float(f64),
834 Bool(bool),
835 Null,
836 List(Vec<RuntimeFilterValue>),
837 Range(Box<RuntimeFilterValue>, Box<RuntimeFilterValue>),
838}
839
840pub(super) fn runtime_filter_to_dsl(filter: RuntimeFilter) -> RedDBResult<DslFilter> {
841 Ok(DslFilter {
842 field: filter.field,
843 op: parse_runtime_filter_op(&filter.op)?,
844 value: match filter.value {
845 Some(value) => runtime_filter_value_to_dsl(value),
846 None => DslFilterValue::Null,
847 },
848 })
849}
850
851pub(super) fn parse_runtime_filter_op(op: &str) -> RedDBResult<DslFilterOp> {
852 match op.trim().to_ascii_lowercase().as_str() {
853 "eq" | "equals" => Ok(DslFilterOp::Equals),
854 "ne" | "not_equals" | "not-equals" => Ok(DslFilterOp::NotEquals),
855 "gt" | "greater_than" | "greater-than" => Ok(DslFilterOp::GreaterThan),
856 "gte" | "greater_than_or_equals" | "greater-than-or-equals" => {
857 Ok(DslFilterOp::GreaterThanOrEquals)
858 }
859 "lt" | "less_than" | "less-than" => Ok(DslFilterOp::LessThan),
860 "lte" | "less_than_or_equals" | "less-than-or-equals" => Ok(DslFilterOp::LessThanOrEquals),
861 "contains" => Ok(DslFilterOp::Contains),
862 "starts_with" | "starts-with" => Ok(DslFilterOp::StartsWith),
863 "ends_with" | "ends-with" => Ok(DslFilterOp::EndsWith),
864 "in" | "in_list" | "in-list" => Ok(DslFilterOp::In),
865 "between" => Ok(DslFilterOp::Between),
866 "is_null" | "is-null" => Ok(DslFilterOp::IsNull),
867 "is_not_null" | "is-not-null" => Ok(DslFilterOp::IsNotNull),
868 other => Err(RedDBError::Query(format!(
869 "unsupported hybrid filter op: {other}"
870 ))),
871 }
872}
873
874pub(super) fn runtime_filter_value_to_dsl(value: RuntimeFilterValue) -> DslFilterValue {
875 match value {
876 RuntimeFilterValue::String(value) => DslFilterValue::String(value),
877 RuntimeFilterValue::Int(value) => DslFilterValue::Int(value),
878 RuntimeFilterValue::Float(value) => DslFilterValue::Float(value),
879 RuntimeFilterValue::Bool(value) => DslFilterValue::Bool(value),
880 RuntimeFilterValue::Null => DslFilterValue::Null,
881 RuntimeFilterValue::List(values) => DslFilterValue::List(
882 values
883 .into_iter()
884 .map(runtime_filter_value_to_dsl)
885 .collect(),
886 ),
887 RuntimeFilterValue::Range(start, end) => DslFilterValue::Range(
888 Box::new(runtime_filter_value_to_dsl(*start)),
889 Box::new(runtime_filter_value_to_dsl(*end)),
890 ),
891 }
892}