Skip to main content

aeo_graph_explorer/
query.rs

1//! Query operations on top of [`AeoGraph`].
2
3use petgraph::algo::astar;
4use petgraph::visit::EdgeRef;
5use serde::{Deserialize, Serialize};
6
7use crate::error::GraphError;
8use crate::graph::{AeoGraph, EdgeKind};
9use crate::model::{AeoEntity, AeoNode};
10
11/// Neighbour result for `/nodes/{id}/neighbors`.
12#[derive(Clone, Debug, Deserialize, Serialize)]
13pub struct NeighborView {
14    /// The center node of the neighbourhood (just the summary).
15    pub center: AeoEntity,
16    /// Outbound: things `center` declared.
17    pub outbound: Vec<NeighborEdge>,
18    /// Inbound: things that declared `center`.
19    pub inbound: Vec<NeighborEdge>,
20}
21
22/// One side of a [`NeighborView`].
23#[derive(Clone, Debug, Deserialize, Serialize)]
24pub struct NeighborEdge {
25    /// Edge kind.
26    pub edge: EdgeKind,
27    /// The other end of the edge.
28    pub entity: AeoEntity,
29}
30
31/// One step in a [`PathResult`].
32#[derive(Clone, Debug, Deserialize, Serialize)]
33pub struct PathHop {
34    /// Entity at this step.
35    pub entity: AeoEntity,
36    /// Edge kind that connects this step to the next one. `None` for the
37    /// final hop.
38    pub via: Option<EdgeKind>,
39}
40
41/// Output of `/shortest-path`.
42#[derive(Clone, Debug, Deserialize, Serialize)]
43pub struct PathResult {
44    /// Whether the search found a path at all.
45    pub found: bool,
46    /// Number of edges (= hops.len() - 1 when `found` is true).
47    pub length: u32,
48    /// Ordered path, source → destination.
49    pub hops: Vec<PathHop>,
50}
51
52/// Match for `/find-by-claim`.
53#[derive(Clone, Debug, Deserialize, Serialize)]
54pub struct ClaimMatch {
55    /// The entity that made the claim.
56    pub entity: AeoEntity,
57    /// Claim id within the entity's `claims` array.
58    pub claim_id: String,
59    /// The matched predicate.
60    pub predicate: String,
61    /// The matched value (verbatim).
62    pub value: serde_json::Value,
63}
64
65/// Query operations over the in-memory graph.
66pub fn neighbors(graph: &AeoGraph, id: &str) -> Result<NeighborView, GraphError> {
67    let idx = graph
68        .idx(id)
69        .ok_or_else(|| GraphError::NotFound(id.to_string()))?;
70    let raw = graph.raw();
71    let center = raw[idx].entity.clone();
72
73    let outbound = raw
74        .edges_directed(idx, petgraph::Outgoing)
75        .map(|e| NeighborEdge {
76            edge: *e.weight(),
77            entity: raw[e.target()].entity.clone(),
78        })
79        .collect();
80
81    let inbound = raw
82        .edges_directed(idx, petgraph::Incoming)
83        .map(|e| NeighborEdge {
84            edge: *e.weight(),
85            entity: raw[e.source()].entity.clone(),
86        })
87        .collect();
88
89    Ok(NeighborView {
90        center,
91        outbound,
92        inbound,
93    })
94}
95
96/// A* over the graph (uniform edge cost). Returns `found=false` if no path
97/// exists.
98pub fn shortest_path(graph: &AeoGraph, from: &str, to: &str) -> Result<PathResult, GraphError> {
99    let from_idx = graph
100        .idx(from)
101        .ok_or_else(|| GraphError::NotFound(from.to_string()))?;
102    let to_idx = graph
103        .idx(to)
104        .ok_or_else(|| GraphError::NotFound(to.to_string()))?;
105    let raw = graph.raw();
106
107    let path = astar(raw, from_idx, |n| n == to_idx, |_| 1u32, |_| 0u32);
108    let Some((cost, nodes)) = path else {
109        return Ok(PathResult {
110            found: false,
111            length: 0,
112            hops: Vec::new(),
113        });
114    };
115
116    let mut hops: Vec<PathHop> = Vec::with_capacity(nodes.len());
117    for (i, idx) in nodes.iter().enumerate() {
118        let via = if i + 1 < nodes.len() {
119            raw.edges_connecting(*idx, nodes[i + 1])
120                .next()
121                .map(|e| *e.weight())
122        } else {
123            None
124        };
125        hops.push(PathHop {
126            entity: raw[*idx].entity.clone(),
127            via,
128        });
129    }
130
131    Ok(PathResult {
132        found: true,
133        length: cost,
134        hops,
135    })
136}
137
138/// Linear scan over every node's claims. Both `predicate` and `value` are
139/// optional but at least one must be supplied. Match semantics:
140///
141/// - `predicate` (when supplied) is exact equality against the claim's
142///   `predicate` string.
143/// - `value` (when supplied) is exact equality on the claim's `value`. If
144///   the claim's value is a string and the search value parses as a string,
145///   the comparison is plain string equality; otherwise the comparison is
146///   JSON equality.
147pub fn find_by_claim(
148    graph: &AeoGraph,
149    predicate: Option<&str>,
150    value: Option<&str>,
151) -> Result<Vec<ClaimMatch>, GraphError> {
152    if predicate.is_none() && value.is_none() {
153        return Err(GraphError::EmptyQuery);
154    }
155
156    let mut out: Vec<ClaimMatch> = Vec::new();
157    for node in graph.nodes() {
158        match_node(node, predicate, value, &mut out);
159    }
160    Ok(out)
161}
162
163fn match_node(
164    node: &AeoNode,
165    predicate: Option<&str>,
166    value: Option<&str>,
167    out: &mut Vec<ClaimMatch>,
168) {
169    let Some(claims) = node.body.get("claims").and_then(|v| v.as_array()) else {
170        return;
171    };
172    for raw in claims {
173        let Some(pred) = raw.get("predicate").and_then(|v| v.as_str()) else {
174            continue;
175        };
176        if let Some(want) = predicate {
177            if pred != want {
178                continue;
179            }
180        }
181        let claim_value = raw.get("value").cloned().unwrap_or(serde_json::Value::Null);
182        if let Some(want) = value {
183            if !value_matches(&claim_value, want) {
184                continue;
185            }
186        }
187        let claim_id = raw
188            .get("id")
189            .and_then(|v| v.as_str())
190            .unwrap_or("")
191            .to_string();
192        out.push(ClaimMatch {
193            entity: node.entity.clone(),
194            claim_id,
195            predicate: pred.to_string(),
196            value: claim_value,
197        });
198    }
199}
200
201fn value_matches(actual: &serde_json::Value, query: &str) -> bool {
202    if let Some(s) = actual.as_str() {
203        return s == query;
204    }
205    serde_json::from_str::<serde_json::Value>(query).is_ok_and(|q| &q == actual)
206}