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 =
27        StatementExecutionFrame::build(runtime, StatementIdentity::Text("GRAPH PROPERTIES"))?;
28    Ok(Some(frame.install(runtime)))
29}
30
31/// Resolve properties from one visible, RLS-admitted version. Exact logical IDs
32/// take precedence over labels, matching the topology resolver's public contract.
33pub(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
151/// Resolve a user-supplied graph node reference to its canonical entity id.
152///
153/// Accepts either a numeric entity id (e.g. `"177"`) — returned as-is when the
154/// node exists — or a node label (e.g. `"cinderella"`) resolved via the label
155/// secondary index. Errors when the label resolves to more than one node, so
156/// callers can fall back to the numeric id form.
157pub(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, &current, 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(&current)?.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
672/// Normalise a user-supplied node-type token to its canonical lower-snake-case
673/// form. Pentest-flavoured aliases (`tech`, `cert`) are kept as a courtesy
674/// but the result is just a label string the caller can intern into the
675/// [`crate::storage::engine::graph_store::LabelRegistry`].
676pub(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        // Unknown token: pass through so callers can register new labels.
684        _ if !token.is_empty() => token,
685        _ => "endpoint".to_string(),
686    }
687}
688
689/// Edge-label counterpart to [`graph_node_label`].
690pub(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}