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 (with optional AUTOINCREMENT)
86    if col.is_primary_key {
87        if col.autoincrement {
88            parts.push("PRIMARY KEY AUTOINCREMENT".to_string());
89        } else {
90            parts.push("PRIMARY KEY".to_string());
91        }
92    }
93
94    // NOT NULL
95    if col.not_null && !col.is_primary_key {
96        parts.push("NOT NULL".to_string());
97    }
98
99    // UNIQUE
100    if col.is_unique {
101        parts.push("UNIQUE".to_string());
102    }
103
104    // DEFAULT
105    if let Some(default) = &col.default {
106        let sql = default.to_sql();
107        // Wrap function calls and complex expressions in parentheses
108        if needs_default_parens(&sql) {
109            parts.push(format!("DEFAULT ({sql})"));
110        } else {
111            parts.push(format!("DEFAULT {sql}"));
112        }
113    }
114
115    // CHECK
116    if let Some(check) = &col.check {
117        parts.push(format!("CHECK ({})", check.to_sql()));
118    }
119
120    // REFERENCES
121    if let Some(fk) = &col.references {
122        let mut fk_str = format!("REFERENCES {}", fk.table.to_sql());
123        if let Some(col_ref) = &fk.column {
124            fk_str.push_str(&format!("({})", col_ref.to_sql()));
125        }
126        if let Some(action) = &fk.on_delete {
127            fk_str.push_str(&format!(" ON DELETE {action}"));
128        }
129        if let Some(action) = &fk.on_update {
130            fk_str.push_str(&format!(" ON UPDATE {action}"));
131        }
132        parts.push(fk_str);
133    }
134
135    parts.join(" ")
136}
137
138fn render_table_constraint(constraint: &TableConstraint) -> String {
139    match constraint {
140        TableConstraint::PrimaryKey { columns, .. } => {
141            let cols: Vec<String> = columns.iter().map(|c| c.to_sql()).collect();
142            format!("  PRIMARY KEY ({})", cols.join(", "))
143        }
144        TableConstraint::Unique { columns, .. } => {
145            let cols: Vec<String> = columns.iter().map(|c| c.to_sql()).collect();
146            format!("  UNIQUE ({})", cols.join(", "))
147        }
148        TableConstraint::Check { expr, .. } => {
149            format!("  CHECK ({})", expr.to_sql())
150        }
151        TableConstraint::ForeignKey {
152            columns,
153            ref_table,
154            ref_columns,
155            on_delete,
156            on_update,
157            ..
158        } => {
159            let cols: Vec<String> = columns.iter().map(|c| c.to_sql()).collect();
160            let ref_cols: Vec<String> = ref_columns.iter().map(|c| c.to_sql()).collect();
161            let mut s = format!(
162                "  FOREIGN KEY ({}) REFERENCES {}({})",
163                cols.join(", "),
164                ref_table.to_sql(),
165                ref_cols.join(", ")
166            );
167            if let Some(action) = on_delete {
168                s.push_str(&format!(" ON DELETE {action}"));
169            }
170            if let Some(action) = on_update {
171                s.push_str(&format!(" ON UPDATE {action}"));
172            }
173            s
174        }
175    }
176}
177
178fn render_index(index: &Index, out: &mut String) {
179    if index.unique {
180        out.push_str("CREATE UNIQUE INDEX ");
181    } else {
182        out.push_str("CREATE INDEX ");
183    }
184
185    out.push_str(&index.name.to_sql());
186    out.push_str(" ON ");
187    out.push_str(&index.table.to_sql());
188    out.push_str(" (");
189
190    let cols: Vec<String> = index
191        .columns
192        .iter()
193        .map(|c| match c {
194            IndexColumn::Column(ident) => ident.to_sql(),
195            IndexColumn::Expression(expr) => expr.to_sql(),
196        })
197        .collect();
198
199    out.push_str(&cols.join(", "));
200    out.push(')');
201
202    if let Some(where_clause) = &index.where_clause {
203        out.push_str(&format!(" WHERE {}", where_clause.to_sql()));
204    }
205
206    out.push_str(";\n");
207}
208
209fn needs_default_parens(sql: &str) -> bool {
210    sql.contains('(') || sql == "CURRENT_TIMESTAMP"
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216    use crate::ir::*;
217
218    fn make_column(name: &str, sqlite_type: SqliteType) -> Column {
219        Column {
220            name: Ident::new(name),
221            pg_type: PgType::Integer,
222            sqlite_type: Some(sqlite_type),
223            not_null: false,
224            default: None,
225            is_primary_key: false,
226            is_unique: false,
227            autoincrement: false,
228            references: None,
229            check: None,
230        }
231    }
232
233    #[test]
234    fn test_render_simple_table() {
235        let model = SchemaModel {
236            tables: vec![Table {
237                name: QualifiedName::new(Ident::new("users")),
238                columns: vec![
239                    {
240                        let mut c = make_column("id", SqliteType::Integer);
241                        c.is_primary_key = true;
242                        c
243                    },
244                    {
245                        let mut c = make_column("name", SqliteType::Text);
246                        c.not_null = true;
247                        c
248                    },
249                ],
250                constraints: vec![],
251            }],
252            ..Default::default()
253        };
254
255        let sql = render(&model, false);
256        assert!(sql.contains("CREATE TABLE users"));
257        assert!(sql.contains("id INTEGER PRIMARY KEY"));
258        assert!(sql.contains("name TEXT NOT NULL"));
259    }
260
261    #[test]
262    fn test_render_with_pragma() {
263        let model = SchemaModel::default();
264        let sql = render(&model, true);
265        assert!(sql.starts_with(
266            "-- Code generated by `pg2sqlite`. DO NOT EDIT.\n\nPRAGMA foreign_keys = ON;"
267        ));
268    }
269
270    #[test]
271    fn test_render_composite_pk() {
272        let model = SchemaModel {
273            tables: vec![Table {
274                name: QualifiedName::new(Ident::new("user_roles")),
275                columns: vec![
276                    make_column("user_id", SqliteType::Integer),
277                    make_column("role_id", SqliteType::Integer),
278                ],
279                constraints: vec![TableConstraint::PrimaryKey {
280                    name: None,
281                    columns: vec![Ident::new("user_id"), Ident::new("role_id")],
282                }],
283            }],
284            ..Default::default()
285        };
286
287        let sql = render(&model, false);
288        assert!(sql.contains("PRIMARY KEY (user_id, role_id)"));
289    }
290
291    #[test]
292    fn test_render_fk_with_cascade() {
293        let model = SchemaModel {
294            tables: vec![Table {
295                name: QualifiedName::new(Ident::new("orders")),
296                columns: vec![make_column("user_id", SqliteType::Integer)],
297                constraints: vec![TableConstraint::ForeignKey {
298                    name: None,
299                    columns: vec![Ident::new("user_id")],
300                    ref_table: QualifiedName::new(Ident::new("users")),
301                    ref_columns: vec![Ident::new("id")],
302                    on_delete: Some(FkAction::Cascade),
303                    on_update: None,
304                    deferrable: false,
305                }],
306            }],
307            ..Default::default()
308        };
309
310        let sql = render(&model, true);
311        assert!(sql.contains("FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE"));
312    }
313
314    #[test]
315    fn test_render_index() {
316        let model = SchemaModel {
317            indexes: vec![Index {
318                name: Ident::new("idx_email"),
319                table: QualifiedName::new(Ident::new("users")),
320                columns: vec![IndexColumn::Column(Ident::new("email"))],
321                unique: true,
322                method: None,
323                where_clause: None,
324            }],
325            ..Default::default()
326        };
327
328        let sql = render(&model, false);
329        assert!(sql.contains("CREATE UNIQUE INDEX idx_email ON users (email);"));
330    }
331
332    #[test]
333    fn test_render_default_current_timestamp() {
334        let model = SchemaModel {
335            tables: vec![Table {
336                name: QualifiedName::new(Ident::new("events")),
337                columns: vec![{
338                    let mut c = make_column("created_at", SqliteType::Text);
339                    c.default = Some(Expr::CurrentTimestamp);
340                    c
341                }],
342                constraints: vec![],
343            }],
344            ..Default::default()
345        };
346
347        let sql = render(&model, false);
348        assert!(sql.contains("DEFAULT (CURRENT_TIMESTAMP)"));
349    }
350}