Skip to main content

orbit_orm/schema/
column.rs

1use chrono::{NaiveDate, NaiveDateTime};
2
3use super::Dialect;
4use crate::{Driver, Error, Value};
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub(crate) enum ColumnType {
8    Id,
9    String(u32),
10    Text,
11    /// A `MEDIUMTEXT` on MySQL and MariaDB, `TEXT` elsewhere.
12    MediumText,
13    /// A `LONGTEXT` on MySQL and MariaDB, `TEXT` elsewhere.
14    LongText,
15    Integer,
16    BigInteger,
17    Boolean,
18    /// A date and time without a time zone, with its fractional-second precision.
19    DateTime(u32),
20    /// A `TIMESTAMP`, with its fractional-second precision.
21    Timestamp(u32),
22    Binary,
23}
24
25/// A column of a table being created or changed. Chain `nullable`, `default`, `use_current`,
26/// `length`, `precision`, `primary` and `change` on it.
27#[derive(Debug, Clone)]
28pub struct Column {
29    pub(crate) name: String,
30    pub(crate) ty: ColumnType,
31    nullable: bool,
32    default: Option<Value>,
33    use_current: bool,
34    pub(crate) primary: bool,
35    pub(crate) change: bool,
36}
37
38impl Column {
39    pub(crate) fn new(name: &str, ty: ColumnType) -> Self {
40        Self {
41            name: name.to_owned(),
42            ty,
43            nullable: false,
44            default: None,
45            use_current: false,
46            primary: false,
47            change: false,
48        }
49    }
50
51    /// Lets the column hold `NULL`. Columns are `NOT NULL` otherwise, as in Laravel.
52    pub fn nullable(&mut self) -> &mut Self {
53        self.nullable = true;
54        self
55    }
56
57    /// Sets the default value, written into the SQL as a literal.
58    pub fn default(&mut self, value: impl Into<Value>) -> &mut Self {
59        self.default = Some(value.into());
60        self
61    }
62
63    /// Sets the length of a `string` column. It has no effect on other types.
64    pub fn length(&mut self, length: u32) -> &mut Self {
65        if let ColumnType::String(n) = &mut self.ty {
66            *n = length;
67        }
68        self
69    }
70
71    /// Sets the fractional-second precision of a `date_time` or `timestamp` column, as the
72    /// `$precision` argument of Laravel's `dateTime` and `timestamp` (default 0): `DATETIME(p)`
73    /// or `TIMESTAMP(p)` on MySQL, `TIMESTAMP(p) WITHOUT TIME ZONE` on PostgreSQL. SQLite keeps
74    /// `DATETIME`. It has no effect on other types.
75    ///
76    /// The valid range is 0 to 6. Orbit does not check it: MySQL and MariaDB reject a larger
77    /// value, and PostgreSQL lowers it to 6 with a warning.
78    pub fn precision(&mut self, precision: u32) -> &mut Self {
79        if let ColumnType::DateTime(p) | ColumnType::Timestamp(p) = &mut self.ty {
80            *p = precision;
81        }
82        self
83    }
84
85    /// Makes the current time the default of a `date_time` or `timestamp` column, as Laravel's
86    /// `->useCurrent()`: `DEFAULT CURRENT_TIMESTAMP`, or `CURRENT_TIMESTAMP(p)` on MySQL with a
87    /// precision p above 0. It takes the place of any `default` value. As in Laravel, it has no
88    /// effect on other types.
89    pub fn use_current(&mut self) -> &mut Self {
90        self.use_current = true;
91        self
92    }
93
94    /// Makes this column the table's primary key, as Laravel's `->primary()`: `PRIMARY KEY` at
95    /// the end of the column definition, for a key that is not an auto-incrementing `id`, such as
96    /// `t.string("id").length(64).primary()`. For a key over several columns, use
97    /// [`Blueprint::primary`](super::Blueprint::primary). In [`schema::table`](super::table)
98    /// SQLite cannot add it, and no database can take it with `.change()`: both are
99    /// [`Error::Unsupported`].
100    pub fn primary(&mut self) -> &mut Self {
101        self.primary = true;
102        self
103    }
104
105    /// Modifies this existing column instead of adding it, as Laravel's `->change()`. It only
106    /// works inside [`schema::table`](super::table): the column takes the type, nullability and
107    /// default described here. SQLite cannot change a column in place, so rendering it there is
108    /// [`Error::Unsupported`]; so is changing an `id` column, or a column marked
109    /// [`primary`](Self::primary), on every database.
110    pub fn change(&mut self) -> &mut Self {
111        self.change = true;
112        self
113    }
114
115    /// The column's SQL: its quoted name, type, nullability, default and `PRIMARY KEY`.
116    pub(crate) fn render(&self, driver: Driver, dialect: Dialect) -> Result<String, Error> {
117        crate::query::validate(&self.name)?;
118        let name = driver.quote(&self.name);
119        if self.ty == ColumnType::Id {
120            let id = match dialect {
121                Dialect::MySql => "BIGINT AUTO_INCREMENT PRIMARY KEY",
122                // BIGSERIAL, as Laravel's PostgreSQL grammar writes `bigIncrements`.
123                Dialect::Postgres => "BIGSERIAL PRIMARY KEY",
124                Dialect::Sqlite => "INTEGER PRIMARY KEY AUTOINCREMENT",
125            };
126            return Ok(format!("{name} {id}"));
127        }
128        let mut sql = format!("{name} {}", self.sql_type(dialect));
129        sql.push_str(if self.nullable { " NULL" } else { " NOT NULL" });
130        if let Some(default) = self.default_sql(driver, dialect)? {
131            sql.push_str(" DEFAULT ");
132            sql.push_str(&default);
133        }
134        if self.primary {
135            sql.push_str(" PRIMARY KEY");
136        }
137        Ok(sql)
138    }
139
140    /// The PostgreSQL clauses that change this column to its description: its type, then its
141    /// nullability, then its default, comma-separated for one `ALTER TABLE`.
142    pub(crate) fn render_change_postgres(&self, driver: Driver) -> Result<String, Error> {
143        crate::query::validate(&self.name)?;
144        let name = driver.quote(&self.name);
145        let ty = self.sql_type(Dialect::Postgres);
146        let null = if self.nullable {
147            "DROP NOT NULL"
148        } else {
149            "SET NOT NULL"
150        };
151        let default = match self.default_sql(driver, Dialect::Postgres)? {
152            Some(default) => format!("SET DEFAULT {default}"),
153            None => "DROP DEFAULT".to_owned(),
154        };
155        Ok(format!(
156            "ALTER COLUMN {name} TYPE {ty}, ALTER COLUMN {name} {null}, \
157             ALTER COLUMN {name} {default}"
158        ))
159    }
160
161    /// The SQL of the column's default, if it has one: the current time for `use_current` on a
162    /// date and time column, or else the literal `default` value.
163    fn default_sql(&self, driver: Driver, dialect: Dialect) -> Result<Option<String>, Error> {
164        if self.use_current {
165            match (self.ty, dialect) {
166                (ColumnType::DateTime(p) | ColumnType::Timestamp(p), Dialect::MySql) if p > 0 => {
167                    return Ok(Some(format!("CURRENT_TIMESTAMP({p})")));
168                }
169                (ColumnType::DateTime(_) | ColumnType::Timestamp(_), _) => {
170                    return Ok(Some("CURRENT_TIMESTAMP".to_owned()));
171                }
172                _ => {}
173            }
174        }
175        self.default
176            .as_ref()
177            .map(|value| literal(value, driver, dialect))
178            .transpose()
179    }
180
181    fn sql_type(&self, dialect: Dialect) -> String {
182        match (self.ty, dialect) {
183            (ColumnType::Id, _) => unreachable!("an id column is rendered whole"),
184            (ColumnType::String(_), Dialect::Sqlite) => "TEXT".to_owned(),
185            (ColumnType::String(n), _) => format!("VARCHAR({n})"),
186            (ColumnType::Text, _) => "TEXT".to_owned(),
187            (ColumnType::MediumText, Dialect::MySql) => "MEDIUMTEXT".to_owned(),
188            (ColumnType::LongText, Dialect::MySql) => "LONGTEXT".to_owned(),
189            (ColumnType::MediumText | ColumnType::LongText, _) => "TEXT".to_owned(),
190            (ColumnType::Integer, _) => "INTEGER".to_owned(),
191            (ColumnType::BigInteger, Dialect::Sqlite) => "INTEGER".to_owned(),
192            (ColumnType::BigInteger, _) => "BIGINT".to_owned(),
193            (ColumnType::Boolean, Dialect::Postgres) => "BOOLEAN".to_owned(),
194            (ColumnType::Boolean, _) => "TINYINT(1)".to_owned(),
195            (ColumnType::DateTime(p) | ColumnType::Timestamp(p), Dialect::Postgres) => {
196                format!("TIMESTAMP({p}) WITHOUT TIME ZONE")
197            }
198            (ColumnType::DateTime(_) | ColumnType::Timestamp(_), Dialect::Sqlite) => {
199                "DATETIME".to_owned()
200            }
201            (ColumnType::DateTime(0), _) => "DATETIME".to_owned(),
202            (ColumnType::DateTime(p), _) => format!("DATETIME({p})"),
203            (ColumnType::Timestamp(0), _) => "TIMESTAMP".to_owned(),
204            (ColumnType::Timestamp(p), _) => format!("TIMESTAMP({p})"),
205            (ColumnType::Binary, Dialect::Postgres) => "BYTEA".to_owned(),
206            (ColumnType::Binary, _) => "BLOB".to_owned(),
207        }
208    }
209}
210
211/// A default value as a SQL literal. DDL cannot take bound values.
212fn literal(value: &Value, driver: Driver, dialect: Dialect) -> Result<String, Error> {
213    let unsupported = || Error::Unsupported {
214        operation: "a column default of this type",
215        driver: driver.name(),
216    };
217    Ok(match value {
218        Value::Null => "NULL".to_owned(),
219        Value::Bool(b) => match (dialect, b) {
220            (Dialect::Postgres, true) => "true".to_owned(),
221            (Dialect::Postgres, false) => "false".to_owned(),
222            (_, true) => "1".to_owned(),
223            (_, false) => "0".to_owned(),
224        },
225        Value::Int(n) => n.to_string(),
226        Value::UInt(n) => n.to_string(),
227        Value::Float(f) if f.is_finite() => format!("{f:?}"),
228        Value::Float(_) => return Err(unsupported()),
229        Value::Text(s) => quote_text(s, dialect),
230        Value::DateTime(d) => quote_text(&format_datetime(d), dialect),
231        Value::Date(d) => quote_text(&format_date(d), dialect),
232        Value::Bytes(_) => return Err(unsupported()),
233    })
234}
235
236fn format_datetime(d: &NaiveDateTime) -> String {
237    d.format("%Y-%m-%d %H:%M:%S").to_string()
238}
239
240fn format_date(d: &NaiveDate) -> String {
241    d.format("%Y-%m-%d").to_string()
242}
243
244/// A quoted string literal. A quote is doubled everywhere; MySQL also reads a backslash as an
245/// escape, so it is doubled there.
246fn quote_text(s: &str, dialect: Dialect) -> String {
247    let mut out = s.replace('\'', "''");
248    if dialect == Dialect::MySql {
249        out = out.replace('\\', "\\\\");
250    }
251    format!("'{out}'")
252}