data_modelling_core/export/sql.rs
1//! SQL exporter for generating CREATE TABLE statements from data models.
2//!
3//! # Security
4//!
5//! All identifiers (table names, column names, schema names) are properly quoted
6//! and escaped to prevent SQL injection. Internal quote characters are escaped
7//! by doubling them according to SQL standards.
8
9use crate::export::{ExportError, ExportResult};
10use crate::models::{DataModel, Table};
11
12/// Exporter for SQL CREATE TABLE format.
13pub struct SQLExporter;
14
15impl SQLExporter {
16 /// Export a table to SQL CREATE TABLE statement.
17 ///
18 /// # Arguments
19 ///
20 /// * `table` - The table to export
21 /// * `dialect` - Optional SQL dialect ("postgres", "mysql", "sqlserver", etc.)
22 ///
23 /// # Returns
24 ///
25 /// A SQL CREATE TABLE statement as a string, with proper identifier quoting
26 /// and escaping based on the dialect.
27 ///
28 /// # Example
29 ///
30 /// ```rust
31 /// use data_modelling_core::export::sql::SQLExporter;
32 /// use data_modelling_core::models::{Table, Column};
33 ///
34 /// let table = Table::new(
35 /// "users".to_string(),
36 /// vec![Column::new("id".to_string(), "INT".to_string())],
37 /// );
38 ///
39 /// let sql = SQLExporter::export_table(&table, Some("postgres"));
40 /// // Returns: CREATE TABLE "users" (\n "id" INT\n);
41 /// ```
42 pub fn export_table(table: &Table, dialect: Option<&str>) -> String {
43 let dialect = dialect.unwrap_or("standard");
44 let mut sql = String::new();
45
46 // CREATE TABLE statement
47 sql.push_str(&format!(
48 "CREATE TABLE {}",
49 Self::quote_identifier(&table.name, dialect)
50 ));
51
52 // Build fully-qualified table name based on catalog and schema
53 sql = match (&table.catalog_name, &table.schema_name) {
54 (Some(catalog), Some(schema)) => {
55 format!(
56 "CREATE TABLE {}.{}.{}",
57 Self::quote_identifier(catalog, dialect),
58 Self::quote_identifier(schema, dialect),
59 Self::quote_identifier(&table.name, dialect)
60 )
61 }
62 (Some(catalog), None) => {
63 format!(
64 "CREATE TABLE {}.{}",
65 Self::quote_identifier(catalog, dialect),
66 Self::quote_identifier(&table.name, dialect)
67 )
68 }
69 (None, Some(schema)) => {
70 format!(
71 "CREATE TABLE {}.{}",
72 Self::quote_identifier(schema, dialect),
73 Self::quote_identifier(&table.name, dialect)
74 )
75 }
76 (None, None) => sql, // Keep default "CREATE TABLE tablename"
77 };
78
79 sql.push_str(" (\n");
80
81 // Column definitions
82 let mut column_defs = Vec::new();
83 for column in &table.columns {
84 let mut col_def = format!(" {}", Self::quote_identifier(&column.name, dialect));
85 col_def.push(' ');
86 col_def.push_str(&column.data_type);
87
88 if !column.nullable {
89 col_def.push_str(" NOT NULL");
90 }
91
92 if column.primary_key {
93 col_def.push_str(" PRIMARY KEY");
94 }
95
96 if !column.description.is_empty() {
97 // Add comment (dialect-specific)
98 match dialect {
99 "postgres" | "postgresql" => {
100 col_def.push_str(&format!(" -- {}", column.description));
101 }
102 "mysql" => {
103 col_def.push_str(&format!(
104 " COMMENT '{}'",
105 column.description.replace('\'', "''")
106 ));
107 }
108 _ => {
109 col_def.push_str(&format!(" -- {}", column.description));
110 }
111 }
112 }
113
114 column_defs.push(col_def);
115 }
116
117 sql.push_str(&column_defs.join(",\n"));
118 sql.push_str("\n);\n");
119
120 // Add table comment if available (from odcl_metadata)
121 if let Some(desc) = table
122 .odcl_metadata
123 .get("description")
124 .and_then(|v| v.as_str())
125 {
126 match dialect {
127 "postgres" | "postgresql" => {
128 sql.push_str(&format!(
129 "COMMENT ON TABLE {} IS '{}';\n",
130 Self::quote_identifier(&table.name, dialect),
131 desc.replace('\'', "''")
132 ));
133 }
134 "mysql" => {
135 sql.push_str(&format!(
136 "ALTER TABLE {} COMMENT = '{}';\n",
137 Self::quote_identifier(&table.name, dialect),
138 desc.replace("'", "''")
139 ));
140 }
141 _ => {
142 // Default: SQL comment
143 sql.push_str(&format!("-- Table: {}\n", table.name));
144 sql.push_str(&format!("-- Description: {}\n", desc));
145 }
146 }
147 }
148
149 sql
150 }
151
152 /// Export tables to SQL CREATE TABLE statements (SDK interface).
153 ///
154 /// # Arguments
155 ///
156 /// * `tables` - Slice of tables to export
157 /// * `dialect` - Optional SQL dialect
158 ///
159 /// # Returns
160 ///
161 /// An `ExportResult` containing the SQL statements for all tables.
162 ///
163 /// # Example
164 ///
165 /// ```rust
166 /// use data_modelling_core::export::sql::SQLExporter;
167 /// use data_modelling_core::models::{Table, Column};
168 ///
169 /// let tables = vec![
170 /// Table::new("users".to_string(), vec![Column::new("id".to_string(), "INT".to_string())]),
171 /// Table::new("orders".to_string(), vec![Column::new("id".to_string(), "INT".to_string())]),
172 /// ];
173 ///
174 /// let exporter = SQLExporter;
175 /// let result = exporter.export(&tables, Some("postgres")).unwrap();
176 /// assert_eq!(result.format, "sql");
177 /// ```
178 pub fn export(
179 &self,
180 tables: &[Table],
181 dialect: Option<&str>,
182 ) -> Result<ExportResult, ExportError> {
183 let mut sql = String::new();
184 for table in tables {
185 sql.push_str(&Self::export_table(table, dialect));
186 sql.push('\n');
187 }
188 Ok(ExportResult {
189 content: sql,
190 format: "sql".to_string(),
191 })
192 }
193
194 /// Export a data model to SQL CREATE TABLE statements (legacy method for compatibility).
195 pub fn export_model(
196 model: &DataModel,
197 table_ids: Option<&[uuid::Uuid]>,
198 dialect: Option<&str>,
199 ) -> String {
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 let mut sql = String::new();
211
212 for table in tables_to_export {
213 sql.push_str(&Self::export_table(table, dialect));
214 sql.push('\n');
215 }
216
217 sql
218 }
219
220 /// Quote and escape identifier based on SQL dialect.
221 ///
222 /// # Security
223 ///
224 /// This function properly escapes quote characters within the identifier
225 /// by doubling them, preventing SQL injection attacks.
226 ///
227 /// # Dialects
228 ///
229 /// - **PostgreSQL**: Uses double quotes (`"identifier"`)
230 /// - **MySQL**: Uses backticks (`` `identifier` ``)
231 /// - **SQL Server**: Uses brackets (`[identifier]`)
232 /// - **Standard SQL**: Uses double quotes
233 ///
234 /// # Example
235 ///
236 /// ```rust,no_run
237 /// use data_modelling_core::export::sql::SQLExporter;
238 ///
239 /// // PostgreSQL style
240 /// // let quoted = SQLExporter::quote_identifier("user-name", "postgres");
241 /// // Returns: "user-name"
242 ///
243 /// // MySQL style
244 /// // let quoted = SQLExporter::quote_identifier("user-name", "mysql");
245 /// // Returns: `user-name`
246 /// ```
247 fn quote_identifier(identifier: &str, dialect: &str) -> String {
248 match dialect {
249 "mysql" => {
250 // MySQL uses backticks; escape internal backticks by doubling
251 format!("`{}`", identifier.replace('`', "``"))
252 }
253 "postgres" | "postgresql" => {
254 // PostgreSQL uses double quotes; escape by doubling
255 format!("\"{}\"", identifier.replace('"', "\"\""))
256 }
257 "sqlserver" | "mssql" => {
258 // SQL Server uses brackets; escape ] by doubling
259 format!("[{}]", identifier.replace(']', "]]"))
260 }
261 _ => {
262 // Standard SQL: use double quotes
263 format!("\"{}\"", identifier.replace('"', "\"\""))
264 }
265 }
266 }
267}