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