Skip to main content

rainier_orm/
schema.rs

1//! Generic DDL: turn an [`Entity`]'s metadata into `CREATE TABLE` +
2//! `CREATE INDEX` statements, rendered for any [`Dialect`].
3//!
4//! The same metadata the derive produces for CRUD also describes the schema —
5//! columns, a primary key, single-column `UNIQUE`s, foreign keys (inline), and
6//! secondary indexes (separate statements). So callers can stand up the
7//! backing schema for a struct without hand-writing a migration — handy for
8//! tests, ephemeral D1 databases, and bootstrap. Production migrations can
9//! still be authored explicitly; this is the convenience path.
10//!
11//! Use [`schema_ddl`] to get every statement in dependency order (table first,
12//! then its indexes); [`create_table_ddl`] returns just the table.
13//!
14//! **Foreign keys & SQLite/D1.** FK clauses are emitted inline in the
15//! `CREATE TABLE` (the only form SQLite accepts). SQLite/D1 do not *enforce*
16//! them unless `PRAGMA foreign_keys = ON` is set on the connection — run that
17//! once after connecting if you want enforcement.
18
19use crate::{ColumnType, Dialect, Entity, RefAction};
20use sea_query::{
21    Alias, ColumnDef, ForeignKey, ForeignKeyAction, ForeignKeyCreateStatement, Index, Table,
22    TableCreateStatement,
23};
24
25/// The type a column is actually declared as.
26///
27/// Identical to the declared one except for an **auto-increment** key, where
28/// an unsigned type is downgraded to its signed equivalent. Three reasons, and
29/// the first is decisive:
30///
31/// 1. Postgres has no unsigned integers, and sea-query *panics* rather than
32///    rendering `BIGSERIAL` for a `BigUnsigned` marked auto-increment. A `u64`
33///    key would make the table impossible to create there at all.
34/// 2. A sequence is signed everywhere that has one, so the portable range of
35///    a generated key is a positive `i64` regardless of what the struct says.
36/// 3. `BIGINT` still holds 9.2 × 10^18 rows. The lost half of the `u64` range
37///    is not a range anyone reaches.
38///
39/// The entity's field stays `u64`; only the column is narrower. Decoding a
40/// positive `i64` into it is lossless.
41fn effective_type(col: &crate::column::ColumnSpec) -> ColumnType {
42    if !col.auto_increment {
43        return col.ty;
44    }
45
46    match col.ty {
47        ColumnType::BigUint => ColumnType::BigInt,
48        ColumnType::Uint => ColumnType::Int,
49        other => other,
50    }
51}
52
53/// Build the `CREATE TABLE` statement for `E` — columns, primary key,
54/// single-column uniques, and inline foreign keys. Secondary indexes are
55/// separate; see [`create_index_ddls`].
56pub fn create_table_stmt<E: Entity>() -> TableCreateStatement {
57    let mut stmt = Table::create();
58    stmt.table(Alias::new(E::table())).if_not_exists();
59
60    for col in E::columns() {
61        // "Keyed" = the column participates in any index/constraint: the primary
62        // key, a single-column unique, or any (single or composite) secondary
63        // index. MySQL can't index a `TEXT` column without a prefix length, so a
64        // keyed text column renders as `VARCHAR` instead (see `apply_type`).
65        let keyed =
66            col.pk || col.unique || E::indexes().iter().any(|ix| ix.columns.contains(&col.name));
67        let mut def = ColumnDef::new(Alias::new(col.name));
68        apply_type(&mut def, effective_type(col), keyed);
69
70        if col.pk {
71            def.primary_key();
72            if col.auto_increment {
73                def.auto_increment();
74            }
75        }
76        if col.nullable {
77            def.null();
78        } else {
79            def.not_null();
80        }
81        if col.unique {
82            def.unique_key();
83        }
84        stmt.col(&mut def);
85    }
86
87    for fk in E::foreign_keys() {
88        stmt.foreign_key(&mut foreign_key_stmt::<E>(fk));
89    }
90    stmt
91}
92
93/// Render `E`'s `CREATE TABLE` (without secondary indexes) as SQL for
94/// `dialect`.
95pub fn create_table_ddl<E: Entity>(dialect: Dialect) -> String {
96    dialect.build_schema(&create_table_stmt::<E>())
97}
98
99/// Render `E`'s secondary indexes as `CREATE [UNIQUE] INDEX` statements for
100/// `dialect`, one string each.
101pub fn create_index_ddls<E: Entity>(dialect: Dialect) -> Vec<String> {
102    E::indexes()
103        .iter()
104        .map(|ix| {
105            let mut stmt = Index::create();
106            stmt.name(ix.name).table(Alias::new(E::table())).if_not_exists();
107            if ix.unique {
108                stmt.unique();
109            }
110            for c in ix.columns {
111                stmt.col(Alias::new(*c));
112            }
113            dialect.build_schema(&stmt)
114        })
115        .collect()
116}
117
118/// Every DDL statement to create `E`'s schema, in dependency order: the table
119/// (with its inline uniques and foreign keys) first, then each secondary
120/// index. Execute them in order.
121pub fn schema_ddl<E: Entity>(dialect: Dialect) -> Vec<String> {
122    let mut out = Vec::with_capacity(1 + E::indexes().len());
123    out.push(create_table_ddl::<E>(dialect));
124    out.extend(create_index_ddls::<E>(dialect));
125    out
126}
127
128fn foreign_key_stmt<E: Entity>(fk: &crate::ForeignKeySpec) -> ForeignKeyCreateStatement {
129    let mut stmt = ForeignKey::create();
130    stmt.name(fk.name).from_tbl(Alias::new(E::table()));
131    for c in fk.columns {
132        stmt.from_col(Alias::new(*c));
133    }
134    stmt.to_tbl(Alias::new(fk.foreign_table));
135    for c in fk.foreign_columns {
136        stmt.to_col(Alias::new(*c));
137    }
138    if let Some(a) = fk.on_delete {
139        stmt.on_delete(map_action(a));
140    }
141    if let Some(a) = fk.on_update {
142        stmt.on_update(map_action(a));
143    }
144    stmt
145}
146
147fn map_action(a: RefAction) -> ForeignKeyAction {
148    match a {
149        RefAction::Cascade => ForeignKeyAction::Cascade,
150        RefAction::Restrict => ForeignKeyAction::Restrict,
151        RefAction::SetNull => ForeignKeyAction::SetNull,
152        RefAction::SetDefault => ForeignKeyAction::SetDefault,
153        RefAction::NoAction => ForeignKeyAction::NoAction,
154    }
155}
156
157/// Apply the column's SQL type to a sea-query [`ColumnDef`]. sea-query maps
158/// each of these to the per-dialect concrete type when rendered (e.g.
159/// `BigUnsigned` → `BIGINT UNSIGNED` on MySQL, `INTEGER` on SQLite).
160///
161/// `keyed` is whether the column participates in an index/constraint: a `Text`
162/// column that does is rendered as `VARCHAR(255)` instead of `TEXT`, because
163/// MySQL rejects indexing a `BLOB`/`TEXT` column without a prefix length. On
164/// SQLite/D1 a `VARCHAR(255)` is just `TEXT` affinity, so nothing is lost there.
165///
166/// `pub(crate)` so the incremental DDL builder ([`crate::ddl`]) renders an
167/// added column's type identically to how `CREATE TABLE` rendered it.
168pub fn apply_type(def: &mut ColumnDef, ty: ColumnType, keyed: bool) {
169    /// Longest VARCHAR that stays under InnoDB's index key-prefix limit at
170    /// utf8mb4 (255 × 4 = 1020 B < 3072 B), so a keyed string is always indexable.
171    const KEYED_TEXT_LEN: u32 = 255;
172    match ty {
173        ColumnType::Bool => def.boolean(),
174        ColumnType::Int => def.integer(),
175        ColumnType::BigInt => def.big_integer(),
176        ColumnType::Uint => def.unsigned(),
177        ColumnType::BigUint => def.big_unsigned(),
178        ColumnType::Double => def.double(),
179        ColumnType::Text if keyed => def.string_len(KEYED_TEXT_LEN),
180        ColumnType::Text => def.text(),
181        ColumnType::Binary => def.blob(),
182        ColumnType::Timestamp => def.timestamp(),
183        ColumnType::Date => def.date(),
184    };
185}
186
187#[cfg(test)]
188mod auto_increment_tests {
189    use super::*;
190
191    #[derive(crate::Entity, Clone)]
192    #[orm(table = "widgets")]
193    struct Widget {
194        #[orm(pk, auto_increment)]
195        id: u64,
196        name: String,
197    }
198
199    #[derive(crate::Entity, Clone)]
200    #[orm(table = "counters")]
201    struct Counter {
202        #[orm(pk)]
203        id: u64,
204        hits: u64,
205    }
206
207    #[test]
208    fn an_auto_increment_u64_key_renders_on_every_dialect() {
209        // sea-query panics on `BigUnsigned` + auto-increment for Postgres, so
210        // before the downgrade this table simply could not be created there.
211        for dialect in [Dialect::Sqlite, Dialect::MySql, Dialect::Postgres] {
212            let ddl = create_table_ddl::<Widget>(dialect);
213            assert!(ddl.contains("widgets"), "{dialect:?}: {ddl}");
214        }
215    }
216
217    #[test]
218    fn postgres_gets_a_sequence_backed_key() {
219        let ddl = create_table_ddl::<Widget>(Dialect::Postgres);
220        assert!(ddl.contains("serial"), "{ddl}");
221    }
222
223    #[test]
224    fn a_non_generated_unsigned_column_keeps_its_type() {
225        // The downgrade is scoped to auto-increment. An ordinary `u64` column
226        // is still unsigned where the backend has unsigned types.
227        let ddl = create_table_ddl::<Counter>(Dialect::MySql);
228        assert!(ddl.to_lowercase().contains("unsigned"), "{ddl}");
229    }
230}