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