use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FieldType {
String,
Bool,
I32,
I64,
F64,
Uuid,
DateTime,
}
impl FieldType {
pub fn parse(raw: &str) -> Result<Self> {
let ty = match raw.to_ascii_lowercase().as_str() {
"string" | "str" => Self::String,
"bool" | "boolean" => Self::Bool,
"i32" | "int" => Self::I32,
"i64" | "bigint" | "long" => Self::I64,
"f64" | "float" | "double" => Self::F64,
"uuid" => Self::Uuid,
"datetime" | "timestamp" => Self::DateTime,
other => bail!(
"unknown field type `{other}` (supported: String, Bool, i32, i64, f64, Uuid, DateTime)"
),
};
Ok(ty)
}
pub fn rust_type(self) -> &'static str {
match self {
Self::String => "String",
Self::Bool => "bool",
Self::I32 => "i32",
Self::I64 => "i64",
Self::F64 => "f64",
Self::Uuid => "uuid::Uuid",
Self::DateTime => "chrono::DateTime<chrono::Utc>",
}
}
pub fn is_copy(self) -> bool {
!matches!(self, Self::String)
}
pub fn as_token(self) -> &'static str {
match self {
Self::String => "String",
Self::Bool => "bool",
Self::I32 => "i32",
Self::I64 => "i64",
Self::F64 => "f64",
Self::Uuid => "Uuid",
Self::DateTime => "DateTime",
}
}
pub fn sql_type(self) -> &'static str {
match self {
Self::String => "TEXT",
Self::Bool => "BOOLEAN",
Self::I32 => "INTEGER",
Self::I64 => "BIGINT",
Self::F64 => "DOUBLE PRECISION",
Self::Uuid => "UUID",
Self::DateTime => "TIMESTAMPTZ",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Field {
pub name: String,
pub ty: FieldType,
}
impl Field {
pub fn to_token(&self) -> String {
format!("{}:{}", self.name, self.ty.as_token())
}
pub fn parse(token: &str) -> Result<Self> {
let (name, ty) = token
.split_once(':')
.with_context(|| format!("field `{token}` must be in the form name:Type"))?;
if name.is_empty() {
bail!("field `{token}` has an empty name");
}
Ok(Self {
name: name.to_string(),
ty: FieldType::parse(ty)?,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Relation {
pub field: String,
pub target: String,
}
impl Relation {
pub fn fk_column(&self) -> String {
format!("{}_id", self.field)
}
pub fn to_token(&self) -> String {
format!("{}:belongs_to:{}", self.field, self.target)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ModelSpec {
pub name: String,
pub fields: Vec<Field>,
pub relations: Vec<Relation>,
}
impl ModelSpec {
pub fn parse(name: impl Into<String>, tokens: &[String]) -> Result<Self> {
let mut fields = Vec::new();
let mut relations = Vec::new();
for token in tokens {
let parts: Vec<&str> = token.splitn(3, ':').collect();
if parts.len() == 3 && parts[1] == "belongs_to" {
let (field, target) = (parts[0], parts[2]);
if field.is_empty() || target.is_empty() {
bail!("relationship `{token}` must be in the form name:belongs_to:target");
}
relations.push(Relation {
field: field.to_string(),
target: target.to_string(),
});
} else {
fields.push(Field::parse(token)?);
}
}
for r in &relations {
fields.push(Field {
name: r.fk_column(),
ty: FieldType::Uuid,
});
}
Ok(Self {
name: name.into(),
fields,
relations,
})
}
pub fn to_field_tokens(&self) -> Vec<String> {
let fk: std::collections::BTreeSet<String> =
self.relations.iter().map(Relation::fk_column).collect();
self.fields
.iter()
.filter(|f| !fk.contains(&f.name))
.map(Field::to_token)
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_a_field() {
let f = Field::parse("email:String").unwrap();
assert_eq!(f.name, "email");
assert_eq!(f.ty, FieldType::String);
assert_eq!(f.ty.rust_type(), "String");
assert_eq!(f.ty.sql_type(), "TEXT");
}
#[test]
fn rejects_unknown_type() {
assert!(Field::parse("x:Blob").is_err());
}
#[test]
fn rejects_malformed_token() {
assert!(Field::parse("notype").is_err());
}
#[test]
fn parses_a_model() {
let m = ModelSpec::parse(
"User",
&["name:String".to_string(), "active:bool".to_string()],
)
.unwrap();
assert_eq!(m.name, "User");
assert_eq!(m.fields.len(), 2);
assert_eq!(m.fields[1].ty, FieldType::Bool);
assert!(m.relations.is_empty());
}
#[test]
fn parses_a_belongs_to_relationship() {
let m = ModelSpec::parse(
"Post",
&[
"title:String".to_string(),
"author:belongs_to:users".to_string(),
],
)
.unwrap();
assert_eq!(m.relations.len(), 1);
assert_eq!(m.relations[0].field, "author");
assert_eq!(m.relations[0].target, "users");
assert_eq!(m.relations[0].fk_column(), "author_id");
let fk = m.fields.iter().find(|f| f.name == "author_id").unwrap();
assert_eq!(fk.ty, FieldType::Uuid);
assert_eq!(m.to_field_tokens(), vec!["title:String".to_string()]);
}
#[test]
fn rejects_malformed_relationship() {
assert!(ModelSpec::parse("Post", &["author:belongs_to:".to_string()]).is_err());
assert!(ModelSpec::parse("Post", &[":belongs_to:users".to_string()]).is_err());
}
}