data_modelling_sdk/model/
saver.rs

1//! Model saving functionality
2//!
3//! Saves models to storage backends, handling YAML serialization.
4//!
5//! File structure:
6//! - Base directory (workspace_path)
7//!   - Domain directories (e.g., `domain1/`, `domain2/`)
8//!     - `domain.yaml` - Domain definition
9//!     - `{name}.odcs.yaml` - ODCS table files
10//!     - `{name}.odps.yaml` - ODPS product files
11//!     - `{name}.cads.yaml` - CADS asset files
12//!   - `tables/` - Legacy: tables not in any domain (backward compatibility)
13
14use crate::export::{cads::CADSExporter, odcs::ODCSExporter, odps::ODPSExporter};
15#[cfg(feature = "bpmn")]
16use crate::models::bpmn::BPMNModel;
17#[cfg(feature = "dmn")]
18use crate::models::dmn::DMNModel;
19#[cfg(feature = "openapi")]
20use crate::models::openapi::{OpenAPIFormat, OpenAPIModel};
21use crate::models::{cads::CADSAsset, domain::Domain, odps::ODPSDataProduct, table::Table};
22use crate::storage::{StorageBackend, StorageError};
23use anyhow::Result;
24use serde_yaml;
25use std::collections::HashMap;
26use tracing::info;
27use uuid::Uuid;
28
29/// Model saver that uses a storage backend
30pub struct ModelSaver<B: StorageBackend> {
31    storage: B,
32}
33
34impl<B: StorageBackend> ModelSaver<B> {
35    /// Create a new model saver with the given storage backend
36    pub fn new(storage: B) -> Self {
37        Self { storage }
38    }
39
40    /// Save a table to storage
41    ///
42    /// Saves the table as a YAML file in the workspace's `tables/` directory.
43    /// The filename will be based on the table name if yaml_file_path is not provided.
44    pub async fn save_table(
45        &self,
46        workspace_path: &str,
47        table: &TableData,
48    ) -> Result<(), StorageError> {
49        let tables_dir = format!("{}/tables", workspace_path);
50
51        // Ensure tables directory exists
52        if !self.storage.dir_exists(&tables_dir).await? {
53            self.storage.create_dir(&tables_dir).await?;
54        }
55
56        // Determine file path
57        let file_path = if let Some(ref yaml_path) = table.yaml_file_path {
58            format!(
59                "{}/{}",
60                workspace_path,
61                yaml_path.strip_prefix('/').unwrap_or(yaml_path)
62            )
63        } else {
64            // Generate filename from table name
65            let sanitized_name = sanitize_filename(&table.name);
66            format!("{}/tables/{}.yaml", workspace_path, sanitized_name)
67        };
68
69        // Serialize table to YAML
70        let yaml_content = serde_yaml::to_string(&table.yaml_value).map_err(|e| {
71            StorageError::SerializationError(format!("Failed to serialize table: {}", e))
72        })?;
73
74        // Write to storage
75        self.storage
76            .write_file(&file_path, yaml_content.as_bytes())
77            .await?;
78
79        info!("Saved table '{}' to {}", table.name, file_path);
80        Ok(())
81    }
82
83    /// Save relationships to storage
84    ///
85    /// Saves relationships to `relationships.yaml` in the workspace directory.
86    /// Note: Relationships are now stored within domain.yaml files, but this method
87    /// is kept for backward compatibility.
88    pub async fn save_relationships(
89        &self,
90        workspace_path: &str,
91        relationships: &[RelationshipData],
92    ) -> Result<(), StorageError> {
93        let file_path = format!("{}/relationships.yaml", workspace_path);
94
95        // Serialize relationships to YAML
96        let mut yaml_map = serde_yaml::Mapping::new();
97        let mut rels_array = serde_yaml::Sequence::new();
98        for rel in relationships {
99            rels_array.push(rel.yaml_value.clone());
100        }
101        yaml_map.insert(
102            serde_yaml::Value::String("relationships".to_string()),
103            serde_yaml::Value::Sequence(rels_array),
104        );
105        let yaml_value = serde_yaml::Value::Mapping(yaml_map);
106
107        let yaml_content = serde_yaml::to_string(&yaml_value).map_err(|e| {
108            StorageError::SerializationError(format!("Failed to write YAML: {}", e))
109        })?;
110
111        // Write to storage
112        self.storage
113            .write_file(&file_path, yaml_content.as_bytes())
114            .await?;
115
116        info!(
117            "Saved {} relationships to {}",
118            relationships.len(),
119            file_path
120        );
121        Ok(())
122    }
123
124    /// Save a domain to storage
125    ///
126    /// Saves the domain as `domain.yaml` in a domain directory named after the domain.
127    /// Also saves all associated ODCS tables, ODPS products, and CADS assets within the domain directory.
128    pub async fn save_domain(
129        &self,
130        workspace_path: &str,
131        domain: &Domain,
132        tables: &HashMap<Uuid, Table>,
133        odps_products: &HashMap<Uuid, ODPSDataProduct>,
134        cads_assets: &HashMap<Uuid, CADSAsset>,
135    ) -> Result<(), StorageError> {
136        let sanitized_domain_name = sanitize_filename(&domain.name);
137        let domain_dir = format!("{}/{}", workspace_path, sanitized_domain_name);
138
139        // Ensure domain directory exists
140        if !self.storage.dir_exists(&domain_dir).await? {
141            self.storage.create_dir(&domain_dir).await?;
142        }
143
144        // Save domain.yaml
145        let domain_yaml = domain.to_yaml().map_err(|e| {
146            StorageError::SerializationError(format!("Failed to serialize domain: {}", e))
147        })?;
148        let domain_file_path = format!("{}/domain.yaml", domain_dir);
149        self.storage
150            .write_file(&domain_file_path, domain_yaml.as_bytes())
151            .await?;
152        info!("Saved domain '{}' to {}", domain.name, domain_file_path);
153
154        // Save ODCS tables referenced by ODCSNodes
155        for odcs_node in &domain.odcs_nodes {
156            if let Some(table_id) = odcs_node.table_id
157                && let Some(table) = tables.get(&table_id)
158            {
159                let sanitized_table_name = sanitize_filename(&table.name);
160                let table_file_path = format!("{}/{}.odcs.yaml", domain_dir, sanitized_table_name);
161                let odcs_yaml = ODCSExporter::export_table(table, "odcs_v3_1_0");
162                self.storage
163                    .write_file(&table_file_path, odcs_yaml.as_bytes())
164                    .await?;
165                info!("Saved ODCS table '{}' to {}", table.name, table_file_path);
166            }
167        }
168
169        // Save ODPS products (if we have a way to identify which products belong to this domain)
170        // For now, we'll save all products that have a matching domain field
171        for product in odps_products.values() {
172            if let Some(product_domain) = &product.domain
173                && product_domain == &domain.name
174            {
175                let sanitized_product_name =
176                    sanitize_filename(product.name.as_ref().unwrap_or(&product.id));
177                let product_file_path =
178                    format!("{}/{}.odps.yaml", domain_dir, sanitized_product_name);
179                let odps_yaml = ODPSExporter::export_product(product);
180                self.storage
181                    .write_file(&product_file_path, odps_yaml.as_bytes())
182                    .await?;
183                info!(
184                    "Saved ODPS product '{}' to {}",
185                    product.id, product_file_path
186                );
187            }
188        }
189
190        // Save CADS assets referenced by CADSNodes
191        for cads_node in &domain.cads_nodes {
192            if let Some(cads_asset_id) = cads_node.cads_asset_id
193                && let Some(asset) = cads_assets.get(&cads_asset_id)
194            {
195                let sanitized_asset_name = sanitize_filename(&asset.name);
196                let asset_file_path = format!("{}/{}.cads.yaml", domain_dir, sanitized_asset_name);
197                let cads_yaml = CADSExporter::export_asset(asset);
198                self.storage
199                    .write_file(&asset_file_path, cads_yaml.as_bytes())
200                    .await?;
201                info!("Saved CADS asset '{}' to {}", asset.name, asset_file_path);
202            }
203        }
204
205        Ok(())
206    }
207
208    /// Save an ODPS product to a domain directory
209    ///
210    /// Saves the product as `{product_name}.odps.yaml` in the specified domain directory.
211    pub async fn save_odps_product(
212        &self,
213        workspace_path: &str,
214        domain_name: &str,
215        product: &ODPSDataProduct,
216    ) -> Result<(), StorageError> {
217        let sanitized_domain_name = sanitize_filename(domain_name);
218        let domain_dir = format!("{}/{}", workspace_path, sanitized_domain_name);
219
220        // Ensure domain directory exists
221        if !self.storage.dir_exists(&domain_dir).await? {
222            self.storage.create_dir(&domain_dir).await?;
223        }
224
225        let sanitized_product_name =
226            sanitize_filename(product.name.as_ref().unwrap_or(&product.id));
227        let product_file_path = format!("{}/{}.odps.yaml", domain_dir, sanitized_product_name);
228        let odps_yaml = ODPSExporter::export_product(product);
229        self.storage
230            .write_file(&product_file_path, odps_yaml.as_bytes())
231            .await?;
232
233        info!(
234            "Saved ODPS product '{}' to {}",
235            product.id, product_file_path
236        );
237        Ok(())
238    }
239
240    /// Save a CADS asset to a domain directory
241    ///
242    /// Saves the asset as `{asset_name}.cads.yaml` in the specified domain directory.
243    pub async fn save_cads_asset(
244        &self,
245        workspace_path: &str,
246        domain_name: &str,
247        asset: &CADSAsset,
248    ) -> Result<(), StorageError> {
249        let sanitized_domain_name = sanitize_filename(domain_name);
250        let domain_dir = format!("{}/{}", workspace_path, sanitized_domain_name);
251
252        // Ensure domain directory exists
253        if !self.storage.dir_exists(&domain_dir).await? {
254            self.storage.create_dir(&domain_dir).await?;
255        }
256
257        let sanitized_asset_name = sanitize_filename(&asset.name);
258        let asset_file_path = format!("{}/{}.cads.yaml", domain_dir, sanitized_asset_name);
259        let cads_yaml = CADSExporter::export_asset(asset);
260        self.storage
261            .write_file(&asset_file_path, cads_yaml.as_bytes())
262            .await?;
263
264        info!("Saved CADS asset '{}' to {}", asset.name, asset_file_path);
265        Ok(())
266    }
267
268    /// Save a BPMN model to a domain directory
269    ///
270    /// Saves the model as `{model_name}.bpmn.xml` in the specified domain directory.
271    #[cfg(feature = "bpmn")]
272    pub async fn save_bpmn_model(
273        &self,
274        workspace_path: &str,
275        domain_name: &str,
276        model: &BPMNModel,
277        xml_content: &str,
278    ) -> Result<(), StorageError> {
279        let sanitized_domain_name = sanitize_filename(domain_name);
280        let domain_dir = format!("{}/{}", workspace_path, sanitized_domain_name);
281
282        // Ensure domain directory exists
283        if !self.storage.dir_exists(&domain_dir).await? {
284            self.storage.create_dir(&domain_dir).await?;
285        }
286
287        let sanitized_model_name = sanitize_filename(&model.name);
288        let model_file_path = format!("{}/{}.bpmn.xml", domain_dir, sanitized_model_name);
289        self.storage
290            .write_file(&model_file_path, xml_content.as_bytes())
291            .await?;
292
293        info!("Saved BPMN model '{}' to {}", model.name, model_file_path);
294        Ok(())
295    }
296
297    /// Save a DMN model to a domain directory
298    ///
299    /// Saves the model as `{model_name}.dmn.xml` in the specified domain directory.
300    #[cfg(feature = "dmn")]
301    pub async fn save_dmn_model(
302        &self,
303        workspace_path: &str,
304        domain_name: &str,
305        model: &DMNModel,
306        xml_content: &str,
307    ) -> Result<(), StorageError> {
308        let sanitized_domain_name = sanitize_filename(domain_name);
309        let domain_dir = format!("{}/{}", workspace_path, sanitized_domain_name);
310
311        // Ensure domain directory exists
312        if !self.storage.dir_exists(&domain_dir).await? {
313            self.storage.create_dir(&domain_dir).await?;
314        }
315
316        let sanitized_model_name = sanitize_filename(&model.name);
317        let model_file_path = format!("{}/{}.dmn.xml", domain_dir, sanitized_model_name);
318        self.storage
319            .write_file(&model_file_path, xml_content.as_bytes())
320            .await?;
321
322        info!("Saved DMN model '{}' to {}", model.name, model_file_path);
323        Ok(())
324    }
325
326    /// Save an OpenAPI specification to a domain directory
327    ///
328    /// Saves the specification as `{api_name}.openapi.yaml` or `.openapi.json` in the specified domain directory.
329    #[cfg(feature = "openapi")]
330    pub async fn save_openapi_model(
331        &self,
332        workspace_path: &str,
333        domain_name: &str,
334        model: &OpenAPIModel,
335        content: &str,
336    ) -> Result<(), StorageError> {
337        let sanitized_domain_name = sanitize_filename(domain_name);
338        let domain_dir = format!("{}/{}", workspace_path, sanitized_domain_name);
339
340        // Ensure domain directory exists
341        if !self.storage.dir_exists(&domain_dir).await? {
342            self.storage.create_dir(&domain_dir).await?;
343        }
344
345        let sanitized_api_name = sanitize_filename(&model.name);
346        let extension = match model.format {
347            OpenAPIFormat::Yaml => "yaml",
348            OpenAPIFormat::Json => "json",
349        };
350        let model_file_path = format!(
351            "{}/{}.openapi.{}",
352            domain_dir, sanitized_api_name, extension
353        );
354        self.storage
355            .write_file(&model_file_path, content.as_bytes())
356            .await?;
357
358        info!("Saved OpenAPI spec '{}' to {}", model.name, model_file_path);
359        Ok(())
360    }
361}
362
363/// Table data to save
364#[derive(Debug, Clone)]
365pub struct TableData {
366    pub id: Uuid,
367    pub name: String,
368    pub yaml_file_path: Option<String>,
369    pub yaml_value: serde_yaml::Value,
370}
371
372/// Relationship data to save
373#[derive(Debug, Clone)]
374pub struct RelationshipData {
375    pub id: Uuid,
376    pub source_table_id: Uuid,
377    pub target_table_id: Uuid,
378    pub yaml_value: serde_yaml::Value,
379}
380
381/// Sanitize a filename by removing invalid characters
382fn sanitize_filename(name: &str) -> String {
383    name.chars()
384        .map(|c| match c {
385            '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
386            _ => c,
387        })
388        .collect()
389}