Skip to main content

doido_generators/generators/
field.rs

1//! Parsing of `name:type[:modifier...]` field specs passed to the model and
2//! scaffold generators (Rails-style), e.g.
3//!
4//! ```text
5//! doido generate model Post title:string body:text published:boolean \
6//!     author:references views:integer:index slug:string:unique
7//! ```
8//!
9//! Each parsed [`Field`] knows how to render both a migration column line
10//! (`t.string("title").not_null();`) and a SeaORM model field
11//! (`pub title: String,`).
12
13use crate::generators::to_snake;
14use doido_core::anyhow::{anyhow, bail};
15use doido_core::Result;
16
17/// A column type supported by both the migration builder and the SeaORM model.
18#[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    /// Parse a type token; accepts common aliases.
38    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    /// The [`TableBuilder`](doido_model::migration) method that adds this column.
59    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    /// The `ColumnDef` type method used inside `alter_table`'s `add_column`
79    /// closure (`c.string()`, `c.integer()`, …). Mirrors [`Self::builder_method`]
80    /// except `References`, which has no `ColumnDef` helper — there it is a plain
81    /// big-integer `<name>_id` column (kept NOT NULL by the field itself).
82    fn column_def_method(self) -> &'static str {
83        match self {
84            Self::References => "big_integer",
85            _ => self.builder_method(),
86        }
87    }
88
89    /// The Rust type used for this column in the SeaORM model struct.
90    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/// A single parsed column definition.
109#[derive(Debug, Clone)]
110pub struct Field {
111    /// Name as written by the user (snake-cased), e.g. `author` for a reference.
112    raw_name: String,
113    ty: ColumnType,
114    not_null: bool,
115    unique: bool,
116    index: bool,
117}
118
119impl Field {
120    /// Parse one `name:type[:modifier...]` spec. Type defaults to `string`.
121    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    /// Parse every spec in `specs`, short-circuiting on the first error.
155    pub fn parse_all(specs: &[&str]) -> Result<Vec<Field>> {
156        specs.iter().map(|s| Field::parse(s)).collect()
157    }
158
159    /// The database column name. References get an `_id` suffix.
160    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    /// Whether the column (and therefore the model field) is NOT NULL.
168    /// References are always non-null (the builder enforces it).
169    pub fn is_required(&self) -> bool {
170        self.not_null || self.ty == ColumnType::References
171    }
172
173    /// Whether this field requested its own index.
174    pub fn wants_index(&self) -> bool {
175        self.index
176    }
177
178    /// The Rust type as written in a scaffold params struct / model field,
179    /// wrapped in `Option<…>` when the column is nullable.
180    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    /// Render a field for the scaffold's form-params struct, e.g.
190    /// `pub title: String,` — mirrors the column's nullability.
191    pub fn params_struct_field(&self) -> String {
192        format!("pub {}: {},", self.column_name(), self.rust_type())
193    }
194
195    /// Render an `ActiveModel` struct-literal entry from a parsed `form`, e.g.
196    /// `title: Set(form.title),` (used by `create`).
197    pub fn active_model_set(&self) -> String {
198        let col = self.column_name();
199        format!("{col}: Set(form.{col}),")
200    }
201
202    /// Render an `ActiveModel` field assignment from a parsed `form`, e.g.
203    /// `record.title = Set(form.title);` (used by `update`).
204    pub fn active_model_assign(&self) -> String {
205        let col = self.column_name();
206        format!("record.{col} = Set(form.{col});")
207    }
208
209    /// The HTML `<input type="…">` (or `textarea`/`checkbox`) for this column,
210    /// used by the scaffold form view.
211    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    /// Render the migration builder line, e.g. `t.string("title").not_null();`.
228    /// `references` passes the bare name (it appends `_id` itself).
229    pub fn migration_line(&self) -> String {
230        let arg = &self.raw_name;
231        let mut line = format!("t.{}(\"{arg}\")", self.ty.builder_method());
232        // `references` is already NOT NULL; only add it for other types.
233        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    /// Render an `alter_table` add-column line, e.g.
244    /// `t.add_column("email", |c| { c.string().not_null(); });`. References
245    /// become a NOT NULL `<name>_id` big integer.
246    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    /// Render an `alter_table` drop-column line, e.g. `t.drop_column("email");`.
259    pub fn alter_drop_line(&self) -> String {
260        format!("t.drop_column(\"{}\");", self.column_name())
261    }
262
263    /// Render the SeaORM model struct field, e.g. `pub title: String,` or
264    /// `pub age: Option<i32>,` for a nullable column.
265    pub fn model_field(&self) -> String {
266        format!("pub {}: {},", self.column_name(), self.rust_type())
267    }
268
269    /// A sample form value for this column, used to build request bodies in the
270    /// generated scaffold controller test. `None` for types that cannot be
271    /// represented as a urlencoded scalar (binary), which are omitted.
272    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    /// `col=value` pair for a urlencoded request body, or `None` to omit.
287    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        // References become a NOT NULL big-integer `<name>_id`.
342        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}