Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 27 additions & 18 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ use serde::{Deserialize, Serialize};

/// Configuration for mapping JSON data to a graph structure.
///
/// The node label is derived from the array key in the JSON (i.e., the last
/// segment of `node_path`). For example, a `node_path` of `"users"` produces
/// nodes with label `:users`, and `"data.Patent"` produces `:Patent`.
///
/// # Example
///
/// ```rust
Expand All @@ -10,21 +14,18 @@ use serde::{Deserialize, Serialize};
/// let config = GraphConfig {
/// node_path: "users".to_string(),
/// id_field: "id".to_string(),
/// label_field: Some("role".to_string()),
/// relation_fields: vec!["friends".to_string()],
/// };
/// ```
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GraphConfig {
/// JSON path to the array of nodes (e.g., "data.users.*" or "users")
/// JSON path to the array of nodes (e.g., "data.users" or "Patent").
/// The last segment is used as the node label.
pub node_path: String,

/// Field name for the node ID
pub id_field: String,

/// Optional field name for the node label
pub label_field: Option<String>,

/// Field names that contain arrays of related node IDs
pub relation_fields: Vec<String>,
}
Expand All @@ -34,13 +35,11 @@ impl GraphConfig {
pub fn new(
node_path: impl Into<String>,
id_field: impl Into<String>,
label_field: Option<String>,
relation_fields: Vec<String>,
) -> Self {
Self {
node_path: node_path.into(),
id_field: id_field.into(),
label_field,
relation_fields,
}
}
Expand All @@ -50,18 +49,27 @@ impl GraphConfig {
Self {
node_path: node_path.into(),
id_field: id_field.into(),
label_field: None,
relation_fields: Vec::new(),
}
}

/// Derive the node label from the last segment of `node_path`.
///
/// For `"users"` → `"users"`, for `"data.Patent"` → `"Patent"`.
pub fn label(&self) -> String {
self.node_path
.rsplit('.')
.next()
.unwrap_or(&self.node_path)
.to_string()
}
}

impl Default for GraphConfig {
fn default() -> Self {
Self {
node_path: "nodes".to_string(),
id_field: "id".to_string(),
label_field: None,
relation_fields: Vec::new(),
}
}
Expand All @@ -76,21 +84,14 @@ mod tests {
let config = GraphConfig::default();
assert_eq!(config.node_path, "nodes");
assert_eq!(config.id_field, "id");
assert!(config.label_field.is_none());
assert!(config.relation_fields.is_empty());
}

#[test]
fn test_new_config() {
let config = GraphConfig::new(
"users",
"id",
Some("role".to_string()),
vec!["friends".to_string()],
);
let config = GraphConfig::new("users", "id", vec!["friends".to_string()]);
assert_eq!(config.node_path, "users");
assert_eq!(config.id_field, "id");
assert_eq!(config.label_field, Some("role".to_string()));
assert_eq!(config.relation_fields, vec!["friends".to_string()]);
}

Expand All @@ -99,7 +100,15 @@ mod tests {
let config = GraphConfig::minimal("users", "id");
assert_eq!(config.node_path, "users");
assert_eq!(config.id_field, "id");
assert!(config.label_field.is_none());
assert!(config.relation_fields.is_empty());
}

#[test]
fn test_label() {
let config = GraphConfig::minimal("users", "id");
assert_eq!(config.label(), "users");

let config = GraphConfig::minimal("data.Patent", "id");
assert_eq!(config.label(), "Patent");
}
}
1 change: 0 additions & 1 deletion src/engine/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,6 @@ mod tests {
let config = GraphConfig {
node_path: "users".to_string(),
id_field: "id".to_string(),
label_field: Some("role".to_string()),
relation_fields: vec![],
};

Expand Down
9 changes: 1 addition & 8 deletions src/engine/storage/json.rs
Original file line number Diff line number Diff line change
Expand Up @@ -111,12 +111,7 @@ pub fn build_graph_from_json(json: &Value, config: &GraphConfig) -> StorageResul
})?
.to_string();

let label = config.label_field.as_ref().and_then(|field| {
node_json
.get(field)
.and_then(|v| v.as_str())
.map(String::from)
});
let label = Some(config.label());

