Skip to main content

orbit_orm/schema/
mod.rs

1//! A database-neutral schema builder, after Laravel's `Schema::create`, `Schema::table` and
2//! `Blueprint`.
3//!
4//! A migration describes a table once and the same code runs on SQLite, MySQL and MariaDB, and
5//! PostgreSQL:
6//!
7//! ```
8//! use orbit_orm::schema::{self, Action};
9//!
10//! let posts = schema::create("posts", |t| {
11//!     t.id();
12//!     t.string("title");
13//!     t.text("body").nullable();
14//!     t.big_integer("user_id");
15//!     t.foreign("user_id").references("users", "id").on_delete(Action::Restrict);
16//!     t.unique(["title"]);
17//! });
18//! # let _ = posts;
19//!
20//! let rename = schema::table("posts", |t| {
21//!     t.string("subtitle").nullable();
22//!     t.rename_column("body", "content");
23//! });
24//! # let _ = rename;
25//! ```
26//!
27//! A [`Statement`] is only a description. [`Statement::to_sql`] renders it for a [`Driver`] as a
28//! list of statements to run in order: PostgreSQL and SQLite create a plain index with a separate
29//! `CREATE INDEX`, and sqlx does not run several statements in one string on every database.
30//!
31//! [`statement`] runs SQL as written, as Laravel's `DB::statement` in a migration, and
32//! [`Statement::only`] limits any statement to one database family.
33
34mod blueprint;
35mod column;
36
37use crate::{Driver, Error};
38
39pub use blueprint::{Action, Blueprint, ForeignKey, Index};
40pub use column::Column;
41
42/// A database family. A [`Driver`] maps to one of these. Unlike [`Driver`], all three variants
43/// exist whatever Cargo features are on, so a migration can name any of them, for example in
44/// [`Statement::only`].
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
46pub enum Dialect {
47    /// MySQL and MariaDB.
48    MySql,
49    /// PostgreSQL.
50    Postgres,
51    /// SQLite.
52    Sqlite,
53}
54
55impl Dialect {
56    /// The family of `driver`.
57    pub fn of(driver: Driver) -> Self {
58        match driver {
59            #[cfg(feature = "mysql")]
60            Driver::MySql => Self::MySql,
61            #[cfg(feature = "postgres")]
62            Driver::Postgres => Self::Postgres,
63            #[cfg(feature = "sqlite")]
64            Driver::Sqlite => Self::Sqlite,
65        }
66    }
67}
68
69/// A schema change, ready to render for any database.
70#[derive(Debug, Clone)]
71pub struct Statement {
72    kind: Kind,
73    /// The only database family the statement renders on, set by [`Statement::only`].
74    only: Option<Dialect>,
75}
76
77#[derive(Debug, Clone)]
78enum Kind {
79    Create {
80        table: String,
81        blueprint: Blueprint,
82        if_not_exists: bool,
83    },
84    Alter {
85        table: String,
86        blueprint: Blueprint,
87    },
88    Drop {
89        table: String,
90        if_exists: bool,
91    },
92    Raw(String),
93}
94
95/// Creates a table. The closure describes its columns, indexes and foreign keys.
96pub fn create(table: &str, f: impl FnOnce(&mut Blueprint)) -> Statement {
97    creating(table, f, false)
98}
99
100/// Creates a table unless it exists: `CREATE TABLE IF NOT EXISTS`. A plain index that PostgreSQL
101/// and SQLite create with a separate statement takes `CREATE INDEX IF NOT EXISTS`.
102///
103/// Laravel has no such method; a Laravel migration writes `if (! Schema::hasTable(...))`. That
104/// needs a live connection, and a schema migration here is rendered without one, so the check is
105/// in the SQL instead. An existing table is left as it is, even when its columns differ.
106pub fn create_if_not_exists(table: &str, f: impl FnOnce(&mut Blueprint)) -> Statement {
107    creating(table, f, true)
108}
109
110fn creating(table: &str, f: impl FnOnce(&mut Blueprint), if_not_exists: bool) -> Statement {
111    let mut blueprint = Blueprint::new();
112    f(&mut blueprint);
113    Statement {
114        kind: Kind::Create {
115            table: table.to_owned(),
116            blueprint,
117            if_not_exists,
118        },
119        only: None,
120    }
121}
122
123/// Changes an existing table, as Laravel's `Schema::table`. A column described in the closure is
124/// added, or modified when it ends with `.change()`; `rename_column` and `drop_column` rename and
125/// drop columns; `index`, `unique`, `primary` and `foreign` add keys; `drop_index` and
126/// `drop_foreign` drop them.
127///
128/// Each operation renders as its own statement on every database, in the order it was called, as
129/// in Laravel 11 and later: `t.drop_column("x"); t.string("x");` drops the column and then adds
130/// it again, and `t.rename_column("a", "b"); t.string("a");` renames and then adds. PostgreSQL and
131/// SQLite add a plain index with `CREATE INDEX` and drop one with `DROP INDEX`, and SQLite adds a
132/// unique index with `CREATE UNIQUE INDEX`; the rest is `ALTER TABLE`. SQLite cannot add or drop
133/// a constraint on an existing table: a foreign key and `drop_foreign` render nothing there, and
134/// a primary key is [`Error::Unsupported`].
135pub fn table(table: &str, f: impl FnOnce(&mut Blueprint)) -> Statement {
136    let mut blueprint = Blueprint::new();
137    f(&mut blueprint);
138    Statement {
139        kind: Kind::Alter {
140            table: table.to_owned(),
141            blueprint,
142        },
143        only: None,
144    }
145}
146
147/// Drops a table. It is an error in the database if the table does not exist.
148pub fn drop(table: &str) -> Statement {
149    Statement {
150        kind: Kind::Drop {
151            table: table.to_owned(),
152            if_exists: false,
153        },
154        only: None,
155    }
156}
157
158/// Drops a table if it exists.
159pub fn drop_if_exists(table: &str) -> Statement {
160    Statement {
161        kind: Kind::Drop {
162            table: table.to_owned(),
163            if_exists: true,
164        },
165        only: None,
166    }
167}
168
169/// Runs `sql` as written, as Laravel's `DB::statement` inside a migration.
170///
171/// The SQL is database-specific: Orbit does not check, quote or translate it, and runs the same
172/// string on every database. Use it for data rows (seeding a role, moving values between
173/// columns) and for what the builder cannot express, such as a MySQL-only column type. Add
174/// [`only`](Statement::only) when the SQL is for one database family. Orbit's own generated code
175/// never uses it.
176///
177/// [`to_sql`](Statement::to_sql) and [`try_to_sql`](Statement::try_to_sql) return the string
178/// unchanged, as one statement. A wrong string is reported by the database when it runs. An
179/// empty string may do nothing, or be an error, depending on the database.
180///
181/// ```
182/// use orbit_orm::schema::{self, Dialect};
183///
184/// let seed = schema::statement("INSERT INTO roles (name) VALUES ('admin')");
185/// let avatar = schema::statement("ALTER TABLE users MODIFY avatar MEDIUMBLOB").only(Dialect::MySql);
186/// # let _ = (seed, avatar);
187/// ```
188pub fn statement(sql: impl Into<String>) -> Statement {
189    Statement {
190        kind: Kind::Raw(sql.into()),
191        only: None,
192    }
193}
194
195impl Statement {
196    /// Limits the statement to one database family, as a Laravel migration wraps a call in
197    /// `if (DB::getDriverName() === 'mysql') { ... }`. On that family the statement renders as
198    /// usual; on the others it renders to an empty list, and nothing runs. It works on every
199    /// statement: [`create`], [`table`], [`drop`] and [`statement`].
200    ///
201    /// A statement for another family is not rendered at all, so an invalid name in it is not
202    /// reported there either. Calling `only` twice keeps the last call.
203    pub fn only(mut self, dialect: Dialect) -> Self {
204        self.only = Some(dialect);
205        self
206    }
207
208    /// Renders the statement for `driver`: one SQL statement per element, to run in order.
209    ///
210    /// # Panics
211    ///
212    /// Panics when a name is invalid or a default has no SQL literal. Use
213    /// [`try_to_sql`](Self::try_to_sql) to get the error instead.
214    pub fn to_sql(&self, driver: Driver) -> Vec<String> {
215        self.try_to_sql(driver)
216            .unwrap_or_else(|e| panic!("cannot render the schema statement: {e}"))
217    }
218
219    /// Renders the statement for `driver`, or the error for an invalid name. A table, column or
220    /// index column name must not be empty or contain a backtick or a double quote, the rule of
221    /// the query builder. An operation the database cannot do, or an alter-only command such as
222    /// `rename_column` inside `create`, is [`Error::Unsupported`]. A raw [`statement`] is never
223    /// an error, and a statement limited by [`only`](Self::only) to another family renders as an
224    /// empty list.
225    pub fn try_to_sql(&self, driver: Driver) -> Result<Vec<String>, Error> {
226        let dialect = Dialect::of(driver);
227        if self.only.is_some_and(|only| only != dialect) {
228            return Ok(Vec::new());
229        }
230        match &self.kind {
231            Kind::Create {
232                table,
233                blueprint,
234                if_not_exists,
235            } => blueprint.render_create(driver, dialect, table, *if_not_exists),
236            Kind::Alter { table, blueprint } => blueprint.render_alter(driver, dialect, table),
237            Kind::Drop { table, if_exists } => {
238                crate::query::validate(table)?;
239                let exists = if *if_exists { "IF EXISTS " } else { "" };
240                Ok(vec![format!("DROP TABLE {exists}{}", driver.quote(table))])
241            }
242            Kind::Raw(sql) => Ok(vec![sql.clone()]),
243        }
244    }
245}