1use 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
16pub type ExistingColumns = BTreeMap<String, Vec<String>>;
18
19#[derive(Clone, Debug, PartialEq, Eq)]
21struct Column {
22 name: String,
23 sql: Option<String>,
26 rust: String,
27 optional: bool,
28 primary_key: bool,
29 auto_increment: bool,
30}
31
32#[derive(Clone, Debug, PartialEq, Eq)]
34struct ModelInfo {
35 type_name: String,
36 table: String,
37 file: String,
38 columns: Vec<Column>,
39}
40
41fn 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 ("Attachment", _) => "TEXT",
68 _ => return (None, optional),
69 };
70 (Some(if optional { ty.to_string() } else { format!("{ty} NOT NULL") }), optional)
71}
72
73fn 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
85fn 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
92fn 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
161fn 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 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
220pub 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
266pub(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;