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 column_def_method(self) -> &'static str {
83 match self {
84 Self::References => "big_integer",
85 _ => self.builder_method(),
86 }
87 }
88
89 fn rust_type(self) -> &'static str {
91 match self {
92 Self::String | Self::Text => "String",
93 Self::Integer => "i32",
94 Self::BigInteger | Self::References => "i64",
95 Self::Float => "f32",
96 Self::Double => "f64",
97 Self::Decimal => "Decimal",
98 Self::Boolean => "bool",
99 Self::Timestamp => "DateTime",
100 Self::Date => "Date",
101 Self::Json => "Json",
102 Self::Uuid => "Uuid",
103 Self::Binary => "Vec<u8>",
104 }
105 }
106}
107
108#[derive(Debug, Clone)]
110pub struct Field {
111 raw_name: String,
113 ty: ColumnType,
114 not_null: bool,
115 unique: bool,
116 index: bool,
117}
118
119impl Field {
120 pub fn parse(spec: &str) -> Result<Self> {
122 let mut parts = spec.split(':');
123 let name = parts
124 .next()
125 .filter(|s| !s.is_empty())
126 .ok_or_else(|| anyhow!("empty field spec"))?;
127
128 let ty = match parts.next() {
129 Some(t) if !t.is_empty() => ColumnType::parse(t)?,
130 _ => ColumnType::String,
131 };
132
133 let mut field = Field {
134 raw_name: to_snake(name),
135 ty,
136 not_null: false,
137 unique: false,
138 index: false,
139 };
140
141 for modifier in parts {
142 match modifier.to_lowercase().as_str() {
143 "" => {}
144 "not_null" | "notnull" | "required" => field.not_null = true,
145 "unique" | "uniq" => field.unique = true,
146 "index" => field.index = true,
147 other => bail!("unknown modifier `{other}` in field `{spec}`"),
148 }
149 }
150
151 Ok(field)
152 }
153
154 pub fn parse_all(specs: &[&str]) -> Result<Vec<Field>> {
156 specs.iter().map(|s| Field::parse(s)).collect()
157 }
158
159 pub fn column_name(&self) -> String {
161 match self.ty {
162 ColumnType::References => format!("{}_id", self.raw_name),
163 _ => self.raw_name.clone(),
164 }
165 }
166
167 pub fn is_required(&self) -> bool {
170 self.not_null || self.ty == ColumnType::References
171 }
172
173 pub fn wants_index(&self) -> bool {
175 self.index
176 }
177
178 fn rust_type(&self) -> String {
181 let ty = self.ty.rust_type();
182 if self.is_required() {
183 ty.to_string()
184 } else {
185 format!("Option<{ty}>")
186 }
187 }
188
189 pub fn params_struct_field(&self) -> String {
192 format!("pub {}: {},", self.column_name(), self.rust_type())
193 }
194
195 pub fn active_model_set(&self) -> String {
198 let col = self.column_name();
199 format!("{col}: Set(form.{col}),")
200 }
201
202 pub fn active_model_assign(&self) -> String {
205 let col = self.column_name();
206 format!("record.{col} = Set(form.{col});")
207 }
208
209 pub fn html_input_type(&self) -> &'static str {
212 match self.ty {
213 ColumnType::Text => "textarea",
214 ColumnType::Boolean => "checkbox",
215 ColumnType::Integer
216 | ColumnType::BigInteger
217 | ColumnType::Float
218 | ColumnType::Double
219 | ColumnType::Decimal
220 | ColumnType::References => "number",
221 ColumnType::Date => "date",
222 ColumnType::Timestamp => "datetime-local",
223 _ => "text",
224 }
225 }
226
227 pub fn migration_line(&self) -> String {
230 let arg = &self.raw_name;
231 let mut line = format!("t.{}(\"{arg}\")", self.ty.builder_method());
232 if self.not_null && self.ty != ColumnType::References {
234 line.push_str(".not_null()");
235 }
236 if self.unique {
237 line.push_str(".unique_key()");
238 }
239 line.push(';');
240 line
241 }
242
243 pub fn alter_add_line(&self) -> String {
247 let col = self.column_name();
248 let mut body = format!("c.{}()", self.ty.column_def_method());
249 if self.is_required() {
250 body.push_str(".not_null()");
251 }
252 if self.unique {
253 body.push_str(".unique_key()");
254 }
255 format!("t.add_column(\"{col}\", |c| {{ {body}; }});")
256 }
257
258 pub fn alter_drop_line(&self) -> String {
260 format!("t.drop_column(\"{}\");", self.column_name())
261 }
262
263 pub fn model_field(&self) -> String {
266 format!("pub {}: {},", self.column_name(), self.rust_type())
267 }
268
269 pub fn sample_form_value(&self) -> Option<&'static str> {
273 Some(match self.ty {
274 ColumnType::String | ColumnType::Text => "Test",
275 ColumnType::Integer | ColumnType::BigInteger | ColumnType::References => "1",
276 ColumnType::Float | ColumnType::Double | ColumnType::Decimal => "1",
277 ColumnType::Boolean => "true",
278 ColumnType::Timestamp => "2020-01-01T00:00:00",
279 ColumnType::Date => "2020-01-01",
280 ColumnType::Json => "null",
281 ColumnType::Uuid => "00000000-0000-0000-0000-000000000000",
282 ColumnType::Binary => return None,
283 })
284 }
285
286 pub fn sample_form_pair(&self) -> Option<String> {
288 self.sample_form_value()
289 .map(|v| format!("{}={}", self.column_name(), v))
290 }
291}
292
293#[cfg(test)]
294mod tests {
295 use super::*;
296
297 #[test]
298 fn defaults_to_string_when_type_omitted() {
299 let f = Field::parse("title").unwrap();
300 assert_eq!(f.column_name(), "title");
301 assert_eq!(f.migration_line(), "t.string(\"title\");");
302 assert_eq!(f.model_field(), "pub title: Option<String>,");
303 }
304
305 #[test]
306 fn maps_types_and_nullability() {
307 let f = Field::parse("age:integer:not_null").unwrap();
308 assert_eq!(f.migration_line(), "t.integer(\"age\").not_null();");
309 assert_eq!(f.model_field(), "pub age: i32,");
310 }
311
312 #[test]
313 fn unique_modifier_renders_unique_key() {
314 let f = Field::parse("email:string:unique").unwrap();
315 assert_eq!(f.migration_line(), "t.string(\"email\").unique_key();");
316 }
317
318 #[test]
319 fn references_get_id_suffix_and_are_non_null() {
320 let f = Field::parse("author:references").unwrap();
321 assert_eq!(f.column_name(), "author_id");
322 assert_eq!(f.migration_line(), "t.references(\"author\");");
323 assert_eq!(f.model_field(), "pub author_id: i64,");
324 }
325
326 #[test]
327 fn alter_add_and_drop_lines() {
328 let f = Field::parse("email:string:not_null:unique").unwrap();
329 assert_eq!(
330 f.alter_add_line(),
331 "t.add_column(\"email\", |c| { c.string().not_null().unique_key(); });"
332 );
333 assert_eq!(f.alter_drop_line(), "t.drop_column(\"email\");");
334
335 let plain = Field::parse("note:text").unwrap();
336 assert_eq!(
337 plain.alter_add_line(),
338 "t.add_column(\"note\", |c| { c.text(); });"
339 );
340
341 let r = Field::parse("author:references").unwrap();
343 assert_eq!(
344 r.alter_add_line(),
345 "t.add_column(\"author_id\", |c| { c.big_integer().not_null(); });"
346 );
347 assert_eq!(r.alter_drop_line(), "t.drop_column(\"author_id\");");
348 }
349
350 #[test]
351 fn index_modifier_is_tracked() {
352 let f = Field::parse("slug:string:index").unwrap();
353 assert!(f.wants_index());
354 }
355
356 #[test]
357 fn params_struct_field_and_active_model_set() {
358 let f = Field::parse("title:string:not_null").unwrap();
359 assert_eq!(f.params_struct_field(), "pub title: String,");
360 assert_eq!(f.active_model_set(), "title: Set(form.title),");
361
362 let r = Field::parse("author:references").unwrap();
363 assert_eq!(r.params_struct_field(), "pub author_id: i64,");
364 assert_eq!(r.active_model_set(), "author_id: Set(form.author_id),");
365 }
366
367 #[test]
368 fn html_input_types_map_by_column_type() {
369 assert_eq!(Field::parse("name").unwrap().html_input_type(), "text");
370 assert_eq!(
371 Field::parse("bio:text").unwrap().html_input_type(),
372 "textarea"
373 );
374 assert_eq!(
375 Field::parse("age:integer").unwrap().html_input_type(),
376 "number"
377 );
378 assert_eq!(
379 Field::parse("ok:boolean").unwrap().html_input_type(),
380 "checkbox"
381 );
382 assert_eq!(Field::parse("born:date").unwrap().html_input_type(), "date");
383 }
384
385 #[test]
386 fn rejects_unknown_type_and_modifier() {
387 assert!(Field::parse("x:notatype").is_err());
388 assert!(Field::parse("x:string:notamod").is_err());
389 }
390
391 #[test]
392 fn aliases_resolve() {
393 assert_eq!(
394 Field::parse("count:int").unwrap().model_field(),
395 "pub count: Option<i32>,"
396 );
397 assert_eq!(
398 Field::parse("active:bool:not_null").unwrap().model_field(),
399 "pub active: bool,"
400 );
401 assert_eq!(
402 Field::parse("meta:jsonb").unwrap().model_field(),
403 "pub meta: Option<Json>,"
404 );
405 }
406}