Skip to main content

rainier_orm/
ddl.rs

1//! A declarative, dialect-portable schema-change language — the *single* way
2//! to express an **incremental** migration step.
3//!
4//! [`crate::schema`] renders an [`Entity`]'s whole `CREATE TABLE` from its
5//! derived metadata: the create-from-scratch path. But a live schema *evolves*
6//! — columns are added, indexes created, tables renamed or dropped — and those
7//! changes can't come from the entity alone (the entity only describes the
8//! *current* shape, not the diff from the last one). This module is that
9//! incremental complement: a small builder, [`Migration`], that accumulates
10//! operations and renders each to the executor's dialect **at apply time**, so
11//! one migration definition runs on MySQL, Postgres, SQLite, and Cloudflare D1
12//! alike — the "write it once" promise extended from CRUD to schema evolution.
13//!
14//! A [`Migration`] *is* a [`migrate::Migration`](crate::migrate::Migration)
15//! step, so you compose it straight into a [`Migrator`](crate::migrate::Migrator)
16//! next to [`create_table`](crate::migrate::Migrator::create_table) steps:
17//!
18//! ```ignore
19//! use rainier_orm::ddl::{Column, Migration};
20//! use rainier_orm::{migrate::Migrator, ColumnType};
21//!
22//! let m = Migration::new("0002_users_add_phone")
23//!     .add_column("users", Column::new("phone", ColumnType::Text))
24//!     .create_index("idx_users_phone", "users", ["phone"]);
25//!
26//! Migrator::new()
27//!     .create_table::<User>("0001_users")   // create-from-entity
28//!     .add(m)                               // incremental change
29//!     .run(&exec)
30//!     .await?;
31//! ```
32//!
33//! **Portability.** Every built-in operation lowers to SQL that MySQL,
34//! Postgres, and SQLite/D1 all accept (one statement per operation — SQLite
35//! permits a single change per `ALTER TABLE`). The exceptions are column *type*
36//! changes (`MODIFY`/`ALTER COLUMN` has no portable, SQLite-supported form) —
37//! reach for [`raw`](Migration::raw) there, scoped to the dialect that needs it.
38
39use crate::{schema, ColumnType, Dialect, Entity};
40use sea_query::{Alias, ColumnDef, Index, Table, Value};
41
42/// A column to **add**, described portably (the dialect-specific type rendering
43/// is deferred to apply time, exactly as [`crate::schema`] does for `CREATE`).
44///
45/// New columns default to **nullable**: adding a `NOT NULL` column to a table
46/// that already has rows fails unless a default backfills them, so call
47/// [`not_null`](Self::not_null) together with [`default`](Self::default).
48pub struct Column {
49    name: String,
50    ty: ColumnType,
51    nullable: bool,
52    default: Option<Value>,
53    keyed: bool,
54}
55
56impl Column {
57    /// A nullable column of `ty`.
58    pub fn new(name: impl Into<String>, ty: ColumnType) -> Self {
59        Self { name: name.into(), ty, nullable: true, default: None, keyed: false }
60    }
61
62    /// Mark the column `NOT NULL`. Pair with [`default`](Self::default) when the
63    /// table may already hold rows.
64    pub fn not_null(mut self) -> Self {
65        self.nullable = false;
66        self
67    }
68
69    /// Mark the column nullable (the default).
70    pub fn null(mut self) -> Self {
71        self.nullable = true;
72        self
73    }
74
75    /// A column default — also backfills existing rows when adding `NOT NULL`.
76    pub fn default(mut self, value: impl Into<Value>) -> Self {
77        self.default = Some(value.into());
78        self
79    }
80
81    /// Hint that this (text) column will be indexed, so it renders as a bounded
82    /// `VARCHAR` rather than `TEXT` on MySQL (which can't index an unbounded
83    /// `TEXT`). No effect on SQLite/D1, where both are `TEXT` affinity.
84    pub fn keyed(mut self) -> Self {
85        self.keyed = true;
86        self
87    }
88
89    fn to_def(&self) -> ColumnDef {
90        let mut def = ColumnDef::new(Alias::new(self.name.clone()));
91        schema::apply_type(&mut def, self.ty, self.keyed);
92        if self.nullable {
93            def.null();
94        } else {
95            def.not_null();
96        }
97        if let Some(v) = &self.default {
98            def.default(v.clone());
99        }
100        def
101    }
102}
103
104/// An ordered set of schema operations under one migration `name` — the unit
105/// the [`Migrator`](crate::migrate::Migrator) records in `_orm_migrations` and
106/// applies once.
107///
108/// Build it fluently; it renders to dialect SQL only when applied (or via
109/// [`render`](Self::render) for inspection/tests). Each builder method appends
110/// one operation, and operations apply in call order.
111pub struct Migration {
112    name: &'static str,
113    // Each op renders itself to zero or more statements for a given dialect.
114    // A boxed renderer (rather than a stored sea-query statement) lets the
115    // entity-create and dialect-scoped-raw ops live in the same list as the
116    // plain schema statements, all resolved at the executor's dialect.
117    ops: Vec<Box<dyn Fn(Dialect) -> Vec<String>>>,
118}
119
120impl Migration {
121    /// A new, empty migration identified by `name` (the ordering + dedupe key).
122    pub fn new(name: &'static str) -> Self {
123        Self { name, ops: Vec::new() }
124    }
125
126    /// This migration's name.
127    pub fn name(&self) -> &'static str {
128        self.name
129    }
130
131    fn push_stmt<S>(mut self, stmt: S) -> Self
132    where
133        S: sea_query::SchemaStatementBuilder + 'static,
134    {
135        self.ops.push(Box::new(move |d: Dialect| vec![d.build_schema(&stmt)]));
136        self
137    }
138
139    /// Create `E`'s table **and its derived indexes**, idempotently
140    /// (`CREATE TABLE IF NOT EXISTS`) — the same DDL
141    /// [`Migrator::create_table`](crate::migrate::Migrator::create_table)
142    /// emits, available here so a create and subsequent alters can share one
143    /// migration step.
144    pub fn create_table<E: Entity + 'static>(mut self) -> Self {
145        self.ops.push(Box::new(|d| schema::schema_ddl::<E>(d)));
146        self
147    }
148
149    /// Append an explicit `sea_query` schema statement — a `CREATE TABLE`,
150    /// `CREATE INDEX`, `ALTER TABLE`, … built directly with the re-exported
151    /// [`sea_query`] module. The bridge for schemas authored as
152    /// sea-query statements rather than through the typed ops above (or derived
153    /// from an [`Entity`]); rendered for the executor's dialect at apply time,
154    /// so one definition still runs on every backend.
155    pub fn statement<S>(self, stmt: S) -> Self
156    where
157        S: sea_query::SchemaStatementBuilder + 'static,
158    {
159        self.push_stmt(stmt)
160    }
161
162    /// Add a column to `table`. See [`Column`] for nullability/default rules.
163    pub fn add_column(self, table: &str, column: Column) -> Self {
164        let mut def = column.to_def();
165        let mut stmt = Table::alter();
166        stmt.table(Alias::new(table)).add_column(&mut def);
167        self.push_stmt(stmt)
168    }
169
170    /// Drop `column` from `table` (SQLite ≥ 3.35 / modern D1 support this).
171    pub fn drop_column(self, table: &str, column: &str) -> Self {
172        let mut stmt = Table::alter();
173        stmt.table(Alias::new(table)).drop_column(Alias::new(column));
174        self.push_stmt(stmt)
175    }
176
177    /// Rename `from` → `to` on `table`.
178    pub fn rename_column(self, table: &str, from: &str, to: &str) -> Self {
179        let mut stmt = Table::alter();
180        stmt.table(Alias::new(table)).rename_column(Alias::new(from), Alias::new(to));
181        self.push_stmt(stmt)
182    }
183
184    /// Rename a table.
185    pub fn rename_table(self, from: &str, to: &str) -> Self {
186        let mut stmt = Table::rename();
187        stmt.table(Alias::new(from), Alias::new(to));
188        self.push_stmt(stmt)
189    }
190
191    /// Drop a table if it exists.
192    pub fn drop_table(self, table: &str) -> Self {
193        let mut stmt = Table::drop();
194        stmt.table(Alias::new(table)).if_exists();
195        self.push_stmt(stmt)
196    }
197
198    /// Create a non-unique secondary index (`IF NOT EXISTS`).
199    pub fn create_index<'c, I>(self, name: &str, table: &str, columns: I) -> Self
200    where
201        I: IntoIterator<Item = &'c str>,
202    {
203        self.index(name, table, columns, false)
204    }
205
206    /// Create a `UNIQUE` index (`IF NOT EXISTS`).
207    pub fn create_unique_index<'c, I>(self, name: &str, table: &str, columns: I) -> Self
208    where
209        I: IntoIterator<Item = &'c str>,
210    {
211        self.index(name, table, columns, true)
212    }
213
214    fn index<'c, I>(self, name: &str, table: &str, columns: I, unique: bool) -> Self
215    where
216        I: IntoIterator<Item = &'c str>,
217    {
218        let mut stmt = Index::create();
219        stmt.name(name).table(Alias::new(table)).if_not_exists();
220        if unique {
221            stmt.unique();
222        }
223        for c in columns {
224            stmt.col(Alias::new(c));
225        }
226        self.push_stmt(stmt)
227    }
228
229    /// Drop a named index off `table`.
230    pub fn drop_index(self, name: &str, table: &str) -> Self {
231        let mut stmt = Index::drop();
232        stmt.name(name).table(Alias::new(table));
233        self.push_stmt(stmt)
234    }
235
236    /// Raw SQL emitted **only** for `dialect` — the escape hatch for anything
237    /// the portable ops can't express (e.g. a MySQL `MODIFY COLUMN`, a Postgres
238    /// `ALTER COLUMN … TYPE`). Ignored on every other dialect, so a migration
239    /// can carry per-dialect variants of the same change side by side.
240    pub fn raw(mut self, dialect: Dialect, sql: impl Into<String>) -> Self {
241        let sql = sql.into();
242        self.ops.push(Box::new(move |d| if d == dialect { vec![sql.clone()] } else { Vec::new() }));
243        self
244    }
245
246    /// Raw SQL emitted for **every** dialect — only sound for statements that
247    /// are byte-identical across backends. Prefer the typed ops or
248    /// [`raw`](Self::raw); this is the last resort.
249    pub fn raw_all(mut self, sql: impl Into<String>) -> Self {
250        let sql = sql.into();
251        self.ops.push(Box::new(move |_| vec![sql.clone()]));
252        self
253    }
254
255    /// Every statement this migration runs on `dialect`, in apply order — for
256    /// inspection, tests, or dumping a `.sql` file.
257    pub fn render(&self, dialect: Dialect) -> Vec<String> {
258        self.ops.iter().flat_map(|op| op(dialect)).collect()
259    }
260}
261
262impl<X: crate::Executor> crate::migrate::Migration<X> for Migration {
263    fn name(&self) -> &'static str {
264        self.name
265    }
266
267    fn up<'a>(&'a self, db: &'a X) -> crate::migrate::StepFuture<'a> {
268        Box::pin(async move {
269            for sql in self.render(db.dialect()) {
270                db.execute(&sql, Vec::new()).await?;
271            }
272            Ok(())
273        })
274    }
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280
281    #[test]
282    fn add_column_renders_per_dialect() {
283        let m = Migration::new("0002_add_phone")
284            .add_column("users", Column::new("phone", ColumnType::Text).keyed());
285        let sqlite = m.render(Dialect::Sqlite).join("\n");
286        let mysql = m.render(Dialect::MySql).join("\n");
287        assert!(sqlite.contains("ALTER TABLE"), "{sqlite}");
288        assert!(sqlite.contains("phone"));
289        // MySQL renders a keyed text column as a bounded VARCHAR, SQLite as text.
290        assert!(mysql.contains("varchar") || mysql.contains("VARCHAR"), "{mysql}");
291        assert!(mysql.contains('`'), "mysql quoting");
292        assert!(sqlite.contains('"'), "sqlite quoting");
293    }
294
295    #[test]
296    fn not_null_with_default_backfills() {
297        let m = Migration::new("0003_add_flag").add_column(
298            "users",
299            Column::new("verified", ColumnType::Bool).not_null().default(false),
300        );
301        let sql = m.render(Dialect::Sqlite).join("\n");
302        assert!(sql.to_uppercase().contains("NOT NULL"), "{sql}");
303        assert!(sql.to_uppercase().contains("DEFAULT"), "{sql}");
304    }
305
306    #[test]
307    fn index_ops_render() {
308        let create = Migration::new("a")
309            .create_index("idx_users_phone", "users", ["phone"])
310            .render(Dialect::Sqlite)
311            .join("\n");
312        assert!(create.contains("CREATE INDEX"), "{create}");
313        assert!(create.contains("idx_users_phone"));
314
315        let uniq = Migration::new("b")
316            .create_unique_index("uq_users_phone", "users", ["phone", "region"])
317            .render(Dialect::MySql)
318            .join("\n");
319        assert!(uniq.to_uppercase().contains("UNIQUE"), "{uniq}");
320
321        let drop = Migration::new("c")
322            .drop_index("idx_users_phone", "users")
323            .render(Dialect::Sqlite)
324            .join("\n");
325        assert!(drop.to_uppercase().contains("DROP INDEX"), "{drop}");
326    }
327
328    #[test]
329    fn rename_and_drop_table() {
330        let rename = Migration::new("a")
331            .rename_table("old_users", "users")
332            .render(Dialect::MySql)
333            .join("\n");
334        assert!(rename.to_uppercase().contains("RENAME"), "{rename}");
335
336        let drop = Migration::new("b").drop_table("dead").render(Dialect::Sqlite).join("\n");
337        assert!(drop.to_uppercase().contains("DROP TABLE"), "{drop}");
338        assert!(drop.to_uppercase().contains("IF EXISTS"), "{drop}");
339    }
340
341    #[test]
342    fn statement_bridges_sea_query() {
343        use sea_query::{Alias, ColumnDef, Table};
344        let mut create = Table::create();
345        create
346            .table(Alias::new("widgets"))
347            .if_not_exists()
348            .col(ColumnDef::new(Alias::new("id")).big_integer().primary_key());
349        let m = Migration::new("0001").statement(create);
350        let sqlite = m.render(Dialect::Sqlite).join("\n");
351        assert!(sqlite.to_uppercase().contains("CREATE TABLE"), "{sqlite}");
352        assert!(sqlite.contains("widgets"));
353        // Same statement, MySQL quoting.
354        assert!(m.render(Dialect::MySql).join("\n").contains('`'));
355    }
356
357    #[test]
358    fn raw_is_dialect_scoped() {
359        let m = Migration::new("a")
360            .raw(Dialect::MySql, "ALTER TABLE t MODIFY c TEXT")
361            .raw(Dialect::Sqlite, "-- noop on sqlite");
362        assert_eq!(m.render(Dialect::MySql), vec!["ALTER TABLE t MODIFY c TEXT"]);
363        assert_eq!(m.render(Dialect::Sqlite), vec!["-- noop on sqlite"]);
364        assert!(m.render(Dialect::Postgres).is_empty());
365    }
366
367    #[test]
368    fn ops_apply_in_call_order() {
369        let m = Migration::new("ordered")
370            .add_column("t", Column::new("a", ColumnType::Int))
371            .add_column("t", Column::new("b", ColumnType::Int));
372        let rendered = m.render(Dialect::Sqlite);
373        assert_eq!(rendered.len(), 2);
374        assert!(rendered[0].contains("\"a\""), "{:?}", rendered);
375        assert!(rendered[1].contains("\"b\""), "{:?}", rendered);
376    }
377}