Skip to main content

pg2sqlite_core/sqlite/
render.rs

1/// SQLite DDL rendering from IR.
2use crate::ir::{Column, Index, IndexColumn, SchemaModel, Table, TableConstraint};
3
4/// Render the schema model as SQLite DDL text.
5pub fn render(model: &SchemaModel, enable_foreign_keys: bool) -> String {
6    let mut output = String::new();
7
8    // PRAGMA
9    if enable_foreign_keys {
10        output.push_str("PRAGMA foreign_keys = ON;\n\n");
11    }
12
13    // CREATE TABLE statements
14    for table in &model.tables {
15        render_table(table, &mut output);
16        output.push('\n');
17    }
18
19    // CREATE INDEX statements (sorted by table name, then index name)
20    let mut indexes: Vec<&Index> = model.indexes.iter().collect();
21    indexes.sort_by(|a, b| {
22        a.table
23            .name
24            .normalized
25            .cmp(&b.table.name.normalized)
26            .then_with(|| a.name.normalized.cmp(&b.name.normalized))
27    });
28
29    for index in indexes {
30        render_index(index, &mut output);
31        output.push('\n');
32    }
33
34    // Remove trailing newline
35    while output.ends_with("\n\n") {
36        output.pop();
37    }
38    if !output.ends_with('\n') {
39        output.push('\n');
40    }
41
42    output
43}
44
45fn render_table(table: &Table, out: &mut String) {
46    out.push_str(&format!("CREATE TABLE {} (\n", table.name.to_sql()));
47
48    let mut parts: Vec<String> = Vec::new();
49
50    // Columns
51    for col in &table.columns {
52        parts.push(render_column(col));
53    }
54
55    // Table-level constraints (PK → UNIQUE → CHECK → FK)
56    let mut sorted_constraints = table.constraints.clone();
57    sorted_constraints.sort_by_key(|c| match c {
58        TableConstraint::PrimaryKey { .. } => 0,
59        TableConstraint::Unique { .. } => 1,
60        TableConstraint::Check { .. } => 2,
61        TableConstraint::ForeignKey { .. } => 3,
62    });
63
64    for constraint in &sorted_constraints {
65        parts.push(render_table_constraint(constraint));
66    }
67
68    out.push_str(&parts.join(",\n"));
69    out.push_str("\n);\n");
70}
71
72fn render_column(col: &Column) -> String {
73    let mut parts = Vec::new();
74
75    parts.push(format!("  {}", col.name.to_sql()));
76
77    // Type
78    if let Some(sqlite_type) = &col.sqlite_type {
79        parts.push(sqlite_type.to_string());
80    }
81
82    // PRIMARY KEY
83    if col.is_primary_key {
84        parts.push("PRIMARY KEY".to_string());
85    }
86
87    // NOT NULL
88    if col.not_null && !col.is_primary_key {
89        parts.push("NOT NULL".to_string());
90    }
91
92    // UNIQUE
93    if col.is_unique {
94        parts.push("UNIQUE".to_string());
95    }
96
97    // DEFAULT
98    if let Some(default) = &col.default {
99        let sql = default.to_sql();
100        // Wrap function calls and complex expressions in parentheses
101        if needs_default_parens(&sql) {
102            parts.push(format!("DEFAULT ({sql})"));
103        } else {
104            parts.push(format!("DEFAULT {sql}"));
105        }
106    }
107
108    // CHECK
109    if let Some(check) = &col.check {
110        parts.push(format!("CHECK ({})", check.to_sql()));
111    }
112
113    // REFERENCES
114    if let Some(fk) = &col.references {
115        let mut fk_str = format!("REFERENCES {}", fk.table.to_sql());
116        if let Some(col_ref) = &fk.column {
117            fk_str.push_str(&format!("({})", col_ref.to_sql()));
118        }
119        if let Some(action) = &fk.on_delete {
120            fk_str.push_str(&format!(" ON DELETE {action}"));
121        }
122        if let Some(action) = &fk.on_update {
123            fk_str.push_str(&format!(" ON UPDATE {action}"));
124        }
125        parts.push(fk_str);
126    }
127
128    parts.join(" ")
129}
130
131fn render_table_constraint(constraint: &TableConstraint) -> String {
132    match constraint {
133        TableConstraint::PrimaryKey { columns, .. } => {
134            let cols: Vec<String> = columns.iter().map(|c| c.to_sql()).collect();
135            format!("  PRIMARY KEY ({})", cols.join(", "))
136        }
137        TableConstraint::Unique { columns, .. } => {
138            let cols: Vec<String> = columns.iter().map(|c| c.to_sql()).collect();
139            format!("  UNIQUE ({})", cols.join(", "))
140        }
141        TableConstraint::Check { expr, .. } => {
142            format!("  CHECK ({})", expr.to_sql())
143        }
144        TableConstraint::ForeignKey {
145            columns,
146            ref_table,
147            ref_columns,
148            on_delete,
149            on_update,
150            ..
151        } => {
152            let cols: Vec<String> = columns.iter().map(|c| c.to_sql()).collect();
153            let ref_cols: Vec<String> = ref_columns.iter().map(|c| c.to_sql()).collect();
154            let mut s = format!(
155                "  FOREIGN KEY ({}) REFERENCES {}({})",
156                cols.join(", "),
157                ref_table.to_sql(),
158                ref_cols.join(", ")
159            );
160            if let Some(action) = on_delete {
161                s.push_str(&format!(" ON DELETE {action}"));
162            }
163            if let Some(action) = on_update {
164                s.push_str(&format!(" ON UPDATE {action}"));
165            }
166            s
167        }
168    }
169}
170
171fn render_index(index: &Index, out: &mut String) {
172    if index.unique {
173        out.push_str("CREATE UNIQUE INDEX ");
174    } else {
175        out.push_str("CREATE INDEX ");
176    }
177
178    out.push_str(&index.name.to_sql());
179    out.push_str(" ON ");
180    out.push_str(&index.table.to_sql());
181    out.push_str(" (");
182
183    let cols: Vec<String> = index
184        .columns
185        .iter()
186        .map(|c| match c {
187            IndexColumn::Column(ident) => ident.to_sql(),
188            IndexColumn::Expression(expr) => expr.to_sql(),
189        })
190        .collect();
191
192    out.push_str(&cols.join(", "));
193    out.push(')');
194
195    if let Some(where_clause) = &index.where_clause {
196        out.push_str(&format!(" WHERE {}", where_clause.to_sql()));
197    }
198
199    out.push_str(";\n");
200}
201
202fn needs_default_parens(sql: &str) -> bool {
203    sql.contains('(') || sql == "CURRENT_TIMESTAMP"
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209    use crate::ir::*;
210
211    fn make_column(name: &str, sqlite_type: SqliteType) -> Column {
212        Column {
213            name: Ident::new(name),
214            pg_type: PgType::Integer,
215            sqlite_type: Some(sqlite_type),
216            not_null: false,
217            default: None,
218            is_primary_key: false,
219            is_unique: false,
220            references: None,
221            check: None,
222        }
223    }
224
225    #[test]
226    fn test_render_simple_table() {
227        let model = SchemaModel {
228            tables: vec![Table {
229                name: QualifiedName::new(Ident::new("users")),
230                columns: vec![
231                    {
232                        let mut c = make_column("id", SqliteType::Integer);
233                        c.is_primary_key = true;
234                        c
235                    },
236                    {
237                        let mut c = make_column("name", SqliteType::Text);
238                        c.not_null = true;
239                        c
240                    },
241                ],
242                constraints: vec![],
243            }],
244            ..Default::default()
245        };
246
247        let sql = render(&model, false);
248        assert!(sql.contains("CREATE TABLE users"));
249        assert!(sql.contains("id INTEGER PRIMARY KEY"));
250        assert!(sql.contains("name TEXT NOT NULL"));
251    }
252
253    #[test]
254    fn test_render_with_pragma() {
255        let model = SchemaModel::default();
256        let sql = render(&model, true);
257        assert!(sql.starts_with("PRAGMA foreign_keys = ON;"));
258    }
259
260    #[test]
261    fn test_render_composite_pk() {
262        let model = SchemaModel {
263            tables: vec![Table {
264                name: QualifiedName::new(Ident::new("user_roles")),
265                columns: vec![
266                    make_column("user_id", SqliteType::Integer),
267                    make_column("role_id", SqliteType::Integer),
268                ],
269                constraints: vec![TableConstraint::PrimaryKey {
270                    name: None,
271                    columns: vec![Ident::new("user_id"), Ident::new("role_id")],
272                }],
273            }],
274            ..Default::default()
275        };
276
277        let sql = render(&model, false);
278        assert!(sql.contains("PRIMARY KEY (user_id, role_id)"));
279    }
280
281    #[test]
282    fn test_render_fk_with_cascade() {
283        let model = SchemaModel {
284            tables: vec![Table {
285                name: QualifiedName::new(Ident::new("orders")),
286                columns: vec![make_column("user_id", SqliteType::Integer)],
287                constraints: vec![TableConstraint::ForeignKey {
288                    name: None,
289                    columns: vec![Ident::new("user_id")],
290                    ref_table: QualifiedName::new(Ident::new("users")),
291                    ref_columns: vec![Ident::new("id")],
292                    on_delete: Some(FkAction::Cascade),
293                    on_update: None,
294                    deferrable: false,
295                }],
296            }],
297            ..Default::default()
298        };
299
300        let sql = render(&model, true);
301        assert!(sql.contains("FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE"));
302    }
303
304    #[test]
305    fn test_render_index() {
306        let model = SchemaModel {
307            indexes: vec![Index {
308                name: Ident::new("idx_email"),
309                table: QualifiedName::new(Ident::new("users")),
310                columns: vec![IndexColumn::Column(Ident::new("email"))],
311                unique: true,
312                method: None,
313                where_clause: None,
314            }],
315            ..Default::default()
316        };
317
318        let sql = render(&model, false);
319        assert!(sql.contains("CREATE UNIQUE INDEX idx_email ON users (email);"));
320    }
321
322    #[test]
323    fn test_render_default_current_timestamp() {
324        let model = SchemaModel {
325            tables: vec![Table {
326                name: QualifiedName::new(Ident::new("events")),
327                columns: vec![{
328                    let mut c = make_column("created_at", SqliteType::Text);
329                    c.default = Some(Expr::CurrentTimestamp);
330                    c
331                }],
332                constraints: vec![],
333            }],
334            ..Default::default()
335        };
336
337        let sql = render(&model, false);
338        assert!(sql.contains("DEFAULT (CURRENT_TIMESTAMP)"));
339    }
340}