Skip to main content

oxide_graph/
query.rs

1//! Query primitives.
2//!
3//! Two builder structs (`NodeQuery`, `EdgeQuery`) plus a small `traverse`
4//! function for breadth-first walks. The query language is intentionally tiny;
5//! the goal is to expose composable predicates rather than to reinvent
6//! Cypher / Gremlin.
7
8use std::collections::{HashSet, VecDeque};
9
10use crate::error::Result;
11use crate::graph::{Edge, GraphStore, Node, NodeId};
12
13/// Filter for [`Node`]s.
14#[derive(Debug, Clone, Default)]
15pub struct NodeQuery {
16    /// Required label (any node carrying this label matches).
17    pub label: Option<String>,
18    /// Required `(property, value)` pairs — exact JSON equality.
19    pub property_eq: Vec<(String, serde_json::Value)>,
20}
21
22impl NodeQuery {
23    /// Build a query that matches any node carrying `label`.
24    pub fn label(label: impl Into<String>) -> Self {
25        Self {
26            label: Some(label.into()),
27            ..Self::default()
28        }
29    }
30
31    /// Builder helper.
32    #[must_use]
33    pub fn property_eq(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
34        self.property_eq.push((key.into(), value));
35        self
36    }
37
38    /// Run the query against `store`.
39    pub async fn run(&self, store: &dyn GraphStore) -> Result<Vec<Node>> {
40        let label = self.label.as_deref().unwrap_or("");
41        let candidates: Vec<Node> = if label.is_empty() {
42            return Ok(Vec::new()); // require at least a label or property filter
43        } else {
44            store.nodes_by_label(label).await?
45        };
46        Ok(candidates
47            .into_iter()
48            .filter(|n| {
49                self.property_eq
50                    .iter()
51                    .all(|(k, v)| n.properties.get(k) == Some(v))
52            })
53            .collect())
54    }
55}
56
57/// Filter for [`Edge`]s incident to a given node.
58#[derive(Debug, Clone, Default)]
59pub struct EdgeQuery {
60    /// Required label.
61    pub label: Option<String>,
62    /// Direction relative to the anchor node.
63    pub direction: EdgeDirection,
64}
65
66/// Edge direction relative to an anchor node.
67#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
68pub enum EdgeDirection {
69    /// Edges where the anchor is the tail.
70    #[default]
71    Out,
72    /// Edges where the anchor is the head.
73    In,
74    /// Both directions.
75    Either,
76}
77
78impl EdgeQuery {
79    /// Build an outbound edge query for `label`.
80    pub fn outbound(label: impl Into<String>) -> Self {
81        Self {
82            label: Some(label.into()),
83            direction: EdgeDirection::Out,
84        }
85    }
86
87    /// Builder helper.
88    #[must_use]
89    pub fn direction(mut self, d: EdgeDirection) -> Self {
90        self.direction = d;
91        self
92    }
93
94    /// Run the query against `store` anchored at `node`.
95    pub async fn run(&self, store: &dyn GraphStore, node: &NodeId) -> Result<Vec<Edge>> {
96        let label = self.label.as_deref();
97        match self.direction {
98            EdgeDirection::Out => store.edges_from(node, label).await,
99            EdgeDirection::In => store.edges_to(node, label).await,
100            EdgeDirection::Either => {
101                let mut out = store.edges_from(node, label).await?;
102                out.extend(store.edges_to(node, label).await?);
103                Ok(out)
104            }
105        }
106    }
107}
108
109/// Breadth-first traversal that walks edges matching `edge_label` outward from
110/// `start`, returning every node reached within `max_depth`.
111pub async fn traverse(
112    store: &dyn GraphStore,
113    start: &NodeId,
114    edge_label: Option<&str>,
115    max_depth: usize,
116) -> Result<Vec<Node>> {
117    let mut seen: HashSet<NodeId> = HashSet::new();
118    let mut queue: VecDeque<(NodeId, usize)> = VecDeque::new();
119    queue.push_back((start.clone(), 0));
120    seen.insert(start.clone());
121    let mut out = Vec::new();
122
123    while let Some((current, depth)) = queue.pop_front() {
124        if let Some(node) = store.get_node(&current).await? {
125            out.push(node);
126        }
127        if depth >= max_depth {
128            continue;
129        }
130        let edges = store.edges_from(&current, edge_label).await?;
131        for e in edges {
132            if seen.insert(e.to.clone()) {
133                queue.push_back((e.to.clone(), depth + 1));
134            }
135        }
136    }
137    Ok(out)
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143    use crate::graph::{Edge, InMemoryGraph, Node};
144    use serde_json::json;
145
146    async fn fixture() -> InMemoryGraph {
147        let g = InMemoryGraph::new();
148        g.upsert_node(
149            Node::new("pet:1", "pet")
150                .with_property("name", json!("Rex"))
151                .with_property("status", json!("available")),
152        )
153        .await
154        .unwrap();
155        g.upsert_node(
156            Node::new("pet:2", "pet")
157                .with_property("name", json!("Buddy"))
158                .with_property("status", json!("sold")),
159        )
160        .await
161        .unwrap();
162        g.upsert_node(Node::new("owner:1", "owner")).await.unwrap();
163        g.upsert_node(Node::new("owner:2", "owner")).await.unwrap();
164        g.add_edge(Edge::new("owner:1", "pet:1", "owns"))
165            .await
166            .unwrap();
167        g.add_edge(Edge::new("owner:2", "pet:2", "owns"))
168            .await
169            .unwrap();
170        g.add_edge(Edge::new("owner:1", "owner:2", "knows"))
171            .await
172            .unwrap();
173        g
174    }
175
176    #[tokio::test]
177    async fn node_query_filters_by_property() {
178        let g = fixture().await;
179        let q = NodeQuery::label("pet").property_eq("status", json!("available"));
180        let results = q.run(&g).await.unwrap();
181        assert_eq!(results.len(), 1);
182        assert_eq!(results[0].id, "pet:1");
183    }
184
185    #[tokio::test]
186    async fn edge_query_directions() {
187        let g = fixture().await;
188        let owns_out = EdgeQuery::outbound("owns")
189            .run(&g, &"owner:1".into())
190            .await
191            .unwrap();
192        assert_eq!(owns_out.len(), 1);
193
194        let either = EdgeQuery {
195            label: None,
196            direction: EdgeDirection::Either,
197        }
198        .run(&g, &"owner:1".into())
199        .await
200        .unwrap();
201        // owner:1 → pet:1 (owns), owner:1 → owner:2 (knows). No inbound.
202        assert_eq!(either.len(), 2);
203    }
204
205    #[tokio::test]
206    async fn traverse_walks_depth() {
207        let g = fixture().await;
208        let nodes = traverse(&g, &"owner:1".into(), None, 2).await.unwrap();
209        let ids: Vec<_> = nodes.iter().map(|n| n.id.as_str()).collect();
210        assert!(ids.contains(&"owner:1"));
211        assert!(ids.contains(&"pet:1"));
212        assert!(ids.contains(&"owner:2"));
213        // pet:2 reachable only at depth 2 via owner:1 → owner:2 → pet:2.
214        assert!(ids.contains(&"pet:2"));
215    }
216}