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