let node = Node::new(id.clone(), label, node_json.clone());
graph.add_node(node);
Expand Down Expand Up @@ -283,7 +278,6 @@ mod tests {
let config = GraphConfig {
node_path: "users".to_string(),
id_field: "id".to_string(),
label_field: Some("role".to_string()),
relation_fields: vec![],
};

Expand Down Expand Up @@ -338,7 +332,6 @@ mod tests {
let config = GraphConfig {
node_path: "users".to_string(),
id_field: "id".to_string(),
label_field: None,
relation_fields: vec!["friends".to_string()],
};

Expand Down
1 change: 0 additions & 1 deletion src/engine/storage/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ mod tests {
let config = GraphConfig {
node_path: "users".to_string(),
id_field: "id".to_string(),
label_field: Some("role".to_string()),
relation_fields: vec![],
};

Expand Down
67 changes: 25 additions & 42 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@
//! let config = GraphConfig {
//! node_path: "users".to_string(),
//! id_field: "id".to_string(),
//! label_field: Some("role".to_string()),
//! relation_fields: vec!["friends".to_string()],
//! };
//!
Expand All @@ -31,9 +30,9 @@
//! let result = engine.execute("MATCH (u) RETURN COUNT(u)").unwrap();
//! assert_eq!(result.get_single_value().unwrap().as_i64(), Some(2));
//!
//! // Count admins
//! let result = engine.execute("MATCH (u:admin) RETURN COUNT(u)").unwrap();
//! assert_eq!(result.get_single_value().unwrap().as_i64(), Some(1));
//! // Match by label (derived from array key)
//! let result = engine.execute("MATCH (u:users) RETURN COUNT(u)").unwrap();
//! assert_eq!(result.get_single_value().unwrap().as_i64(), Some(2));
//!
//! // Sum ages
//! let result = engine.execute("MATCH (u) RETURN SUM(u.age)").unwrap();
Expand Down Expand Up @@ -129,7 +128,6 @@ impl CypherEngine {
/// let config = GraphConfig {
/// node_path: "users".to_string(),
/// id_field: "id".to_string(),
/// label_field: Some("role".to_string()),
/// relation_fields: vec![],
/// };
///
Expand Down Expand Up @@ -347,7 +345,7 @@ impl CypherEngine {
let mut labels_by_label: std::collections::HashMap<String, Vec<&graph::Node>> =
std::collections::HashMap::new();
for node in &self.graph.nodes {
let label = node.label.as_ref().unwrap_or(&"Node".to_string()).clone();
let label = node.label.as_ref().unwrap().clone();
labels_by_label.entry(label).or_default().push(node);
}

Expand Down Expand Up @@ -402,16 +400,8 @@ impl CypherEngine {
> = std::collections::HashMap::new();

for edge in &self.graph.edges {
let from_label = self.graph.nodes[edge.from]
.label
.as_ref()
.unwrap_or(&"Node".to_string())
.clone();
let to_label = self.graph.nodes[edge.to]
.label
.as_ref()
.unwrap_or(&"Node".to_string())
.clone();
let from_label = self.graph.nodes[edge.from].label.as_ref().unwrap().clone();
let to_label = self.graph.nodes[edge.to].label.as_ref().unwrap().clone();

rel_types
.entry(edge.rel_type.clone())
Expand Down Expand Up @@ -471,7 +461,7 @@ mod tests {
let config = GraphConfig {
node_path: "users".to_string(),
id_field: "id".to_string(),
label_field: Some("role".to_string()),

relation_fields: vec![],
};

Expand All @@ -481,13 +471,9 @@ mod tests {
let result = engine.execute("MATCH (u) RETURN COUNT(u)").unwrap();
assert_eq!(result.get_single_value().unwrap().as_i64(), Some(3));

// Count admins
let result = engine.execute("MATCH (u:admin) RETURN COUNT(u)").unwrap();
assert_eq!(result.get_single_value().unwrap().as_i64(), Some(2));

// Count regular users
let result = engine.execute("MATCH (u:user) RETURN COUNT(u)").unwrap();
assert_eq!(result.get_single_value().unwrap().as_i64(), Some(1));
// Count by label (derived from array key)
let result = engine.execute("MATCH (u:users) RETURN COUNT(u)").unwrap();
assert_eq!(result.get_single_value().unwrap().as_i64(), Some(3));
}

#[test]
Expand All @@ -503,7 +489,7 @@ mod tests {
let config = GraphConfig {
node_path: "users".to_string(),
id_field: "id".to_string(),
label_field: Some("role".to_string()),

relation_fields: vec![],
};
let engine = CypherEngine::from_json(&data, config).unwrap();
Expand All @@ -512,9 +498,9 @@ mod tests {
let result = engine.execute("MATCH (u) RETURN SUM(u.age)").unwrap();
assert_eq!(result.get_single_value().unwrap().as_i64(), Some(90));

// Sum admin ages
let result = engine.execute("MATCH (u:admin) RETURN SUM(u.age)").unwrap();
assert_eq!(result.get_single_value().unwrap().as_i64(), Some(65));
// Sum ages by label
let result = engine.execute("MATCH (u:users) RETURN SUM(u.age)").unwrap();
assert_eq!(result.get_single_value().unwrap().as_i64(), Some(90));
}

#[test]
Expand All @@ -529,7 +515,7 @@ mod tests {
let config = GraphConfig {
node_path: "users".to_string(),
id_field: "id".to_string(),
label_field: Some("role".to_string()),

relation_fields: vec![],
};

Expand Down Expand Up @@ -563,7 +549,7 @@ mod tests {
let config = GraphConfig {
node_path: "users".to_string(),
id_field: "id".to_string(),
label_field: None,

relation_fields: vec!["friends".to_string()],
};

Expand Down Expand Up @@ -594,7 +580,7 @@ mod tests {
let config = GraphConfig {
node_path: "data.users".to_string(),
id_field: "id".to_string(),
label_field: Some("role".to_string()),

relation_fields: vec![],
};

Expand Down Expand Up @@ -667,15 +653,15 @@ mod tests {
let config = GraphConfig {
node_path: "users".to_string(),
id_field: "id".to_string(),
label_field: Some("role".to_string()),

relation_fields: vec![],
};

let engine = CypherEngine::from_json(&data, config).unwrap();

// AND - admin and active
// AND - admin and active (using WHERE on property)
let result = engine
.execute("MATCH (u:admin) WHERE u.active = \"true\" RETURN COUNT(u)")
.execute("MATCH (u) WHERE u.role = \"admin\" AND u.active = \"true\" RETURN COUNT(u)")
.unwrap();
assert_eq!(result.get_single_value().unwrap().as_i64(), Some(1));

Expand Down Expand Up @@ -721,9 +707,9 @@ mod tests {
let result = engine.execute("MATCH (u) RETURN COUNT(u)").unwrap();
assert_eq!(result.get_single_value().unwrap().as_i64(), Some(2));

// Label should be detected
let result = engine.execute("MATCH (u:admin) RETURN COUNT(u)").unwrap();
assert_eq!(result.get_single_value().unwrap().as_i64(), Some(1));
// Label should be derived from array key
let result = engine.execute("MATCH (u:users) RETURN COUNT(u)").unwrap();
assert_eq!(result.get_single_value().unwrap().as_i64(), Some(2));
}

#[test]
Expand All @@ -743,7 +729,6 @@ mod tests {
let primary = schema.primary_recommendation.as_ref().unwrap();
assert_eq!(primary.path, "users");
assert_eq!(primary.recommended_id_field, Some("id".to_string()));
assert_eq!(primary.recommended_label_field, Some("role".to_string()));
assert!(
primary
.recommended_relation_fields
Expand All @@ -767,7 +752,6 @@ mod tests {
let primary = schema.primary_recommendation.as_ref().unwrap();
assert_eq!(primary.path, "data.network.users");
assert_eq!(primary.recommended_id_field, Some("id".to_string()));
assert_eq!(primary.recommended_label_field, Some("type".to_string()));
}

#[test]
Expand Down Expand Up @@ -801,7 +785,7 @@ mod tests {
let config = GraphConfig {
node_path: "users".to_string(),
id_field: "id".to_string(),
label_field: Some("role".to_string()),

relation_fields: vec!["friends".to_string()],
};

Expand All @@ -811,8 +795,7 @@ mod tests {
// Verify schema contains expected elements
assert!(schema.contains("Graph Schema"));
assert!(schema.contains("Node Types:"));
assert!(schema.contains("(:admin"));
assert!(schema.contains("(:user"));
assert!(schema.contains("(:users"));
assert!(schema.contains("Relationship Types:"));
assert!(schema.contains("friends"));
}
Expand Down
Loading
Loading