1use std::collections::{BTreeMap, BTreeSet};
8
9use serde::Deserialize;
10use serde_json::Value;
11use thiserror::Error;
12
13use crate::{Edge, ExternalId, Graph, GraphError, GraphKind, IdentitySelection, Node};
14
15pub const PRIVATE_PREFIX: &str = "__helix_graph_";
17pub const NODE_ID: &str = "__helix_graph_node_id";
19pub const EXTERNAL_ID: &str = "__helix_graph_external_id";
21pub const NODE_LABEL: &str = "__helix_graph_node_label";
23pub const EDGE_ID: &str = "__helix_graph_edge_id";
25pub const EDGE_KEY: &str = "__helix_graph_edge_key";
27pub const EDGE_SOURCE: &str = "__helix_graph_edge_source";
29pub const EDGE_TARGET: &str = "__helix_graph_edge_target";
31pub const EDGE_LABEL: &str = "__helix_graph_edge_label";
33pub const EDGE_WEIGHT: &str = "__helix_graph_edge_weight";
35
36#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct GraphLoadSpec {
39 pub kind: GraphKind,
41 pub node_identity: IdentitySelection,
43 pub edge_key_identity: Option<IdentitySelection>,
45 pub node_limit: Option<usize>,
47 pub edge_limit: Option<usize>,
49}
50
51#[derive(Debug, Error)]
53pub enum GraphLoadError {
54 #[error("invalid graph query response: {0}")]
56 InvalidResponse(String),
57 #[error("invalid {kind} row {index}: {details}")]
59 InvalidRow {
60 kind: &'static str,
62 index: usize,
64 details: String,
66 },
67 #[error("graph selection exceeded the {kind} safety limit of {limit}")]
69 IncompleteSelection {
70 kind: &'static str,
72 limit: usize,
74 },
75 #[error("duplicate external node identity: {0}")]
77 DuplicateExternalIdentity(ExternalId),
78 #[error("graph metadata selection returned {count} rows; expected at most one")]
80 MultipleGraphMetadataRows {
81 count: usize,
83 },
84 #[error(transparent)]
86 Graph(#[from] GraphError),
87}
88
89#[derive(Debug, Deserialize)]
90struct GraphResponse {
91 nodes: Vec<BTreeMap<String, Value>>,
92 edges: Vec<BTreeMap<String, Value>>,
93 #[serde(default)]
94 metadata: Vec<BTreeMap<String, Value>>,
95}
96
97pub fn graph_from_response(spec: GraphLoadSpec, response: &[u8]) -> Result<Graph, GraphLoadError> {
99 let response: GraphResponse = serde_json::from_slice(response)
100 .map_err(|error| GraphLoadError::InvalidResponse(error.to_string()))?;
101 if let Some(limit) = spec.node_limit
102 && response.nodes.len() > limit
103 {
104 return Err(GraphLoadError::IncompleteSelection {
105 kind: "node",
106 limit,
107 });
108 }
109 if let Some(limit) = spec.edge_limit
110 && response.edges.len() > limit
111 {
112 return Err(GraphLoadError::IncompleteSelection {
113 kind: "edge",
114 limit,
115 });
116 }
117 let graph_attributes = match response.metadata.len() {
118 0 => BTreeMap::new(),
119 1 => {
120 let attributes = response
121 .metadata
122 .into_iter()
123 .next()
124 .expect("metadata length is one");
125 if let Some(alias) = attributes
126 .keys()
127 .find(|alias| alias.starts_with(PRIVATE_PREFIX))
128 {
129 return Err(GraphLoadError::InvalidRow {
130 kind: "metadata",
131 index: 0,
132 details: format!("reserved projection alias {alias}"),
133 });
134 }
135 attributes
136 }
137 count => return Err(GraphLoadError::MultipleGraphMetadataRows { count }),
138 };
139
140 let mut internal_to_external = BTreeMap::new();
141 let mut external_ids = BTreeSet::new();
142 let nodes = response
143 .nodes
144 .into_iter()
145 .enumerate()
146 .map(|(index, mut row)| {
147 let internal = take_identity(&mut row, NODE_ID, "node", index)?;
148 let external =
149 take_external_id(&mut row, EXTERNAL_ID, "node", index, &spec.node_identity)?;
150 if internal_to_external
151 .insert(internal.clone(), external.clone())
152 .is_some()
153 {
154 return Err(invalid_row(
155 "node",
156 index,
157 format!("duplicate internal ID {internal}"),
158 ));
159 }
160 if !external_ids.insert(external.clone()) {
161 return Err(GraphLoadError::DuplicateExternalIdentity(external));
162 }
163 let label = take_optional_string(&mut row, NODE_LABEL, "node", index)?;
164 let mut node = Node::new(external).with_attributes(row);
165 if let Some(label) = label {
166 node = node.with_label(label);
167 }
168 Ok(node)
169 })
170 .collect::<Result<Vec<_>, GraphLoadError>>()?;
171
172 let edges = response
173 .edges
174 .into_iter()
175 .enumerate()
176 .map(|(index, mut row)| {
177 let edge_id = take_identity(&mut row, EDGE_ID, "edge", index)?;
178 let source_internal = take_identity(&mut row, EDGE_SOURCE, "edge", index)?;
179 let target_internal = take_identity(&mut row, EDGE_TARGET, "edge", index)?;
180 let source = internal_to_external
181 .get(&source_internal)
182 .cloned()
183 .ok_or_else(|| {
184 invalid_row(
185 "edge",
186 index,
187 format!("source {source_internal} is outside the node selection"),
188 )
189 })?;
190 let target = internal_to_external
191 .get(&target_internal)
192 .cloned()
193 .ok_or_else(|| {
194 invalid_row(
195 "edge",
196 index,
197 format!("target {target_internal} is outside the node selection"),
198 )
199 })?;
200 let key = match &spec.edge_key_identity {
201 Some(selection) => {
202 take_optional_external_id(&mut row, EDGE_KEY, "edge", index, selection)?
203 }
204 None => {
205 row.remove(EDGE_KEY);
206 None
207 }
208 };
209 let label = take_optional_string(&mut row, EDGE_LABEL, "edge", index)?;
210 let weight = take_optional_weight(&mut row, EDGE_WEIGHT, index)?;
211 let mut edge = Edge::new(edge_id, source, target).with_attributes(row);
212 if let Some(key) = key {
213 edge = edge.with_graphify_key(key);
214 }
215 if let Some(label) = label {
216 edge = edge.with_label(label);
217 }
218 if let Some(weight) = weight {
219 edge = edge.with_weight(weight);
220 }
221 Ok(edge)
222 })
223 .collect::<Result<Vec<_>, GraphLoadError>>()?;
224 Graph::with_attributes(spec.kind, graph_attributes, nodes, edges).map_err(Into::into)
225}
226
227fn take_identity(
228 row: &mut BTreeMap<String, Value>,
229 field: &str,
230 kind: &'static str,
231 index: usize,
232) -> Result<String, GraphLoadError> {
233 let value = row
234 .remove(field)
235 .ok_or_else(|| invalid_row(kind, index, format!("missing {field}")))?;
236 identity(value).ok_or_else(|| invalid_row(kind, index, format!("invalid {field}")))
237}
238
239fn take_external_id(
240 row: &mut BTreeMap<String, Value>,
241 field: &str,
242 kind: &'static str,
243 index: usize,
244 selection: &IdentitySelection,
245) -> Result<ExternalId, GraphLoadError> {
246 let value = row
247 .remove(field)
248 .ok_or_else(|| invalid_row(kind, index, format!("missing {field}")))?;
249 decode_external_id(value, selection)
250 .map_err(|error| invalid_row(kind, index, format!("invalid {field}: {error}")))
251}
252
253fn take_optional_external_id(
254 row: &mut BTreeMap<String, Value>,
255 field: &str,
256 kind: &'static str,
257 index: usize,
258 selection: &IdentitySelection,
259) -> Result<Option<ExternalId>, GraphLoadError> {
260 match row.remove(field) {
261 None => Ok(None),
262 Some(value) => decode_external_id(value, selection)
263 .map(Some)
264 .map_err(|error| invalid_row(kind, index, format!("invalid {field}: {error}"))),
265 }
266}
267
268fn decode_external_id(
269 value: Value,
270 selection: &IdentitySelection,
271) -> Result<ExternalId, GraphError> {
272 match selection {
273 IdentitySelection::InternalId | IdentitySelection::ScalarProperty(_) => {
274 ExternalId::from_scalar(value)
275 }
276 IdentitySelection::TaggedProperty(_) => ExternalId::from_tagged_value(value),
277 }
278}
279
280fn identity(value: Value) -> Option<String> {
281 match value {
282 Value::String(value) if !value.is_empty() => Some(value),
283 Value::Number(value) => Some(value.to_string()),
284 Value::Null | Value::Bool(_) | Value::String(_) | Value::Array(_) | Value::Object(_) => {
285 None
286 }
287 }
288}
289
290fn take_optional_string(
291 row: &mut BTreeMap<String, Value>,
292 field: &str,
293 kind: &'static str,
294 index: usize,
295) -> Result<Option<String>, GraphLoadError> {
296 match row.remove(field) {
297 None | Some(Value::Null) => Ok(None),
298 Some(Value::String(value)) => Ok(Some(value)),
299 Some(_) => Err(invalid_row(kind, index, format!("invalid {field}"))),
300 }
301}
302
303fn take_optional_weight(
304 row: &mut BTreeMap<String, Value>,
305 field: &str,
306 index: usize,
307) -> Result<Option<f64>, GraphLoadError> {
308 match row.remove(field) {
309 None | Some(Value::Null) => Ok(None),
310 Some(Value::Number(value)) => value
311 .as_f64()
312 .filter(|weight| weight.is_finite() && *weight >= 0.0)
313 .map(Some)
314 .ok_or_else(|| invalid_row("edge", index, format!("invalid {field}"))),
315 Some(_) => Err(invalid_row("edge", index, format!("invalid {field}"))),
316 }
317}
318
319fn invalid_row(kind: &'static str, index: usize, details: String) -> GraphLoadError {
320 GraphLoadError::InvalidRow {
321 kind,
322 index,
323 details,
324 }
325}
326
327#[cfg(test)]
328mod tests {
329 use serde_json::json;
330
331 use super::*;
332 use crate::NodeId;
333
334 fn response(nodes: Value, edges: Value) -> Vec<u8> {
335 serde_json::to_vec(&json!({ "nodes": nodes, "edges": edges })).expect("fixture JSON")
336 }
337
338 fn response_with_metadata(nodes: Value, edges: Value, metadata: Value) -> Vec<u8> {
339 serde_json::to_vec(&json!({
340 "nodes": nodes,
341 "edges": edges,
342 "metadata": metadata,
343 }))
344 .expect("fixture JSON")
345 }
346
347 fn spec() -> GraphLoadSpec {
348 GraphLoadSpec {
349 kind: GraphKind::DiGraph,
350 node_identity: IdentitySelection::ScalarProperty(
351 crate::GraphProperty::new("external_id").unwrap(),
352 ),
353 edge_key_identity: Some(IdentitySelection::ScalarProperty(
354 crate::GraphProperty::new("key").unwrap(),
355 )),
356 node_limit: None,
357 edge_limit: None,
358 }
359 }
360
361 #[test]
362 fn loader_preserves_identity_topology_properties_and_weights() {
363 let bytes = response(
364 json!([
365 { (NODE_ID): "n1", (EXTERNAL_ID): "a", (NODE_LABEL): "File", "name": "A" },
366 { (NODE_ID): "n2", (EXTERNAL_ID): "b", (NODE_LABEL): "File", "name": "B" }
367 ]),
368 json!([{
369 (EDGE_ID): "e1", (EDGE_SOURCE): "n1", (EDGE_TARGET): "n2",
370 (EDGE_KEY): "imports", (EDGE_LABEL): "DEPENDS_ON", (EDGE_WEIGHT): 2.5,
371 "relation": "import"
372 }]),
373 );
374 let graph = graph_from_response(spec(), &bytes).expect("valid graph response");
375 assert_eq!(graph.node_count(), 2);
376 let edge = graph.edge("e1").expect("edge");
377 assert_eq!(
378 (&edge.source, &edge.target),
379 (&NodeId::from("a"), &NodeId::from("b"))
380 );
381 assert_eq!(edge.weight, Some(2.5));
382 }
383
384 #[test]
385 fn loader_rejects_duplicate_external_identity_and_missing_endpoint() {
386 let duplicate = response(
387 json!([
388 { (NODE_ID): "n1", (EXTERNAL_ID): "same", (NODE_LABEL): null },
389 { (NODE_ID): "n2", (EXTERNAL_ID): "same", (NODE_LABEL): null }
390 ]),
391 json!([]),
392 );
393 assert!(matches!(
394 graph_from_response(spec(), &duplicate),
395 Err(GraphLoadError::DuplicateExternalIdentity(id)) if id == "same"
396 ));
397
398 let outside = response(
399 json!([{ (NODE_ID): "n1", (EXTERNAL_ID): "a", (NODE_LABEL): null }]),
400 json!([{
401 (EDGE_ID): "e1", (EDGE_SOURCE): "n1", (EDGE_TARGET): "missing",
402 (EDGE_LABEL): null
403 }]),
404 );
405 assert!(matches!(
406 graph_from_response(spec(), &outside),
407 Err(GraphLoadError::InvalidRow { kind: "edge", .. })
408 ));
409 }
410
411 #[test]
412 fn loader_rejects_malformed_rows_invalid_weights_and_truncation() {
413 assert!(matches!(
414 graph_from_response(spec(), b"[]"),
415 Err(GraphLoadError::InvalidResponse(_))
416 ));
417 let bad_weight = response(
418 json!([{ (NODE_ID): "n1", (EXTERNAL_ID): "a", (NODE_LABEL): null }]),
419 json!([{
420 (EDGE_ID): "e1", (EDGE_SOURCE): "n1", (EDGE_TARGET): "n1",
421 (EDGE_LABEL): null, (EDGE_WEIGHT): "heavy"
422 }]),
423 );
424 assert!(matches!(
425 graph_from_response(spec(), &bad_weight),
426 Err(GraphLoadError::InvalidRow { kind: "edge", .. })
427 ));
428
429 let nodes = response(
430 json!([
431 { (NODE_ID): "n1", (EXTERNAL_ID): "a", (NODE_LABEL): null },
432 { (NODE_ID): "n2", (EXTERNAL_ID): "b", (NODE_LABEL): null }
433 ]),
434 json!([]),
435 );
436 assert!(matches!(
437 graph_from_response(
438 GraphLoadSpec {
439 node_limit: Some(1),
440 ..spec()
441 },
442 &nodes
443 ),
444 Err(GraphLoadError::IncompleteSelection {
445 kind: "node",
446 limit: 1
447 })
448 ));
449
450 let edges = response(
451 json!([{ (NODE_ID): 1, (EXTERNAL_ID): 10, (NODE_LABEL): null }]),
452 json!([{
453 (EDGE_ID): 2, (EDGE_SOURCE): 1, (EDGE_TARGET): 1,
454 (EDGE_LABEL): null
455 }]),
456 );
457 let graph = graph_from_response(spec(), &edges).expect("numeric identities are scalar");
458 assert!(graph.contains_node(10_i64));
459 assert!(matches!(
460 graph_from_response(
461 GraphLoadSpec {
462 edge_limit: Some(0),
463 ..spec()
464 },
465 &edges
466 ),
467 Err(GraphLoadError::IncompleteSelection {
468 kind: "edge",
469 limit: 0
470 })
471 ));
472 }
473
474 #[test]
475 fn loader_rejects_duplicate_internal_ids_missing_sources_and_invalid_optional_fields() {
476 let duplicate = response(
477 json!([
478 { (NODE_ID): "n1", (EXTERNAL_ID): "a", (NODE_LABEL): null },
479 { (NODE_ID): "n1", (EXTERNAL_ID): "b", (NODE_LABEL): null }
480 ]),
481 json!([]),
482 );
483 assert!(matches!(
484 graph_from_response(spec(), &duplicate),
485 Err(GraphLoadError::InvalidRow { kind: "node", .. })
486 ));
487 let missing_source = response(
488 json!([{ (NODE_ID): "n1", (EXTERNAL_ID): "a", (NODE_LABEL): null }]),
489 json!([{
490 (EDGE_ID): "e", (EDGE_SOURCE): "missing", (EDGE_TARGET): "n1",
491 (EDGE_LABEL): null
492 }]),
493 );
494 assert!(matches!(
495 graph_from_response(spec(), &missing_source),
496 Err(GraphLoadError::InvalidRow { kind: "edge", .. })
497 ));
498 let invalid_key = response(
499 json!([{ (NODE_ID): "n1", (EXTERNAL_ID): "a", (NODE_LABEL): null }]),
500 json!([{
501 (EDGE_ID): "e", (EDGE_SOURCE): "n1", (EDGE_TARGET): "n1",
502 (EDGE_KEY): {}, (EDGE_LABEL): 1
503 }]),
504 );
505 assert!(matches!(
506 graph_from_response(spec(), &invalid_key),
507 Err(GraphLoadError::InvalidRow { kind: "edge", .. })
508 ));
509 }
510
511 #[test]
512 fn scalar_and_tagged_identities_preserve_types_without_collisions() {
513 let scalar = response(
514 json!([
515 { (NODE_ID): "n1", (EXTERNAL_ID): 1, (NODE_LABEL): null },
516 { (NODE_ID): "n2", (EXTERNAL_ID): "1", (NODE_LABEL): null }
517 ]),
518 json!([]),
519 );
520 let graph = graph_from_response(spec(), &scalar).unwrap();
521 assert!(graph.contains_node(1_i64));
522 assert!(graph.contains_node("1"));
523
524 let tuple =
525 ExternalId::tuple(vec![ExternalId::from(1_i64), ExternalId::from("1")]).unwrap();
526 let bytes = ExternalId::Bytes(vec![0, 255]);
527 let tagged = response(
528 json!([
529 {
530 (NODE_ID): "n1",
531 (EXTERNAL_ID): serde_json::to_value(&tuple).unwrap(),
532 (NODE_LABEL): null
533 },
534 {
535 (NODE_ID): "n2",
536 (EXTERNAL_ID): serde_json::to_value(&bytes).unwrap(),
537 (NODE_LABEL): null
538 }
539 ]),
540 json!([]),
541 );
542 let graph = graph_from_response(
543 GraphLoadSpec {
544 node_identity: IdentitySelection::TaggedProperty(
545 crate::GraphProperty::new("external_id").unwrap(),
546 ),
547 edge_key_identity: None,
548 ..spec()
549 },
550 &tagged,
551 )
552 .unwrap();
553 assert!(graph.contains_node(tuple));
554 assert!(graph.contains_node(bytes));
555 }
556
557 #[test]
558 fn internal_identity_and_no_edge_key_selection_are_explicit() {
559 let bytes = response(
560 json!([
561 { (NODE_ID): "n1", (EXTERNAL_ID): "n1", (NODE_LABEL): null },
562 { (NODE_ID): "n2", (EXTERNAL_ID): "n2", (NODE_LABEL): null }
563 ]),
564 json!([{
565 (EDGE_ID): "e1", (EDGE_SOURCE): "n1", (EDGE_TARGET): "n2",
566 (EDGE_KEY): {"ignored": true}, (EDGE_LABEL): null, (EDGE_WEIGHT): null
567 }]),
568 );
569 let graph = graph_from_response(
570 GraphLoadSpec {
571 node_identity: IdentitySelection::InternalId,
572 edge_key_identity: None,
573 ..spec()
574 },
575 &bytes,
576 )
577 .unwrap();
578 assert!(graph.contains_node("n1"));
579 assert_eq!(graph.edge("e1").unwrap().graphify_key, None);
580 assert!(!graph.edge("e1").unwrap().attributes.contains_key(EDGE_KEY));
581
582 let invalid_label = response(
583 json!([{ (NODE_ID): "n1", (EXTERNAL_ID): "n1", (NODE_LABEL): null }]),
584 json!([{
585 (EDGE_ID): "e1", (EDGE_SOURCE): "n1", (EDGE_TARGET): "n1",
586 (EDGE_LABEL): 1
587 }]),
588 );
589 assert!(matches!(
590 graph_from_response(spec(), &invalid_label),
591 Err(GraphLoadError::InvalidRow { kind: "edge", .. })
592 ));
593 }
594
595 #[test]
596 fn declared_kind_controls_parallel_edges_even_for_small_graphs() {
597 let parallel = response(
598 json!([
599 { (NODE_ID): "n1", (EXTERNAL_ID): "a", (NODE_LABEL): null },
600 { (NODE_ID): "n2", (EXTERNAL_ID): "b", (NODE_LABEL): null }
601 ]),
602 json!([
603 {
604 (EDGE_ID): "e1", (EDGE_SOURCE): "n1", (EDGE_TARGET): "n2",
605 (EDGE_LABEL): null
606 },
607 {
608 (EDGE_ID): "e2", (EDGE_SOURCE): "n1", (EDGE_TARGET): "n2",
609 (EDGE_LABEL): null
610 }
611 ]),
612 );
613 assert!(matches!(
614 graph_from_response(spec(), ¶llel),
615 Err(GraphLoadError::Graph(GraphError::ParallelEdge { .. }))
616 ));
617 let multigraph = graph_from_response(
618 GraphLoadSpec {
619 kind: GraphKind::MultiDiGraph,
620 ..spec()
621 },
622 ¶llel,
623 )
624 .unwrap();
625 assert!(multigraph.is_multigraph());
626
627 let empty = graph_from_response(
628 GraphLoadSpec {
629 kind: GraphKind::MultiGraph,
630 ..spec()
631 },
632 &response(json!([]), json!([])),
633 )
634 .unwrap();
635 assert!(empty.is_multigraph());
636 }
637
638 #[test]
639 fn metadata_loads_once_and_rejects_multiple_or_reserved_rows() {
640 let graph = graph_from_response(
641 spec(),
642 &response_with_metadata(json!([]), json!([]), json!([{"name": "demo"}])),
643 )
644 .unwrap();
645 assert_eq!(graph.attributes()["name"], json!("demo"));
646
647 assert!(matches!(
648 graph_from_response(
649 spec(),
650 &response_with_metadata(
651 json!([]),
652 json!([]),
653 json!([{"name": "one"}, {"name": "two"}])
654 )
655 ),
656 Err(GraphLoadError::MultipleGraphMetadataRows { count: 2 })
657 ));
658 assert!(matches!(
659 graph_from_response(
660 spec(),
661 &response_with_metadata(json!([]), json!([]), json!([{(NODE_ID): "private"}]))
662 ),
663 Err(GraphLoadError::InvalidRow {
664 kind: "metadata",
665 ..
666 })
667 ));
668 }
669}