1use crate::error::{GraphError, Result};
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct GraphConfig {
28 pub node_mappings: HashMap<String, NodeMapping>,
35
36 pub relationship_mappings: HashMap<String, RelationshipMapping>,
43
44 pub default_node_id_field: String,
46
47 pub default_relationship_type_field: String,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct NodeMapping {
54 pub label: String,
56 pub id_field: String,
58 pub property_fields: Vec<String>,
60 pub filter_conditions: Option<String>,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct RelationshipMapping {
67 pub relationship_type: String,
69 pub source_id_field: String,
71 pub target_id_field: String,
73 pub type_field: Option<String>,
75 pub property_fields: Vec<String>,
77 pub filter_conditions: Option<String>,
79 #[serde(default)]
83 pub source_label: Option<String>,
84 #[serde(default)]
85 pub target_label: Option<String>,
86}
87
88impl Default for GraphConfig {
89 fn default() -> Self {
90 Self {
91 node_mappings: HashMap::new(),
92 relationship_mappings: HashMap::new(),
93 default_node_id_field: "id".to_string(),
94 default_relationship_type_field: "type".to_string(),
95 }
96 }
97}
98
99impl GraphConfig {
100 pub fn builder() -> GraphConfigBuilder {
102 GraphConfigBuilder::new()
103 }
104
105 pub fn get_node_mapping(&self, label: &str) -> Option<&NodeMapping> {
110 self.node_mappings.get(&label.to_lowercase())
111 }
112
113 pub fn get_relationship_mapping(&self, rel_type: &str) -> Option<&RelationshipMapping> {
118 self.relationship_mappings.get(&rel_type.to_lowercase())
119 }
120
121 pub fn iter_node_mappings(&self) -> impl ExactSizeIterator<Item = (&String, &NodeMapping)> {
123 self.node_mappings.iter()
124 }
125
126 pub fn iter_relationship_mappings(
128 &self,
129 ) -> impl ExactSizeIterator<Item = (&String, &RelationshipMapping)> {
130 self.relationship_mappings.iter()
131 }
132
133 pub fn validate(&self) -> Result<()> {
140 for (label, mapping) in &self.node_mappings {
142 if label != &label.to_lowercase() {
144 return Err(GraphError::ConfigError {
145 message: format!(
146 "Node mapping key '{}' is not normalized. \
147 Keys must be lowercase. Use GraphConfigBuilder to ensure proper normalization.",
148 label
149 ),
150 location: snafu::Location::new(file!(), line!(), column!()),
151 });
152 }
153
154 if mapping.id_field.is_empty() {
155 return Err(GraphError::ConfigError {
156 message: format!("Node mapping for '{}' has empty id_field", label),
157 location: snafu::Location::new(file!(), line!(), column!()),
158 });
159 }
160 }
161
162 for (rel_type, mapping) in &self.relationship_mappings {
164 if rel_type != &rel_type.to_lowercase() {
166 return Err(GraphError::ConfigError {
167 message: format!(
168 "Relationship mapping key '{}' is not normalized. \
169 Keys must be lowercase. Use GraphConfigBuilder to ensure proper normalization.",
170 rel_type
171 ),
172 location: snafu::Location::new(file!(), line!(), column!()),
173 });
174 }
175
176 if mapping.source_id_field.is_empty() || mapping.target_id_field.is_empty() {
177 return Err(GraphError::ConfigError {
178 message: format!(
179 "Relationship mapping for '{}' has empty source or target id field",
180 rel_type
181 ),
182 location: snafu::Location::new(file!(), line!(), column!()),
183 });
184 }
185
186 for label in [&mapping.source_label, &mapping.target_label]
187 .into_iter()
188 .flatten()
189 {
190 if self.get_node_mapping(label).is_none() {
191 return Err(GraphError::ConfigError {
192 message: format!(
193 "Relationship '{}' names endpoint label '{}', which has no node mapping",
194 rel_type, label
195 ),
196 location: snafu::Location::new(file!(), line!(), column!()),
197 });
198 }
199 }
200 }
201
202 Ok(())
203 }
204}
205
206#[derive(Debug, Default, Clone)]
208pub struct GraphConfigBuilder {
209 node_mappings: HashMap<String, NodeMapping>,
210 relationship_mappings: HashMap<String, RelationshipMapping>,
211 default_node_id_field: Option<String>,
212 default_relationship_type_field: Option<String>,
213}
214
215impl GraphConfigBuilder {
216 pub fn new() -> Self {
218 Self::default()
219 }
220
221 pub fn node_table<S: Into<String>>(mut self, label: S, primary_key: S) -> Self {
226 let label_str = label.into();
227 let normalized_key = label_str.to_lowercase();
228 self.node_mappings.insert(
229 normalized_key,
230 NodeMapping {
231 label: label_str, id_field: primary_key.into(),
233 property_fields: Vec::new(),
234 filter_conditions: None,
235 },
236 );
237 self
238 }
239
240 pub fn rel_table<S: Into<String>>(
245 mut self,
246 rel_type: S,
247 from_table: S,
248 to_table: S,
249 src: S,
250 dst: S,
251 ) -> Self {
252 let type_str = rel_type.into();
253 let normalized_key = type_str.to_lowercase();
254 self.relationship_mappings.insert(
255 normalized_key,
256 RelationshipMapping {
257 relationship_type: type_str, source_id_field: src.into(),
259 target_id_field: dst.into(),
260 type_field: None,
261 property_fields: Vec::new(),
262 filter_conditions: None,
263 source_label: Some(from_table.into()),
264 target_label: Some(to_table.into()),
265 },
266 );
267 self
268 }
269
270 #[deprecated(since = "0.2.0", note = "use `node_table`")]
272 pub fn with_node_label<S: Into<String>>(self, label: S, id_field: S) -> Self {
273 self.node_table(label, id_field)
274 }
275
276 pub fn with_node_mapping(mut self, mapping: NodeMapping) -> Self {
278 let normalized_key = mapping.label.to_lowercase();
279 self.node_mappings.insert(normalized_key, mapping);
280 self
281 }
282
283 #[deprecated(
285 since = "0.2.0",
286 note = "use `rel_table`, which also takes the node tables at both ends"
287 )]
288 pub fn with_relationship<S: Into<String>>(
289 mut self,
290 rel_type: S,
291 source_field: S,
292 target_field: S,
293 ) -> Self {
294 let type_str = rel_type.into();
295 let normalized_key = type_str.to_lowercase();
296 self.relationship_mappings.insert(
297 normalized_key,
298 RelationshipMapping {
299 relationship_type: type_str, source_id_field: source_field.into(),
301 target_id_field: target_field.into(),
302 type_field: None,
303 property_fields: Vec::new(),
304 filter_conditions: None,
305 source_label: None,
306 target_label: None,
307 },
308 );
309 self
310 }
311
312 #[deprecated(
315 since = "0.2.0",
316 note = "use `rel_table`, which takes the endpoint node tables"
317 )]
318 pub fn with_relationship_endpoints<S: Into<String>>(
319 mut self,
320 rel_type: &str,
321 source_label: S,
322 target_label: S,
323 ) -> Self {
324 if let Some(mapping) = self.relationship_mappings.get_mut(&rel_type.to_lowercase()) {
325 mapping.source_label = Some(source_label.into());
326 mapping.target_label = Some(target_label.into());
327 }
328 self
329 }
330
331 pub fn with_relationship_mapping(mut self, mapping: RelationshipMapping) -> Self {
333 let normalized_key = mapping.relationship_type.to_lowercase();
334 self.relationship_mappings.insert(normalized_key, mapping);
335 self
336 }
337
338 pub fn with_default_node_id_field<S: Into<String>>(mut self, field: S) -> Self {
340 self.default_node_id_field = Some(field.into());
341 self
342 }
343
344 pub fn with_default_relationship_type_field<S: Into<String>>(mut self, field: S) -> Self {
346 self.default_relationship_type_field = Some(field.into());
347 self
348 }
349
350 pub fn build(self) -> Result<GraphConfig> {
352 let config = GraphConfig {
353 node_mappings: self.node_mappings,
354 relationship_mappings: self.relationship_mappings,
355 default_node_id_field: self
356 .default_node_id_field
357 .unwrap_or_else(|| "id".to_string()),
358 default_relationship_type_field: self
359 .default_relationship_type_field
360 .unwrap_or_else(|| "type".to_string()),
361 };
362
363 config.validate()?;
364 Ok(config)
365 }
366}
367
368impl NodeMapping {
369 pub fn new<S: Into<String>>(label: S, id_field: S) -> Self {
371 Self {
372 label: label.into(),
373 id_field: id_field.into(),
374 property_fields: Vec::new(),
375 filter_conditions: None,
376 }
377 }
378
379 pub fn with_properties(mut self, fields: Vec<String>) -> Self {
381 self.property_fields = fields;
382 self
383 }
384
385 pub fn with_filter<S: Into<String>>(mut self, filter: S) -> Self {
387 self.filter_conditions = Some(filter.into());
388 self
389 }
390}
391
392impl RelationshipMapping {
393 pub fn new<S: Into<String>>(rel_type: S, source_field: S, target_field: S) -> Self {
395 Self {
396 relationship_type: rel_type.into(),
397 source_id_field: source_field.into(),
398 target_id_field: target_field.into(),
399 type_field: None,
400 property_fields: Vec::new(),
401 filter_conditions: None,
402 source_label: None,
403 target_label: None,
404 }
405 }
406
407 pub fn with_endpoints<S: Into<String>>(mut self, source_label: S, target_label: S) -> Self {
409 self.source_label = Some(source_label.into());
410 self.target_label = Some(target_label.into());
411 self
412 }
413
414 pub fn with_type_field<S: Into<String>>(mut self, type_field: S) -> Self {
416 self.type_field = Some(type_field.into());
417 self
418 }
419
420 pub fn with_properties(mut self, fields: Vec<String>) -> Self {
422 self.property_fields = fields;
423 self
424 }
425
426 pub fn with_filter<S: Into<String>>(mut self, filter: S) -> Self {
428 self.filter_conditions = Some(filter.into());
429 self
430 }
431}
432
433#[cfg(test)]
434mod tests {
435 use super::*;
436
437 #[test]
438 fn test_mapping_iterators_keep_normalized_keys_and_original_labels() {
439 let config = GraphConfig::builder()
440 .node_table("Person", "id")
441 .rel_table("KNOWS", "Person", "Person", "src", "dst")
442 .build()
443 .unwrap();
444 let (key, node) = config.iter_node_mappings().next().unwrap();
445 assert_eq!(key, "person");
446 assert_eq!(node.label, "Person");
447 assert!(std::ptr::eq(
448 node,
449 config.get_node_mapping("PERSON").unwrap()
450 ));
451 let (key, relationship) = config.iter_relationship_mappings().next().unwrap();
452 assert_eq!(key, "knows");
453 assert_eq!(relationship.relationship_type, "KNOWS");
454 assert!(std::ptr::eq(
455 relationship,
456 config.get_relationship_mapping("KnOwS").unwrap()
457 ));
458 }
459
460 #[test]
461 fn test_graph_config_builder() {
462 let config = GraphConfig::builder()
463 .node_table("Person", "person_id")
464 .node_table("Company", "company_id")
465 .rel_table("WORKS_FOR", "Person", "Company", "person_id", "company_id")
466 .build()
467 .unwrap();
468
469 assert_eq!(config.iter_node_mappings().len(), 2);
470 assert_eq!(config.iter_relationship_mappings().len(), 1);
471
472 let person_mapping = config.get_node_mapping("Person").unwrap();
473 assert_eq!(person_mapping.id_field, "person_id");
474
475 let works_for_mapping = config.get_relationship_mapping("WORKS_FOR").unwrap();
476 assert_eq!(works_for_mapping.source_id_field, "person_id");
477 assert_eq!(works_for_mapping.target_id_field, "company_id");
478 assert_eq!(works_for_mapping.source_label.as_deref(), Some("Person"));
479 assert_eq!(works_for_mapping.target_label.as_deref(), Some("Company"));
480 }
481
482 #[test]
483 fn rel_table_endpoints_must_be_node_tables() {
484 let result = GraphConfig::builder()
485 .node_table("Person", "id")
486 .rel_table("WORKS_FOR", "Person", "Company", "src", "dst")
487 .build();
488 let message = result.unwrap_err().to_string();
489 assert!(message.contains("Company"), "{message}");
490 }
491
492 #[test]
493 #[allow(deprecated)]
494 fn deprecated_builder_methods_still_configure_the_graph() {
495 let config = GraphConfig::builder()
496 .with_node_label("Person", "id")
497 .with_relationship("KNOWS", "src", "dst")
498 .with_relationship_endpoints("KNOWS", "Person", "Person")
499 .build()
500 .unwrap();
501 let knows = config.get_relationship_mapping("knows").unwrap();
502 assert_eq!(config.get_node_mapping("person").unwrap().id_field, "id");
503 assert_eq!(knows.source_label.as_deref(), Some("Person"));
504 }
505
506 #[test]
507 fn test_validation_empty_id_field() {
508 let mut config = GraphConfig::default();
509 config.node_mappings.insert(
510 "Person".to_string(),
511 NodeMapping {
512 label: "Person".to_string(),
513 id_field: "".to_string(),
514 property_fields: Vec::new(),
515 filter_conditions: None,
516 },
517 );
518
519 assert!(config.validate().is_err());
520 }
521
522 #[test]
523 fn test_node_mapping_with_properties() {
524 let mapping = NodeMapping::new("Person", "id")
525 .with_properties(vec!["name".to_string(), "age".to_string()])
526 .with_filter("age > 18".to_string());
527
528 assert_eq!(mapping.property_fields.len(), 2);
529 assert!(mapping.filter_conditions.is_some());
530 }
531
532 #[test]
533 fn test_case_insensitive_node_label_lookup() {
534 let config = GraphConfig::builder()
536 .node_table("Person", "person_id")
537 .node_table("Company", "company_id")
538 .build()
539 .unwrap();
540
541 assert!(config.get_node_mapping("Person").is_some());
543 assert!(config.get_node_mapping("person").is_some());
544 assert!(config.get_node_mapping("PERSON").is_some());
545 assert!(config.get_node_mapping("PeRsOn").is_some());
546
547 assert!(config.get_node_mapping("Company").is_some());
548 assert!(config.get_node_mapping("company").is_some());
549 assert!(config.get_node_mapping("COMPANY").is_some());
550
551 assert!(config.get_node_mapping("Unknown").is_none());
553 assert!(config.get_node_mapping("unknown").is_none());
554
555 let mapping1 = config.get_node_mapping("Person").unwrap();
557 let mapping2 = config.get_node_mapping("person").unwrap();
558 let mapping3 = config.get_node_mapping("PERSON").unwrap();
559
560 assert_eq!(mapping1.id_field, mapping2.id_field);
561 assert_eq!(mapping2.id_field, mapping3.id_field);
562 assert_eq!(mapping1.id_field, "person_id");
563 }
564
565 #[test]
566 fn test_case_insensitive_relationship_type_lookup() {
567 let config = GraphConfig::builder()
569 .node_table("Person", "person_id")
570 .node_table("Company", "company_id")
571 .rel_table("FOLLOWS", "Person", "Person", "src_id", "dst_id")
572 .rel_table("WORKS_FOR", "Person", "Company", "person_id", "company_id")
573 .build()
574 .unwrap();
575
576 assert!(config.get_relationship_mapping("FOLLOWS").is_some());
578 assert!(config.get_relationship_mapping("follows").is_some());
579 assert!(config.get_relationship_mapping("Follows").is_some());
580
581 assert!(config.get_relationship_mapping("WORKS_FOR").is_some());
582 assert!(config.get_relationship_mapping("works_for").is_some());
583 assert!(config.get_relationship_mapping("Works_For").is_some());
584
585 assert!(config.get_relationship_mapping("UNKNOWN").is_none());
587 assert!(config.get_relationship_mapping("unknown").is_none());
588
589 let mapping1 = config.get_relationship_mapping("FOLLOWS").unwrap();
591 let mapping2 = config.get_relationship_mapping("follows").unwrap();
592 let mapping3 = config.get_relationship_mapping("Follows").unwrap();
593
594 assert_eq!(mapping1.source_id_field, mapping2.source_id_field);
595 assert_eq!(mapping2.source_id_field, mapping3.source_id_field);
596 assert_eq!(mapping1.source_id_field, "src_id");
597 }
598
599 #[test]
600 fn test_duplicate_label_different_case_should_overwrite() {
601 let builder = GraphConfig::builder()
602 .node_table("Person", "id")
603 .node_table("person", "id2"); assert_eq!(builder.node_mappings.len(), 1);
607
608 let mapping = builder.node_mappings.get("person").unwrap();
610 assert_eq!(mapping.id_field, "id2");
611 }
612}