use crate::error::{GraphError, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GraphConfig {
pub node_mappings: HashMap<String, NodeMapping>,
pub relationship_mappings: HashMap<String, RelationshipMapping>,
pub default_node_id_field: String,
pub default_relationship_type_field: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NodeMapping {
pub label: String,
pub id_field: String,
pub property_fields: Vec<String>,
pub filter_conditions: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RelationshipMapping {
pub relationship_type: String,
pub source_id_field: String,
pub target_id_field: String,
pub type_field: Option<String>,
pub property_fields: Vec<String>,
pub filter_conditions: Option<String>,
}
impl Default for GraphConfig {
fn default() -> Self {
Self {
node_mappings: HashMap::new(),
relationship_mappings: HashMap::new(),
default_node_id_field: "id".to_string(),
default_relationship_type_field: "type".to_string(),
}
}
}
impl GraphConfig {
pub fn builder() -> GraphConfigBuilder {
GraphConfigBuilder::new()
}
pub fn get_node_mapping(&self, label: &str) -> Option<&NodeMapping> {
self.node_mappings.get(&label.to_lowercase())
}
pub fn get_relationship_mapping(&self, rel_type: &str) -> Option<&RelationshipMapping> {
self.relationship_mappings.get(&rel_type.to_lowercase())
}
pub fn validate(&self) -> Result<()> {
for (label, mapping) in &self.node_mappings {
if label != &label.to_lowercase() {
return Err(GraphError::ConfigError {
message: format!(
"Node mapping key '{}' is not normalized. \
Keys must be lowercase. Use GraphConfigBuilder to ensure proper normalization.",
label
),
location: snafu::Location::new(file!(), line!(), column!()),
});
}
if mapping.id_field.is_empty() {
return Err(GraphError::ConfigError {
message: format!("Node mapping for '{}' has empty id_field", label),
location: snafu::Location::new(file!(), line!(), column!()),
});
}
}
for (rel_type, mapping) in &self.relationship_mappings {
if rel_type != &rel_type.to_lowercase() {
return Err(GraphError::ConfigError {
message: format!(
"Relationship mapping key '{}' is not normalized. \
Keys must be lowercase. Use GraphConfigBuilder to ensure proper normalization.",
rel_type
),
location: snafu::Location::new(file!(), line!(), column!()),
});
}
if mapping.source_id_field.is_empty() || mapping.target_id_field.is_empty() {
return Err(GraphError::ConfigError {
message: format!(
"Relationship mapping for '{}' has empty source or target id field",
rel_type
),
location: snafu::Location::new(file!(), line!(), column!()),
});
}
}
Ok(())
}
}
#[derive(Debug, Default, Clone)]
pub struct GraphConfigBuilder {
node_mappings: HashMap<String, NodeMapping>,
relationship_mappings: HashMap<String, RelationshipMapping>,
default_node_id_field: Option<String>,
default_relationship_type_field: Option<String>,
}
impl GraphConfigBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn with_node_label<S: Into<String>>(mut self, label: S, id_field: S) -> Self {
let label_str = label.into();
let normalized_key = label_str.to_lowercase();
self.node_mappings.insert(
normalized_key,
NodeMapping {
label: label_str, id_field: id_field.into(),
property_fields: Vec::new(),
filter_conditions: None,
},
);
self
}
pub fn with_node_mapping(mut self, mapping: NodeMapping) -> Self {
let normalized_key = mapping.label.to_lowercase();
self.node_mappings.insert(normalized_key, mapping);
self
}
pub fn with_relationship<S: Into<String>>(
mut self,
rel_type: S,
source_field: S,
target_field: S,
) -> Self {
let type_str = rel_type.into();
let normalized_key = type_str.to_lowercase();
self.relationship_mappings.insert(
normalized_key,
RelationshipMapping {
relationship_type: type_str, source_id_field: source_field.into(),
target_id_field: target_field.into(),
type_field: None,
property_fields: Vec::new(),
filter_conditions: None,
},
);
self
}
pub fn with_relationship_mapping(mut self, mapping: RelationshipMapping) -> Self {
let normalized_key = mapping.relationship_type.to_lowercase();
self.relationship_mappings.insert(normalized_key, mapping);
self
}
pub fn with_default_node_id_field<S: Into<String>>(mut self, field: S) -> Self {
self.default_node_id_field = Some(field.into());
self
}
pub fn with_default_relationship_type_field<S: Into<String>>(mut self, field: S) -> Self {
self.default_relationship_type_field = Some(field.into());
self
}
pub fn build(self) -> Result<GraphConfig> {
let config = GraphConfig {
node_mappings: self.node_mappings,
relationship_mappings: self.relationship_mappings,
default_node_id_field: self
.default_node_id_field
.unwrap_or_else(|| "id".to_string()),
default_relationship_type_field: self
.default_relationship_type_field
.unwrap_or_else(|| "type".to_string()),
};
config.validate()?;
Ok(config)
}
}
impl NodeMapping {
pub fn new<S: Into<String>>(label: S, id_field: S) -> Self {
Self {
label: label.into(),
id_field: id_field.into(),
property_fields: Vec::new(),
filter_conditions: None,
}
}
pub fn with_properties(mut self, fields: Vec<String>) -> Self {
self.property_fields = fields;
self
}
pub fn with_filter<S: Into<String>>(mut self, filter: S) -> Self {
self.filter_conditions = Some(filter.into());
self
}
}
impl RelationshipMapping {
pub fn new<S: Into<String>>(rel_type: S, source_field: S, target_field: S) -> Self {
Self {
relationship_type: rel_type.into(),
source_id_field: source_field.into(),
target_id_field: target_field.into(),
type_field: None,
property_fields: Vec::new(),
filter_conditions: None,
}
}
pub fn with_type_field<S: Into<String>>(mut self, type_field: S) -> Self {
self.type_field = Some(type_field.into());
self
}
pub fn with_properties(mut self, fields: Vec<String>) -> Self {
self.property_fields = fields;
self
}
pub fn with_filter<S: Into<String>>(mut self, filter: S) -> Self {
self.filter_conditions = Some(filter.into());
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_graph_config_builder() {
let config = GraphConfig::builder()
.with_node_label("Person", "person_id")
.with_node_label("Company", "company_id")
.with_relationship("WORKS_FOR", "person_id", "company_id")
.build()
.unwrap();
assert_eq!(config.node_mappings.len(), 2);
assert_eq!(config.relationship_mappings.len(), 1);
let person_mapping = config.get_node_mapping("Person").unwrap();
assert_eq!(person_mapping.id_field, "person_id");
let works_for_mapping = config.get_relationship_mapping("WORKS_FOR").unwrap();
assert_eq!(works_for_mapping.source_id_field, "person_id");
assert_eq!(works_for_mapping.target_id_field, "company_id");
}
#[test]
fn test_validation_empty_id_field() {
let mut config = GraphConfig::default();
config.node_mappings.insert(
"Person".to_string(),
NodeMapping {
label: "Person".to_string(),
id_field: "".to_string(),
property_fields: Vec::new(),
filter_conditions: None,
},
);
assert!(config.validate().is_err());
}
#[test]
fn test_node_mapping_with_properties() {
let mapping = NodeMapping::new("Person", "id")
.with_properties(vec!["name".to_string(), "age".to_string()])
.with_filter("age > 18".to_string());
assert_eq!(mapping.property_fields.len(), 2);
assert!(mapping.filter_conditions.is_some());
}
#[test]
fn test_case_insensitive_node_label_lookup() {
let config = GraphConfig::builder()
.with_node_label("Person", "person_id")
.with_node_label("Company", "company_id")
.build()
.unwrap();
assert!(config.get_node_mapping("Person").is_some());
assert!(config.get_node_mapping("person").is_some());
assert!(config.get_node_mapping("PERSON").is_some());
assert!(config.get_node_mapping("PeRsOn").is_some());
assert!(config.get_node_mapping("Company").is_some());
assert!(config.get_node_mapping("company").is_some());
assert!(config.get_node_mapping("COMPANY").is_some());
assert!(config.get_node_mapping("Unknown").is_none());
assert!(config.get_node_mapping("unknown").is_none());
let mapping1 = config.get_node_mapping("Person").unwrap();
let mapping2 = config.get_node_mapping("person").unwrap();
let mapping3 = config.get_node_mapping("PERSON").unwrap();
assert_eq!(mapping1.id_field, mapping2.id_field);
assert_eq!(mapping2.id_field, mapping3.id_field);
assert_eq!(mapping1.id_field, "person_id");
}
#[test]
fn test_case_insensitive_relationship_type_lookup() {
let config = GraphConfig::builder()
.with_relationship("FOLLOWS", "src_id", "dst_id")
.with_relationship("WORKS_FOR", "person_id", "company_id")
.build()
.unwrap();
assert!(config.get_relationship_mapping("FOLLOWS").is_some());
assert!(config.get_relationship_mapping("follows").is_some());
assert!(config.get_relationship_mapping("Follows").is_some());
assert!(config.get_relationship_mapping("WORKS_FOR").is_some());
assert!(config.get_relationship_mapping("works_for").is_some());
assert!(config.get_relationship_mapping("Works_For").is_some());
assert!(config.get_relationship_mapping("UNKNOWN").is_none());
assert!(config.get_relationship_mapping("unknown").is_none());
let mapping1 = config.get_relationship_mapping("FOLLOWS").unwrap();
let mapping2 = config.get_relationship_mapping("follows").unwrap();
let mapping3 = config.get_relationship_mapping("Follows").unwrap();
assert_eq!(mapping1.source_id_field, mapping2.source_id_field);
assert_eq!(mapping2.source_id_field, mapping3.source_id_field);
assert_eq!(mapping1.source_id_field, "src_id");
}
#[test]
fn test_duplicate_label_different_case_should_overwrite() {
let builder = GraphConfig::builder()
.with_node_label("Person", "id")
.with_node_label("person", "id2");
assert_eq!(builder.node_mappings.len(), 1);
let mapping = builder.node_mappings.get("person").unwrap();
assert_eq!(mapping.id_field, "id2");
}
}