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}