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}