Skip to main content

reddb_server/runtime/
graph_dsl.rs

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
17/// Native API calls need the same snapshot and transaction-local scope as RQL.
18/// Nested graph reads inherit the enclosing statement instead of resnapshotting.
19fn 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
33/// Resolve properties from one visible, RLS-admitted version. Exact logical IDs
34/// take precedence over labels, matching the topology resolver's public contract.
35pub(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
153/// Resolve a user-supplied graph node reference to its canonical entity id.
154///
155/// Accepts either a numeric entity id (e.g. `"177"`) — returned as-is when the
156/// node exists — or a node label (e.g. `"cinderella"`) resolved via the label
157/// secondary index. Errors when the label resolves to more than one node, so
158/// callers can fall back to the numeric id form.
159pub(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, &current, 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(&current)?.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
674/// Normalise a user-supplied node-type token to its canonical lower-snake-case
675/// form. Pentest-flavoured aliases (`tech`, `cert`) are kept as a courtesy
676/// but the result is just a label string the caller can intern into the
677/// [`crate::storage::engine::graph_store::LabelRegistry`].
678pub(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        // Unknown token: pass through so callers can register new labels.
686        _ if !token.is_empty() => token,
687        _ => "endpoint".to_string(),
688    }
689}
690
691/// Edge-label counterpart to [`graph_node_label`].
692pub(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}