Skip to main content

helix_db/
graph.rs

1//! One-query loading for the immutable native graph implementation.
2//!
3//! [`Client::graph`] executes one ordinary read batch. The returned
4//! [`helix_graph_algorithms::Graph`] is self-contained: algorithms and graph-object reads
5//! never query Helix again.
6
7use std::collections::BTreeSet;
8use std::num::NonZeroUsize;
9
10use helix_ast::prelude::{OnEdges, OnNodes, ReadOnly, Traversal};
11use helix_graph_algorithms as graph_core;
12use helix_graph_algorithms::loader::{
13    self, GraphLoadSpec, EDGE_ID, EDGE_KEY, EDGE_LABEL, EDGE_SOURCE, EDGE_TARGET, EDGE_WEIGHT,
14    EXTERNAL_ID, NODE_ID, NODE_LABEL, PRIVATE_PREFIX,
15};
16pub use helix_graph_algorithms::{
17    Attributes, BetweennessMode, BetweennessOptions, Community, CommunityResult, Cycle,
18    CycleOptions, CycleResult, DegreeKind, Edge, EdgeId, EdgeScore, EdgeTraversalDirection, Graph,
19    HubExpansionPolicy, LayoutOptions, LouvainOptions, Node, NodeDegree, NodeId, NodePosition,
20    NodeScore, NonNegativeFiniteF64, PathEdge, PathResult, PathWeight, PositiveFiniteF64,
21    TraversalDirection, TraversalOptions, TraversalResult, TraversalStrategy, TraversedEdge, Visit,
22};
23use thiserror::Error;
24
25use crate::{read_batch, Client, HelixError, Projection, QueryRequest};
26
27/// Direction-only compatibility contract for the existing Rust SDK surface.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
29#[serde(rename_all = "snake_case")]
30pub enum GraphDirection {
31    /// Preserve stored edge direction.
32    Directed,
33    /// Treat stored edges as traversable in both directions.
34    Undirected,
35}
36
37/// A validated property name used by graph selections.
38#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
39pub struct GraphProperty(String);
40
41impl GraphProperty {
42    /// Construct a selected property name.
43    pub fn new(name: impl Into<String>) -> Result<Self, GraphSelectionError> {
44        let name = name.into();
45        if name.is_empty() {
46            return Err(GraphSelectionError::EmptyProperty);
47        }
48        if name.starts_with(PRIVATE_PREFIX) {
49            return Err(GraphSelectionError::ReservedProperty(name));
50        }
51        Ok(Self(name))
52    }
53
54    /// Borrow the property name.
55    pub fn as_str(&self) -> &str {
56        &self.0
57    }
58}
59
60/// Whether a selection is expected to be filtered or deliberately scans all
61/// nodes and edges.
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum GraphScanPolicy {
64    /// The supplied traversals constrain the selected graph.
65    Filtered,
66    /// The caller explicitly accepts a full graph scan.
67    AllowFullScan,
68}
69
70/// Typed inputs used to construct the one graph-loading read batch.
71#[derive(Debug, Clone, PartialEq)]
72pub struct GraphSelection {
73    node_traversal: Traversal<OnNodes, ReadOnly>,
74    edge_traversal: Traversal<OnEdges, ReadOnly>,
75    direction: GraphDirection,
76    node_properties: BTreeSet<GraphProperty>,
77    edge_properties: BTreeSet<GraphProperty>,
78    external_identity: Option<GraphProperty>,
79    graphify_edge_key: Option<GraphProperty>,
80    weight: Option<GraphProperty>,
81    node_limit: Option<NonZeroUsize>,
82    edge_limit: Option<NonZeroUsize>,
83    scan_policy: GraphScanPolicy,
84}
85
86impl GraphSelection {
87    /// Construct a filtered selection from node- and edge-producing read
88    /// traversals.
89    pub fn new(
90        node_traversal: Traversal<OnNodes, ReadOnly>,
91        edge_traversal: Traversal<OnEdges, ReadOnly>,
92        direction: GraphDirection,
93    ) -> Self {
94        Self {
95            node_traversal,
96            edge_traversal,
97            direction,
98            node_properties: BTreeSet::new(),
99            edge_properties: BTreeSet::new(),
100            external_identity: None,
101            graphify_edge_key: None,
102            weight: None,
103            node_limit: None,
104            edge_limit: None,
105            scan_policy: GraphScanPolicy::Filtered,
106        }
107    }
108
109    /// Explicitly permit unfiltered node and edge traversals.
110    #[must_use]
111    pub fn allow_full_scan(mut self) -> Self {
112        self.scan_policy = GraphScanPolicy::AllowFullScan;
113        self
114    }
115
116    /// Select node properties retained by the graph.
117    pub fn with_node_properties(
118        mut self,
119        properties: impl IntoIterator<Item = impl Into<String>>,
120    ) -> Result<Self, GraphSelectionError> {
121        self.node_properties = properties
122            .into_iter()
123            .map(GraphProperty::new)
124            .collect::<Result<_, _>>()?;
125        Ok(self)
126    }
127
128    /// Select edge properties retained by the graph.
129    pub fn with_edge_properties(
130        mut self,
131        properties: impl IntoIterator<Item = impl Into<String>>,
132    ) -> Result<Self, GraphSelectionError> {
133        self.edge_properties = properties
134            .into_iter()
135            .map(GraphProperty::new)
136            .collect::<Result<_, _>>()?;
137        Ok(self)
138    }
139
140    /// Use a selected node property as the public graph node identity.
141    pub fn with_external_identity(
142        mut self,
143        property: impl Into<String>,
144    ) -> Result<Self, GraphSelectionError> {
145        self.external_identity = Some(GraphProperty::new(property)?);
146        Ok(self)
147    }
148
149    /// Preserve a Graphify multigraph key separately from the Helix edge ID.
150    pub fn with_graphify_edge_key(
151        mut self,
152        property: impl Into<String>,
153    ) -> Result<Self, GraphSelectionError> {
154        self.graphify_edge_key = Some(GraphProperty::new(property)?);
155        Ok(self)
156    }
157
158    /// Select the non-negative finite numeric edge weight used by weighted
159    /// algorithms.
160    pub fn with_weight(mut self, property: impl Into<String>) -> Result<Self, GraphSelectionError> {
161        self.weight = Some(GraphProperty::new(property)?);
162        Ok(self)
163    }
164
165    /// Reject selections returning more than `limit` nodes.
166    #[must_use]
167    pub fn with_node_limit(mut self, limit: NonZeroUsize) -> Self {
168        self.node_limit = Some(limit);
169        self
170    }
171
172    /// Reject selections returning more than `limit` edges.
173    #[must_use]
174    pub fn with_edge_limit(mut self, limit: NonZeroUsize) -> Self {
175        self.edge_limit = Some(limit);
176        self
177    }
178
179    /// Direction semantics of the constructed graph.
180    pub const fn direction(&self) -> GraphDirection {
181        self.direction
182    }
183
184    /// Full-scan policy chosen by the caller.
185    pub const fn scan_policy(&self) -> GraphScanPolicy {
186        self.scan_policy
187    }
188
189    fn validate_scan_policy(&self) -> Result<(), GraphSelectionError> {
190        let starts_with_full_scan = |traversal: &serde_json::Value, source: &str| {
191            let mut current = traversal;
192            loop {
193                let Some(object) = current.as_object() else {
194                    return false;
195                };
196                if object
197                    .get(source)
198                    .and_then(serde_json::Value::as_object)
199                    .and_then(|source| source.get("reference"))
200                    .is_some_and(|reference| reference == "all")
201                {
202                    return true;
203                }
204                let Some(input) = object
205                    .values()
206                    .find_map(serde_json::Value::as_object)
207                    .and_then(|fields| fields.get("input"))
208                else {
209                    return false;
210                };
211                current = input;
212            }
213        };
214        let nodes = serde_json::to_value(self.node_traversal.root())
215            .expect("Helix traversal AST serialization is infallible");
216        let edges = serde_json::to_value(self.edge_traversal.root())
217            .expect("Helix traversal AST serialization is infallible");
218        if self.scan_policy == GraphScanPolicy::Filtered
219            && (starts_with_full_scan(&nodes, "nodes") || starts_with_full_scan(&edges, "edges"))
220        {
221            return Err(GraphSelectionError::FullScanRequiresOptIn);
222        }
223        Ok(())
224    }
225
226    /// Build the ordinary read request used by [`Client::graph`].
227    pub fn to_query_request(&self) -> QueryRequest {
228        let mut node_projection = vec![
229            Projection::property("$id", NODE_ID),
230            Projection::property(
231                self.external_identity
232                    .as_ref()
233                    .map_or("$id", GraphProperty::as_str),
234                EXTERNAL_ID,
235            ),
236            Projection::property("$label", NODE_LABEL),
237        ];
238        node_projection.extend(
239            self.node_properties
240                .iter()
241                .map(|property| Projection::property(property.as_str(), property.as_str())),
242        );
243
244        let mut edge_projection = vec![
245            Projection::property("$id", EDGE_ID),
246            Projection::from_endpoint("$id", EDGE_SOURCE),
247            Projection::to_endpoint("$id", EDGE_TARGET),
248            Projection::property("$label", EDGE_LABEL),
249        ];
250        if let Some(property) = &self.graphify_edge_key {
251            edge_projection.push(Projection::property(property.as_str(), EDGE_KEY));
252        }
253        if let Some(property) = &self.weight {
254            edge_projection.push(Projection::property(property.as_str(), EDGE_WEIGHT));
255        }
256        edge_projection.extend(
257            self.edge_properties
258                .iter()
259                .map(|property| Projection::property(property.as_str(), property.as_str())),
260        );
261
262        let nodes = match self.node_limit {
263            Some(limit) => self
264                .node_traversal
265                .clone()
266                .limit(limit.get().saturating_add(1)),
267            None => self.node_traversal.clone(),
268        };
269        let edges = match self.edge_limit {
270            Some(limit) => self
271                .edge_traversal
272                .clone()
273                .limit(limit.get().saturating_add(1)),
274            None => self.edge_traversal.clone(),
275        };
276        QueryRequest::read(
277            read_batch()
278                .var_as("nodes", nodes.project(node_projection))
279                .var_as("edges", edges.project(edge_projection))
280                .returning(["nodes", "edges"]),
281        )
282    }
283}
284
285/// Invalid selection metadata rejected before a query executes.
286#[derive(Debug, Clone, PartialEq, Eq, Error)]
287pub enum GraphSelectionError {
288    /// Property names must not be empty.
289    #[error("graph property names must not be empty")]
290    EmptyProperty,
291    /// The name would collide with loader-owned result metadata.
292    #[error("graph property name uses reserved prefix: {0}")]
293    ReservedProperty(String),
294    /// An all-nodes or all-edges source requires explicit acknowledgement.
295    #[error("full graph scans require GraphSelection::allow_full_scan()")]
296    FullScanRequiresOptIn,
297}
298
299/// Failure to execute or validate a graph selection.
300#[derive(Debug, Error)]
301pub enum GraphLoadError {
302    /// The graph selection is unsafe or invalid.
303    #[error(transparent)]
304    Selection(#[from] GraphSelectionError),
305    /// The ordinary Helix query failed.
306    #[error(transparent)]
307    Helix(#[from] HelixError),
308    /// Rust graph response validation or construction failed.
309    #[error(transparent)]
310    Graph(#[from] loader::GraphLoadError),
311}
312
313impl Client {
314    /// Execute one ordinary read batch and construct an immutable native graph.
315    ///
316    /// All methods on the returned graph operate locally and perform no
317    /// additional Helix reads.
318    pub async fn graph(&self, selection: &GraphSelection) -> Result<Graph, GraphLoadError> {
319        selection.validate_scan_policy()?;
320        let response = self
321            .query_raw(selection.to_query_request())
322            .send_bytes()
323            .await?;
324        graph_from_response(selection, &response)
325    }
326}
327
328/// Validate raw `/v2/query` response bytes and construct the immutable graph.
329///
330/// This is public so native SDK bindings can pass response bytes directly to
331/// Rust without materializing a second language-level graph.
332pub fn graph_from_response(
333    selection: &GraphSelection,
334    response: &[u8],
335) -> Result<Graph, GraphLoadError> {
336    loader::graph_from_response(
337        GraphLoadSpec {
338            kind: match selection.direction {
339                GraphDirection::Directed => graph_core::GraphKind::DiGraph,
340                GraphDirection::Undirected => graph_core::GraphKind::Graph,
341            },
342            node_identity: match &selection.external_identity {
343                Some(property) => graph_core::IdentitySelection::ScalarProperty(
344                    graph_core::GraphProperty::new(property.as_str())
345                        .expect("SDK graph properties are validated"),
346                ),
347                None => graph_core::IdentitySelection::InternalId,
348            },
349            edge_key_identity: selection.graphify_edge_key.as_ref().map(|property| {
350                graph_core::IdentitySelection::ScalarProperty(
351                    graph_core::GraphProperty::new(property.as_str())
352                        .expect("SDK graph properties are validated"),
353                )
354            }),
355            node_limit: selection.node_limit.map(NonZeroUsize::get),
356            edge_limit: selection.edge_limit.map(NonZeroUsize::get),
357        },
358        response,
359    )
360    .map_err(Into::into)
361}
362
363#[cfg(test)]
364mod tests {
365    use std::num::NonZeroUsize;
366
367    use serde_json::{json, Value};
368
369    use super::*;
370    use crate::{g, EdgeRef, NodeRef, SourcePredicate};
371
372    fn selection() -> GraphSelection {
373        GraphSelection::new(
374            g().n_where(SourcePredicate::has_key("$id")),
375            g().e_where(SourcePredicate::has_key("$id")),
376            GraphDirection::Directed,
377        )
378        .with_node_properties(["name"])
379        .expect("valid property")
380        .with_edge_properties(["relation"])
381        .expect("valid property")
382        .with_external_identity("external_id")
383        .expect("valid property")
384        .with_graphify_edge_key("key")
385        .expect("valid property")
386        .with_weight("weight")
387        .expect("valid property")
388    }
389
390    fn response(nodes: Value, edges: Value) -> Vec<u8> {
391        serde_json::to_vec(&json!({ "nodes": nodes, "edges": edges })).expect("fixture JSON")
392    }
393
394    #[test]
395    fn query_uses_private_aliases_and_limit_sentinels() {
396        let request = selection()
397            .with_node_limit(NonZeroUsize::new(2).expect("non-zero"))
398            .with_edge_limit(NonZeroUsize::new(3).expect("non-zero"))
399            .to_query_request()
400            .to_json_string()
401            .expect("serialize request");
402        assert!(request.contains(NODE_ID));
403        assert!(request.contains(EXTERNAL_ID));
404        assert!(request.contains(EDGE_SOURCE));
405        assert!(request.contains("\"literal\":3"), "{request}");
406        assert!(request.contains("\"literal\":4"), "{request}");
407    }
408
409    #[test]
410    fn full_scan_requires_explicit_opt_in_even_after_a_filter_step() {
411        let selection = GraphSelection::new(
412            g().n(NodeRef::all()).has_label("File"),
413            g().e(EdgeRef::all()).has_label("DEPENDS_ON"),
414            GraphDirection::Directed,
415        );
416        assert_eq!(
417            selection.validate_scan_policy(),
418            Err(GraphSelectionError::FullScanRequiresOptIn)
419        );
420        assert!(selection.allow_full_scan().validate_scan_policy().is_ok());
421    }
422
423    #[test]
424    fn loader_preserves_identity_topology_properties_and_weights() {
425        let bytes = response(
426            json!([
427                { (NODE_ID): "n1", (EXTERNAL_ID): "a", (NODE_LABEL): "File", "name": "A" },
428                { (NODE_ID): "n2", (EXTERNAL_ID): "b", (NODE_LABEL): "File", "name": "B" }
429            ]),
430            json!([{
431                (EDGE_ID): "e1", (EDGE_SOURCE): "n1", (EDGE_TARGET): "n2",
432                (EDGE_KEY): "imports", (EDGE_LABEL): "DEPENDS_ON", (EDGE_WEIGHT): 2.5,
433                "relation": "import"
434            }]),
435        );
436        let graph = graph_from_response(&selection(), &bytes).expect("valid graph response");
437        assert_eq!(graph.node_count(), 2);
438        assert_eq!(graph.edge_count(), 1);
439        assert_eq!(
440            graph.node("a").expect("node").label.as_deref(),
441            Some("File")
442        );
443        let edge = graph.edge("e1").expect("edge");
444        assert_eq!(edge.source, "a");
445        assert_eq!(edge.target, "b");
446        assert_eq!(edge.graphify_key, Some("imports".into()));
447        assert_eq!(edge.weight, Some(2.5));
448    }
449
450    #[test]
451    fn loader_rejects_duplicates_missing_endpoints_and_bad_rows() {
452        let duplicate = response(
453            json!([
454                { (NODE_ID): "n1", (EXTERNAL_ID): "same", (NODE_LABEL): null },
455                { (NODE_ID): "n2", (EXTERNAL_ID): "same", (NODE_LABEL): null }
456            ]),
457            json!([]),
458        );
459        assert!(matches!(
460            graph_from_response(&selection(), &duplicate),
461            Err(GraphLoadError::Graph(loader::GraphLoadError::DuplicateExternalIdentity(id)))
462                if id == "same"
463        ));
464
465        let outside = response(
466            json!([{ (NODE_ID): "n1", (EXTERNAL_ID): "a", (NODE_LABEL): null }]),
467            json!([{
468                (EDGE_ID): "e1", (EDGE_SOURCE): "n1", (EDGE_TARGET): "missing",
469                (EDGE_LABEL): null
470            }]),
471        );
472        assert!(matches!(
473            graph_from_response(&selection(), &outside),
474            Err(GraphLoadError::Graph(loader::GraphLoadError::InvalidRow {
475                kind: "edge",
476                ..
477            }))
478        ));
479
480        assert!(matches!(
481            graph_from_response(&selection(), b"[]"),
482            Err(GraphLoadError::Graph(
483                loader::GraphLoadError::InvalidResponse(_)
484            ))
485        ));
486    }
487
488    #[test]
489    fn loader_never_returns_a_truncated_graph() {
490        let bytes = response(
491            json!([
492                { (NODE_ID): "n1", (EXTERNAL_ID): "a", (NODE_LABEL): null },
493                { (NODE_ID): "n2", (EXTERNAL_ID): "b", (NODE_LABEL): null }
494            ]),
495            json!([]),
496        );
497        let selection = selection().with_node_limit(NonZeroUsize::new(1).expect("non-zero"));
498        assert!(matches!(
499            graph_from_response(&selection, &bytes),
500            Err(GraphLoadError::Graph(
501                loader::GraphLoadError::IncompleteSelection {
502                    kind: "node",
503                    limit: 1
504                }
505            ))
506        ));
507    }
508
509    #[test]
510    fn property_names_reject_empty_and_reserved_aliases() {
511        assert_eq!(
512            GraphProperty::new(""),
513            Err(GraphSelectionError::EmptyProperty)
514        );
515        assert!(matches!(
516            GraphProperty::new("__helix_graph_bad"),
517            Err(GraphSelectionError::ReservedProperty(_))
518        ));
519    }
520
521    #[tokio::test]
522    async fn graph_loads_once_algorithms_do_not_read_and_a_new_load_gets_a_new_snapshot() {
523        use tokio::io::{AsyncReadExt, AsyncWriteExt};
524
525        let first = response(
526            json!([{ (NODE_ID): "n1", (EXTERNAL_ID): "a", (NODE_LABEL): null }]),
527            json!([]),
528        );
529        let second = response(
530            json!([
531                { (NODE_ID): "n1", (EXTERNAL_ID): "a", (NODE_LABEL): null },
532                { (NODE_ID): "n2", (EXTERNAL_ID): "b", (NODE_LABEL): null }
533            ]),
534            json!([]),
535        );
536        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
537        let base = format!("http://{}", listener.local_addr().unwrap());
538        let server = tokio::spawn(async move {
539            for body in [first, second] {
540                let (mut socket, _) = listener.accept().await.unwrap();
541                let mut request = [0_u8; 16_384];
542                let _ = socket.read(&mut request).await.unwrap();
543                let header = format!(
544                    "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
545                    body.len()
546                );
547                socket.write_all(header.as_bytes()).await.unwrap();
548                socket.write_all(&body).await.unwrap();
549            }
550            2_usize
551        });
552        let client = Client::new(Some(&base)).unwrap();
553        let first = client.graph(&selection()).await.unwrap();
554        assert_eq!(first.node_count(), 1);
555        assert_eq!(
556            first
557                .betweenness_centrality(helix_graph_algorithms::BetweennessOptions::default())
558                .unwrap()
559                .len(),
560            1
561        );
562        let second = client.graph(&selection()).await.unwrap();
563        assert_eq!(second.node_count(), 2);
564        assert_eq!(server.await.unwrap(), 2);
565    }
566}