Skip to main content

rws_proxy/generator/
from_models.rs

1//! `rws gen migration <name> --from-models` (18.35.0): the migration that
2//! brings a database up to the project's `#[derive(Model)]` structs —
3//! `CREATE TABLE` for a model whose table isn't there, `ALTER TABLE … ADD
4//! COLUMN` for a field whose column isn't. Additive only: nothing is ever
5//! dropped or changed; columns the models no longer have, and fields whose
6//! type it can't map, are listed in the plan's notes for a person to write.
7
8use std::collections::BTreeMap;
9use std::path::Path;
10
11use super::names::check_snake;
12use super::{join, migration_paths, Builder, Db, Disk, Files, GenError, Plan};
13
14const VERSION: &str = env!("CARGO_PKG_VERSION");
15
16/// The database as `--from-models` sees it: each table's column names.
17pub type ExistingColumns = BTreeMap<String, Vec<String>>;
18
19/// A model field as its column.
20#[derive(Clone, Debug, PartialEq, Eq)]
21struct Column {
22    name: String,
23    /// The SQL type with `NOT NULL` when required; `None`: a type this
24    /// doesn't map.
25    sql: Option<String>,
26    rust: String,
27    optional: bool,
28    primary_key: bool,
29    auto_increment: bool,
30}
31
32/// A `#[derive(Model)]` struct as found in a file.
33#[derive(Clone, Debug, PartialEq, Eq)]
34struct ModelInfo {
35    type_name: String,
36    table: String,
37    file: String,
38    columns: Vec<Column>,
39}
40
41/// The SQL type of a Rust field type on `db`, `NOT NULL` unless optional.
42fn sql_for(rust: &str, db: Db) -> (Option<String>, bool) {
43    let rust = rust.trim();
44    let (inner, optional) = match rust.strip_prefix("Option<").and_then(|r| r.strip_suffix('>')) {
45        Some(inner) => (inner.trim(), true),
46        None => (rust, false),
47    };
48    let last = inner.rsplit("::").next().unwrap_or(inner);
49    let ty = match (last, db) {
50        ("i64" | "u64", Db::Sqlite) | ("i32" | "u32" | "i16", Db::Sqlite) => "INTEGER",
51        ("i64" | "u64", _) => "BIGINT",
52        ("i32" | "u32", Db::Postgres) => "INTEGER",
53        ("i32" | "u32", Db::Mysql) => "INT",
54        ("i16", _) => "SMALLINT",
55        ("f64" | "f32", Db::Sqlite) => "REAL",
56        ("f64" | "f32", Db::Postgres) => "DOUBLE PRECISION",
57        ("f64" | "f32", Db::Mysql) => "DOUBLE",
58        ("bool", Db::Sqlite) => "INTEGER",
59        ("bool", _) => "BOOLEAN",
60        ("String", _) => "TEXT",
61        ("Timestamp", Db::Sqlite) => "TEXT",
62        ("Timestamp", Db::Postgres) => "TIMESTAMPTZ",
63        ("Timestamp", Db::Mysql) => "DATETIME(6)",
64        ("Uuid", Db::Postgres) => "UUID",
65        ("Uuid", _) => "CHAR(36)",
66        // A file in storage: one text column (`rust_web_server::attachment`).
67        ("Attachment", _) => "TEXT",
68        _ => return (None, optional),
69    };
70    (Some(if optional { ty.to_string() } else { format!("{ty} NOT NULL") }), optional)
71}
72
73/// A literal to fill a new `NOT NULL` column of existing rows with.
74fn default_for(rust: &str, db: Db) -> Option<&'static str> {
75    let last = rust.trim().rsplit("::").next().unwrap_or(rust);
76    Some(match last {
77        "i64" | "u64" | "i32" | "u32" | "i16" | "f64" | "f32" => "0",
78        "bool" if db == Db::Sqlite => "0",
79        "bool" => "FALSE",
80        "String" | "Attachment" => "''",
81        _ => return None,
82    })
83}
84
85/// The value of `name = "…"` inside an attribute line.
86fn attr_value(line: &str, name: &str) -> Option<String> {
87    let at = line.find(name)?;
88    let rest = line[at + name.len()..].trim_start().strip_prefix('=')?.trim_start().strip_prefix('"')?;
89    Some(rest[..rest.find('"')?].to_string())
90}
91
92/// Every `#[derive(… Model …)]` struct in `text`. Line based, one pass.
93fn models_in(text: &str, file: &str, db: Db) -> Vec<ModelInfo> {
94    let mut out = Vec::new();
95    let mut attrs: Vec<&str> = Vec::new();
96    let mut lines = text.lines();
97    while let Some(line) = lines.next() {
98        let t = line.trim();
99        if t.starts_with("#[") {
100            attrs.push(t);
101            continue;
102        }
103        if t.is_empty() || t.starts_with("//") {
104            continue;
105        }
106        let is_model = attrs.iter().any(|a| a.starts_with("#[derive(") && a.split(|c: char| !c.is_alphanumeric() && c != '_').any(|w| w == "Model"));
107        let header = t.strip_prefix("pub ").unwrap_or(t).strip_prefix("struct ").map(|r| r.trim_end_matches('{').trim());
108        match header {
109            Some(name) if is_model && t.ends_with('{') => {
110                let table = attrs.iter().find(|a| a.starts_with("#[table(")).and_then(|a| attr_value(a, "name")).unwrap_or_else(|| name.to_lowercase());
111                let mut columns = Vec::new();
112                let mut field_attrs: Vec<&str> = Vec::new();
113                for field in lines.by_ref() {
114                    let f = field.trim();
115                    if f == "}" {
116                        break;
117                    }
118                    if f.starts_with("#[") {
119                        field_attrs.push(f);
120                        continue;
121                    }
122                    if f.is_empty() || f.starts_with("//") {
123                        continue;
124                    }
125                    let decl = f.strip_prefix("pub(crate) ").or_else(|| f.strip_prefix("pub ")).unwrap_or(f).trim_end_matches(',');
126                    if let Some((fname, ty)) = decl.split_once(':') {
127                        let ignored = field_attrs.iter().any(|a| a.starts_with("#[ignore"));
128                        if !ignored {
129                            let column = field_attrs.iter().find(|a| a.starts_with("#[column(")).and_then(|a| attr_value(a, "name")).unwrap_or_else(|| fname.trim().to_string());
130                            let pk = field_attrs.iter().find(|a| a.starts_with("#[primary_key"));
131                            let (sql, optional) = sql_for(ty, db);
132                            columns.push(Column {
133                                name: column,
134                                sql,
135                                rust: ty.trim().to_string(),
136                                optional,
137                                primary_key: pk.is_some(),
138                                auto_increment: pk.is_some_and(|a| a.contains("auto_increment")),
139                            });
140                        }
141                    }
142                    field_attrs.clear();
143                }
144                out.push(ModelInfo { type_name: name.to_string(), table, file: file.to_string(), columns });
145            }
146            _ => {}
147        }
148        attrs.clear();
149    }
150    out
151}
152
153fn auto_increment_column(db: Db) -> &'static str {
154    match db {
155        Db::Sqlite => "INTEGER PRIMARY KEY AUTOINCREMENT",
156        Db::Postgres => "BIGSERIAL PRIMARY KEY",
157        Db::Mysql => "BIGINT AUTO_INCREMENT PRIMARY KEY",
158    }
159}
160
161/// What the database lacks, as up and down SQL, and the notes.
162fn diff(models: &[ModelInfo], existing: &ExistingColumns, db: Db) -> (Vec<String>, Vec<String>, Vec<String>) {
163    let (mut up, mut down, mut notes) = (Vec::new(), Vec::new(), Vec::new());
164    let lower: BTreeMap<String, Vec<String>> = existing.iter().map(|(t, cs)| (t.to_lowercase(), cs.iter().map(|c| c.to_lowercase()).collect())).collect();
165    for model in models {
166        let unmapped: Vec<&Column> = model.columns.iter().filter(|c| c.sql.is_none()).collect();
167        match lower.get(&model.table.to_lowercase()) {
168            None => {
169                let mut lines = Vec::new();
170                for c in &model.columns {
171                    let Some(sql) = &c.sql else { continue };
172                    let ty = if c.auto_increment {
173                        auto_increment_column(db).to_string()
174                    } else if c.primary_key {
175                        format!("{sql} PRIMARY KEY")
176                    } else {
177                        sql.clone()
178                    };
179                    lines.push(format!("    {} {ty}", c.name));
180                }
181                up.push(format!("-- {} ({})\nCREATE TABLE {} (\n{}\n);", model.type_name, model.file, model.table, lines.join(",\n")));
182                down.push(format!("DROP TABLE {};", model.table));
183            }
184            Some(columns) => {
185                for c in &model.columns {
186                    if columns.contains(&c.name.to_lowercase()) {
187                        continue;
188                    }
189                    let Some(sql) = &c.sql else { continue };
190                    if c.primary_key {
191                        notes.push(format!("{}.{}: the table exists without its primary key column; change the key by hand", model.table, c.name));
192                        continue;
193                    }
194                    // Existing rows need a value for a NOT NULL column.
195                    let sql = match (c.optional, default_for(&c.rust, db)) {
196                        (false, Some(value)) => format!("{sql} DEFAULT {value}"),
197                        (false, None) => {
198                            notes.push(format!("{}.{} is NOT NULL with no default: fill existing rows by hand, or make the field an Option", model.table, c.name));
199                            sql.clone()
200                        }
201                        (true, _) => sql.clone(),
202                    };
203                    up.push(format!("ALTER TABLE {} ADD COLUMN {} {sql};", model.table, c.name));
204                    down.push(format!("ALTER TABLE {} DROP COLUMN {};", model.table, c.name));
205                }
206                let model_columns: Vec<String> = model.columns.iter().map(|c| c.name.to_lowercase()).collect();
207                for extra in columns.iter().filter(|c| !model_columns.contains(c)) {
208                    notes.push(format!("{}.{} is in the database but not in {}: drop it by hand if it's gone (never generated)", model.table, extra, model.type_name));
209                }
210            }
211        }
212        for c in unmapped {
213            notes.push(format!("{}.{}: no SQL type for `{}`: add the column by hand", model.table, c.name, c.rust));
214        }
215    }
216    down.reverse();
217    (up, down, notes)
218}
219
220/// The migration `name` that adds what the models in the project at `dir`
221/// have and `existing` (the database's tables and columns) lacks. Reads
222/// only; a plan without changes and a note when the database already
223/// matches. `db: None` takes the database from the `model-*` feature in
224/// `Cargo.toml`, else SQLite.
225pub fn plan_model_migration(dir: impl AsRef<Path>, name: &str, existing: &ExistingColumns, db: Option<Db>) -> Result<Plan, GenError> {
226    let dir = dir.as_ref();
227    if !dir.join("Cargo.toml").is_file() {
228        return Err(GenError::refused(format!("{} has no Cargo.toml: run rws gen in the project's root (or pass --dir)", dir.display())));
229    }
230    plan_with(&Disk(dir.to_path_buf()), name, existing, db)
231}
232
233pub(crate) fn plan_with(files: &dyn Files, name: &str, existing: &ExistingColumns, db: Option<Db>) -> Result<Plan, GenError> {
234    check_snake(name, "migration")?;
235    let manifest = files.manifest()?;
236    let db = super::choose_db(&super::cargo(files)?, db)?;
237    let command = format!("rws gen migration {name} --from-models");
238    let mut b = Builder { files, changes: Vec::new(), notes: Vec::new() };
239    let models_dir = manifest.paths.models.clone();
240    let mut models = Vec::new();
241    let mut names = files.list(&models_dir);
242    names.sort();
243    for file in names.iter().filter(|n| n.ends_with(".rs")) {
244        let path = join(&models_dir, file);
245        if let Some(text) = files.read(&path) {
246            models.extend(models_in(&text, &path, db));
247        }
248    }
249    if models.is_empty() {
250        return Err(GenError::refused(format!("no #[derive(Model)] struct in {models_dir}")));
251    }
252    let (up, down, notes) = diff(&models, existing, db);
253    b.notes.extend(notes);
254    if up.is_empty() {
255        b.note("the database already has every model's table and columns: no migration");
256        return Ok(Plan { command, changes: b.changes, notes: b.notes });
257    }
258    let dir = manifest.paths.migrations.clone();
259    let (up_path, down_path, stem) = migration_paths(files, &dir, name);
260    let header = format!("-- {stem}: generated by `{command}` (rws {VERSION}) from the models\n-- and the database it saw ({}). Additive only: review before applying.\n", db.as_str());
261    b.create(&up_path, format!("{header}\n{}\n", up.join("\n\n")));
262    b.create(&down_path, format!("-- Undoes {stem}.\n{}\n", down.join("\n")));
263    Ok(Plan { command, changes: b.changes, notes: b.notes })
264}
265
266/// The model scanner on arbitrary text (the generator's fuzz target).
267pub(crate) fn fuzz_models(input: &str) {
268    let _ = models_in(input, "fuzz.rs", Db::Postgres);
269    let _ = attr_value(input, "name");
270}
271
272#[cfg(test)]
273mod tests;