Skip to main content

orbit_orm/schema/
blueprint.rs

1use super::Dialect;
2use super::column::{Column, ColumnType};
3use crate::query::validate;
4use crate::{Driver, Error};
5
6/// What a foreign key does to this table's rows when the referenced row is deleted or updated.
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum Action {
9    Cascade,
10    Restrict,
11    SetNull,
12    SetDefault,
13    NoAction,
14}
15
16impl Action {
17    fn sql(self) -> &'static str {
18        match self {
19            Self::Cascade => "CASCADE",
20            Self::Restrict => "RESTRICT",
21            Self::SetNull => "SET NULL",
22            Self::SetDefault => "SET DEFAULT",
23            Self::NoAction => "NO ACTION",
24        }
25    }
26}
27
28/// A foreign key under construction. Chain `references`, `on_delete`, `on_update` and `name`.
29#[derive(Debug, Clone)]
30pub struct ForeignKey {
31    column: String,
32    name: Option<String>,
33    target: Option<(String, String)>,
34    on_delete: Option<Action>,
35    on_update: Option<Action>,
36}
37
38impl ForeignKey {
39    /// The table and column that this column points at.
40    pub fn references(&mut self, table: &str, column: &str) -> &mut Self {
41        self.target = Some((table.to_owned(), column.to_owned()));
42        self
43    }
44
45    /// What happens when the referenced row is deleted.
46    pub fn on_delete(&mut self, action: Action) -> &mut Self {
47        self.on_delete = Some(action);
48        self
49    }
50
51    /// What happens when the referenced key is updated.
52    pub fn on_update(&mut self, action: Action) -> &mut Self {
53        self.on_update = Some(action);
54        self
55    }
56
57    /// Names the constraint, as the second argument of Laravel's `$table->foreign('col', 'name')`.
58    /// Without it the name is `{table}_{column}_foreign`.
59    pub fn name(&mut self, name: &str) -> &mut Self {
60        self.name = Some(name.to_owned());
61        self
62    }
63}
64
65/// A plain or unique index under construction. Chain `name` to choose its name.
66#[derive(Debug, Clone)]
67pub struct Index {
68    columns: Vec<String>,
69    unique: bool,
70    name: Option<String>,
71}
72
73impl Index {
74    /// Names the index, as the second argument of Laravel's `$table->index(['a'], 'name')`.
75    /// Without it the name is `{table}_{columns}_index`, or `{table}_{columns}_unique` for a
76    /// unique index.
77    pub fn name(&mut self, name: &str) -> &mut Self {
78        self.name = Some(name.to_owned());
79        self
80    }
81
82    /// The index's name, checked: the custom one, or the default for `table`.
83    fn name_for(&self, table: &str) -> Result<String, Error> {
84        match &self.name {
85            Some(name) => {
86                validate(name)?;
87                Ok(name.clone())
88            }
89            None => Ok(name_of(
90                table,
91                &self.columns,
92                if self.unique { "unique" } else { "index" },
93            )),
94        }
95    }
96}
97
98/// Every operation of a blueprint, in call order. A column is recorded as `Column`, its index in
99/// the column list, at the point it is declared, as Laravel 11+ records each column as a command.
100/// `schema::create` renders the columns, then the indexes, primary keys and foreign keys inside
101/// `CREATE TABLE`; `schema::table` renders every command as its own statement in call order.
102#[derive(Debug, Clone)]
103enum Command {
104    Column(usize),
105    Index(Index),
106    Primary { columns: Vec<String> },
107    Foreign(ForeignKey),
108    RenameColumn { from: String, to: String },
109    DropColumn { name: String },
110    DropIndex { name: String },
111    DropForeign { name: String },
112}
113
114/// The columns and commands (indexes, keys, renames, drops) of a table, in the order they were
115/// added.
116#[derive(Debug, Clone, Default)]
117pub struct Blueprint {
118    columns: Vec<Column>,
119    commands: Vec<Command>,
120}
121
122impl Blueprint {
123    pub(crate) fn new() -> Self {
124        Self::default()
125    }
126
127    fn add(&mut self, name: &str, ty: ColumnType) -> &mut Column {
128        self.commands.push(Command::Column(self.columns.len()));
129        self.columns.push(Column::new(name, ty));
130        self.columns.last_mut().expect("just pushed")
131    }
132
133    /// An auto-incrementing 64-bit primary key named `id`. In [`schema::table`](super::table)
134    /// SQLite cannot add it, as SQLite cannot add a primary key column to an existing table.
135    pub fn id(&mut self) -> &mut Column {
136        self.add("id", ColumnType::Id)
137    }
138
139    /// A `VARCHAR(255)`; `TEXT` on SQLite. Change the length with `.length(n)`.
140    pub fn string(&mut self, name: &str) -> &mut Column {
141        self.add(name, ColumnType::String(255))
142    }
143
144    /// A `TEXT` column.
145    pub fn text(&mut self, name: &str) -> &mut Column {
146        self.add(name, ColumnType::Text)
147    }
148
149    /// An `INTEGER` column.
150    pub fn integer(&mut self, name: &str) -> &mut Column {
151        self.add(name, ColumnType::Integer)
152    }
153
154    /// A `BIGINT` column; `INTEGER` on SQLite, whose integers are 64-bit already.
155    pub fn big_integer(&mut self, name: &str) -> &mut Column {
156        self.add(name, ColumnType::BigInteger)
157    }
158
159    /// A boolean: `BOOLEAN` on PostgreSQL, `TINYINT(1)` elsewhere.
160    pub fn boolean(&mut self, name: &str) -> &mut Column {
161        self.add(name, ColumnType::Boolean)
162    }
163
164    /// A date and time without a time zone: `DATETIME`, or `TIMESTAMP(0)` on PostgreSQL. Set
165    /// fractional seconds with `.precision(p)` and a default of the current time with
166    /// `.use_current()`.
167    pub fn date_time(&mut self, name: &str) -> &mut Column {
168        self.add(name, ColumnType::DateTime(0))
169    }
170
171    /// A timestamp, as Laravel's `timestamp`: `TIMESTAMP` on MySQL, `TIMESTAMP(0) WITHOUT TIME
172    /// ZONE` on PostgreSQL, `DATETIME` on SQLite. Set fractional seconds with `.precision(p)` and
173    /// a default of the current time with `.use_current()`.
174    ///
175    /// On MySQL and MariaDB with `explicit_defaults_for_timestamp` off (the MariaDB default
176    /// before 10.10), the first `TIMESTAMP` column of a table that is `NOT NULL` and has no
177    /// default gets `DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP` from the server.
178    /// Laravel's columns get the same. Add `.nullable()` or a default to avoid it.
179    pub fn timestamp(&mut self, name: &str) -> &mut Column {
180        self.add(name, ColumnType::Timestamp(0))
181    }
182
183    /// A column of raw bytes, as Laravel's `binary`: `BLOB`, or `BYTEA` on PostgreSQL.
184    pub fn binary(&mut self, name: &str) -> &mut Column {
185        self.add(name, ColumnType::Binary)
186    }
187
188    /// Nullable `created_at` and `updated_at` columns.
189    pub fn timestamps(&mut self) {
190        self.date_time("created_at").nullable();
191        self.date_time("updated_at").nullable();
192    }
193
194    /// A unique constraint over `columns`, named `{table}_{columns}_unique` unless `.name()`
195    /// gives another name.
196    pub fn unique<I, S>(&mut self, columns: I) -> &mut Index
197    where
198        I: IntoIterator<Item = S>,
199        S: AsRef<str>,
200    {
201        self.add_index(columns, true)
202    }
203
204    /// A plain index over `columns`, named `{table}_{columns}_index` unless `.name()` gives
205    /// another name.
206    pub fn index<I, S>(&mut self, columns: I) -> &mut Index
207    where
208        I: IntoIterator<Item = S>,
209        S: AsRef<str>,
210    {
211        self.add_index(columns, false)
212    }
213
214    fn add_index<I, S>(&mut self, columns: I, unique: bool) -> &mut Index
215    where
216        I: IntoIterator<Item = S>,
217        S: AsRef<str>,
218    {
219        self.commands.push(Command::Index(Index {
220            columns: collect(columns),
221            unique,
222            name: None,
223        }));
224        match self.commands.last_mut() {
225            Some(Command::Index(index)) => index,
226            _ => unreachable!("just pushed an index"),
227        }
228    }
229
230    /// A primary key over `columns`, as Laravel's `$table->primary(['a', 'b'])`: the table
231    /// constraint `PRIMARY KEY (a, b)`. For a key of one column, `.primary()` on the column does
232    /// the same. In [`schema::table`](super::table) SQLite cannot add it: it is
233    /// [`Error::Unsupported`] there.
234    pub fn primary<I, S>(&mut self, columns: I)
235    where
236        I: IntoIterator<Item = S>,
237        S: AsRef<str>,
238    {
239        self.commands.push(Command::Primary {
240            columns: collect(columns),
241        });
242    }
243
244    /// A foreign key on `column`, named `{table}_{column}_foreign` unless `.name()` gives another
245    /// name. In [`schema::table`](super::table) SQLite renders nothing, as SQLite cannot add a
246    /// constraint to an existing table.
247    pub fn foreign(&mut self, column: &str) -> &mut ForeignKey {
248        self.commands.push(Command::Foreign(ForeignKey {
249            column: column.to_owned(),
250            name: None,
251            target: None,
252            on_delete: None,
253            on_update: None,
254        }));
255        match self.commands.last_mut() {
256            Some(Command::Foreign(fk)) => fk,
257            _ => unreachable!("just pushed a foreign key"),
258        }
259    }
260
261    /// Renames the column `from` to `to`: `ALTER TABLE ... RENAME COLUMN`. Only in
262    /// [`schema::table`](super::table).
263    ///
264    /// `RENAME COLUMN` needs MySQL 8.0.3 or later, or MariaDB 10.5.2 or later. On older servers
265    /// Laravel falls back to `CHANGE old new <definition>`; Orbit does not, and the server
266    /// reports a syntax error.
267    pub fn rename_column(&mut self, from: &str, to: &str) {
268        self.commands.push(Command::RenameColumn {
269            from: from.to_owned(),
270            to: to.to_owned(),
271        });
272    }
273
274    /// Drops the column `name`: `ALTER TABLE ... DROP COLUMN`. Only in
275    /// [`schema::table`](super::table). SQLite needs version 3.35 or later.
276    pub fn drop_column(&mut self, name: &str) {
277        self.commands.push(Command::DropColumn {
278            name: name.to_owned(),
279        });
280    }
281
282    /// Drops the index `name`, as Laravel's `dropIndex('name')`. Only in
283    /// [`schema::table`](super::table).
284    ///
285    /// On MySQL it drops a unique index too. On SQLite it drops a unique only when `unique` added
286    /// it in `schema::table`: a unique declared in [`schema::create`](super::create) is an inline
287    /// constraint there and cannot be dropped this way. On PostgreSQL `unique` always adds a
288    /// constraint, and `DROP INDEX` cannot drop the index of a constraint. Orbit has no
289    /// `drop_unique` (Laravel's `dropUnique`).
290    pub fn drop_index(&mut self, name: &str) {
291        self.commands.push(Command::DropIndex {
292            name: name.to_owned(),
293        });
294    }
295
296    /// Drops the foreign key `name`, as Laravel's `dropForeign('name')`. Only in
297    /// [`schema::table`](super::table). SQLite renders nothing, as it cannot drop a constraint
298    /// from an existing table and `foreign` added none there.
299    pub fn drop_foreign(&mut self, name: &str) {
300        self.commands.push(Command::DropForeign {
301            name: name.to_owned(),
302        });
303    }
304
305    /// Renders `CREATE TABLE`, and the separate `CREATE INDEX` statements of PostgreSQL and
306    /// SQLite. With `if_not_exists`, both take `IF NOT EXISTS`.
307    pub(crate) fn render_create(
308        &self,
309        driver: Driver,
310        dialect: Dialect,
311        table: &str,
312        if_not_exists: bool,
313    ) -> Result<Vec<String>, Error> {
314        validate(table)?;
315        let unsupported = |operation| Error::Unsupported {
316            operation,
317            driver: driver.name(),
318        };
319        let q = |name: &str| driver.quote(name);
320        let exists = if if_not_exists { "IF NOT EXISTS " } else { "" };
321
322        let mut parts = Vec::new();
323        for column in &self.columns {
324            if column.change {
325                return Err(unsupported("changing a column in create"));
326            }
327            parts.push(column.render(driver, dialect)?);
328        }
329        let mut after = Vec::new();
330        for command in &self.commands {
331            match command {
332                Command::Column(_) => {}
333                Command::Index(index) => {
334                    let columns = column_list(driver, &index.columns)?;
335                    let name = index.name_for(table)?;
336                    if index.unique {
337                        parts.push(format!("CONSTRAINT {} UNIQUE ({columns})", q(&name)));
338                    } else {
339                        match dialect {
340                            Dialect::MySql => parts.push(format!("INDEX {} ({columns})", q(&name))),
341                            _ => after.push(format!(
342                                "CREATE INDEX {exists}{} ON {} ({columns})",
343                                q(&name),
344                                q(table)
345                            )),
346                        }
347                    }
348                }
349                Command::Primary { columns } => {
350                    parts.push(format!("PRIMARY KEY ({})", column_list(driver, columns)?));
351                }
352                Command::Foreign(_) => {}
353                Command::RenameColumn { .. } => {
354                    return Err(unsupported("renaming a column in create"));
355                }
356                Command::DropColumn { .. } => {
357                    return Err(unsupported("dropping a column in create"));
358                }
359                Command::DropIndex { .. } => {
360                    return Err(unsupported("dropping an index in create"));
361                }
362                Command::DropForeign { .. } => {
363                    return Err(unsupported("dropping a foreign key in create"));
364                }
365            }
366        }
367        // Foreign keys follow the indexes inside CREATE TABLE.
368        for command in &self.commands {
369            if let Command::Foreign(fk) = command {
370                parts.push(fk.render(driver, table)?);
371            }
372        }
373        let mut out = vec![format!(
374            "CREATE TABLE {exists}{} ({})",
375            q(table),
376            parts.join(", ")
377        )];
378        out.extend(after);
379        Ok(out)
380    }
381
382    /// Renders the changes to an existing table, one statement per operation, in call order as
383    /// Laravel 11+ does: an added or changed column renders where it was declared, among the
384    /// other commands. A foreign key and `drop_foreign` render nothing on SQLite.
385    pub(crate) fn render_alter(
386        &self,
387        driver: Driver,
388        dialect: Dialect,
389        table: &str,
390    ) -> Result<Vec<String>, Error> {
391        validate(table)?;
392        let unsupported = |operation| Error::Unsupported {
393            operation,
394            driver: driver.name(),
395        };
396        let q = |name: &str| driver.quote(name);
397        let alter = format!("ALTER TABLE {}", q(table));
398
399        let mut out = Vec::new();
400        for command in &self.commands {
401            match command {
402                Command::Column(i) => {
403                    let column = &self.columns[*i];
404                    if column.change {
405                        if dialect == Dialect::Sqlite {
406                            return Err(unsupported("changing a column"));
407                        }
408                        if column.ty == ColumnType::Id {
409                            return Err(unsupported("changing an id column"));
410                        }
411                        if column.primary {
412                            return Err(unsupported("changing a column into a primary key"));
413                        }
414                        out.push(match dialect {
415                            Dialect::Postgres => {
416                                format!("{alter} {}", column.render_change_postgres(driver)?)
417                            }
418                            _ => {
419                                format!("{alter} MODIFY COLUMN {}", column.render(driver, dialect)?)
420                            }
421                        });
422                    } else {
423                        if (column.ty == ColumnType::Id || column.primary)
424                            && dialect == Dialect::Sqlite
425                        {
426                            return Err(unsupported("adding a primary key column"));
427                        }
428                        out.push(format!(
429                            "{alter} ADD COLUMN {}",
430                            column.render(driver, dialect)?
431                        ));
432                    }
433                }
434                Command::Index(index) => {
435                    let columns = column_list(driver, &index.columns)?;
436                    let name = q(&index.name_for(table)?);
437                    out.push(match (dialect, index.unique) {
438                        (Dialect::MySql, false) => format!("{alter} ADD INDEX {name} ({columns})"),
439                        (Dialect::MySql, true) => format!("{alter} ADD UNIQUE {name} ({columns})"),
440                        (Dialect::Postgres, true) => {
441                            format!("{alter} ADD CONSTRAINT {name} UNIQUE ({columns})")
442                        }
443                        (Dialect::Sqlite, true) => {
444                            format!("CREATE UNIQUE INDEX {name} ON {} ({columns})", q(table))
445                        }
446                        (_, false) => format!("CREATE INDEX {name} ON {} ({columns})", q(table)),
447                    });
448                }
449                Command::Primary { columns } => {
450                    let columns = column_list(driver, columns)?;
451                    if dialect == Dialect::Sqlite {
452                        return Err(unsupported("adding a primary key"));
453                    }
454                    out.push(format!("{alter} ADD PRIMARY KEY ({columns})"));
455                }
456                Command::Foreign(fk) => {
457                    // Rendered on SQLite too, so a missing target or a bad name is still an error.
458                    let constraint = fk.render(driver, table)?;
459                    if dialect != Dialect::Sqlite {
460                        out.push(format!("{alter} ADD {constraint}"));
461                    }
462                }
463                Command::RenameColumn { from, to } => {
464                    validate(from)?;
465                    validate(to)?;
466                    out.push(format!("{alter} RENAME COLUMN {} TO {}", q(from), q(to)));
467                }
468                Command::DropColumn { name } => {
469                    validate(name)?;
470                    out.push(format!("{alter} DROP COLUMN {}", q(name)));
471                }
472                Command::DropIndex { name } => {
473                    validate(name)?;
474                    out.push(match dialect {
475                        Dialect::MySql => format!("{alter} DROP INDEX {}", q(name)),
476                        _ => format!("DROP INDEX {}", q(name)),
477                    });
478                }
479                Command::DropForeign { name } => {
480                    validate(name)?;
481                    match dialect {
482                        Dialect::MySql => out.push(format!("{alter} DROP FOREIGN KEY {}", q(name))),
483                        Dialect::Postgres => {
484                            out.push(format!("{alter} DROP CONSTRAINT {}", q(name)))
485                        }
486                        Dialect::Sqlite => {}
487                    }
488                }
489            }
490        }
491        Ok(out)
492    }
493}
494
495impl ForeignKey {
496    /// The `CONSTRAINT ... FOREIGN KEY ... REFERENCES ...` clause, with its custom name or the
497    /// default one for `table`.
498    fn render(&self, driver: Driver, table: &str) -> Result<String, Error> {
499        let q = |name: &str| driver.quote(name);
500        validate(&self.column)?;
501        let Some((ref_table, ref_column)) = &self.target else {
502            return Err(Error::Unsupported {
503                operation: "a foreign key without references()",
504                driver: driver.name(),
505            });
506        };
507        validate(ref_table)?;
508        validate(ref_column)?;
509        let name = match &self.name {
510            Some(name) => {
511                validate(name)?;
512                name.clone()
513            }
514            None => name_of(table, std::slice::from_ref(&self.column), "foreign"),
515        };
516        let mut sql = format!(
517            "CONSTRAINT {} FOREIGN KEY ({}) REFERENCES {} ({})",
518            q(&name),
519            q(&self.column),
520            q(ref_table),
521            q(ref_column)
522        );
523        if let Some(a) = self.on_delete {
524            sql.push_str(&format!(" ON DELETE {}", a.sql()));
525        }
526        if let Some(a) = self.on_update {
527            sql.push_str(&format!(" ON UPDATE {}", a.sql()));
528        }
529        Ok(sql)
530    }
531}
532
533/// The default name of an index or constraint: `{table}_{columns}_{suffix}`, as Laravel names it.
534fn name_of(table: &str, columns: &[String], suffix: &str) -> String {
535    format!("{table}_{}_{suffix}", columns.join("_"))
536}
537
538/// The quoted, comma-separated column list of an index, each name checked.
539fn column_list(driver: Driver, columns: &[String]) -> Result<String, Error> {
540    for c in columns {
541        validate(c)?;
542    }
543    Ok(columns
544        .iter()
545        .map(|c| driver.quote(c))
546        .collect::<Vec<_>>()
547        .join(", "))
548}
549
550fn collect<I, S>(columns: I) -> Vec<String>
551where
552    I: IntoIterator<Item = S>,
553    S: AsRef<str>,
554{
555    columns.into_iter().map(|c| c.as_ref().to_owned()).collect()
556}