data_modelling_sdk/export/
json_schema.rs

1//! JSON Schema exporter for generating JSON Schema from data models.
2
3use super::{ExportError, ExportResult};
4use crate::models::{DataModel, Table};
5use serde_json::{Value, json};
6
7/// Exporter for JSON Schema format.
8pub struct JSONSchemaExporter;
9
10impl JSONSchemaExporter {
11    /// Export tables to JSON Schema format (SDK interface).
12    ///
13    /// # Arguments
14    ///
15    /// * `tables` - Slice of tables to export
16    ///
17    /// # Returns
18    ///
19    /// An `ExportResult` containing JSON Schema with all tables in the `definitions` section.
20    ///
21    /// # Example
22    ///
23    /// ```rust
24    /// use data_modelling_sdk::export::json_schema::JSONSchemaExporter;
25    /// use data_modelling_sdk::models::{Table, Column};
26    ///
27    /// let tables = vec![
28    ///     Table::new("User".to_string(), vec![Column::new("id".to_string(), "INTEGER".to_string())]),
29    /// ];
30    ///
31    /// let exporter = JSONSchemaExporter;
32    /// let result = exporter.export(&tables).unwrap();
33    /// assert_eq!(result.format, "json_schema");
34    /// assert!(result.content.contains("\"definitions\""));
35    /// ```
36    pub fn export(&self, tables: &[Table]) -> Result<ExportResult, ExportError> {
37        let schema = Self::export_model_from_tables(tables);
38        Ok(ExportResult {
39            content: serde_json::to_string_pretty(&schema)
40                .map_err(|e| ExportError::SerializationError(e.to_string()))?,
41            format: "json_schema".to_string(),
42        })
43    }
44
45    fn export_model_from_tables(tables: &[Table]) -> serde_json::Value {
46        let mut definitions = serde_json::Map::new();
47        for table in tables {
48            let schema = Self::export_table(table);
49            definitions.insert(table.name.clone(), schema);
50        }
51        let mut root = serde_json::Map::new();
52        root.insert(
53            "$schema".to_string(),
54            serde_json::json!("http://json-schema.org/draft-07/schema#"),
55        );
56        root.insert("type".to_string(), serde_json::json!("object"));
57        root.insert("definitions".to_string(), serde_json::json!(definitions));
58        serde_json::json!(root)
59    }
60
61    /// Export a table to JSON Schema format.
62    ///
63    /// # Arguments
64    ///
65    /// * `table` - The table to export
66    ///
67    /// # Returns
68    ///
69    /// A `serde_json::Value` representing the JSON Schema for the table.
70    ///
71    /// # Example
72    ///
73    /// ```rust
74    /// use data_modelling_sdk::export::json_schema::JSONSchemaExporter;
75    /// use data_modelling_sdk::models::{Table, Column};
76    ///
77    /// let table = Table::new(
78    ///     "User".to_string(),
79    ///     vec![Column::new("id".to_string(), "INTEGER".to_string())],
80    /// );
81    ///
82    /// let schema = JSONSchemaExporter::export_table(&table);
83    /// assert_eq!(schema["title"], "User");
84    /// assert_eq!(schema["type"], "object");
85    /// ```
86    pub fn export_table(table: &Table) -> Value {
87        let mut properties = serde_json::Map::new();
88
89        for column in &table.columns {
90            let mut property = serde_json::Map::new();
91
92            // Map data types to JSON Schema types
93            let (json_type, format) = Self::map_data_type_to_json_schema(&column.data_type);
94            property.insert("type".to_string(), json!(json_type));
95
96            if let Some(fmt) = format {
97                property.insert("format".to_string(), json!(fmt));
98            }
99
100            if !column.nullable {
101                // Note: JSON Schema uses "required" array at schema level
102            }
103
104            if !column.description.is_empty() {
105                property.insert("description".to_string(), json!(column.description));
106            }
107
108            // Export $ref if present
109            if let Some(ref_path) = &column.ref_path {
110                property.insert("$ref".to_string(), json!(ref_path));
111            }
112
113            // Export enum values
114            if !column.enum_values.is_empty() {
115                let enum_vals: Vec<Value> = column
116                    .enum_values
117                    .iter()
118                    .map(|v| {
119                        // Try to parse as number or boolean, otherwise use as string
120                        if let Ok(num) = v.parse::<i64>() {
121                            json!(num)
122                        } else if let Ok(num) = v.parse::<f64>() {
123                            json!(num)
124                        } else if let Ok(b) = v.parse::<bool>() {
125                            json!(b)
126                        } else if v == "null" {
127                            json!(null)
128                        } else {
129                            json!(v)
130                        }
131                    })
132                    .collect();
133                property.insert("enum".to_string(), json!(enum_vals));
134            }
135
136            // Export validation keywords from quality rules
137            Self::export_validation_keywords(&mut property, column);
138
139            properties.insert(column.name.clone(), json!(property));
140        }
141
142        let mut schema = serde_json::Map::new();
143        schema.insert(
144            "$schema".to_string(),
145            json!("http://json-schema.org/draft-07/schema#"),
146        );
147        schema.insert("type".to_string(), json!("object"));
148        schema.insert("title".to_string(), json!(table.name));
149        schema.insert("properties".to_string(), json!(properties));
150
151        // Add required fields (non-nullable columns)
152        let required: Vec<String> = table
153            .columns
154            .iter()
155            .filter(|c| !c.nullable)
156            .map(|c| c.name.clone())
157            .collect();
158
159        if !required.is_empty() {
160            schema.insert("required".to_string(), json!(required));
161        }
162
163        // Add tags if present
164        if !table.tags.is_empty() {
165            let tags_array: Vec<String> = table.tags.iter().map(|t| t.to_string()).collect();
166            schema.insert("tags".to_string(), json!(tags_array));
167        }
168
169        json!(schema)
170    }
171
172    /// Export a data model to JSON Schema format (legacy method for compatibility).
173    pub fn export_model(model: &DataModel, table_ids: Option<&[uuid::Uuid]>) -> Value {
174        let mut definitions = serde_json::Map::new();
175
176        let tables_to_export: Vec<&Table> = if let Some(ids) = table_ids {
177            model
178                .tables
179                .iter()
180                .filter(|t| ids.contains(&t.id))
181                .collect()
182        } else {
183            model.tables.iter().collect()
184        };
185
186        for table in tables_to_export {
187            let schema = Self::export_table(table);
188            definitions.insert(table.name.clone(), schema);
189        }
190
191        let mut root = serde_json::Map::new();
192        root.insert(
193            "$schema".to_string(),
194            json!("http://json-schema.org/draft-07/schema#"),
195        );
196        root.insert("title".to_string(), json!(model.name));
197        root.insert("type".to_string(), json!("object"));
198        root.insert("definitions".to_string(), json!(definitions));
199
200        json!(root)
201    }
202
203    /// Map SQL/ODCL data types to JSON Schema types and formats.
204    fn map_data_type_to_json_schema(data_type: &str) -> (String, Option<String>) {
205        let dt_lower = data_type.to_lowercase();
206
207        match dt_lower.as_str() {
208            "int" | "integer" | "bigint" | "smallint" | "tinyint" => ("integer".to_string(), None),
209            "float" | "double" | "real" | "decimal" | "numeric" => ("number".to_string(), None),
210            "boolean" | "bool" => ("boolean".to_string(), None),
211            "date" => ("string".to_string(), Some("date".to_string())),
212            "time" => ("string".to_string(), Some("time".to_string())),
213            "timestamp" | "datetime" => ("string".to_string(), Some("date-time".to_string())),
214            "uuid" => ("string".to_string(), Some("uuid".to_string())),
215            "uri" | "url" => ("string".to_string(), Some("uri".to_string())),
216            "email" => ("string".to_string(), Some("email".to_string())),
217            _ => {
218                // Default to string for VARCHAR, TEXT, CHAR, etc.
219                ("string".to_string(), None)
220            }
221        }
222    }
223
224    /// Export validation keywords from quality rules to JSON Schema property.
225    fn export_validation_keywords(
226        property: &mut serde_json::Map<String, Value>,
227        column: &crate::models::Column,
228    ) {
229        for rule in &column.quality {
230            // Only process rules that came from JSON Schema (have source="json_schema")
231            // or don't have a source field (for backward compatibility)
232            let source = rule.get("source").and_then(|v| v.as_str());
233            if source.is_some() && source != Some("json_schema") {
234                continue;
235            }
236
237            if let Some(rule_type) = rule.get("type").and_then(|v| v.as_str()) {
238                match rule_type {
239                    "pattern" => {
240                        if let Some(pattern) = rule.get("pattern").or_else(|| rule.get("value")) {
241                            property.insert("pattern".to_string(), pattern.clone());
242                        }
243                    }
244                    "minimum" => {
245                        if let Some(value) = rule.get("value") {
246                            property.insert("minimum".to_string(), value.clone());
247                            if let Some(exclusive) = rule.get("exclusive")
248                                && exclusive.as_bool() == Some(true)
249                            {
250                                property.insert("exclusiveMinimum".to_string(), json!(true));
251                            }
252                        }
253                    }
254                    "maximum" => {
255                        if let Some(value) = rule.get("value") {
256                            property.insert("maximum".to_string(), value.clone());
257                            if let Some(exclusive) = rule.get("exclusive")
258                                && exclusive.as_bool() == Some(true)
259                            {
260                                property.insert("exclusiveMaximum".to_string(), json!(true));
261                            }
262                        }
263                    }
264                    "minLength" => {
265                        if let Some(value) = rule.get("value") {
266                            property.insert("minLength".to_string(), value.clone());
267                        }
268                    }
269                    "maxLength" => {
270                        if let Some(value) = rule.get("value") {
271                            property.insert("maxLength".to_string(), value.clone());
272                        }
273                    }
274                    "multipleOf" => {
275                        if let Some(value) = rule.get("value") {
276                            property.insert("multipleOf".to_string(), value.clone());
277                        }
278                    }
279                    "const" => {
280                        if let Some(value) = rule.get("value") {
281                            property.insert("const".to_string(), value.clone());
282                        }
283                    }
284                    "minItems" => {
285                        if let Some(value) = rule.get("value") {
286                            property.insert("minItems".to_string(), value.clone());
287                        }
288                    }
289                    "maxItems" => {
290                        if let Some(value) = rule.get("value") {
291                            property.insert("maxItems".to_string(), value.clone());
292                        }
293                    }
294                    "uniqueItems" => {
295                        if let Some(value) = rule.get("value")
296                            && value.as_bool() == Some(true)
297                        {
298                            property.insert("uniqueItems".to_string(), json!(true));
299                        }
300                    }
301                    "minProperties" => {
302                        if let Some(value) = rule.get("value") {
303                            property.insert("minProperties".to_string(), value.clone());
304                        }
305                    }
306                    "maxProperties" => {
307                        if let Some(value) = rule.get("value") {
308                            property.insert("maxProperties".to_string(), value.clone());
309                        }
310                    }
311                    "additionalProperties" => {
312                        if let Some(value) = rule.get("value") {
313                            property.insert("additionalProperties".to_string(), value.clone());
314                        }
315                    }
316                    "format" => {
317                        // Format is already handled in map_data_type_to_json_schema,
318                        // but if it's in quality rules, use it
319                        if let Some(value) = rule.get("value").and_then(|v| v.as_str()) {
320                            // Only set if not already set
321                            if !property.contains_key("format") {
322                                property.insert("format".to_string(), json!(value));
323                            }
324                        }
325                    }
326                    "allOf" | "anyOf" | "oneOf" | "not" => {
327                        // Complex validation keywords
328                        if let Some(value) = rule.get("value") {
329                            property.insert(rule_type.to_string(), value.clone());
330                        }
331                    }
332                    _ => {
333                        // Unknown rule type - preserve as custom property or skip
334                        // Could add to a customProperties field if needed
335                    }
336                }
337            }
338        }
339
340        // Also handle constraints that might map to JSON Schema
341        for constraint in &column.constraints {
342            // Try to parse common constraint patterns
343            let constraint_upper = constraint.to_uppercase();
344            if constraint_upper.contains("UNIQUE") {
345                // For string types, uniqueItems doesn't apply, but we could add a custom property
346                // For now, skip as JSON Schema doesn't have a direct unique constraint
347            } else if constraint_upper.starts_with("CHECK") {
348                // CHECK constraints could be preserved as a custom property
349                // For now, we'll skip as JSON Schema doesn't have CHECK
350            }
351        }
352    }
353}