Skip to main content

data_modelling_core/models/
relationship.rs

1//! Relationship model for the SDK
2
3use super::enums::{
4    Cardinality, EndpointCardinality, FlowDirection, InfrastructureType, RelationshipType,
5};
6use super::table::{ContactDetails, SlaProperty};
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9use uuid::Uuid;
10
11/// Foreign key column mapping details
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
13#[serde(rename_all = "camelCase")]
14pub struct ForeignKeyDetails {
15    /// Column name in the source table
16    #[serde(alias = "source_column")]
17    pub source_column: String,
18    /// Column name in the target table
19    #[serde(alias = "target_column")]
20    pub target_column: String,
21}
22
23/// ETL job metadata for data flow relationships
24#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
25#[serde(rename_all = "camelCase")]
26pub struct ETLJobMetadata {
27    /// Name of the ETL job that creates this relationship
28    #[serde(alias = "job_name")]
29    pub job_name: String,
30    /// Optional notes about the ETL job
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub notes: Option<String>,
33    /// Job execution frequency (e.g., "daily", "hourly")
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub frequency: Option<String>,
36}
37
38/// Connection point coordinates for relationship visualization
39#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
40pub struct ConnectionPoint {
41    /// X coordinate
42    pub x: f64,
43    /// Y coordinate
44    pub y: f64,
45}
46
47/// Visual metadata for relationship rendering on canvas
48#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
49#[serde(rename_all = "camelCase")]
50pub struct VisualMetadata {
51    /// Connection point identifier on source table
52    #[serde(
53        skip_serializing_if = "Option::is_none",
54        alias = "source_connection_point"
55    )]
56    pub source_connection_point: Option<String>,
57    /// Connection point identifier on target table
58    #[serde(
59        skip_serializing_if = "Option::is_none",
60        alias = "target_connection_point"
61    )]
62    pub target_connection_point: Option<String>,
63    /// Waypoints for routing the relationship line
64    #[serde(default, alias = "routing_waypoints")]
65    pub routing_waypoints: Vec<ConnectionPoint>,
66    /// Position for the relationship label
67    #[serde(skip_serializing_if = "Option::is_none", alias = "label_position")]
68    pub label_position: Option<ConnectionPoint>,
69}
70
71/// Edge attachment point positions on a node
72///
73/// Defines 12 possible handle positions around the perimeter of a node,
74/// organized by edge (top, right, bottom, left) and position on that edge.
75#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
76#[serde(rename_all = "kebab-case")]
77pub enum ConnectionHandle {
78    /// Top edge, left position
79    TopLeft,
80    /// Top edge, center position
81    TopCenter,
82    /// Top edge, right position
83    TopRight,
84    /// Right edge, top position
85    RightTop,
86    /// Right edge, center position
87    RightCenter,
88    /// Right edge, bottom position
89    RightBottom,
90    /// Bottom edge, right position
91    BottomRight,
92    /// Bottom edge, center position
93    BottomCenter,
94    /// Bottom edge, left position
95    BottomLeft,
96    /// Left edge, bottom position
97    LeftBottom,
98    /// Left edge, center position
99    LeftCenter,
100    /// Left edge, top position
101    LeftTop,
102}
103
104/// Relationship model representing a connection between two tables
105///
106/// Relationships can represent foreign keys, data flows, dependencies, or ETL transformations.
107/// They connect a source table to a target table with optional metadata about cardinality,
108/// foreign key details, and ETL job information.
109///
110/// # Example
111///
112/// ```rust
113/// use data_modelling_core::models::Relationship;
114///
115/// let source_id = uuid::Uuid::new_v4();
116/// let target_id = uuid::Uuid::new_v4();
117/// let relationship = Relationship::new(source_id, target_id);
118/// ```
119///
120/// # Example with Metadata (Data Flow Relationship)
121///
122/// ```rust
123/// use data_modelling_core::models::{Relationship, InfrastructureType, ContactDetails, SlaProperty};
124/// use serde_json::json;
125/// use uuid::Uuid;
126///
127/// let source_id = Uuid::new_v4();
128/// let target_id = Uuid::new_v4();
129/// let mut relationship = Relationship::new(source_id, target_id);
130/// relationship.owner = Some("Data Engineering Team".to_string());
131/// relationship.infrastructure_type = Some(InfrastructureType::Kafka);
132/// relationship.contact_details = Some(ContactDetails {
133///     email: Some("team@example.com".to_string()),
134///     phone: None,
135///     name: Some("Data Team".to_string()),
136///     role: Some("Data Owner".to_string()),
137///     other: None,
138/// });
139/// relationship.sla = Some(vec![SlaProperty {
140///     property: "latency".to_string(),
141///     value: json!(2),
142///     unit: "hours".to_string(),
143///     description: Some("Data flow must complete within 2 hours".to_string()),
144///     element: None,
145///     driver: Some("operational".to_string()),
146///     scheduler: None,
147///     schedule: None,
148/// }]);
149/// relationship.notes = Some("ETL pipeline from source to target".to_string());
150/// ```
151#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
152#[serde(rename_all = "camelCase")]
153pub struct Relationship {
154    /// Unique identifier for the relationship (UUIDv4)
155    pub id: Uuid,
156    /// ID of the source table
157    #[serde(alias = "source_table_id")]
158    pub source_table_id: Uuid,
159    /// ID of the target table
160    #[serde(alias = "target_table_id")]
161    pub target_table_id: Uuid,
162    /// Human-readable label for the relationship (displayed on the edge in UI)
163    #[serde(skip_serializing_if = "Option::is_none")]
164    pub label: Option<String>,
165    /// Key/column name on the source side of the relationship
166    #[serde(skip_serializing_if = "Option::is_none", alias = "source_key")]
167    pub source_key: Option<String>,
168    /// Key/column name on the target side of the relationship
169    #[serde(skip_serializing_if = "Option::is_none", alias = "target_key")]
170    pub target_key: Option<String>,
171    /// Legacy cardinality (OneToOne, OneToMany, ManyToMany) - for backward compatibility
172    #[serde(skip_serializing_if = "Option::is_none")]
173    pub cardinality: Option<Cardinality>,
174    /// Whether the source side is optional (nullable foreign key) - legacy field
175    #[serde(skip_serializing_if = "Option::is_none", alias = "source_optional")]
176    pub source_optional: Option<bool>,
177    /// Whether the target side is optional - legacy field
178    #[serde(skip_serializing_if = "Option::is_none", alias = "target_optional")]
179    pub target_optional: Option<bool>,
180    /// Crow's feet cardinality at the source end (zeroOrOne, exactlyOne, zeroOrMany, oneOrMany)
181    #[serde(skip_serializing_if = "Option::is_none", alias = "source_cardinality")]
182    pub source_cardinality: Option<EndpointCardinality>,
183    /// Crow's feet cardinality at the target end (zeroOrOne, exactlyOne, zeroOrMany, oneOrMany)
184    #[serde(skip_serializing_if = "Option::is_none", alias = "target_cardinality")]
185    pub target_cardinality: Option<EndpointCardinality>,
186    /// Direction of data flow (sourceToTarget, targetToSource, bidirectional)
187    #[serde(skip_serializing_if = "Option::is_none", alias = "flow_direction")]
188    pub flow_direction: Option<FlowDirection>,
189    /// Foreign key column mapping details
190    #[serde(skip_serializing_if = "Option::is_none", alias = "foreign_key_details")]
191    pub foreign_key_details: Option<ForeignKeyDetails>,
192    /// ETL job metadata for data flow relationships
193    #[serde(skip_serializing_if = "Option::is_none", alias = "etl_job_metadata")]
194    pub etl_job_metadata: Option<ETLJobMetadata>,
195    /// Type of relationship (ForeignKey, DataFlow, Dependency, ETL)
196    #[serde(skip_serializing_if = "Option::is_none", alias = "relationship_type")]
197    pub relationship_type: Option<RelationshipType>,
198    /// Optional notes about the relationship
199    #[serde(skip_serializing_if = "Option::is_none")]
200    pub notes: Option<String>,
201    /// Owner information (person, team, or organization name) for Data Flow relationships
202    #[serde(skip_serializing_if = "Option::is_none")]
203    pub owner: Option<String>,
204    /// SLA (Service Level Agreement) information (ODCS-inspired but lightweight format)
205    #[serde(skip_serializing_if = "Option::is_none")]
206    pub sla: Option<Vec<SlaProperty>>,
207    /// Contact details for responsible parties
208    #[serde(skip_serializing_if = "Option::is_none", alias = "contact_details")]
209    pub contact_details: Option<ContactDetails>,
210    /// Infrastructure type (hosting platform, service, or tool) for Data Flow relationships
211    #[serde(skip_serializing_if = "Option::is_none", alias = "infrastructure_type")]
212    pub infrastructure_type: Option<InfrastructureType>,
213    /// Visual metadata for canvas rendering
214    #[serde(skip_serializing_if = "Option::is_none", alias = "visual_metadata")]
215    pub visual_metadata: Option<VisualMetadata>,
216    /// Draw.io edge ID for diagram integration
217    #[serde(skip_serializing_if = "Option::is_none", alias = "drawio_edge_id")]
218    pub drawio_edge_id: Option<String>,
219    /// Color for the relationship line in the UI (hex color code or named color)
220    #[serde(skip_serializing_if = "Option::is_none")]
221    pub color: Option<String>,
222    /// Edge attachment point on the source node
223    #[serde(skip_serializing_if = "Option::is_none", alias = "source_handle")]
224    pub source_handle: Option<ConnectionHandle>,
225    /// Edge attachment point on the target node
226    #[serde(skip_serializing_if = "Option::is_none", alias = "target_handle")]
227    pub target_handle: Option<ConnectionHandle>,
228    /// Creation timestamp
229    #[serde(alias = "created_at")]
230    pub created_at: DateTime<Utc>,
231    /// Last update timestamp
232    #[serde(alias = "updated_at")]
233    pub updated_at: DateTime<Utc>,
234}
235
236impl Relationship {
237    /// Create a new relationship between two tables
238    ///
239    /// # Arguments
240    ///
241    /// * `source_table_id` - UUID of the source table
242    /// * `target_table_id` - UUID of the target table
243    ///
244    /// # Returns
245    ///
246    /// A new `Relationship` instance with a generated UUIDv4 ID and current timestamps.
247    ///
248    /// # Example
249    ///
250    /// ```rust
251    /// use data_modelling_core::models::Relationship;
252    ///
253    /// let source_id = uuid::Uuid::new_v4();
254    /// let target_id = uuid::Uuid::new_v4();
255    /// let rel = Relationship::new(source_id, target_id);
256    /// ```
257    pub fn new(source_table_id: Uuid, target_table_id: Uuid) -> Self {
258        let now = Utc::now();
259        let id = Self::generate_id(source_table_id, target_table_id);
260        Self {
261            id,
262            source_table_id,
263            target_table_id,
264            label: None,
265            source_key: None,
266            target_key: None,
267            cardinality: None,
268            source_optional: None,
269            target_optional: None,
270            source_cardinality: None,
271            target_cardinality: None,
272            flow_direction: None,
273            foreign_key_details: None,
274            etl_job_metadata: None,
275            relationship_type: None,
276            notes: None,
277            owner: None,
278            sla: None,
279            contact_details: None,
280            infrastructure_type: None,
281            visual_metadata: None,
282            drawio_edge_id: None,
283            color: None,
284            source_handle: None,
285            target_handle: None,
286            created_at: now,
287            updated_at: now,
288        }
289    }
290
291    /// Generate a UUIDv4 for a new relationship id.
292    ///
293    /// Note: params are retained for backward-compatibility with previous deterministic-v5 API.
294    pub fn generate_id(_source_table_id: Uuid, _target_table_id: Uuid) -> Uuid {
295        Uuid::new_v4()
296    }
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302
303    #[test]
304    fn test_relationship_new() {
305        let source_id = Uuid::new_v4();
306        let target_id = Uuid::new_v4();
307        let rel = Relationship::new(source_id, target_id);
308
309        assert_eq!(rel.source_table_id, source_id);
310        assert_eq!(rel.target_table_id, target_id);
311        assert!(rel.label.is_none());
312        assert!(rel.source_key.is_none());
313        assert!(rel.target_key.is_none());
314    }
315
316    #[test]
317    fn test_relationship_with_label_and_keys() {
318        let source_id = Uuid::new_v4();
319        let target_id = Uuid::new_v4();
320        let mut rel = Relationship::new(source_id, target_id);
321        rel.label = Some("references".to_string());
322        rel.source_key = Some("customer_id".to_string());
323        rel.target_key = Some("id".to_string());
324
325        let json = serde_json::to_string(&rel).unwrap();
326        let parsed: Relationship = serde_json::from_str(&json).unwrap();
327
328        assert_eq!(parsed.label, Some("references".to_string()));
329        assert_eq!(parsed.source_key, Some("customer_id".to_string()));
330        assert_eq!(parsed.target_key, Some("id".to_string()));
331    }
332
333    #[test]
334    fn test_relationship_yaml_roundtrip() {
335        let source_id = Uuid::new_v4();
336        let target_id = Uuid::new_v4();
337        let mut rel = Relationship::new(source_id, target_id);
338        rel.label = Some("has many".to_string());
339        rel.source_key = Some("order_id".to_string());
340        rel.target_key = Some("id".to_string());
341        rel.source_cardinality = Some(EndpointCardinality::ExactlyOne);
342        rel.target_cardinality = Some(EndpointCardinality::ZeroOrMany);
343
344        let yaml = serde_yaml::to_string(&rel).unwrap();
345        let parsed: Relationship = serde_yaml::from_str(&yaml).unwrap();
346
347        assert_eq!(parsed.label, Some("has many".to_string()));
348        assert_eq!(parsed.source_key, Some("order_id".to_string()));
349        assert_eq!(parsed.target_key, Some("id".to_string()));
350        assert_eq!(
351            parsed.source_cardinality,
352            Some(EndpointCardinality::ExactlyOne)
353        );
354        assert_eq!(
355            parsed.target_cardinality,
356            Some(EndpointCardinality::ZeroOrMany)
357        );
358    }
359
360    #[test]
361    fn test_relationship_backward_compatibility() {
362        // Ensure old YAML without label, source_key, target_key still parses
363        let yaml = r#"
364id: 550e8400-e29b-41d4-a716-446655440000
365sourceTableId: 660e8400-e29b-41d4-a716-446655440001
366targetTableId: 770e8400-e29b-41d4-a716-446655440002
367createdAt: 2025-01-01T09:00:00Z
368updatedAt: 2025-01-01T09:00:00Z
369"#;
370        let parsed: Relationship = serde_yaml::from_str(yaml).unwrap();
371        assert!(parsed.label.is_none());
372        assert!(parsed.source_key.is_none());
373        assert!(parsed.target_key.is_none());
374    }
375}