data_modelling_core/export/
odcl.rs

1//! ODCS exporter for generating ODCS v3.1.0 YAML from data models.
2//!
3//! This module exports data models to ODCS (Open Data Contract Standard) v3.1.0 format only.
4//! Legacy ODCL formats are no longer supported for export.
5
6use super::{ExportError, ExportResult};
7use crate::models::{Column, DataModel, Table};
8use serde_yaml;
9use std::collections::HashMap;
10
11/// Extract $ref path from column relationships.
12/// Returns the first foreignKey relationship as a $ref path.
13fn get_ref_path_from_relationships(column: &Column) -> Option<String> {
14    column.relationships.iter().find_map(|rel| {
15        if rel.relationship_type == "foreignKey" {
16            // Convert back to $ref format
17            Some(format!("#/{}", rel.to))
18        } else {
19            None
20        }
21    })
22}
23
24/// Exporter for ODCS (Open Data Contract Standard) v3.1.0 YAML format.
25pub struct ODCLExporter;
26
27impl ODCLExporter {
28    /// Export a table to ODCS v3.1.0 YAML format.
29    ///
30    /// Note: Only ODCS v3.1.0 format is supported. Legacy formats have been removed.
31    pub fn export_table(table: &Table, _format: &str) -> String {
32        // All exports use ODCS v3.1.0 format
33        Self::export_odcs_v3_1_0_format(table)
34    }
35
36    /// Helper to convert serde_json::Value to serde_yaml::Value
37    fn json_to_yaml_value(json: &serde_json::Value) -> serde_yaml::Value {
38        match json {
39            serde_json::Value::Null => serde_yaml::Value::Null,
40            serde_json::Value::Bool(b) => serde_yaml::Value::Bool(*b),
41            serde_json::Value::Number(n) => {
42                if let Some(i) = n.as_i64() {
43                    serde_yaml::Value::Number(serde_yaml::Number::from(i))
44                } else if let Some(f) = n.as_f64() {
45                    serde_yaml::Value::Number(serde_yaml::Number::from(f))
46                } else {
47                    serde_yaml::Value::String(n.to_string())
48                }
49            }
50            serde_json::Value::String(s) => serde_yaml::Value::String(s.clone()),
51            serde_json::Value::Array(arr) => {
52                let yaml_arr: Vec<serde_yaml::Value> =
53                    arr.iter().map(Self::json_to_yaml_value).collect();
54                serde_yaml::Value::Sequence(yaml_arr)
55            }
56            serde_json::Value::Object(obj) => {
57                let mut yaml_map = serde_yaml::Mapping::new();
58                for (k, v) in obj {
59                    yaml_map.insert(
60                        serde_yaml::Value::String(k.clone()),
61                        Self::json_to_yaml_value(v),
62                    );
63                }
64                serde_yaml::Value::Mapping(yaml_map)
65            }
66        }
67    }
68
69    /// Export in ODCS v3.1.0 format (the only supported export format).
70    fn export_odcs_v3_1_0_format(table: &Table) -> String {
71        let mut yaml = serde_yaml::Mapping::new();
72
73        // Required ODCS v3.1.0 fields
74        yaml.insert(
75            serde_yaml::Value::String("apiVersion".to_string()),
76            serde_yaml::Value::String("v3.1.0".to_string()),
77        );
78        yaml.insert(
79            serde_yaml::Value::String("kind".to_string()),
80            serde_yaml::Value::String("DataContract".to_string()),
81        );
82
83        // ID - use table UUID (ODCS spec: "A unique identifier used to reduce the risk of dataset name collisions, such as a UUID.")
84        yaml.insert(
85            serde_yaml::Value::String("id".to_string()),
86            serde_yaml::Value::String(table.id.to_string()),
87        );
88
89        // Name
90        yaml.insert(
91            serde_yaml::Value::String("name".to_string()),
92            serde_yaml::Value::String(table.name.clone()),
93        );
94
95        // Version - from metadata or default
96        let version = table
97            .odcl_metadata
98            .get("version")
99            .and_then(|v| v.as_str())
100            .map(|s| s.to_string())
101            .unwrap_or_else(|| "1.0.0".to_string());
102        yaml.insert(
103            serde_yaml::Value::String("version".to_string()),
104            serde_yaml::Value::String(version),
105        );
106
107        // Status - from metadata or default
108        if let Some(status) = table.odcl_metadata.get("status")
109            && !status.is_null()
110        {
111            yaml.insert(
112                serde_yaml::Value::String("status".to_string()),
113                Self::json_to_yaml_value(status),
114            );
115        }
116
117        // Domain - from metadata
118        if let Some(domain) = table.odcl_metadata.get("domain")
119            && !domain.is_null()
120        {
121            yaml.insert(
122                serde_yaml::Value::String("domain".to_string()),
123                Self::json_to_yaml_value(domain),
124            );
125        }
126
127        // Data Product - from metadata
128        if let Some(data_product) = table.odcl_metadata.get("dataProduct")
129            && !data_product.is_null()
130        {
131            yaml.insert(
132                serde_yaml::Value::String("dataProduct".to_string()),
133                Self::json_to_yaml_value(data_product),
134            );
135        }
136
137        // Tenant - from metadata
138        if let Some(tenant) = table.odcl_metadata.get("tenant")
139            && !tenant.is_null()
140        {
141            yaml.insert(
142                serde_yaml::Value::String("tenant".to_string()),
143                Self::json_to_yaml_value(tenant),
144            );
145        }
146
147        // Description - from metadata (can be object or string)
148        if let Some(description) = table.odcl_metadata.get("description")
149            && !description.is_null()
150        {
151            yaml.insert(
152                serde_yaml::Value::String("description".to_string()),
153                Self::json_to_yaml_value(description),
154            );
155        }
156
157        // Tags
158        if !table.tags.is_empty() {
159            let tags_yaml: Vec<serde_yaml::Value> = table
160                .tags
161                .iter()
162                .map(|t| serde_yaml::Value::String(t.to_string()))
163                .collect();
164            yaml.insert(
165                serde_yaml::Value::String("tags".to_string()),
166                serde_yaml::Value::Sequence(tags_yaml),
167            );
168        }
169
170        // Team - from metadata
171        if let Some(team) = table.odcl_metadata.get("team")
172            && !team.is_null()
173        {
174            yaml.insert(
175                serde_yaml::Value::String("team".to_string()),
176                Self::json_to_yaml_value(team),
177            );
178        }
179
180        // Roles - from metadata
181        if let Some(roles) = table.odcl_metadata.get("roles")
182            && !roles.is_null()
183        {
184            yaml.insert(
185                serde_yaml::Value::String("roles".to_string()),
186                Self::json_to_yaml_value(roles),
187            );
188        }
189
190        // Pricing - from metadata (ODCS uses "price")
191        if let Some(pricing) = table.odcl_metadata.get("pricing")
192            && !pricing.is_null()
193        {
194            yaml.insert(
195                serde_yaml::Value::String("price".to_string()),
196                Self::json_to_yaml_value(pricing),
197            );
198        }
199
200        // Terms - from metadata
201        if let Some(terms) = table.odcl_metadata.get("terms")
202            && !terms.is_null()
203        {
204            yaml.insert(
205                serde_yaml::Value::String("terms".to_string()),
206                Self::json_to_yaml_value(terms),
207            );
208        }
209
210        // Servers - from metadata
211        if let Some(servers) = table.odcl_metadata.get("servers")
212            && !servers.is_null()
213        {
214            yaml.insert(
215                serde_yaml::Value::String("servers".to_string()),
216                Self::json_to_yaml_value(servers),
217            );
218        }
219
220        // Service Levels - from metadata
221        if let Some(servicelevels) = table.odcl_metadata.get("servicelevels")
222            && !servicelevels.is_null()
223        {
224            yaml.insert(
225                serde_yaml::Value::String("servicelevels".to_string()),
226                Self::json_to_yaml_value(servicelevels),
227            );
228        }
229
230        // Links - from metadata
231        if let Some(links) = table.odcl_metadata.get("links")
232            && !links.is_null()
233        {
234            yaml.insert(
235                serde_yaml::Value::String("links".to_string()),
236                Self::json_to_yaml_value(links),
237            );
238        }
239
240        // Infrastructure - from metadata
241        if let Some(infrastructure) = table.odcl_metadata.get("infrastructure")
242            && !infrastructure.is_null()
243        {
244            yaml.insert(
245                serde_yaml::Value::String("infrastructure".to_string()),
246                Self::json_to_yaml_value(infrastructure),
247            );
248        }
249
250        // Schema array (ODCS v3.1.0 uses array of SchemaObject)
251        let mut schema_array = Vec::new();
252        let mut schema_obj = serde_yaml::Mapping::new();
253
254        schema_obj.insert(
255            serde_yaml::Value::String("name".to_string()),
256            serde_yaml::Value::String(table.name.clone()),
257        );
258
259        // Build properties from columns
260        let mut properties = serde_yaml::Mapping::new();
261
262        // Helper function to build nested properties structure
263        fn build_nested_properties(
264            parent_name: &str,
265            all_columns: &[crate::models::Column],
266            json_to_yaml_fn: &dyn Fn(&serde_json::Value) -> serde_yaml::Value,
267        ) -> Option<serde_yaml::Mapping> {
268            let parent_prefix = format!("{}.", parent_name);
269            let nested_columns: Vec<&crate::models::Column> = all_columns
270                .iter()
271                .filter(|col| col.name.starts_with(&parent_prefix))
272                .collect();
273
274            if nested_columns.is_empty() {
275                return None;
276            }
277
278            let mut nested_props = serde_yaml::Mapping::new();
279
280            // Group nested columns by their immediate child name (first level only)
281            let mut child_map: std::collections::HashMap<String, Vec<&crate::models::Column>> =
282                std::collections::HashMap::new();
283
284            for nested_col in &nested_columns {
285                // Safety: skip columns that don't start with the expected prefix
286                let Some(relative_name) = nested_col.name.strip_prefix(&parent_prefix) else {
287                    continue;
288                };
289                if let Some(dot_pos) = relative_name.find('.') {
290                    let child_name = &relative_name[..dot_pos];
291                    child_map
292                        .entry(child_name.to_string())
293                        .or_default()
294                        .push(nested_col);
295                } else {
296                    // Direct child - add to map
297                    child_map
298                        .entry(relative_name.to_string())
299                        .or_default()
300                        .push(nested_col);
301                }
302            }
303
304            // Build properties for each child
305            for (child_name, child_cols) in child_map {
306                // Find the direct child column (no dots in relative name)
307                let direct_child = child_cols.iter().find(|col| {
308                    col.name
309                        .strip_prefix(&parent_prefix)
310                        .map(|rel_name| !rel_name.contains('.'))
311                        .unwrap_or(false)
312                });
313
314                if let Some(child_col) = direct_child {
315                    let mut child_prop = serde_yaml::Mapping::new();
316
317                    // Check if this child has nested children
318                    let child_has_nested = child_cols.iter().any(|col| {
319                        col.name
320                            .strip_prefix(&parent_prefix)
321                            .map(|rel| {
322                                rel.starts_with(&format!("{}.", child_name)) && rel != child_name
323                            })
324                            .unwrap_or(false)
325                    });
326
327                    // Handle ARRAY<OBJECT> or ARRAY<STRUCT> types
328                    let data_type_upper = child_col.data_type.to_uppercase();
329                    let is_array_object = data_type_upper.starts_with("ARRAY<")
330                        && (data_type_upper.contains("OBJECT")
331                            || data_type_upper.contains("STRUCT"));
332                    let is_struct_or_object = data_type_upper == "STRUCT"
333                        || data_type_upper == "OBJECT"
334                        || data_type_upper.starts_with("STRUCT<");
335
336                    // Try to build nested properties first (regardless of type)
337                    let nested_props_map =
338                        build_nested_properties(&child_col.name, all_columns, json_to_yaml_fn);
339
340                    if is_array_object && (child_has_nested || nested_props_map.is_some()) {
341                        // ARRAY<OBJECT> with nested fields
342                        child_prop.insert(
343                            serde_yaml::Value::String("type".to_string()),
344                            serde_yaml::Value::String("array".to_string()),
345                        );
346
347                        let mut items = serde_yaml::Mapping::new();
348                        items.insert(
349                            serde_yaml::Value::String("type".to_string()),
350                            serde_yaml::Value::String("object".to_string()),
351                        );
352
353                        // Add nested properties if they exist
354                        if let Some(nested_props) = nested_props_map {
355                            items.insert(
356                                serde_yaml::Value::String("properties".to_string()),
357                                serde_yaml::Value::Mapping(nested_props),
358                            );
359                        }
360
361                        child_prop.insert(
362                            serde_yaml::Value::String("items".to_string()),
363                            serde_yaml::Value::Mapping(items),
364                        );
365                    } else if is_struct_or_object || child_has_nested || nested_props_map.is_some()
366                    {
367                        // OBJECT/STRUCT with nested properties, or any column with nested children
368                        child_prop.insert(
369                            serde_yaml::Value::String("type".to_string()),
370                            serde_yaml::Value::String("object".to_string()),
371                        );
372
373                        // Add nested properties if they exist
374                        if let Some(nested_props) = nested_props_map {
375                            child_prop.insert(
376                                serde_yaml::Value::String("properties".to_string()),
377                                serde_yaml::Value::Mapping(nested_props),
378                            );
379                        }
380                    } else {
381                        // Simple field
382                        child_prop.insert(
383                            serde_yaml::Value::String("type".to_string()),
384                            serde_yaml::Value::String(child_col.data_type.clone().to_lowercase()),
385                        );
386                    }
387
388                    if !child_col.nullable {
389                        child_prop.insert(
390                            serde_yaml::Value::String("required".to_string()),
391                            serde_yaml::Value::Bool(true),
392                        );
393                    }
394
395                    if !child_col.description.is_empty() {
396                        child_prop.insert(
397                            serde_yaml::Value::String("description".to_string()),
398                            serde_yaml::Value::String(child_col.description.clone()),
399                        );
400                    }
401
402                    // Export column-level quality rules for nested columns
403                    if !child_col.quality.is_empty() {
404                        let quality_array: Vec<serde_yaml::Value> = child_col
405                            .quality
406                            .iter()
407                            .map(|rule| {
408                                let mut rule_map = serde_yaml::Mapping::new();
409                                for (k, v) in rule {
410                                    rule_map.insert(
411                                        serde_yaml::Value::String(k.clone()),
412                                        json_to_yaml_fn(v),
413                                    );
414                                }
415                                serde_yaml::Value::Mapping(rule_map)
416                            })
417                            .collect();
418                        child_prop.insert(
419                            serde_yaml::Value::String("quality".to_string()),
420                            serde_yaml::Value::Sequence(quality_array),
421                        );
422                    }
423
424                    // Export $ref reference for nested columns if present (from relationships)
425                    if let Some(ref_path) = get_ref_path_from_relationships(child_col) {
426                        child_prop.insert(
427                            serde_yaml::Value::String("$ref".to_string()),
428                            serde_yaml::Value::String(ref_path),
429                        );
430                    }
431
432                    // Export enum values for nested columns
433                    if !child_col.enum_values.is_empty() {
434                        let enum_yaml: Vec<serde_yaml::Value> = child_col
435                            .enum_values
436                            .iter()
437                            .map(|e| serde_yaml::Value::String(e.clone()))
438                            .collect();
439                        child_prop.insert(
440                            serde_yaml::Value::String("enum".to_string()),
441                            serde_yaml::Value::Sequence(enum_yaml),
442                        );
443                    }
444
445                    // Export constraints for nested columns
446                    if !child_col.constraints.is_empty() {
447                        let constraints_yaml: Vec<serde_yaml::Value> = child_col
448                            .constraints
449                            .iter()
450                            .map(|c| serde_yaml::Value::String(c.clone()))
451                            .collect();
452                        child_prop.insert(
453                            serde_yaml::Value::String("constraints".to_string()),
454                            serde_yaml::Value::Sequence(constraints_yaml),
455                        );
456                    }
457
458                    // Export foreign key for nested columns
459                    if let Some(ref fk) = child_col.foreign_key {
460                        let mut fk_map = serde_yaml::Mapping::new();
461                        fk_map.insert(
462                            serde_yaml::Value::String("table".to_string()),
463                            serde_yaml::Value::String(fk.table_id.clone()),
464                        );
465                        fk_map.insert(
466                            serde_yaml::Value::String("column".to_string()),
467                            serde_yaml::Value::String(fk.column_name.clone()),
468                        );
469                        child_prop.insert(
470                            serde_yaml::Value::String("foreignKey".to_string()),
471                            serde_yaml::Value::Mapping(fk_map),
472                        );
473                    }
474
475                    nested_props.insert(
476                        serde_yaml::Value::String(child_name),
477                        serde_yaml::Value::Mapping(child_prop),
478                    );
479                }
480            }
481
482            if nested_props.is_empty() {
483                None
484            } else {
485                Some(nested_props)
486            }
487        }
488
489        for column in &table.columns {
490            // Skip nested columns (they're handled as part of parent columns)
491            if column.name.contains('.') {
492                continue;
493            }
494
495            let mut prop = serde_yaml::Mapping::new();
496
497            // Check if this column has nested columns
498            let has_nested = table.columns.iter().any(|col| {
499                col.name.starts_with(&format!("{}.", column.name)) && col.name != column.name
500            });
501
502            // Determine the type - handle ARRAY<OBJECT>, STRUCT, OBJECT, etc.
503            let data_type_upper = column.data_type.to_uppercase();
504            let is_array_object = data_type_upper.starts_with("ARRAY<")
505                && (data_type_upper.contains("OBJECT") || data_type_upper.contains("STRUCT"));
506            let is_struct_or_object = data_type_upper == "STRUCT"
507                || data_type_upper == "OBJECT"
508                || data_type_upper.starts_with("STRUCT<");
509
510            // Always check for nested properties if nested columns exist
511            if has_nested {
512                // Try to build nested properties first
513                let nested_props = build_nested_properties(
514                    &column.name,
515                    &table.columns,
516                    &Self::json_to_yaml_value,
517                );
518
519                if is_array_object {
520                    // ARRAY<OBJECT> with nested fields
521                    prop.insert(
522                        serde_yaml::Value::String("type".to_string()),
523                        serde_yaml::Value::String("array".to_string()),
524                    );
525
526                    let mut items = serde_yaml::Mapping::new();
527                    items.insert(
528                        serde_yaml::Value::String("type".to_string()),
529                        serde_yaml::Value::String("object".to_string()),
530                    );
531
532                    // Add nested properties if they exist
533                    if let Some(nested_props_map) = nested_props {
534                        items.insert(
535                            serde_yaml::Value::String("properties".to_string()),
536                            serde_yaml::Value::Mapping(nested_props_map),
537                        );
538                    }
539
540                    prop.insert(
541                        serde_yaml::Value::String("items".to_string()),
542                        serde_yaml::Value::Mapping(items),
543                    );
544                } else if is_struct_or_object || nested_props.is_some() {
545                    // OBJECT/STRUCT with nested fields, or any column with nested columns
546                    prop.insert(
547                        serde_yaml::Value::String("type".to_string()),
548                        serde_yaml::Value::String("object".to_string()),
549                    );
550
551                    // Add nested properties if they exist
552                    if let Some(nested_props_map) = nested_props {
553                        prop.insert(
554                            serde_yaml::Value::String("properties".to_string()),
555                            serde_yaml::Value::Mapping(nested_props_map),
556                        );
557                    }
558                } else {
559                    // Has nested columns but couldn't build structure - use simple type
560                    prop.insert(
561                        serde_yaml::Value::String("type".to_string()),
562                        serde_yaml::Value::String(column.data_type.clone().to_lowercase()),
563                    );
564                }
565            } else {
566                // No nested columns - use simple type
567                prop.insert(
568                    serde_yaml::Value::String("type".to_string()),
569                    serde_yaml::Value::String(column.data_type.clone().to_lowercase()),
570                );
571            }
572
573            if !column.nullable {
574                prop.insert(
575                    serde_yaml::Value::String("required".to_string()),
576                    serde_yaml::Value::Bool(true),
577                );
578            }
579
580            if column.primary_key {
581                prop.insert(
582                    serde_yaml::Value::String("primaryKey".to_string()),
583                    serde_yaml::Value::Bool(true),
584                );
585            }
586
587            if column.secondary_key {
588                prop.insert(
589                    serde_yaml::Value::String("businessKey".to_string()),
590                    serde_yaml::Value::Bool(true),
591                );
592            }
593
594            if !column.description.is_empty() {
595                prop.insert(
596                    serde_yaml::Value::String("description".to_string()),
597                    serde_yaml::Value::String(column.description.clone()),
598                );
599            }
600
601            // Export column-level quality rules
602            if !column.quality.is_empty() {
603                let quality_array: Vec<serde_yaml::Value> = column
604                    .quality
605                    .iter()
606                    .map(|rule| {
607                        let mut rule_map = serde_yaml::Mapping::new();
608                        for (k, v) in rule {
609                            rule_map.insert(
610                                serde_yaml::Value::String(k.clone()),
611                                Self::json_to_yaml_value(v),
612                            );
613                        }
614                        serde_yaml::Value::Mapping(rule_map)
615                    })
616                    .collect();
617                prop.insert(
618                    serde_yaml::Value::String("quality".to_string()),
619                    serde_yaml::Value::Sequence(quality_array),
620                );
621            }
622
623            // Export $ref reference if present (from relationships)
624            if let Some(ref_path) = get_ref_path_from_relationships(column) {
625                prop.insert(
626                    serde_yaml::Value::String("$ref".to_string()),
627                    serde_yaml::Value::String(ref_path),
628                );
629            }
630
631            // Export enum values
632            if !column.enum_values.is_empty() {
633                let enum_yaml: Vec<serde_yaml::Value> = column
634                    .enum_values
635                    .iter()
636                    .map(|e| serde_yaml::Value::String(e.clone()))
637                    .collect();
638                prop.insert(
639                    serde_yaml::Value::String("enum".to_string()),
640                    serde_yaml::Value::Sequence(enum_yaml),
641                );
642            }
643
644            // Export constraints
645            if !column.constraints.is_empty() {
646                let constraints_yaml: Vec<serde_yaml::Value> = column
647                    .constraints
648                    .iter()
649                    .map(|c| serde_yaml::Value::String(c.clone()))
650                    .collect();
651                prop.insert(
652                    serde_yaml::Value::String("constraints".to_string()),
653                    serde_yaml::Value::Sequence(constraints_yaml),
654                );
655            }
656
657            // Export foreign key
658            if let Some(ref fk) = column.foreign_key {
659                let mut fk_map = serde_yaml::Mapping::new();
660                fk_map.insert(
661                    serde_yaml::Value::String("table".to_string()),
662                    serde_yaml::Value::String(fk.table_id.clone()),
663                );
664                fk_map.insert(
665                    serde_yaml::Value::String("column".to_string()),
666                    serde_yaml::Value::String(fk.column_name.clone()),
667                );
668                prop.insert(
669                    serde_yaml::Value::String("foreignKey".to_string()),
670                    serde_yaml::Value::Mapping(fk_map),
671                );
672            }
673
674            properties.insert(
675                serde_yaml::Value::String(column.name.clone()),
676                serde_yaml::Value::Mapping(prop),
677            );
678        }
679
680        schema_obj.insert(
681            serde_yaml::Value::String("properties".to_string()),
682            serde_yaml::Value::Mapping(properties),
683        );
684
685        schema_array.push(serde_yaml::Value::Mapping(schema_obj));
686        yaml.insert(
687            serde_yaml::Value::String("schema".to_string()),
688            serde_yaml::Value::Sequence(schema_array),
689        );
690
691        // Table-level quality rules
692        if !table.quality.is_empty() {
693            let quality_array: Vec<serde_yaml::Value> = table
694                .quality
695                .iter()
696                .map(|rule| {
697                    let mut rule_map = serde_yaml::Mapping::new();
698                    for (k, v) in rule {
699                        rule_map.insert(
700                            serde_yaml::Value::String(k.clone()),
701                            Self::json_to_yaml_value(v),
702                        );
703                    }
704                    serde_yaml::Value::Mapping(rule_map)
705                })
706                .collect();
707            yaml.insert(
708                serde_yaml::Value::String("quality".to_string()),
709                serde_yaml::Value::Sequence(quality_array),
710            );
711        }
712
713        // Custom Properties from metadata (excluding already exported fields)
714        let excluded_keys = [
715            "id",
716            "version",
717            "status",
718            "domain",
719            "dataProduct",
720            "tenant",
721            "description",
722            "team",
723            "roles",
724            "pricing",
725            "terms",
726            "servers",
727            "servicelevels",
728            "links",
729            "apiVersion",
730            "kind",
731            "info",
732            "dataContractSpecification",
733        ];
734
735        let mut custom_props = Vec::new();
736        for (key, value) in &table.odcl_metadata {
737            if !excluded_keys.contains(&key.as_str()) && !value.is_null() {
738                let mut prop = serde_yaml::Mapping::new();
739                prop.insert(
740                    serde_yaml::Value::String("property".to_string()),
741                    serde_yaml::Value::String(key.clone()),
742                );
743                prop.insert(
744                    serde_yaml::Value::String("value".to_string()),
745                    Self::json_to_yaml_value(value),
746                );
747                custom_props.push(serde_yaml::Value::Mapping(prop));
748            }
749        }
750
751        // Add database type as custom property if present
752        if let Some(ref db_type) = table.database_type {
753            let mut prop = serde_yaml::Mapping::new();
754            prop.insert(
755                serde_yaml::Value::String("property".to_string()),
756                serde_yaml::Value::String("databaseType".to_string()),
757            );
758            prop.insert(
759                serde_yaml::Value::String("value".to_string()),
760                serde_yaml::Value::String(format!("{:?}", db_type)),
761            );
762            custom_props.push(serde_yaml::Value::Mapping(prop));
763        }
764
765        // Add medallion layers as custom property if present
766        if !table.medallion_layers.is_empty() {
767            let layers: Vec<serde_yaml::Value> = table
768                .medallion_layers
769                .iter()
770                .map(|l| serde_yaml::Value::String(format!("{:?}", l)))
771                .collect();
772            let mut prop = serde_yaml::Mapping::new();
773            prop.insert(
774                serde_yaml::Value::String("property".to_string()),
775                serde_yaml::Value::String("medallionLayers".to_string()),
776            );
777            prop.insert(
778                serde_yaml::Value::String("value".to_string()),
779                serde_yaml::Value::Sequence(layers),
780            );
781            custom_props.push(serde_yaml::Value::Mapping(prop));
782        }
783
784        // Add SCD pattern as custom property if present
785        if let Some(ref scd_pattern) = table.scd_pattern {
786            let mut prop = serde_yaml::Mapping::new();
787            prop.insert(
788                serde_yaml::Value::String("property".to_string()),
789                serde_yaml::Value::String("scdPattern".to_string()),
790            );
791            prop.insert(
792                serde_yaml::Value::String("value".to_string()),
793                serde_yaml::Value::String(format!("{:?}", scd_pattern)),
794            );
795            custom_props.push(serde_yaml::Value::Mapping(prop));
796        }
797
798        // Add Data Vault classification as custom property if present
799        if let Some(ref dv_class) = table.data_vault_classification {
800            let mut prop = serde_yaml::Mapping::new();
801            prop.insert(
802                serde_yaml::Value::String("property".to_string()),
803                serde_yaml::Value::String("dataVaultClassification".to_string()),
804            );
805            prop.insert(
806                serde_yaml::Value::String("value".to_string()),
807                serde_yaml::Value::String(format!("{:?}", dv_class)),
808            );
809            custom_props.push(serde_yaml::Value::Mapping(prop));
810        }
811
812        // Add catalog/schema names as custom properties if present
813        if let Some(ref catalog) = table.catalog_name {
814            let mut prop = serde_yaml::Mapping::new();
815            prop.insert(
816                serde_yaml::Value::String("property".to_string()),
817                serde_yaml::Value::String("catalogName".to_string()),
818            );
819            prop.insert(
820                serde_yaml::Value::String("value".to_string()),
821                serde_yaml::Value::String(catalog.clone()),
822            );
823            custom_props.push(serde_yaml::Value::Mapping(prop));
824        }
825
826        if let Some(ref schema) = table.schema_name {
827            let mut prop = serde_yaml::Mapping::new();
828            prop.insert(
829                serde_yaml::Value::String("property".to_string()),
830                serde_yaml::Value::String("schemaName".to_string()),
831            );
832            prop.insert(
833                serde_yaml::Value::String("value".to_string()),
834                serde_yaml::Value::String(schema.clone()),
835            );
836            custom_props.push(serde_yaml::Value::Mapping(prop));
837        }
838
839        if !custom_props.is_empty() {
840            yaml.insert(
841                serde_yaml::Value::String("customProperties".to_string()),
842                serde_yaml::Value::Sequence(custom_props),
843            );
844        }
845
846        // Contract created timestamp
847        yaml.insert(
848            serde_yaml::Value::String("contractCreatedTs".to_string()),
849            serde_yaml::Value::String(table.created_at.to_rfc3339()),
850        );
851
852        serde_yaml::to_string(&yaml).unwrap_or_default()
853    }
854
855    /// Export tables to ODCS v3.1.0 YAML format (SDK interface).
856    pub fn export(
857        &self,
858        tables: &[Table],
859        _format: &str,
860    ) -> Result<HashMap<String, ExportResult>, ExportError> {
861        let mut exports = HashMap::new();
862        for table in tables {
863            // All exports use ODCS v3.1.0 format
864            let yaml = Self::export_odcs_v3_1_0_format(table);
865            exports.insert(
866                table.name.clone(),
867                ExportResult {
868                    content: yaml,
869                    format: "odcs_v3_1_0".to_string(),
870                },
871            );
872        }
873        Ok(exports)
874    }
875
876    /// Export a data model to ODCS v3.1.0 YAML format (legacy method for compatibility).
877    pub fn export_model(
878        model: &DataModel,
879        table_ids: Option<&[uuid::Uuid]>,
880        _format: &str,
881    ) -> HashMap<String, String> {
882        let tables_to_export: Vec<&Table> = if let Some(ids) = table_ids {
883            model
884                .tables
885                .iter()
886                .filter(|t| ids.contains(&t.id))
887                .collect()
888        } else {
889            model.tables.iter().collect()
890        };
891
892        let mut exports = HashMap::new();
893        for table in tables_to_export {
894            // All exports use ODCS v3.1.0 format
895            let yaml = Self::export_odcs_v3_1_0_format(table);
896            exports.insert(table.name.clone(), yaml);
897        }
898
899        exports
900    }
901}
902
903#[cfg(test)]
904mod tests {
905    use super::*;
906    use crate::export::odcs::ODCSExporter;
907    use crate::models::{Column, Tag};
908
909    #[test]
910    fn test_export_odcs_v3_1_0_basic() {
911        let table = Table {
912            id: Table::generate_id("test_table", None, None, None),
913            name: "test_table".to_string(),
914            columns: vec![Column {
915                name: "id".to_string(),
916                data_type: "BIGINT".to_string(),
917                nullable: false,
918                primary_key: true,
919                description: "Primary key".to_string(),
920                ..Default::default()
921            }],
922            database_type: None,
923            catalog_name: None,
924            schema_name: None,
925            medallion_layers: Vec::new(),
926            scd_pattern: None,
927            data_vault_classification: None,
928            modeling_level: None,
929            tags: vec![Tag::Simple("test".to_string())],
930            odcl_metadata: HashMap::new(),
931            owner: None,
932            sla: None,
933            contact_details: None,
934            infrastructure_type: None,
935            notes: None,
936            position: None,
937            yaml_file_path: None,
938            drawio_cell_id: None,
939            quality: Vec::new(),
940            errors: Vec::new(),
941            created_at: chrono::Utc::now(),
942            updated_at: chrono::Utc::now(),
943        };
944
945        let yaml = ODCSExporter::export_table(&table, "odcs_v3_1_0");
946
947        assert!(yaml.contains("apiVersion: v3.1.0"));
948        assert!(yaml.contains("kind: DataContract"));
949        assert!(yaml.contains("name: test_table"));
950        assert!(yaml.contains("tags:"));
951        assert!(yaml.contains("- test"));
952    }
953}