Skip to main content

doido_model/migration/
table.rs

1//! Table operations as free functions — [`create_table`], [`drop_table`],
2//! [`rename_table`], [`alter_table`] — plus the column-definition builders they
3//! use ([`TableBuilder`] for create, [`AlterTableBuilder`] for alter).
4
5use crate::sea_orm::sea_query::{
6    Alias, ColumnDef, Expr, Table as SqTable, TableAlterStatement, TableCreateStatement,
7    TableDropStatement, TableRenameStatement,
8};
9use crate::sea_orm::{ConnectionTrait, DbErr};
10use crate::sea_orm_migration::SchemaManager;
11
12/// Collects column definitions inside [`create_table`].
13///
14/// A big-integer, auto-incrementing `id` primary key is added automatically,
15/// matching Rails' implicit primary key.
16pub struct TableBuilder {
17    columns: Vec<ColumnDef>,
18}
19
20impl TableBuilder {
21    fn new() -> Self {
22        let mut id = ColumnDef::new(Alias::new("id"));
23        id.big_integer().not_null().auto_increment().primary_key();
24        TableBuilder { columns: vec![id] }
25    }
26
27    /// Pushes a fully built column and returns it for further chaining
28    /// (e.g. `.not_null()`, `.default(...)`, `.unique_key()`).
29    pub fn column(&mut self, col: ColumnDef) -> &mut ColumnDef {
30        self.columns.push(col);
31        self.columns.last_mut().expect("just pushed a column")
32    }
33
34    fn typed(&mut self, name: &str, apply: impl FnOnce(&mut ColumnDef)) -> &mut ColumnDef {
35        let mut col = ColumnDef::new(Alias::new(name));
36        apply(&mut col);
37        self.column(col)
38    }
39
40    pub fn string(&mut self, name: &str) -> &mut ColumnDef {
41        self.typed(name, |c| {
42            c.string();
43        })
44    }
45    pub fn text(&mut self, name: &str) -> &mut ColumnDef {
46        self.typed(name, |c| {
47            c.text();
48        })
49    }
50    pub fn integer(&mut self, name: &str) -> &mut ColumnDef {
51        self.typed(name, |c| {
52            c.integer();
53        })
54    }
55    pub fn big_integer(&mut self, name: &str) -> &mut ColumnDef {
56        self.typed(name, |c| {
57            c.big_integer();
58        })
59    }
60    pub fn float(&mut self, name: &str) -> &mut ColumnDef {
61        self.typed(name, |c| {
62            c.float();
63        })
64    }
65    pub fn double(&mut self, name: &str) -> &mut ColumnDef {
66        self.typed(name, |c| {
67            c.double();
68        })
69    }
70    pub fn decimal(&mut self, name: &str) -> &mut ColumnDef {
71        self.typed(name, |c| {
72            c.decimal();
73        })
74    }
75    pub fn boolean(&mut self, name: &str) -> &mut ColumnDef {
76        self.typed(name, |c| {
77            c.boolean();
78        })
79    }
80    pub fn timestamp(&mut self, name: &str) -> &mut ColumnDef {
81        self.typed(name, |c| {
82            c.timestamp();
83        })
84    }
85    pub fn date(&mut self, name: &str) -> &mut ColumnDef {
86        self.typed(name, |c| {
87            c.date();
88        })
89    }
90    pub fn json(&mut self, name: &str) -> &mut ColumnDef {
91        self.typed(name, |c| {
92            c.json();
93        })
94    }
95    pub fn uuid(&mut self, name: &str) -> &mut ColumnDef {
96        self.typed(name, |c| {
97            c.uuid();
98        })
99    }
100    pub fn binary(&mut self, name: &str) -> &mut ColumnDef {
101        self.typed(name, |c| {
102            c.binary();
103        })
104    }
105
106    /// Rails `t.references :user` — adds a non-null `<name>_id` big-integer column.
107    pub fn references(&mut self, name: &str) -> &mut ColumnDef {
108        self.typed(&format!("{name}_id"), |c| {
109            c.big_integer().not_null();
110        })
111    }
112
113    /// Rails `t.timestamps` — adds non-null `created_at` and `updated_at`,
114    /// each defaulting to the current timestamp.
115    pub fn timestamps(&mut self) {
116        self.typed("created_at", |c| {
117            c.timestamp().not_null().default(Expr::current_timestamp());
118        });
119        self.typed("updated_at", |c| {
120            c.timestamp().not_null().default(Expr::current_timestamp());
121        });
122    }
123}
124
125/// One pending change collected by [`AlterTableBuilder`].
126enum AlterOp {
127    // `ColumnDef` is large; box it so the enum's other variants stay small.
128    Add(Box<ColumnDef>),
129    Drop(String),
130    Rename(String, String),
131}
132
133/// Collects changes inside [`alter_table`]. Each change is applied as its own
134/// `ALTER TABLE` statement, so this works uniformly across backends (including
135/// SQLite, which permits only one alteration per statement).
136pub struct AlterTableBuilder {
137    ops: Vec<AlterOp>,
138}
139
140impl AlterTableBuilder {
141    fn new() -> Self {
142        AlterTableBuilder { ops: Vec::new() }
143    }
144
145    /// `add_column :name` — `f` configures the column type and modifiers. The
146    /// returned `&mut ColumnDef` allows further chaining.
147    pub fn add_column(&mut self, name: &str, f: impl FnOnce(&mut ColumnDef)) -> &mut ColumnDef {
148        let mut col = ColumnDef::new(Alias::new(name));
149        f(&mut col);
150        self.ops.push(AlterOp::Add(Box::new(col)));
151        match self.ops.last_mut().expect("just pushed an add op") {
152            AlterOp::Add(col) => col.as_mut(),
153            _ => unreachable!("last op is the add we just pushed"),
154        }
155    }
156
157    /// `drop_column :name`.
158    pub fn drop_column(&mut self, name: &str) {
159        self.ops.push(AlterOp::Drop(name.to_string()));
160    }
161
162    /// `rename_column :from, :to`.
163    pub fn rename_column(&mut self, from: &str, to: &str) {
164        self.ops
165            .push(AlterOp::Rename(from.to_string(), to.to_string()));
166    }
167}
168
169fn create_table_statement(name: &str, f: impl FnOnce(&mut TableBuilder)) -> TableCreateStatement {
170    let mut builder = TableBuilder::new();
171    f(&mut builder);
172    let mut stmt = SqTable::create();
173    stmt.table(Alias::new(name)).if_not_exists();
174    for col in builder.columns {
175        stmt.col(col);
176    }
177    stmt
178}
179
180fn drop_table_statement(name: &str) -> TableDropStatement {
181    let mut stmt = SqTable::drop();
182    stmt.table(Alias::new(name)).if_exists();
183    stmt
184}
185
186fn rename_table_statement(from: &str, to: &str) -> TableRenameStatement {
187    let mut stmt = SqTable::rename();
188    stmt.table(Alias::new(from), Alias::new(to));
189    stmt
190}
191
192fn alter_table_statements(name: &str, ops: Vec<AlterOp>) -> Vec<TableAlterStatement> {
193    ops.into_iter()
194        .map(|op| {
195            let mut stmt = SqTable::alter();
196            stmt.table(Alias::new(name));
197            match op {
198                AlterOp::Add(col) => {
199                    stmt.add_column(*col);
200                }
201                AlterOp::Drop(name) => {
202                    stmt.drop_column(Alias::new(name));
203                }
204                AlterOp::Rename(from, to) => {
205                    stmt.rename_column(Alias::new(from), Alias::new(to));
206                }
207            }
208            stmt
209        })
210        .collect()
211}
212
213/// `create_table :name do |t| ... end` — creates a table with an implicit
214/// auto-incrementing `id` primary key plus the columns added in `f`.
215pub async fn create_table(
216    manager: &SchemaManager<'_>,
217    name: &str,
218    f: impl FnOnce(&mut TableBuilder),
219) -> Result<(), DbErr> {
220    manager
221        .get_connection()
222        .execute(&create_table_statement(name, f))
223        .await
224        .map(|_| ())
225}
226
227/// `drop_table :name` — drops the table if it exists.
228pub async fn drop_table(manager: &SchemaManager<'_>, name: &str) -> Result<(), DbErr> {
229    manager
230        .get_connection()
231        .execute(&drop_table_statement(name))
232        .await
233        .map(|_| ())
234}
235
236/// `rename_table :from, :to`.
237pub async fn rename_table(manager: &SchemaManager<'_>, from: &str, to: &str) -> Result<(), DbErr> {
238    manager
239        .get_connection()
240        .execute(&rename_table_statement(from, to))
241        .await
242        .map(|_| ())
243}
244
245/// `alter_table :name do |t| ... end` — applies the column changes collected in
246/// `f` (add/drop/rename), each as its own `ALTER TABLE` statement.
247pub async fn alter_table(
248    manager: &SchemaManager<'_>,
249    name: &str,
250    f: impl FnOnce(&mut AlterTableBuilder),
251) -> Result<(), DbErr> {
252    let mut builder = AlterTableBuilder::new();
253    f(&mut builder);
254    let conn = manager.get_connection();
255    for stmt in alter_table_statements(name, builder.ops) {
256        conn.execute(&stmt).await.map(|_| ())?;
257    }
258    Ok(())
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264    use crate::sea_orm::sea_query::PostgresQueryBuilder;
265
266    #[test]
267    fn create_table_adds_implicit_id_and_columns() {
268        let sql = create_table_statement("users", |t| {
269            t.string("email").not_null();
270            t.timestamps();
271        })
272        .to_string(PostgresQueryBuilder);
273        assert!(sql.contains("CREATE TABLE IF NOT EXISTS \"users\""));
274        assert!(sql.contains("\"id\""));
275        assert!(sql.contains("\"email\""));
276        assert!(sql.contains("\"created_at\""));
277        assert!(sql.contains("\"updated_at\""));
278    }
279
280    #[test]
281    fn references_adds_id_suffixed_column() {
282        let sql = create_table_statement("comments", |t| {
283            t.references("post");
284        })
285        .to_string(PostgresQueryBuilder);
286        assert!(sql.contains("\"post_id\""));
287    }
288
289    #[test]
290    fn drop_table_is_conditional() {
291        let sql = drop_table_statement("users").to_string(PostgresQueryBuilder);
292        assert!(sql.contains("DROP TABLE IF EXISTS \"users\""));
293    }
294
295    #[test]
296    fn alter_table_emits_one_statement_per_change() {
297        let mut builder = AlterTableBuilder::new();
298        builder.add_column("age", |c| {
299            c.integer();
300        });
301        builder.drop_column("legacy");
302        builder.rename_column("sku", "code");
303
304        let stmts = alter_table_statements("items", builder.ops);
305        assert_eq!(stmts.len(), 3);
306        let add = stmts[0].to_string(PostgresQueryBuilder);
307        assert!(add.contains("ALTER TABLE \"items\""));
308        assert!(add.contains("\"age\""));
309        assert!(stmts[1]
310            .to_string(PostgresQueryBuilder)
311            .contains("\"legacy\""));
312        let rename = stmts[2].to_string(PostgresQueryBuilder);
313        assert!(rename.contains("\"sku\""));
314        assert!(rename.contains("\"code\""));
315    }
316}