1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
29#[serde(rename_all = "snake_case")]
30pub enum GraphDirection {
31 Directed,
33 Undirected,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
39pub struct GraphProperty(String);
40
41impl GraphProperty {
42 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 pub fn as_str(&self) -> &str {
56 &self.0
57 }
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum GraphScanPolicy {
64 Filtered,
66 AllowFullScan,
68}
69
70#[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 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 #[must_use]
111 pub fn allow_full_scan(mut self) -> Self {
112 self.scan_policy = GraphScanPolicy::AllowFullScan;
113 self
114 }
115
116 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 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 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 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 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 #[must_use]
167 pub fn with_node_limit(mut self, limit: NonZeroUsize) -> Self {
168 self.node_limit = Some(limit);
169 self
170 }
171
172 #[must_use]
174 pub fn with_edge_limit(mut self, limit: NonZeroUsize) -> Self {
175 self.edge_limit = Some(limit);
176 self
177 }
178
179 pub const fn direction(&self) -> GraphDirection {
181 self.direction
182 }
183
184 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 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#[derive(Debug, Clone, PartialEq, Eq, Error)]
287pub enum GraphSelectionError {
288 #[error("graph property names must not be empty")]
290 EmptyProperty,
291 #[error("graph property name uses reserved prefix: {0}")]
293 ReservedProperty(String),
294 #[error("full graph scans require GraphSelection::allow_full_scan()")]
296 FullScanRequiresOptIn,
297}
298
299#[derive(Debug, Error)]
301pub enum GraphLoadError {
302 #[error(transparent)]
304 Selection(#[from] GraphSelectionError),
305 #[error(transparent)]
307 Helix(#[from] HelixError),
308 #[error(transparent)]
310 Graph(#[from] loader::GraphLoadError),
311}
312
313impl Client {
314 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
328pub 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}