oxide_sql_core/migrations/dialect/
sqlite.rs

1//! SQLite dialect for migrations.
2
3use super::MigrationDialect;
4use crate::ast::DataType;
5use crate::migrations::operation::{
6    AlterColumnChange, AlterColumnOp, DropIndexOp, RenameColumnOp, RenameTableOp,
7};
8use crate::schema::RustTypeMapping;
9
10/// SQLite dialect for migration SQL generation.
11#[derive(Debug, Clone, Copy, Default)]
12pub struct SqliteDialect;
13
14impl SqliteDialect {
15    /// Creates a new SQLite dialect.
16    #[must_use]
17    pub const fn new() -> Self {
18        Self
19    }
20}
21
22impl MigrationDialect for SqliteDialect {
23    fn name(&self) -> &'static str {
24        "sqlite"
25    }
26
27    fn map_data_type(&self, dt: &DataType) -> String {
28        // SQLite has dynamic typing with type affinity
29        match dt {
30            DataType::Smallint | DataType::Integer | DataType::Bigint => "INTEGER".to_string(),
31            DataType::Real | DataType::Double => "REAL".to_string(),
32            DataType::Decimal { .. } | DataType::Numeric { .. } => "REAL".to_string(),
33            DataType::Char(_) | DataType::Varchar(_) | DataType::Text => "TEXT".to_string(),
34            DataType::Blob | DataType::Binary(_) | DataType::Varbinary(_) => "BLOB".to_string(),
35            DataType::Date | DataType::Time | DataType::Timestamp | DataType::Datetime => {
36                "TEXT".to_string()
37            }
38            DataType::Boolean => "INTEGER".to_string(), // SQLite has no bool, use 0/1
39            DataType::Custom(name) => name.clone(),
40        }
41    }
42
43    fn autoincrement_keyword(&self) -> String {
44        " AUTOINCREMENT".to_string()
45    }
46
47    fn rename_table(&self, op: &RenameTableOp) -> String {
48        format!(
49            "ALTER TABLE {} RENAME TO {}",
50            self.quote_identifier(&op.old_name),
51            self.quote_identifier(&op.new_name)
52        )
53    }
54
55    fn rename_column(&self, op: &RenameColumnOp) -> String {
56        // SQLite 3.25.0+ supports RENAME COLUMN
57        format!(
58            "ALTER TABLE {} RENAME COLUMN {} TO {}",
59            self.quote_identifier(&op.table),
60            self.quote_identifier(&op.old_name),
61            self.quote_identifier(&op.new_name)
62        )
63    }
64
65    fn alter_column(&self, op: &AlterColumnOp) -> String {
66        // SQLite has very limited ALTER TABLE support.
67        // Most column alterations require recreating the table.
68        // We generate a comment noting this limitation.
69        match &op.change {
70            AlterColumnChange::SetDataType(_) => {
71                format!(
72                    "-- SQLite does not support ALTER COLUMN TYPE directly for {}.{}; \
73                     table recreation required",
74                    op.table, op.column
75                )
76            }
77            AlterColumnChange::SetNullable(_) => {
78                format!(
79                    "-- SQLite does not support ALTER COLUMN NULL/NOT NULL directly for {}.{}; \
80                     table recreation required",
81                    op.table, op.column
82                )
83            }
84            AlterColumnChange::SetDefault(default) => {
85                // SQLite doesn't support ALTER COLUMN SET DEFAULT either
86                format!(
87                    "-- SQLite does not support ALTER COLUMN SET DEFAULT directly for {}.{}; \
88                     would set to: {}",
89                    op.table,
90                    op.column,
91                    self.render_default(default)
92                )
93            }
94            AlterColumnChange::DropDefault => {
95                format!(
96                    "-- SQLite does not support ALTER COLUMN DROP DEFAULT directly for {}.{}; \
97                     table recreation required",
98                    op.table, op.column
99                )
100            }
101        }
102    }
103
104    fn drop_index(&self, op: &DropIndexOp) -> String {
105        let mut sql = String::from("DROP INDEX ");
106        if op.if_exists {
107            sql.push_str("IF EXISTS ");
108        }
109        // SQLite index names are global, not per-table
110        sql.push_str(&self.quote_identifier(&op.name));
111        sql
112    }
113
114    fn drop_foreign_key(&self, op: &super::super::operation::DropForeignKeyOp) -> String {
115        // SQLite does not support DROP CONSTRAINT; requires table recreation
116        format!(
117            "-- SQLite does not support DROP CONSTRAINT; \
118             table recreation required to remove foreign key {} from {}",
119            op.name, op.table
120        )
121    }
122}
123
124impl RustTypeMapping for SqliteDialect {
125    fn map_type(&self, rust_type: &str) -> DataType {
126        match rust_type {
127            "bool" => DataType::Integer,
128            "i8" | "i16" | "u8" | "u16" | "i32" | "u32" => DataType::Integer,
129            "i64" | "u64" | "i128" | "u128" | "isize" | "usize" => DataType::Bigint,
130            "f32" => DataType::Real,
131            "f64" => DataType::Double,
132            "String" => DataType::Text,
133            "Vec<u8>" => DataType::Blob,
134            s if s.contains("DateTime") => DataType::Text,
135            s if s.contains("NaiveDate") => DataType::Text,
136            _ => DataType::Text, // safe fallback for SQLite
137        }
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144    use crate::migrations::column_builder::{bigint, boolean, timestamp, varchar};
145    use crate::migrations::operation::{DropTableOp, Operation};
146    use crate::migrations::table_builder::CreateTableBuilder;
147
148    #[test]
149    fn test_sqlite_data_types() {
150        let dialect = SqliteDialect::new();
151        assert_eq!(dialect.map_data_type(&DataType::Integer), "INTEGER");
152        assert_eq!(dialect.map_data_type(&DataType::Bigint), "INTEGER");
153        assert_eq!(dialect.map_data_type(&DataType::Text), "TEXT");
154        assert_eq!(dialect.map_data_type(&DataType::Varchar(Some(255))), "TEXT");
155        assert_eq!(dialect.map_data_type(&DataType::Blob), "BLOB");
156        assert_eq!(dialect.map_data_type(&DataType::Boolean), "INTEGER");
157        assert_eq!(dialect.map_data_type(&DataType::Timestamp), "TEXT");
158    }
159
160    #[test]
161    fn test_create_table_sql() {
162        let dialect = SqliteDialect::new();
163        let op = CreateTableBuilder::new()
164            .name("users")
165            .column(bigint("id").primary_key().autoincrement().build())
166            .column(varchar("username", 255).not_null().unique().build())
167            .column(varchar("email", 255).build())
168            .column(
169                timestamp("created_at")
170                    .not_null()
171                    .default_expr("CURRENT_TIMESTAMP")
172                    .build(),
173            )
174            .build();
175
176        let sql = dialect.create_table(&op);
177        assert!(sql.contains("CREATE TABLE \"users\""));
178        assert!(sql.contains("\"id\" INTEGER PRIMARY KEY AUTOINCREMENT"));
179        assert!(sql.contains("\"username\" TEXT NOT NULL UNIQUE"));
180        assert!(sql.contains("DEFAULT CURRENT_TIMESTAMP"));
181    }
182
183    #[test]
184    fn test_drop_table_sql() {
185        let dialect = SqliteDialect::new();
186
187        let op = DropTableOp {
188            name: "users".to_string(),
189            if_exists: false,
190            cascade: false,
191        };
192        assert_eq!(dialect.drop_table(&op), "DROP TABLE \"users\"");
193
194        let op = DropTableOp {
195            name: "users".to_string(),
196            if_exists: true,
197            cascade: false,
198        };
199        assert_eq!(dialect.drop_table(&op), "DROP TABLE IF EXISTS \"users\"");
200    }
201
202    #[test]
203    fn test_add_column_sql() {
204        let dialect = SqliteDialect::new();
205        let op = Operation::add_column(
206            "users",
207            boolean("active").not_null().default_bool(true).build(),
208        );
209
210        if let Operation::AddColumn(add_op) = op {
211            let sql = dialect.add_column(&add_op);
212            assert!(sql.contains("ALTER TABLE \"users\" ADD COLUMN"));
213            assert!(sql.contains("\"active\" INTEGER NOT NULL DEFAULT TRUE"));
214        }
215    }
216
217    #[test]
218    fn test_rename_table_sql() {
219        let dialect = SqliteDialect::new();
220        let op = RenameTableOp {
221            old_name: "old_users".to_string(),
222            new_name: "users".to_string(),
223        };
224        assert_eq!(
225            dialect.rename_table(&op),
226            "ALTER TABLE \"old_users\" RENAME TO \"users\""
227        );
228    }
229
230    #[test]
231    fn test_rename_column_sql() {
232        let dialect = SqliteDialect::new();
233        let op = RenameColumnOp {
234            table: "users".to_string(),
235            old_name: "name".to_string(),
236            new_name: "full_name".to_string(),
237        };
238        assert_eq!(
239            dialect.rename_column(&op),
240            "ALTER TABLE \"users\" RENAME COLUMN \"name\" TO \"full_name\""
241        );
242    }
243}