use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RelationshipConfig {
pub relationship_types: Vec<RelationshipTypeConfig>,
pub allow_orphans: bool,
pub orphan_probability: f64,
pub allow_circular: bool,
pub max_circular_depth: u32,
}
impl Default for RelationshipConfig {
fn default() -> Self {
Self {
relationship_types: Vec::new(),
allow_orphans: true,
orphan_probability: 0.01,
allow_circular: false,
max_circular_depth: 3,
}
}
}
impl RelationshipConfig {
pub fn with_types(types: Vec<RelationshipTypeConfig>) -> Self {
Self {
relationship_types: types,
..Default::default()
}
}
pub fn allow_orphans(mut self, allow: bool) -> Self {
self.allow_orphans = allow;
self
}
pub fn orphan_probability(mut self, prob: f64) -> Self {
self.orphan_probability = prob.clamp(0.0, 1.0);
self
}
pub fn allow_circular(mut self, allow: bool) -> Self {
self.allow_circular = allow;
self
}
pub fn max_circular_depth(mut self, depth: u32) -> Self {
self.max_circular_depth = depth;
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RelationshipTypeConfig {
pub name: String,
pub source_type: String,
pub target_type: String,
pub cardinality: CardinalityRule,
pub weight: f64,
pub properties: Vec<PropertyGenerationRule>,
pub required: bool,
pub directed: bool,
}
impl Default for RelationshipTypeConfig {
fn default() -> Self {
Self {
name: String::new(),
source_type: String::new(),
target_type: String::new(),
cardinality: CardinalityRule::OneToMany { min: 1, max: 5 },
weight: 1.0,
properties: Vec::new(),
required: false,
directed: true,
}
}
}
impl RelationshipTypeConfig {
pub fn new(
name: impl Into<String>,
source_type: impl Into<String>,
target_type: impl Into<String>,
) -> Self {
Self {
name: name.into(),
source_type: source_type.into(),
target_type: target_type.into(),
..Default::default()
}
}
pub fn with_cardinality(mut self, cardinality: CardinalityRule) -> Self {
self.cardinality = cardinality;
self
}
pub fn with_weight(mut self, weight: f64) -> Self {
self.weight = weight.max(0.0);
self
}
pub fn with_property(mut self, property: PropertyGenerationRule) -> Self {
self.properties.push(property);
self
}
pub fn required(mut self, required: bool) -> Self {
self.required = required;
self
}
pub fn directed(mut self, directed: bool) -> Self {
self.directed = directed;
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CardinalityRule {
OneToOne,
OneToMany {
min: u32,
max: u32,
},
ManyToOne {
min: u32,
max: u32,
},
ManyToMany {
min_per_source: u32,
max_per_source: u32,
},
}
impl Default for CardinalityRule {
fn default() -> Self {
Self::OneToMany { min: 1, max: 5 }
}
}
impl CardinalityRule {
pub fn one_to_one() -> Self {
Self::OneToOne
}
pub fn one_to_many(min: u32, max: u32) -> Self {
Self::OneToMany {
min,
max: max.max(min),
}
}
pub fn many_to_one(min: u32, max: u32) -> Self {
Self::ManyToOne {
min,
max: max.max(min),
}
}
pub fn many_to_many(min_per_source: u32, max_per_source: u32) -> Self {
Self::ManyToMany {
min_per_source,
max_per_source: max_per_source.max(min_per_source),
}
}
pub fn bounds(&self) -> (u32, u32) {
match self {
Self::OneToOne => (1, 1),
Self::OneToMany { min, max } => (*min, *max),
Self::ManyToOne { min, max } => (*min, *max),
Self::ManyToMany {
min_per_source,
max_per_source,
} => (*min_per_source, *max_per_source),
}
}
pub fn is_multi_target(&self) -> bool {
matches!(self, Self::OneToMany { .. } | Self::ManyToMany { .. })
}
pub fn is_multi_source(&self) -> bool {
matches!(self, Self::ManyToOne { .. } | Self::ManyToMany { .. })
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PropertyGenerationRule {
pub name: String,
pub value_type: PropertyValueType,
pub generator: PropertyGenerator,
}
impl PropertyGenerationRule {
pub fn new(
name: impl Into<String>,
value_type: PropertyValueType,
generator: PropertyGenerator,
) -> Self {
Self {
name: name.into(),
value_type,
generator,
}
}
pub fn constant_string(name: impl Into<String>, value: impl Into<String>) -> Self {
Self::new(
name,
PropertyValueType::String,
PropertyGenerator::Constant(Value::String(value.into())),
)
}
pub fn constant_number(name: impl Into<String>, value: f64) -> Self {
Self::new(
name,
PropertyValueType::Float,
PropertyGenerator::Constant(Value::Number(
serde_json::Number::from_f64(value).unwrap_or_else(|| serde_json::Number::from(0)),
)),
)
}
pub fn range(name: impl Into<String>, min: f64, max: f64) -> Self {
Self::new(
name,
PropertyValueType::Float,
PropertyGenerator::Range { min, max },
)
}
pub fn random_choice(name: impl Into<String>, choices: Vec<Value>) -> Self {
Self::new(
name,
PropertyValueType::String,
PropertyGenerator::RandomChoice(choices),
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PropertyValueType {
String,
Integer,
Float,
Boolean,
DateTime,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PropertyGenerator {
Constant(Value),
RandomChoice(Vec<Value>),
Range {
min: f64,
max: f64,
},
FromSourceProperty(String),
FromTargetProperty(String),
Uuid,
Timestamp,
}
impl Default for PropertyGenerator {
fn default() -> Self {
Self::Constant(Value::Null)
}
}
#[derive(Debug, Clone)]
pub struct RelationshipValidation {
pub valid: bool,
pub errors: Vec<String>,
pub warnings: Vec<String>,
}
impl RelationshipValidation {
pub fn valid() -> Self {
Self {
valid: true,
errors: Vec::new(),
warnings: Vec::new(),
}
}
pub fn invalid(error: impl Into<String>) -> Self {
Self {
valid: false,
errors: vec![error.into()],
warnings: Vec::new(),
}
}
pub fn with_error(mut self, error: impl Into<String>) -> Self {
self.valid = false;
self.errors.push(error.into());
self
}
pub fn with_warning(mut self, warning: impl Into<String>) -> Self {
self.warnings.push(warning.into());
self
}
}
pub mod accounting {
use super::*;
pub fn debits_relationship() -> RelationshipTypeConfig {
RelationshipTypeConfig::new("debits", "journal_entry", "account")
.with_cardinality(CardinalityRule::one_to_many(1, 5))
.required(true)
.with_property(PropertyGenerationRule::range("amount", 0.01, 1_000_000.0))
}
pub fn credits_relationship() -> RelationshipTypeConfig {
RelationshipTypeConfig::new("credits", "journal_entry", "account")
.with_cardinality(CardinalityRule::one_to_many(1, 5))
.required(true)
.with_property(PropertyGenerationRule::range("amount", 0.01, 1_000_000.0))
}
pub fn created_by_relationship() -> RelationshipTypeConfig {
RelationshipTypeConfig::new("created_by", "journal_entry", "user")
.with_cardinality(CardinalityRule::ManyToOne { min: 1, max: 1 })
.required(true)
}
pub fn approved_by_relationship() -> RelationshipTypeConfig {
RelationshipTypeConfig::new("approved_by", "journal_entry", "user")
.with_cardinality(CardinalityRule::ManyToOne { min: 0, max: 1 })
}
pub fn vendor_belongs_to_company() -> RelationshipTypeConfig {
RelationshipTypeConfig::new("belongs_to", "vendor", "company")
.with_cardinality(CardinalityRule::ManyToOne { min: 1, max: 1 })
.required(true)
}
pub fn document_references() -> RelationshipTypeConfig {
RelationshipTypeConfig::new("references", "document", "document")
.with_cardinality(CardinalityRule::ManyToMany {
min_per_source: 0,
max_per_source: 5,
})
.with_property(PropertyGenerationRule::random_choice(
"reference_type",
vec![
Value::String("follow_on".into()),
Value::String("reversal".into()),
Value::String("payment".into()),
],
))
}
pub fn default_accounting_config() -> RelationshipConfig {
RelationshipConfig::with_types(vec![
debits_relationship(),
credits_relationship(),
created_by_relationship(),
approved_by_relationship(),
])
.allow_orphans(true)
.orphan_probability(0.01)
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn test_cardinality_bounds() {
let one_to_one = CardinalityRule::one_to_one();
assert_eq!(one_to_one.bounds(), (1, 1));
let one_to_many = CardinalityRule::one_to_many(2, 5);
assert_eq!(one_to_many.bounds(), (2, 5));
let many_to_one = CardinalityRule::many_to_one(1, 3);
assert_eq!(many_to_one.bounds(), (1, 3));
let many_to_many = CardinalityRule::many_to_many(1, 10);
assert_eq!(many_to_many.bounds(), (1, 10));
}
#[test]
fn test_cardinality_multi() {
assert!(!CardinalityRule::one_to_one().is_multi_target());
assert!(!CardinalityRule::one_to_one().is_multi_source());
assert!(CardinalityRule::one_to_many(1, 5).is_multi_target());
assert!(!CardinalityRule::one_to_many(1, 5).is_multi_source());
assert!(!CardinalityRule::many_to_one(1, 5).is_multi_target());
assert!(CardinalityRule::many_to_one(1, 5).is_multi_source());
assert!(CardinalityRule::many_to_many(1, 5).is_multi_target());
assert!(CardinalityRule::many_to_many(1, 5).is_multi_source());
}
#[test]
fn test_relationship_type_config() {
let config = RelationshipTypeConfig::new("debits", "journal_entry", "account")
.with_cardinality(CardinalityRule::one_to_many(1, 5))
.with_weight(2.0)
.required(true)
.directed(true);
assert_eq!(config.name, "debits");
assert_eq!(config.source_type, "journal_entry");
assert_eq!(config.target_type, "account");
assert_eq!(config.weight, 2.0);
assert!(config.required);
assert!(config.directed);
}
#[test]
fn test_property_generation_rule() {
let constant = PropertyGenerationRule::constant_string("status", "active");
assert_eq!(constant.name, "status");
let range = PropertyGenerationRule::range("amount", 0.0, 1000.0);
assert_eq!(range.name, "amount");
let choice = PropertyGenerationRule::random_choice(
"type",
vec![Value::String("A".into()), Value::String("B".into())],
);
assert_eq!(choice.name, "type");
}
#[test]
fn test_relationship_config() {
let config = RelationshipConfig::default()
.allow_orphans(false)
.orphan_probability(0.05)
.allow_circular(true)
.max_circular_depth(5);
assert!(!config.allow_orphans);
assert_eq!(config.orphan_probability, 0.05);
assert!(config.allow_circular);
assert_eq!(config.max_circular_depth, 5);
}
#[test]
fn test_accounting_relationships() {
let config = accounting::default_accounting_config();
assert_eq!(config.relationship_types.len(), 4);
let debits = config
.relationship_types
.iter()
.find(|t| t.name == "debits")
.unwrap();
assert!(debits.required);
assert_eq!(debits.source_type, "journal_entry");
assert_eq!(debits.target_type, "account");
}
#[test]
fn test_validation() {
let valid = RelationshipValidation::valid();
assert!(valid.valid);
assert!(valid.errors.is_empty());
let invalid = RelationshipValidation::invalid("Missing source")
.with_warning("Consider adding target");
assert!(!invalid.valid);
assert_eq!(invalid.errors.len(), 1);
assert_eq!(invalid.warnings.len(), 1);
}
}