use crate::{StarResult, StarStore, StarTerm, StarTriple};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use tracing::{info, instrument, warn};
use scirs2_core::profiling::Profiler;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConversionConfig {
pub use_uri_ids: bool,
pub preserve_blank_nodes: bool,
pub literals_as_properties: bool,
pub max_property_size: usize,
pub namespace_prefixes: HashMap<String, String>,
pub default_edge_label: String,
pub type_property_name: String,
pub quoted_as_edge_properties: bool,
pub bidirectional_edges: bool,
}
impl Default for ConversionConfig {
fn default() -> Self {
let mut prefixes = HashMap::new();
prefixes.insert(
"http://www.w3.org/1999/02/22-rdf-syntax-ns#".to_string(),
"rdf".to_string(),
);
prefixes.insert(
"http://www.w3.org/2000/01/rdf-schema#".to_string(),
"rdfs".to_string(),
);
prefixes.insert(
"http://www.w3.org/2002/07/owl#".to_string(),
"owl".to_string(),
);
prefixes.insert("http://xmlns.com/foaf/0.1/".to_string(), "foaf".to_string());
prefixes.insert(
"http://purl.org/dc/elements/1.1/".to_string(),
"dc".to_string(),
);
Self {
use_uri_ids: true,
preserve_blank_nodes: true,
literals_as_properties: true,
max_property_size: 10_000,
namespace_prefixes: prefixes,
default_edge_label: "related".to_string(),
type_property_name: "rdf_type".to_string(),
quoted_as_edge_properties: true,
bidirectional_edges: false,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LpgNode {
pub id: String,
pub labels: Vec<String>,
pub properties: HashMap<String, PropertyValue>,
}
impl LpgNode {
pub fn new(id: impl Into<String>) -> Self {
Self {
id: id.into(),
labels: Vec::new(),
properties: HashMap::new(),
}
}
pub fn add_label(&mut self, label: impl Into<String>) {
self.labels.push(label.into());
}
pub fn set_property(&mut self, key: impl Into<String>, value: PropertyValue) {
self.properties.insert(key.into(), value);
}
pub fn get_property(&self, key: &str) -> Option<&PropertyValue> {
self.properties.get(key)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LpgEdge {
pub from: String,
pub to: String,
pub label: String,
pub properties: HashMap<String, PropertyValue>,
}
impl LpgEdge {
pub fn new(from: impl Into<String>, label: impl Into<String>, to: impl Into<String>) -> Self {
Self {
from: from.into(),
label: label.into(),
to: to.into(),
properties: HashMap::new(),
}
}
pub fn set_property(&mut self, key: impl Into<String>, value: PropertyValue) {
self.properties.insert(key.into(), value);
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum PropertyValue {
String(String),
Integer(i64),
Float(f64),
Boolean(bool),
List(Vec<PropertyValue>),
Map(HashMap<String, PropertyValue>),
}
impl PropertyValue {
pub fn to_string_repr(&self) -> String {
match self {
PropertyValue::String(s) => format!("\"{}\"", s.replace('"', "\\\"")),
PropertyValue::Integer(i) => i.to_string(),
PropertyValue::Float(f) => f.to_string(),
PropertyValue::Boolean(b) => b.to_string(),
PropertyValue::List(items) => {
let inner: Vec<_> = items.iter().map(|v| v.to_string_repr()).collect();
format!("[{}]", inner.join(", "))
}
PropertyValue::Map(map) => {
let inner: Vec<_> = map
.iter()
.map(|(k, v)| format!("{}: {}", k, v.to_string_repr()))
.collect();
format!("{{{}}}", inner.join(", "))
}
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct LabeledPropertyGraph {
pub nodes: HashMap<String, LpgNode>,
pub edges: Vec<LpgEdge>,
pub metadata: HashMap<String, String>,
}
impl LabeledPropertyGraph {
pub fn new() -> Self {
Self::default()
}
pub fn add_node(&mut self, node: LpgNode) {
self.nodes.insert(node.id.clone(), node);
}
pub fn get_node(&self, id: &str) -> Option<&LpgNode> {
self.nodes.get(id)
}
pub fn get_node_mut(&mut self, id: &str) -> Option<&mut LpgNode> {
self.nodes.get_mut(id)
}
pub fn add_edge(&mut self, edge: LpgEdge) {
self.edges.push(edge);
}
pub fn get_edges_from(&self, node_id: &str) -> Vec<&LpgEdge> {
self.edges.iter().filter(|e| e.from == node_id).collect()
}
pub fn get_edges_to(&self, node_id: &str) -> Vec<&LpgEdge> {
self.edges.iter().filter(|e| e.to == node_id).collect()
}
pub fn get_edges_between(&self, from: &str, to: &str) -> Vec<&LpgEdge> {
self.edges
.iter()
.filter(|e| e.from == from && e.to == to)
.collect()
}
#[instrument(skip(self))]
pub fn to_cypher_script(&self) -> StarResult<String> {
let mut script = String::new();
script.push_str("// Neo4j Cypher Script - Generated from RDF-star\n");
script.push_str(
"// WARNING: This will create nodes and edges. Run in an empty database.\n\n",
);
script.push_str("// Create Nodes\n");
for node in self.nodes.values() {
let labels = if node.labels.is_empty() {
String::new()
} else {
format!(":{}", node.labels.join(":"))
};
let props = if node.properties.is_empty() {
String::new()
} else {
let props: Vec<_> = node
.properties
.iter()
.map(|(k, v)| format!("{}: {}", Self::escape_cypher_id(k), v.to_string_repr()))
.collect();
format!(" {{{}}}", props.join(", "))
};
script.push_str(&format!("CREATE (n{}{})\n", labels, props));
}
script.push_str("\n// Create Edges\n");
for edge in &self.edges {
let props = if edge.properties.is_empty() {
String::new()
} else {
let props: Vec<_> = edge
.properties
.iter()
.map(|(k, v)| format!("{}: {}", Self::escape_cypher_id(k), v.to_string_repr()))
.collect();
format!(" {{{}}}", props.join(", "))
};
script.push_str(&format!(
"MATCH (a {{id: \"{}\"}}), (b {{id: \"{}\"}})\n",
edge.from.replace('"', "\\\""),
edge.to.replace('"', "\\\"")
));
script.push_str(&format!(
"CREATE (a)-[:{}{}]->(b)\n\n",
Self::escape_cypher_label(&edge.label),
props
));
}
Ok(script)
}
fn escape_cypher_id(id: &str) -> String {
if id.chars().all(|c| c.is_alphanumeric() || c == '_') {
id.to_string()
} else {
format!("`{}`", id.replace('`', "``"))
}
}
fn escape_cypher_label(label: &str) -> String {
label
.chars()
.map(|c| {
if c.is_alphanumeric() || c == '_' {
c
} else {
'_'
}
})
.collect()
}
pub fn statistics(&self) -> LpgStatistics {
let mut label_counts: HashMap<String, usize> = HashMap::new();
for node in self.nodes.values() {
for label in &node.labels {
*label_counts.entry(label.clone()).or_insert(0) += 1;
}
}
let mut edge_type_counts: HashMap<String, usize> = HashMap::new();
for edge in &self.edges {
*edge_type_counts.entry(edge.label.clone()).or_insert(0) += 1;
}
LpgStatistics {
node_count: self.nodes.len(),
edge_count: self.edges.len(),
label_counts,
edge_type_counts,
}
}
}
#[derive(Debug, Clone)]
pub struct LpgStatistics {
pub node_count: usize,
pub edge_count: usize,
pub label_counts: HashMap<String, usize>,
pub edge_type_counts: HashMap<String, usize>,
}
pub struct PropertyGraphBridge {
config: ConversionConfig,
#[allow(dead_code)]
profiler: Profiler,
}
impl PropertyGraphBridge {
pub fn new(config: ConversionConfig) -> Self {
Self {
config,
profiler: Profiler::new(),
}
}
#[instrument(skip(self, store), fields(triple_count = store.len()))]
pub fn rdf_to_lpg(&self, store: &StarStore) -> StarResult<LabeledPropertyGraph> {
info!("Converting RDF-star to property graph");
let mut lpg = LabeledPropertyGraph::new();
let mut node_ids: HashSet<String> = HashSet::new();
for triple in store.iter() {
let subj_id = self.term_to_node_id(&triple.subject);
let obj_id = self.term_to_node_id(&triple.object);
node_ids.insert(subj_id);
if !self.config.literals_as_properties || !matches!(triple.object, StarTerm::Literal(_))
{
node_ids.insert(obj_id);
}
}
for node_id in &node_ids {
let mut node = LpgNode::new(node_id.clone());
node.set_property("id", PropertyValue::String(node_id.clone()));
lpg.add_node(node);
}
for triple in store.iter() {
let subj_id = self.term_to_node_id(&triple.subject);
let pred_label = self.term_to_edge_label(&triple.predicate);
match &triple.object {
StarTerm::Literal(lit) if self.config.literals_as_properties => {
if let Some(node) = lpg.get_node_mut(&subj_id) {
let value = self.literal_to_property_value(lit)?;
node.set_property(pred_label, value);
}
}
_ => {
let obj_id = self.term_to_node_id(&triple.object);
let edge = LpgEdge::new(subj_id, pred_label, obj_id);
lpg.add_edge(edge);
}
}
if let StarTerm::QuotedTriple(qt) = &triple.subject {
self.add_quoted_triple_metadata(&mut lpg, qt, &triple)?;
}
}
lpg.metadata
.insert("source".to_string(), "rdf-star".to_string());
lpg.metadata
.insert("conversion_config".to_string(), "default".to_string());
info!(
"Conversion complete: {} nodes, {} edges",
lpg.nodes.len(),
lpg.edges.len()
);
Ok(lpg)
}
#[instrument(skip(self, lpg), fields(node_count = lpg.nodes.len(), edge_count = lpg.edges.len()))]
pub fn lpg_to_rdf(&self, lpg: &LabeledPropertyGraph) -> StarResult<StarStore> {
info!("Converting property graph to RDF-star");
let store = StarStore::new();
for (node_id, node) in &lpg.nodes {
let subject = StarTerm::iri(node_id)?;
for label in &node.labels {
let type_triple = StarTriple::new(
subject.clone(),
StarTerm::iri("http://www.w3.org/1999/02/22-rdf-syntax-ns#type")?,
StarTerm::iri(label)?,
);
store.insert(&type_triple)?;
}
for (key, value) in &node.properties {
if key != "id" {
let predicate = StarTerm::iri(&self.expand_namespace(key))?;
let object = self.property_value_to_term(value)?;
let triple = StarTriple::new(subject.clone(), predicate, object);
store.insert(&triple)?;
}
}
}
for edge in &lpg.edges {
let subject = StarTerm::iri(&edge.from)?;
let predicate = StarTerm::iri(&self.expand_namespace(&edge.label))?;
let object = StarTerm::iri(&edge.to)?;
let base_triple = StarTriple::new(subject.clone(), predicate.clone(), object.clone());
store.insert(&base_triple)?;
if !edge.properties.is_empty() {
for (key, value) in &edge.properties {
let meta_pred = StarTerm::iri(&self.expand_namespace(key))?;
let meta_obj = self.property_value_to_term(value)?;
let quoted_triple = StarTriple::new(
StarTerm::quoted_triple(base_triple.clone()),
meta_pred,
meta_obj,
);
store.insert("ed_triple)?;
}
}
}
info!("Conversion complete: {} triples", store.len());
Ok(store)
}
fn term_to_node_id(&self, term: &StarTerm) -> String {
match term {
StarTerm::NamedNode(nn) => {
if self.config.use_uri_ids {
nn.iri.clone()
} else {
self.compact_uri(&nn.iri)
}
}
StarTerm::BlankNode(bn) => format!("_:{}", bn.id),
StarTerm::Literal(lit) => format!("literal:{}", lit.value),
StarTerm::Variable(var) => format!("?{}", var.name),
StarTerm::QuotedTriple(qt) => {
format!(
"<<{} {} {}>>",
self.term_to_node_id(&qt.subject),
self.term_to_node_id(&qt.predicate),
self.term_to_node_id(&qt.object)
)
}
}
}
fn term_to_edge_label(&self, term: &StarTerm) -> String {
match term {
StarTerm::NamedNode(nn) => self.compact_uri(&nn.iri),
_ => self.config.default_edge_label.clone(),
}
}
fn compact_uri(&self, uri: &str) -> String {
for (namespace, prefix) in &self.config.namespace_prefixes {
if uri.starts_with(namespace) {
let local = &uri[namespace.len()..];
return format!("{}_{}", prefix, local);
}
}
uri.rsplit('/').next().unwrap_or(uri).to_string()
}
fn expand_namespace(&self, name: &str) -> String {
for (namespace, prefix) in &self.config.namespace_prefixes {
let prefix_with_underscore = format!("{}_", prefix);
if name.starts_with(&prefix_with_underscore) {
let local = &name[prefix_with_underscore.len()..];
return format!("{}{}", namespace, local);
}
}
if name.starts_with("http://") || name.starts_with("https://") {
name.to_string()
} else {
format!("http://example.org/{}", name)
}
}
fn literal_to_property_value(&self, lit: &crate::model::Literal) -> StarResult<PropertyValue> {
if let Ok(i) = lit.value.parse::<i64>() {
return Ok(PropertyValue::Integer(i));
}
if let Ok(f) = lit.value.parse::<f64>() {
return Ok(PropertyValue::Float(f));
}
if let Ok(b) = lit.value.parse::<bool>() {
return Ok(PropertyValue::Boolean(b));
}
Ok(PropertyValue::String(lit.value.clone()))
}
fn property_value_to_term(&self, value: &PropertyValue) -> StarResult<StarTerm> {
match value {
PropertyValue::String(s) => StarTerm::literal(s),
PropertyValue::Integer(i) => StarTerm::literal(&i.to_string()),
PropertyValue::Float(f) => StarTerm::literal(&f.to_string()),
PropertyValue::Boolean(b) => StarTerm::literal(&b.to_string()),
PropertyValue::List(items) => {
let str_items: Vec<_> = items.iter().map(|v| v.to_string_repr()).collect();
StarTerm::literal(&format!("[{}]", str_items.join(", ")))
}
PropertyValue::Map(map) => {
let str_items: Vec<_> = map
.iter()
.map(|(k, v)| format!("{}: {}", k, v.to_string_repr()))
.collect();
StarTerm::literal(&format!("{{{}}}", str_items.join(", ")))
}
}
}
fn add_quoted_triple_metadata(
&self,
lpg: &mut LabeledPropertyGraph,
quoted_triple: &StarTriple,
meta_triple: &StarTriple,
) -> StarResult<()> {
let from = self.term_to_node_id("ed_triple.subject);
let to = self.term_to_node_id("ed_triple.object);
for edge in &mut lpg.edges {
if edge.from == from && edge.to == to {
let prop_key = self.term_to_edge_label(&meta_triple.predicate);
if let StarTerm::Literal(lit) = &meta_triple.object {
let prop_value = self.literal_to_property_value(lit)?;
edge.set_property(prop_key, prop_value);
break;
}
}
}
Ok(())
}
pub fn sparql_to_cypher(&self, sparql: &str) -> StarResult<String> {
let mut cypher = String::new();
if sparql.to_lowercase().contains("select") {
cypher.push_str("MATCH ");
if sparql.contains("?s ?p ?o") {
cypher.push_str("(s)-[r]->(o) RETURN s, r, o");
} else if sparql.contains("<< ?s ?p ?o >>") {
cypher.push_str("(s)-[r]->(o) RETURN s, r, o, properties(r)");
} else {
cypher.push_str("(n) RETURN n LIMIT 100");
}
}
Ok(cypher)
}
pub fn cypher_to_sparql(&self, cypher: &str) -> StarResult<String> {
let mut sparql = String::new();
if cypher.to_lowercase().contains("match") {
sparql.push_str("SELECT * WHERE { ");
if cypher.contains("(a)-[r]->(b)") {
sparql.push_str("?a ?r ?b . ");
} else {
sparql.push_str("?s ?p ?o . ");
}
sparql.push('}');
}
Ok(sparql)
}
pub fn conversion_statistics(&self) -> ConversionStatistics {
ConversionStatistics {
conversions_performed: 0, }
}
}
#[derive(Debug, Clone)]
pub struct ConversionStatistics {
pub conversions_performed: u64,
}
pub struct CypherQueryBuilder {
query: String,
}
impl CypherQueryBuilder {
pub fn new() -> Self {
Self {
query: String::new(),
}
}
pub fn match_pattern(mut self, pattern: &str) -> Self {
if !self.query.is_empty() {
self.query.push(' ');
}
self.query.push_str("MATCH ");
self.query.push_str(pattern);
self
}
pub fn where_clause(mut self, condition: &str) -> Self {
self.query.push_str(" WHERE ");
self.query.push_str(condition);
self
}
pub fn return_clause(mut self, items: &str) -> Self {
self.query.push_str(" RETURN ");
self.query.push_str(items);
self
}
pub fn limit(mut self, count: usize) -> Self {
self.query.push_str(&format!(" LIMIT {}", count));
self
}
pub fn build(self) -> String {
self.query
}
}
impl Default for CypherQueryBuilder {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_conversion_config_default() {
let config = ConversionConfig::default();
assert!(config.use_uri_ids);
assert!(config.literals_as_properties);
assert_eq!(config.default_edge_label, "related");
}
#[test]
fn test_lpg_node_creation() {
let mut node = LpgNode::new("n1");
node.add_label("Person");
node.set_property("name", PropertyValue::String("Alice".to_string()));
node.set_property("age", PropertyValue::Integer(30));
assert_eq!(node.id, "n1");
assert_eq!(node.labels.len(), 1);
assert_eq!(node.properties.len(), 2);
assert_eq!(
node.get_property("name"),
Some(&PropertyValue::String("Alice".to_string()))
);
}
#[test]
fn test_lpg_edge_creation() {
let mut edge = LpgEdge::new("n1", "knows", "n2");
edge.set_property("since", PropertyValue::Integer(2020));
assert_eq!(edge.from, "n1");
assert_eq!(edge.to, "n2");
assert_eq!(edge.label, "knows");
assert_eq!(edge.properties.len(), 1);
}
#[test]
fn test_property_value_string_repr() {
assert_eq!(
PropertyValue::String("test".to_string()).to_string_repr(),
"\"test\""
);
assert_eq!(PropertyValue::Integer(42).to_string_repr(), "42");
assert_eq!(PropertyValue::Float(2.5).to_string_repr(), "2.5");
assert_eq!(PropertyValue::Boolean(true).to_string_repr(), "true");
let list = PropertyValue::List(vec![PropertyValue::Integer(1), PropertyValue::Integer(2)]);
assert_eq!(list.to_string_repr(), "[1, 2]");
}
#[test]
fn test_labeled_property_graph() {
let mut lpg = LabeledPropertyGraph::new();
let mut node1 = LpgNode::new("n1");
node1.add_label("Person");
node1.set_property("name", PropertyValue::String("Alice".to_string()));
let mut node2 = LpgNode::new("n2");
node2.add_label("Person");
node2.set_property("name", PropertyValue::String("Bob".to_string()));
lpg.add_node(node1);
lpg.add_node(node2);
let edge = LpgEdge::new("n1", "knows", "n2");
lpg.add_edge(edge);
assert_eq!(lpg.nodes.len(), 2);
assert_eq!(lpg.edges.len(), 1);
assert!(lpg.get_node("n1").is_some());
assert_eq!(lpg.get_edges_from("n1").len(), 1);
}
#[test]
fn test_rdf_to_lpg_simple() {
let store = StarStore::new();
let triple = StarTriple::new(
StarTerm::iri("http://example.org/alice").unwrap(),
StarTerm::iri("http://example.org/knows").unwrap(),
StarTerm::iri("http://example.org/bob").unwrap(),
);
store.insert(&triple).unwrap();
let config = ConversionConfig::default();
let bridge = PropertyGraphBridge::new(config);
let lpg = bridge.rdf_to_lpg(&store).unwrap();
assert!(lpg.nodes.len() >= 2);
assert_eq!(lpg.edges.len(), 1);
}
#[test]
fn test_rdf_to_lpg_with_literal() {
let store = StarStore::new();
let triple = StarTriple::new(
StarTerm::iri("http://example.org/alice").unwrap(),
StarTerm::iri("http://example.org/age").unwrap(),
StarTerm::literal("30").unwrap(),
);
store.insert(&triple).unwrap();
let config = ConversionConfig::default();
let bridge = PropertyGraphBridge::new(config);
let lpg = bridge.rdf_to_lpg(&store).unwrap();
let alice_node = lpg.get_node("http://example.org/alice").unwrap();
assert!(
alice_node.properties.contains_key("foaf_age")
|| alice_node.properties.contains_key("age")
|| !lpg.edges.is_empty()
); }
#[test]
fn test_lpg_to_rdf() {
let mut lpg = LabeledPropertyGraph::new();
let mut node = LpgNode::new("http://example.org/alice");
node.add_label("http://example.org/Person");
node.set_property("name", PropertyValue::String("Alice".to_string()));
lpg.add_node(node);
let config = ConversionConfig::default();
let bridge = PropertyGraphBridge::new(config);
let store = bridge.lpg_to_rdf(&lpg).unwrap();
assert!(store.len() >= 2);
}
#[test]
fn test_lpg_to_cypher() {
let mut lpg = LabeledPropertyGraph::new();
let mut node1 = LpgNode::new("n1");
node1.add_label("Person");
node1.set_property("name", PropertyValue::String("Alice".to_string()));
lpg.add_node(node1);
let cypher = lpg.to_cypher_script().unwrap();
assert!(cypher.contains("CREATE"));
assert!(cypher.contains("Person"));
assert!(cypher.contains("Alice"));
}
#[test]
fn test_cypher_query_builder() {
let query = CypherQueryBuilder::new()
.match_pattern("(a:Person)-[:knows]->(b:Person)")
.where_clause("a.age > 25")
.return_clause("a, b")
.limit(10)
.build();
assert!(query.contains("MATCH (a:Person)-[:knows]->(b:Person)"));
assert!(query.contains("WHERE a.age > 25"));
assert!(query.contains("RETURN a, b"));
assert!(query.contains("LIMIT 10"));
}
#[test]
fn test_compact_uri() {
let config = ConversionConfig::default();
let bridge = PropertyGraphBridge::new(config);
let compacted = bridge.compact_uri("http://xmlns.com/foaf/0.1/name");
assert_eq!(compacted, "foaf_name");
let compacted2 = bridge.compact_uri("http://example.org/unknown");
assert_eq!(compacted2, "unknown");
}
#[test]
fn test_roundtrip_conversion() {
let store = StarStore::new();
let triple1 = StarTriple::new(
StarTerm::iri("http://example.org/alice").unwrap(),
StarTerm::iri("http://xmlns.com/foaf/0.1/name").unwrap(),
StarTerm::literal("Alice").unwrap(),
);
store.insert(&triple1).unwrap();
let triple2 = StarTriple::new(
StarTerm::iri("http://example.org/alice").unwrap(),
StarTerm::iri("http://xmlns.com/foaf/0.1/knows").unwrap(),
StarTerm::iri("http://example.org/bob").unwrap(),
);
store.insert(&triple2).unwrap();
let config = ConversionConfig::default();
let bridge = PropertyGraphBridge::new(config.clone());
let lpg = bridge.rdf_to_lpg(&store).unwrap();
assert!(!lpg.nodes.is_empty());
let restored = bridge.lpg_to_rdf(&lpg).unwrap();
assert!(restored.len() >= store.len());
}
#[test]
fn test_lpg_statistics() {
let mut lpg = LabeledPropertyGraph::new();
let mut node1 = LpgNode::new("n1");
node1.add_label("Person");
lpg.add_node(node1);
let mut node2 = LpgNode::new("n2");
node2.add_label("Person");
lpg.add_node(node2);
let mut node3 = LpgNode::new("n3");
node3.add_label("Organization");
lpg.add_node(node3);
lpg.add_edge(LpgEdge::new("n1", "knows", "n2"));
lpg.add_edge(LpgEdge::new("n1", "worksFor", "n3"));
let stats = lpg.statistics();
assert_eq!(stats.node_count, 3);
assert_eq!(stats.edge_count, 2);
assert_eq!(stats.label_counts.get("Person"), Some(&2));
assert_eq!(stats.label_counts.get("Organization"), Some(&1));
assert_eq!(stats.edge_type_counts.get("knows"), Some(&1));
}
}