use crate::generators::to_snake;
use doido_core::anyhow::{anyhow, bail};
use doido_core::Result;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ColumnType {
String,
Text,
Integer,
BigInteger,
Float,
Double,
Decimal,
Boolean,
Timestamp,
Date,
Json,
Uuid,
Binary,
References,
}
impl ColumnType {
fn parse(token: &str) -> Result<Self> {
Ok(match token.to_lowercase().as_str() {
"string" => Self::String,
"text" => Self::Text,
"integer" | "int" => Self::Integer,
"bigint" | "biginteger" | "big_integer" | "long" => Self::BigInteger,
"float" => Self::Float,
"double" => Self::Double,
"decimal" | "numeric" => Self::Decimal,
"boolean" | "bool" => Self::Boolean,
"timestamp" | "datetime" => Self::Timestamp,
"date" => Self::Date,
"json" | "jsonb" => Self::Json,
"uuid" => Self::Uuid,
"binary" | "blob" | "bytes" => Self::Binary,
"references" | "reference" | "belongs_to" => Self::References,
other => bail!("unknown column type `{other}`"),
})
}
fn builder_method(self) -> &'static str {
match self {
Self::String => "string",
Self::Text => "text",
Self::Integer => "integer",
Self::BigInteger => "big_integer",
Self::Float => "float",
Self::Double => "double",
Self::Decimal => "decimal",
Self::Boolean => "boolean",
Self::Timestamp => "timestamp",
Self::Date => "date",
Self::Json => "json",
Self::Uuid => "uuid",
Self::Binary => "binary",
Self::References => "references",
}
}
fn rust_type(self) -> &'static str {
match self {
Self::String | Self::Text => "String",
Self::Integer => "i32",
Self::BigInteger | Self::References => "i64",
Self::Float => "f32",
Self::Double => "f64",
Self::Decimal => "Decimal",
Self::Boolean => "bool",
Self::Timestamp => "DateTime",
Self::Date => "Date",
Self::Json => "Json",
Self::Uuid => "Uuid",
Self::Binary => "Vec<u8>",
}
}
}
#[derive(Debug, Clone)]
pub struct Field {
raw_name: String,
ty: ColumnType,
not_null: bool,
unique: bool,
index: bool,
}
impl Field {
pub fn parse(spec: &str) -> Result<Self> {
let mut parts = spec.split(':');
let name = parts
.next()
.filter(|s| !s.is_empty())
.ok_or_else(|| anyhow!("empty field spec"))?;
let ty = match parts.next() {
Some(t) if !t.is_empty() => ColumnType::parse(t)?,
_ => ColumnType::String,
};
let mut field = Field {
raw_name: to_snake(name),
ty,
not_null: false,
unique: false,
index: false,
};
for modifier in parts {
match modifier.to_lowercase().as_str() {
"" => {}
"not_null" | "notnull" | "required" => field.not_null = true,
"unique" | "uniq" => field.unique = true,
"index" => field.index = true,
other => bail!("unknown modifier `{other}` in field `{spec}`"),
}
}
Ok(field)
}
pub fn parse_all(specs: &[&str]) -> Result<Vec<Field>> {
specs.iter().map(|s| Field::parse(s)).collect()
}
pub fn column_name(&self) -> String {
match self.ty {
ColumnType::References => format!("{}_id", self.raw_name),
_ => self.raw_name.clone(),
}
}
pub fn is_required(&self) -> bool {
self.not_null || self.ty == ColumnType::References
}
pub fn wants_index(&self) -> bool {
self.index
}
fn rust_type(&self) -> String {
let ty = self.ty.rust_type();
if self.is_required() {
ty.to_string()
} else {
format!("Option<{ty}>")
}
}
pub fn params_struct_field(&self) -> String {
format!("pub {}: {},", self.column_name(), self.rust_type())
}
pub fn active_model_set(&self) -> String {
let col = self.column_name();
format!("{col}: Set(form.{col}),")
}
pub fn active_model_assign(&self) -> String {
let col = self.column_name();
format!("record.{col} = Set(form.{col});")
}
pub fn html_input_type(&self) -> &'static str {
match self.ty {
ColumnType::Text => "textarea",
ColumnType::Boolean => "checkbox",
ColumnType::Integer
| ColumnType::BigInteger
| ColumnType::Float
| ColumnType::Double
| ColumnType::Decimal
| ColumnType::References => "number",
ColumnType::Date => "date",
ColumnType::Timestamp => "datetime-local",
_ => "text",
}
}
pub fn migration_line(&self) -> String {
let arg = &self.raw_name;
let mut line = format!("t.{}(\"{arg}\")", self.ty.builder_method());
if self.not_null && self.ty != ColumnType::References {
line.push_str(".not_null()");
}
if self.unique {
line.push_str(".unique_key()");
}
line.push(';');
line
}
pub fn model_field(&self) -> String {
format!("pub {}: {},", self.column_name(), self.rust_type())
}
pub fn sample_form_value(&self) -> Option<&'static str> {
Some(match self.ty {
ColumnType::String | ColumnType::Text => "Test",
ColumnType::Integer | ColumnType::BigInteger | ColumnType::References => "1",
ColumnType::Float | ColumnType::Double | ColumnType::Decimal => "1",
ColumnType::Boolean => "true",
ColumnType::Timestamp => "2020-01-01T00:00:00",
ColumnType::Date => "2020-01-01",
ColumnType::Json => "null",
ColumnType::Uuid => "00000000-0000-0000-0000-000000000000",
ColumnType::Binary => return None,
})
}
pub fn sample_form_pair(&self) -> Option<String> {
self.sample_form_value()
.map(|v| format!("{}={}", self.column_name(), v))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_to_string_when_type_omitted() {
let f = Field::parse("title").unwrap();
assert_eq!(f.column_name(), "title");
assert_eq!(f.migration_line(), "t.string(\"title\");");
assert_eq!(f.model_field(), "pub title: Option<String>,");
}
#[test]
fn maps_types_and_nullability() {
let f = Field::parse("age:integer:not_null").unwrap();
assert_eq!(f.migration_line(), "t.integer(\"age\").not_null();");
assert_eq!(f.model_field(), "pub age: i32,");
}
#[test]
fn unique_modifier_renders_unique_key() {
let f = Field::parse("email:string:unique").unwrap();
assert_eq!(f.migration_line(), "t.string(\"email\").unique_key();");
}
#[test]
fn references_get_id_suffix_and_are_non_null() {
let f = Field::parse("author:references").unwrap();
assert_eq!(f.column_name(), "author_id");
assert_eq!(f.migration_line(), "t.references(\"author\");");
assert_eq!(f.model_field(), "pub author_id: i64,");
}
#[test]
fn index_modifier_is_tracked() {
let f = Field::parse("slug:string:index").unwrap();
assert!(f.wants_index());
}
#[test]
fn params_struct_field_and_active_model_set() {
let f = Field::parse("title:string:not_null").unwrap();
assert_eq!(f.params_struct_field(), "pub title: String,");
assert_eq!(f.active_model_set(), "title: Set(form.title),");
let r = Field::parse("author:references").unwrap();
assert_eq!(r.params_struct_field(), "pub author_id: i64,");
assert_eq!(r.active_model_set(), "author_id: Set(form.author_id),");
}
#[test]
fn html_input_types_map_by_column_type() {
assert_eq!(Field::parse("name").unwrap().html_input_type(), "text");
assert_eq!(
Field::parse("bio:text").unwrap().html_input_type(),
"textarea"
);
assert_eq!(
Field::parse("age:integer").unwrap().html_input_type(),
"number"
);
assert_eq!(
Field::parse("ok:boolean").unwrap().html_input_type(),
"checkbox"
);
assert_eq!(Field::parse("born:date").unwrap().html_input_type(), "date");
}
#[test]
fn rejects_unknown_type_and_modifier() {
assert!(Field::parse("x:notatype").is_err());
assert!(Field::parse("x:string:notamod").is_err());
}
#[test]
fn aliases_resolve() {
assert_eq!(
Field::parse("count:int").unwrap().model_field(),
"pub count: Option<i32>,"
);
assert_eq!(
Field::parse("active:bool:not_null").unwrap().model_field(),
"pub active: bool,"
);
assert_eq!(
Field::parse("meta:jsonb").unwrap().model_field(),
"pub meta: Option<Json>,"
);
}
}