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 Rust type used for this column in the SeaORM model struct.
79    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/// A single parsed column definition.
98#[derive(Debug, Clone)]
99pub struct Field {
100    /// Name as written by the user (snake-cased), e.g. `author` for a reference.
101    raw_name: String,
102    ty: ColumnType,
103    not_null: bool,
104    unique: bool,
105    index: bool,
106}
107
108impl Field {
109    /// Parse one `name:type[:modifier...]` spec. Type defaults to `string`.
110    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    /// Parse every spec in `specs`, short-circuiting on the first error.
144    pub fn parse_all(specs: &[&str]) -> Result<Vec<Field>> {
145        specs.iter().map(|s| Field::parse(s)).collect()
146    }
147
148    /// The database column name. References get an `_id` suffix.
149    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    /// Whether the column (and therefore the model field) is NOT NULL.
157    /// References are always non-null (the builder enforces it).
158    pub fn is_required(&self) -> bool {
159        self.not_null || self.ty == ColumnType::References
160    }
161
162    /// Whether this field requested its own index.
163    pub fn wants_index(&self) -> bool {
164        self.index
165    }
166
167    /// The Rust type as written in a scaffold params struct / model field,
168    /// wrapped in `Option<…>` when the column is nullable.
169    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    /// Render a field for the scaffold's form-params struct, e.g.
179    /// `pub title: String,` — mirrors the column's nullability.
180    pub fn params_struct_field(&self) -> String {
181        format!("pub {}: {},", self.column_name(), self.rust_type())
182    }
183
184    /// Render an `ActiveModel` struct-literal entry from a parsed `form`, e.g.
185    /// `title: Set(form.title),` (used by `create`).
186    pub fn active_model_set(&self) -> String {
187        let col = self.column_name();
188        format!("{col}: Set(form.{col}),")
189    }
190
191    /// Render an `ActiveModel` field assignment from a parsed `form`, e.g.
192    /// `record.title = Set(form.title);` (used by `update`).
193    pub fn active_model_assign(&self) -> String {
194        let col = self.column_name();
195        format!("record.{col} = Set(form.{col});")
196    }
197
198    /// The HTML `<input type="…">` (or `textarea`/`checkbox`) for this column,
199    /// used by the scaffold form view.
200    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    /// Render the migration builder line, e.g. `t.string("title").not_null();`.
217    /// `references` passes the bare name (it appends `_id` itself).
218    pub fn migration_line(&self) -> String {
219        let arg = &self.raw_name;
220        let mut line = format!("t.{}(\"{arg}\")", self.ty.builder_method());
221        // `references` is already NOT NULL; only add it for other types.
222        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    /// Render the SeaORM model struct field, e.g. `pub title: String,` or
233    /// `pub age: Option<i32>,` for a nullable column.
234    pub fn model_field(&self) -> String {
235        format!("pub {}: {},", self.column_name(), self.rust_type())
236    }
237
238    /// A sample form value for this column, used to build request bodies in the
239    /// generated scaffold controller test. `None` for types that cannot be
240    /// represented as a urlencoded scalar (binary), which are omitted.
241    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    /// `col=value` pair for a urlencoded request body, or `None` to omit.
256    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}