rullst_orm/
schema_diff.rs1use 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 let snake = struct_name.to_lowercase();
67 format!("{}s", snake) });
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 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}