data_modelling_core/export/
json_schema.rs

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