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