data_modelling_core/models/odcs/
contract.rs

1//! ODCSContract type for ODCS native data structures
2//!
3//! Represents the root data contract document following the ODCS v3.1.0 specification.
4
5use super::schema::SchemaObject;
6use super::supporting::{
7    AuthoritativeDefinition, CustomProperty, Description, Link, Price, QualityRule, Role, Server,
8    ServiceLevel, Support, Team, Terms,
9};
10use serde::{Deserialize, Serialize};
11
12/// ODCSContract - the root data contract document (ODCS v3.1.0)
13///
14/// This is the top-level structure that represents an entire ODCS data contract.
15/// It contains all contract-level metadata plus one or more schema objects (tables).
16///
17/// # Example
18///
19/// ```rust
20/// use data_modelling_core::models::odcs::{ODCSContract, SchemaObject, Property};
21///
22/// let contract = ODCSContract::new("customer-contract", "v1.0.0")
23///     .with_domain("retail")
24///     .with_status("active")
25///     .with_schema(
26///         SchemaObject::new("customers")
27///             .with_physical_type("table")
28///             .with_properties(vec![
29///                 Property::new("id", "integer").with_primary_key(true),
30///                 Property::new("name", "string").with_required(true),
31///             ])
32///     );
33/// ```
34#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
35#[serde(rename_all = "camelCase")]
36pub struct ODCSContract {
37    // === Required Identity Fields ===
38    /// API version (e.g., "v3.1.0")
39    pub api_version: String,
40    /// Kind identifier (always "DataContract")
41    pub kind: String,
42    /// Unique contract ID (UUID or other identifier)
43    pub id: String,
44    /// Contract version (semantic versioning recommended)
45    pub version: String,
46    /// Contract name
47    pub name: String,
48
49    // === Status ===
50    /// Contract status: "draft", "active", "deprecated", "retired"
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub status: Option<String>,
53
54    // === Organization ===
55    /// Domain this contract belongs to
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub domain: Option<String>,
58    /// Data product this contract belongs to
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub data_product: Option<String>,
61    /// Tenant identifier for multi-tenant systems
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub tenant: Option<String>,
64
65    // === Description ===
66    /// Contract description (can be simple string or structured object)
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub description: Option<Description>,
69
70    // === Schema (Tables) ===
71    /// Schema objects (tables, views, topics) in this contract
72    #[serde(default)]
73    pub schema: Vec<SchemaObject>,
74
75    // === Configuration ===
76    /// Server configurations
77    #[serde(default, skip_serializing_if = "Vec::is_empty")]
78    pub servers: Vec<Server>,
79    /// Team information
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub team: Option<Team>,
82    /// Support information
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub support: Option<Support>,
85    /// Role definitions
86    #[serde(default, skip_serializing_if = "Vec::is_empty")]
87    pub roles: Vec<Role>,
88
89    // === SLA & Quality ===
90    /// Service level agreements
91    #[serde(default, skip_serializing_if = "Vec::is_empty")]
92    pub service_levels: Vec<ServiceLevel>,
93    /// Contract-level quality rules
94    #[serde(default, skip_serializing_if = "Vec::is_empty")]
95    pub quality: Vec<QualityRule>,
96
97    // === Pricing & Terms ===
98    /// Price information
99    #[serde(skip_serializing_if = "Option::is_none")]
100    pub price: Option<Price>,
101    /// Terms and conditions
102    #[serde(skip_serializing_if = "Option::is_none")]
103    pub terms: Option<Terms>,
104
105    // === Links & References ===
106    /// External links
107    #[serde(default, skip_serializing_if = "Vec::is_empty")]
108    pub links: Vec<Link>,
109    /// Authoritative definitions
110    #[serde(default, skip_serializing_if = "Vec::is_empty")]
111    pub authoritative_definitions: Vec<AuthoritativeDefinition>,
112
113    // === Tags & Custom Properties ===
114    /// Contract-level tags
115    #[serde(default, skip_serializing_if = "Vec::is_empty")]
116    pub tags: Vec<String>,
117    /// Custom properties for format-specific metadata
118    #[serde(default, skip_serializing_if = "Vec::is_empty")]
119    pub custom_properties: Vec<CustomProperty>,
120
121    // === Timestamps ===
122    /// Contract creation timestamp (ISO 8601)
123    #[serde(skip_serializing_if = "Option::is_none")]
124    pub contract_created_ts: Option<String>,
125}
126
127impl Default for ODCSContract {
128    fn default() -> Self {
129        Self {
130            api_version: "v3.1.0".to_string(),
131            kind: "DataContract".to_string(),
132            id: String::new(),
133            version: "1.0.0".to_string(),
134            name: String::new(),
135            status: None,
136            domain: None,
137            data_product: None,
138            tenant: None,
139            description: None,
140            schema: Vec::new(),
141            servers: Vec::new(),
142            team: None,
143            support: None,
144            roles: Vec::new(),
145            service_levels: Vec::new(),
146            quality: Vec::new(),
147            price: None,
148            terms: None,
149            links: Vec::new(),
150            authoritative_definitions: Vec::new(),
151            tags: Vec::new(),
152            custom_properties: Vec::new(),
153            contract_created_ts: None,
154        }
155    }
156}
157
158impl ODCSContract {
159    /// Create a new contract with the given name and version
160    pub fn new(name: impl Into<String>, version: impl Into<String>) -> Self {
161        Self {
162            name: name.into(),
163            version: version.into(),
164            id: uuid::Uuid::new_v4().to_string(),
165            ..Default::default()
166        }
167    }
168
169    /// Create a new contract with a specific ID
170    pub fn new_with_id(
171        id: impl Into<String>,
172        name: impl Into<String>,
173        version: impl Into<String>,
174    ) -> Self {
175        Self {
176            id: id.into(),
177            name: name.into(),
178            version: version.into(),
179            ..Default::default()
180        }
181    }
182
183    /// Set the API version
184    pub fn with_api_version(mut self, api_version: impl Into<String>) -> Self {
185        self.api_version = api_version.into();
186        self
187    }
188
189    /// Set the status
190    pub fn with_status(mut self, status: impl Into<String>) -> Self {
191        self.status = Some(status.into());
192        self
193    }
194
195    /// Set the domain
196    pub fn with_domain(mut self, domain: impl Into<String>) -> Self {
197        self.domain = Some(domain.into());
198        self
199    }
200
201    /// Set the data product
202    pub fn with_data_product(mut self, data_product: impl Into<String>) -> Self {
203        self.data_product = Some(data_product.into());
204        self
205    }
206
207    /// Set the tenant
208    pub fn with_tenant(mut self, tenant: impl Into<String>) -> Self {
209        self.tenant = Some(tenant.into());
210        self
211    }
212
213    /// Set the description (simple string)
214    pub fn with_description(mut self, description: impl Into<String>) -> Self {
215        self.description = Some(Description::Simple(description.into()));
216        self
217    }
218
219    /// Set a structured description
220    pub fn with_structured_description(mut self, description: Description) -> Self {
221        self.description = Some(description);
222        self
223    }
224
225    /// Add a schema object
226    pub fn with_schema(mut self, schema: SchemaObject) -> Self {
227        self.schema.push(schema);
228        self
229    }
230
231    /// Set all schema objects
232    pub fn with_schemas(mut self, schemas: Vec<SchemaObject>) -> Self {
233        self.schema = schemas;
234        self
235    }
236
237    /// Add a server configuration
238    pub fn with_server(mut self, server: Server) -> Self {
239        self.servers.push(server);
240        self
241    }
242
243    /// Set the team information
244    pub fn with_team(mut self, team: Team) -> Self {
245        self.team = Some(team);
246        self
247    }
248
249    /// Set the support information
250    pub fn with_support(mut self, support: Support) -> Self {
251        self.support = Some(support);
252        self
253    }
254
255    /// Add a role
256    pub fn with_role(mut self, role: Role) -> Self {
257        self.roles.push(role);
258        self
259    }
260
261    /// Add a service level
262    pub fn with_service_level(mut self, service_level: ServiceLevel) -> Self {
263        self.service_levels.push(service_level);
264        self
265    }
266
267    /// Add a quality rule
268    pub fn with_quality_rule(mut self, rule: QualityRule) -> Self {
269        self.quality.push(rule);
270        self
271    }
272
273    /// Set the price
274    pub fn with_price(mut self, price: Price) -> Self {
275        self.price = Some(price);
276        self
277    }
278
279    /// Set the terms
280    pub fn with_terms(mut self, terms: Terms) -> Self {
281        self.terms = Some(terms);
282        self
283    }
284
285    /// Add a link
286    pub fn with_link(mut self, link: Link) -> Self {
287        self.links.push(link);
288        self
289    }
290
291    /// Add an authoritative definition
292    pub fn with_authoritative_definition(mut self, definition: AuthoritativeDefinition) -> Self {
293        self.authoritative_definitions.push(definition);
294        self
295    }
296
297    /// Add a tag
298    pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
299        self.tags.push(tag.into());
300        self
301    }
302
303    /// Set all tags
304    pub fn with_tags(mut self, tags: Vec<String>) -> Self {
305        self.tags = tags;
306        self
307    }
308
309    /// Add a custom property
310    pub fn with_custom_property(mut self, custom_property: CustomProperty) -> Self {
311        self.custom_properties.push(custom_property);
312        self
313    }
314
315    /// Set the contract creation timestamp
316    pub fn with_contract_created_ts(mut self, timestamp: impl Into<String>) -> Self {
317        self.contract_created_ts = Some(timestamp.into());
318        self
319    }
320
321    /// Get the number of schema objects
322    pub fn schema_count(&self) -> usize {
323        self.schema.len()
324    }
325
326    /// Get a schema object by name
327    pub fn get_schema(&self, name: &str) -> Option<&SchemaObject> {
328        self.schema.iter().find(|s| s.name == name)
329    }
330
331    /// Get a mutable schema object by name
332    pub fn get_schema_mut(&mut self, name: &str) -> Option<&mut SchemaObject> {
333        self.schema.iter_mut().find(|s| s.name == name)
334    }
335
336    /// Get all schema names
337    pub fn schema_names(&self) -> Vec<&str> {
338        self.schema.iter().map(|s| s.name.as_str()).collect()
339    }
340
341    /// Check if this is a multi-table contract
342    pub fn is_multi_table(&self) -> bool {
343        self.schema.len() > 1
344    }
345
346    /// Get the first schema (for single-table contracts)
347    pub fn first_schema(&self) -> Option<&SchemaObject> {
348        self.schema.first()
349    }
350
351    /// Get the description as a simple string
352    pub fn description_string(&self) -> Option<String> {
353        self.description.as_ref().map(|d| d.as_string())
354    }
355}
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360    use crate::models::odcs::Property;
361
362    #[test]
363    fn test_contract_creation() {
364        let contract = ODCSContract::new("my-contract", "1.0.0")
365            .with_domain("retail")
366            .with_status("active");
367
368        assert_eq!(contract.name, "my-contract");
369        assert_eq!(contract.version, "1.0.0");
370        assert_eq!(contract.domain, Some("retail".to_string()));
371        assert_eq!(contract.status, Some("active".to_string()));
372        assert_eq!(contract.api_version, "v3.1.0");
373        assert_eq!(contract.kind, "DataContract");
374        assert!(!contract.id.is_empty()); // UUID was generated
375    }
376
377    #[test]
378    fn test_contract_with_schema() {
379        let contract = ODCSContract::new("order-contract", "2.0.0")
380            .with_schema(
381                SchemaObject::new("orders")
382                    .with_physical_type("table")
383                    .with_properties(vec![
384                        Property::new("id", "integer").with_primary_key(true),
385                        Property::new("customer_id", "integer"),
386                        Property::new("total", "number"),
387                    ]),
388            )
389            .with_schema(
390                SchemaObject::new("order_items")
391                    .with_physical_type("table")
392                    .with_properties(vec![
393                        Property::new("id", "integer").with_primary_key(true),
394                        Property::new("order_id", "integer"),
395                        Property::new("product_id", "integer"),
396                    ]),
397            );
398
399        assert_eq!(contract.schema_count(), 2);
400        assert!(contract.is_multi_table());
401        assert_eq!(contract.schema_names(), vec!["orders", "order_items"]);
402
403        let orders = contract.get_schema("orders");
404        assert!(orders.is_some());
405        assert_eq!(orders.unwrap().property_count(), 3);
406    }
407
408    #[test]
409    fn test_contract_serialization() {
410        let contract = ODCSContract::new_with_id(
411            "550e8400-e29b-41d4-a716-446655440000",
412            "test-contract",
413            "1.0.0",
414        )
415        .with_domain("test")
416        .with_status("draft")
417        .with_description("A test contract")
418        .with_tag("test")
419        .with_schema(SchemaObject::new("test_table").with_property(Property::new("id", "string")));
420
421        let json = serde_json::to_string_pretty(&contract).unwrap();
422
423        assert!(json.contains("\"apiVersion\": \"v3.1.0\""));
424        assert!(json.contains("\"kind\": \"DataContract\""));
425        assert!(json.contains("\"id\": \"550e8400-e29b-41d4-a716-446655440000\""));
426        assert!(json.contains("\"name\": \"test-contract\""));
427        assert!(json.contains("\"domain\": \"test\""));
428        assert!(json.contains("\"status\": \"draft\""));
429
430        // Verify camelCase
431        assert!(json.contains("apiVersion"));
432        assert!(!json.contains("api_version"));
433    }
434
435    #[test]
436    fn test_contract_deserialization() {
437        let json = r#"{
438            "apiVersion": "v3.1.0",
439            "kind": "DataContract",
440            "id": "test-id-123",
441            "version": "2.0.0",
442            "name": "customer-contract",
443            "status": "active",
444            "domain": "customers",
445            "description": "Customer data contract",
446            "schema": [
447                {
448                    "name": "customers",
449                    "physicalType": "table",
450                    "properties": [
451                        {
452                            "name": "id",
453                            "logicalType": "integer",
454                            "primaryKey": true
455                        },
456                        {
457                            "name": "name",
458                            "logicalType": "string",
459                            "required": true
460                        }
461                    ]
462                }
463            ],
464            "tags": ["customer", "pii"]
465        }"#;
466
467        let contract: ODCSContract = serde_json::from_str(json).unwrap();
468        assert_eq!(contract.api_version, "v3.1.0");
469        assert_eq!(contract.kind, "DataContract");
470        assert_eq!(contract.id, "test-id-123");
471        assert_eq!(contract.version, "2.0.0");
472        assert_eq!(contract.name, "customer-contract");
473        assert_eq!(contract.status, Some("active".to_string()));
474        assert_eq!(contract.domain, Some("customers".to_string()));
475        assert_eq!(contract.schema_count(), 1);
476        assert_eq!(contract.tags, vec!["customer", "pii"]);
477
478        let customers = contract.get_schema("customers").unwrap();
479        assert_eq!(customers.property_count(), 2);
480    }
481
482    #[test]
483    fn test_structured_description() {
484        let json = r#"{
485            "apiVersion": "v3.1.0",
486            "kind": "DataContract",
487            "id": "test",
488            "version": "1.0.0",
489            "name": "test",
490            "description": {
491                "purpose": "Store customer information",
492                "usage": "Read-only access for analytics"
493            }
494        }"#;
495
496        let contract: ODCSContract = serde_json::from_str(json).unwrap();
497        assert_eq!(
498            contract.description_string(),
499            Some("Store customer information".to_string())
500        );
501    }
502}