Skip to main content

rullst_orm/
schema_diff.rs

1use std::fs;
2use std::path::Path;
3use walkdir::WalkDir;
4
5#[derive(Debug)]
6pub struct ParsedField {
7    pub name: String,
8    pub rust_type: String,
9    pub is_option: bool,
10}
11
12#[derive(Debug)]
13pub struct ParsedTable {
14    pub table_name: String,
15    pub struct_name: String,
16    pub fields: Vec<ParsedField>,
17}
18
19#[cfg_attr(mutants, mutants::skip)]
20pub fn extract_tables_from_ast() -> Vec<ParsedTable> {
21    extract_tables_from_root("src")
22}
23
24fn extract_tables_from_root(root: impl AsRef<Path>) -> Vec<ParsedTable> {
25    let mut tables = Vec::new();
26    let walker = WalkDir::new(root).into_iter().filter_map(|e| e.ok());
27
28    for entry in walker {
29        if entry.path().extension().is_some_and(|ext| ext == "rs") {
30            let content = match fs::read_to_string(entry.path()) {
31                Ok(c) => c,
32                Err(_) => continue,
33            };
34            if let Ok(ast) = syn::parse_file(&content) {
35                for item in ast.items {
36                    if let syn::Item::Struct(item_struct) = item {
37                        let mut has_orm_derive = false;
38                        let mut table_name = None;
39
40                        for attr in &item_struct.attrs {
41                            if attr.path().is_ident("derive") {
42                                let _ = attr.parse_nested_meta(|meta| {
43                                    if meta.path.is_ident("Orm") {
44                                        has_orm_derive = true;
45                                    }
46                                    Ok(())
47                                });
48                            }
49                            if attr.path().is_ident("orm") {
50                                let _ = attr.parse_nested_meta(|meta| {
51                                    if meta.path.is_ident("table")
52                                        && let Ok(value) = meta.value()
53                                        && let Ok(lit) = value.parse::<syn::LitStr>()
54                                    {
55                                        table_name = Some(lit.value());
56                                    }
57                                    Ok(())
58                                });
59                            }
60                        }
61
62                        if has_orm_derive {
63                            let struct_name = item_struct.ident.to_string();
64                            let t_name = table_name.unwrap_or_else(|| {
65                                // Default table name: snake_case and pluralized (simple logic)
66                                let snake = struct_name.to_lowercase();
67                                format!("{}s", snake) // VERY rudimentary default
68                            });
69
70                            let mut fields = Vec::new();
71                            if let syn::Fields::Named(named_fields) = item_struct.fields {
72                                for field in named_fields.named {
73                                    if let Some(ident) = field.ident {
74                                        let field_name = ident.to_string();
75                                        let (rust_type, is_option) = extract_type_name(&field.ty);
76
77                                        // Skip fields with #[sqlx(skip)] or #[orm(skip)]
78                                        let mut skip = false;
79                                        for f_attr in &field.attrs {
80                                            if f_attr.path().is_ident("sqlx")
81                                                || f_attr.path().is_ident("orm")
82                                            {
83                                                let _ = f_attr.parse_nested_meta(|meta| {
84                                                    if meta.path.is_ident("skip") {
85                                                        skip = true;
86                                                    }
87                                                    Ok(())
88                                                });
89                                            }
90                                        }
91                                        if skip {
92                                            continue;
93                                        }
94
95                                        fields.push(ParsedField {
96                                            name: field_name,
97                                            rust_type,
98                                            is_option,
99                                        });
100                                    }
101                                }
102                            }
103
104                            tables.push(ParsedTable {
105                                table_name: t_name,
106                                struct_name,
107                                fields,
108                            });
109                        }
110                    }
111                }
112            }
113        }
114    }
115    tables
116}
117
118#[cfg_attr(mutants, mutants::skip)]
119fn extract_type_name(ty: &syn::Type) -> (String, bool) {
120    if let syn::Type::Path(type_path) = ty
121        && let Some(segment) = type_path.path.segments.last()
122    {
123        let type_name = segment.ident.to_string();
124        if type_name == "Option"
125            && let syn::PathArguments::AngleBracketed(args) = &segment.arguments
126            && let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first()
127        {
128            let (inner_name, _) = extract_type_name(inner_ty);
129            return (inner_name, true);
130        }
131        return (type_name, false);
132    }
133    ("Unknown".to_string(), false)
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    #[test]
141    fn test_extract_type_name() {
142        let ty_i32: syn::Type = syn::parse_str("i32").unwrap();
143        let (name, is_opt) = extract_type_name(&ty_i32);
144        assert_eq!(name, "i32");
145        assert!(!is_opt);
146
147        let ty_opt_string: syn::Type = syn::parse_str("Option<String>").unwrap();
148        let (name, is_opt) = extract_type_name(&ty_opt_string);
149        assert_eq!(name, "String");
150        assert!(is_opt);
151
152        let ty_opt_i64: syn::Type = syn::parse_str("Option<i64>").unwrap();
153        let (name, is_opt) = extract_type_name(&ty_opt_i64);
154        assert_eq!(name, "i64");
155        assert!(is_opt);
156
157        let ty_unknown: syn::Type = syn::parse_str("&[u8]").unwrap();
158        let (name, is_opt) = extract_type_name(&ty_unknown);
159        assert_eq!(name, "Unknown");
160        assert!(!is_opt);
161    }
162
163    #[test]
164    fn test_extract_tables_from_ast_call() {
165        let _ = extract_tables_from_ast();
166    }
167
168    #[test]
169    fn extracts_explicit_default_optional_and_skipped_model_fields() {
170        let directory =
171            std::env::temp_dir().join(format!("rullst-schema-diff-{}", uuid::Uuid::new_v4()));
172        std::fs::create_dir(&directory).unwrap();
173        std::fs::write(
174            directory.join("models.rs"),
175            r#"
176                #[derive(Debug, Orm)]
177                #[orm(table = "people")]
178                struct Person {
179                    id: i64,
180                    name: Option<String>,
181                    #[orm(skip)]
182                    transient: String,
183                    #[sqlx(skip)]
184                    computed: i32,
185                }
186
187                #[derive(Orm)]
188                struct BlogPost {
189                    id: u64,
190                    payload: Vec<u8>,
191                }
192
193                #[derive(Debug)]
194                struct NotAModel { id: i64 }
195            "#,
196        )
197        .unwrap();
198        std::fs::write(directory.join("malformed.rs"), "struct {").unwrap();
199        std::fs::write(
200            directory.join("ignored.txt"),
201            "#[derive(Orm)] struct Hidden;",
202        )
203        .unwrap();
204
205        let mut tables = extract_tables_from_root(&directory);
206        tables.sort_by(|left, right| left.table_name.cmp(&right.table_name));
207        assert_eq!(tables.len(), 2);
208        assert_eq!(tables[0].table_name, "blogposts");
209        assert_eq!(tables[0].struct_name, "BlogPost");
210        assert_eq!(tables[0].fields.len(), 2);
211        assert_eq!(tables[0].fields[1].rust_type, "Vec");
212        assert!(!tables[0].fields[1].is_option);
213
214        assert_eq!(tables[1].table_name, "people");
215        assert_eq!(tables[1].fields.len(), 2);
216        assert_eq!(tables[1].fields[1].name, "name");
217        assert_eq!(tables[1].fields[1].rust_type, "String");
218        assert!(tables[1].fields[1].is_option);
219
220        std::fs::remove_dir_all(&directory).unwrap();
221    }
222}