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