doido_generators/generators/
field.rs1use crate::generators::to_snake;
14use doido_core::anyhow::{anyhow, bail};
15use doido_core::Result;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum ColumnType {
20 String,
21 Text,
22 Integer,
23 BigInteger,
24 Float,
25 Double,
26 Decimal,
27 Boolean,
28 Timestamp,
29 Date,
30 Json,
31 Uuid,
32 Binary,
33 References,
34}
35
36impl ColumnType {
37 fn parse(token: &str) -> Result<Self> {
39 Ok(match token.to_lowercase().as_str() {
40 "string" => Self::String,
41 "text" => Self::Text,
42 "integer" | "int" => Self::Integer,
43 "bigint" | "biginteger" | "big_integer" | "long" => Self::BigInteger,
44 "float" => Self::Float,
45 "double" => Self::Double,
46 "decimal" | "numeric" => Self::Decimal,
47 "boolean" | "bool" => Self::Boolean,
48 "timestamp" | "datetime" => Self::Timestamp,
49 "date" => Self::Date,
50 "json" | "jsonb" => Self::Json,
51 "uuid" => Self::Uuid,
52 "binary" | "blob" | "bytes" => Self::Binary,
53 "references" | "reference" | "belongs_to" => Self::References,
54 other => bail!("unknown column type `{other}`"),
55 })
56 }
57
58 fn builder_method(self) -> &'static str {
60 match self {
61 Self::String => "string",
62 Self::Text => "text",
63 Self::Integer => "integer",
64 Self::BigInteger => "big_integer",
65 Self::Float => "float",
66 Self::Double => "double",
67 Self::Decimal => "decimal",
68 Self::Boolean => "boolean",
69 Self::Timestamp => "timestamp",
70 Self::Date => "date",
71 Self::Json => "json",
72 Self::Uuid => "uuid",
73 Self::Binary => "binary",
74 Self::References => "references",
75 }
76 }
77
78 fn rust_type(self) -> &'static str {
80 match self {
81 Self::String | Self::Text => "String",
82 Self::Integer => "i32",
83 Self::BigInteger | Self::References => "i64",
84 Self::Float => "f32",
85 Self::Double => "f64",
86 Self::Decimal => "Decimal",
87 Self::Boolean => "bool",
88 Self::Timestamp => "DateTime",
89 Self::Date => "Date",
90 Self::Json => "Json",
91 Self::Uuid => "Uuid",
92 Self::Binary => "Vec<u8>",
93 }
94 }
95}
96
97#[derive(Debug, Clone)]
99pub struct Field {
100 raw_name: String,
102 ty: ColumnType,
103 not_null: bool,
104 unique: bool,
105 index: bool,
106}
107
108impl Field {
109 pub fn parse(spec: &str) -> Result<Self> {
111 let mut parts = spec.split(':');
112 let name = parts
113 .next()
114 .filter(|s| !s.is_empty())
115 .ok_or_else(|| anyhow!("empty field spec"))?;
116
117 let ty = match parts.next() {
118 Some(t) if !t.is_empty() => ColumnType::parse(t)?,
119 _ => ColumnType::String,
120 };
121
122 let mut field = Field {
123 raw_name: to_snake(name),
124 ty,
125 not_null: false,
126 unique: false,
127 index: false,
128 };
129
130 for modifier in parts {
131 match modifier.to_lowercase().as_str() {
132 "" => {}
133 "not_null" | "notnull" | "required" => field.not_null = true,
134 "unique" | "uniq" => field.unique = true,
135 "index" => field.index = true,
136 other => bail!("unknown modifier `{other}` in field `{spec}`"),
137 }
138 }
139
140 Ok(field)
141 }
142
143 pub fn parse_all(specs: &[&str]) -> Result<Vec<Field>> {
145 specs.iter().map(|s| Field::parse(s)).collect()
146 }
147
148 pub fn column_name(&self) -> String {
150 match self.ty {
151 ColumnType::References => format!("{}_id", self.raw_name),
152 _ => self.raw_name.clone(),
153 }
154 }
155
156 pub fn is_required(&self) -> bool {
159 self.not_null || self.ty == ColumnType::References
160 }
161
162 pub fn wants_index(&self) -> bool {
164 self.index
165 }
166
167 fn rust_type(&self) -> String {
170 let ty = self.ty.rust_type();
171 if self.is_required() {
172 ty.to_string()
173 } else {
174 format!("Option<{ty}>")
175 }
176 }
177
178 pub fn params_struct_field(&self) -> String {
181 format!("pub {}: {},", self.column_name(), self.rust_type())
182 }
183
184 pub fn active_model_set(&self) -> String {
187 let col = self.column_name();
188 format!("{col}: Set(form.{col}),")
189 }
190
191 pub fn active_model_assign(&self) -> String {
194 let col = self.column_name();
195 format!("record.{col} = Set(form.{col});")
196 }
197
198 pub fn html_input_type(&self) -> &'static str {
201 match self.ty {
202 ColumnType::Text => "textarea",
203 ColumnType::Boolean => "checkbox",
204 ColumnType::Integer
205 | ColumnType::BigInteger
206 | ColumnType::Float
207 | ColumnType::Double
208 | ColumnType::Decimal
209 | ColumnType::References => "number",
210 ColumnType::Date => "date",
211 ColumnType::Timestamp => "datetime-local",
212 _ => "text",
213 }
214 }
215
216 pub fn migration_line(&self) -> String {
219 let arg = &self.raw_name;
220 let mut line = format!("t.{}(\"{arg}\")", self.ty.builder_method());
221 if self.not_null && self.ty != ColumnType::References {
223 line.push_str(".not_null()");
224 }
225 if self.unique {
226 line.push_str(".unique_key()");
227 }
228 line.push(';');
229 line
230 }
231
232 pub fn model_field(&self) -> String {
235 format!("pub {}: {},", self.column_name(), self.rust_type())
236 }
237
238 pub fn sample_form_value(&self) -> Option<&'static str> {
242 Some(match self.ty {
243 ColumnType::String | ColumnType::Text => "Test",
244 ColumnType::Integer | ColumnType::BigInteger | ColumnType::References => "1",
245 ColumnType::Float | ColumnType::Double | ColumnType::Decimal => "1",
246 ColumnType::Boolean => "true",
247 ColumnType::Timestamp => "2020-01-01T00:00:00",
248 ColumnType::Date => "2020-01-01",
249 ColumnType::Json => "null",
250 ColumnType::Uuid => "00000000-0000-0000-0000-000000000000",
251 ColumnType::Binary => return None,
252 })
253 }
254
255 pub fn sample_form_pair(&self) -> Option<String> {
257 self.sample_form_value()
258 .map(|v| format!("{}={}", self.column_name(), v))
259 }
260}
261
262#[cfg(test)]
263mod tests {
264 use super::*;
265
266 #[test]
267 fn defaults_to_string_when_type_omitted() {
268 let f = Field::parse("title").unwrap();
269 assert_eq!(f.column_name(), "title");
270 assert_eq!(f.migration_line(), "t.string(\"title\");");
271 assert_eq!(f.model_field(), "pub title: Option<String>,");
272 }
273
274 #[test]
275 fn maps_types_and_nullability() {
276 let f = Field::parse("age:integer:not_null").unwrap();
277 assert_eq!(f.migration_line(), "t.integer(\"age\").not_null();");
278 assert_eq!(f.model_field(), "pub age: i32,");
279 }
280
281 #[test]
282 fn unique_modifier_renders_unique_key() {
283 let f = Field::parse("email:string:unique").unwrap();
284 assert_eq!(f.migration_line(), "t.string(\"email\").unique_key();");
285 }
286
287 #[test]
288 fn references_get_id_suffix_and_are_non_null() {
289 let f = Field::parse("author:references").unwrap();
290 assert_eq!(f.column_name(), "author_id");
291 assert_eq!(f.migration_line(), "t.references(\"author\");");
292 assert_eq!(f.model_field(), "pub author_id: i64,");
293 }
294
295 #[test]
296 fn index_modifier_is_tracked() {
297 let f = Field::parse("slug:string:index").unwrap();
298 assert!(f.wants_index());
299 }
300
301 #[test]
302 fn params_struct_field_and_active_model_set() {
303 let f = Field::parse("title:string:not_null").unwrap();
304 assert_eq!(f.params_struct_field(), "pub title: String,");
305 assert_eq!(f.active_model_set(), "title: Set(form.title),");
306
307 let r = Field::parse("author:references").unwrap();
308 assert_eq!(r.params_struct_field(), "pub author_id: i64,");
309 assert_eq!(r.active_model_set(), "author_id: Set(form.author_id),");
310 }
311
312 #[test]
313 fn html_input_types_map_by_column_type() {
314 assert_eq!(Field::parse("name").unwrap().html_input_type(), "text");
315 assert_eq!(
316 Field::parse("bio:text").unwrap().html_input_type(),
317 "textarea"
318 );
319 assert_eq!(
320 Field::parse("age:integer").unwrap().html_input_type(),
321 "number"
322 );
323 assert_eq!(
324 Field::parse("ok:boolean").unwrap().html_input_type(),
325 "checkbox"
326 );
327 assert_eq!(Field::parse("born:date").unwrap().html_input_type(), "date");
328 }
329
330 #[test]
331 fn rejects_unknown_type_and_modifier() {
332 assert!(Field::parse("x:notatype").is_err());
333 assert!(Field::parse("x:string:notamod").is_err());
334 }
335
336 #[test]
337 fn aliases_resolve() {
338 assert_eq!(
339 Field::parse("count:int").unwrap().model_field(),
340 "pub count: Option<i32>,"
341 );
342 assert_eq!(
343 Field::parse("active:bool:not_null").unwrap().model_field(),
344 "pub active: bool,"
345 );
346 assert_eq!(
347 Field::parse("meta:jsonb").unwrap().model_field(),
348 "pub meta: Option<Json>,"
349 );
350 }
351}