use crate::validation::{validate_column, validate_identifier, validate_model_name};
use anyhow::{Context, Result, bail, ensure};
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"))?;
validate_column(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 name = name.into();
validate_model_name(&name)?;
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,
});
}
let model = Self {
name,
fields,
relations,
};
model.validate()?;
Ok(model)
}
pub fn validate_for_dialect(&self, dialect: crate::Dialect) -> Result<()> {
self.validate()?;
let check = crate::validation::validate_sql_identifier_for_dialect;
check(
&crate::naming::table_name(&self.name),
"generated table",
dialect,
)?;
for field in &self.fields {
check(&field.name, "field", dialect)?;
}
for relation in &self.relations {
check(&relation.target, "relationship target", dialect)?;
}
Ok(())
}
pub fn validate(&self) -> Result<()> {
use std::collections::BTreeSet;
validate_model_name(&self.name)?;
let mut names = BTreeSet::new();
for field in &self.fields {
validate_column(&field.name)?;
ensure!(
names.insert(field.name.as_str()),
"duplicate field `{}` in model `{}` (including generated relationship columns)",
field.name,
self.name
);
}
let mut relations = BTreeSet::new();
for relation in &self.relations {
validate_identifier(&relation.field, "relationship")?;
validate_identifier(&relation.target, "relationship target")?;
ensure!(
relations.insert(relation.field.as_str()),
"duplicate relationship `{}` in model `{}`",
relation.field,
self.name
);
ensure!(
!names.contains(relation.field.as_str()),
"relationship `{}` collides with a field in model `{}`",
relation.field,
self.name
);
let fk = relation.fk_column();
ensure!(
self.fields
.iter()
.any(|f| f.name == fk && f.ty == FieldType::Uuid),
"relationship `{}` requires generated UUID field `{fk}`",
relation.field
);
}
Ok(())
}
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());
}
#[test]
fn rejects_invalid_and_reserved_field_names() {
for name in [
"",
"_",
"9name",
"first-name",
"first name",
"Name",
"café",
"r#type",
"../field",
"type",
"async",
"gen",
"id",
"created_at",
"updated_at",
] {
let error = Field::parse(&format!("{name}:String"))
.unwrap_err()
.to_string();
assert!(error.contains(name), "{error}");
}
assert!(Field::parse(&format!("{}:String", "a".repeat(64))).is_err());
for name in ["name", "first_name", "address2", "_internal"] {
Field::parse(&format!("{name}:String")).unwrap();
}
}
#[test]
fn rejects_duplicates_and_relationship_collisions() {
for fields in [
vec!["name:String", "name:i32"],
vec!["author_id:Uuid", "author:belongs_to:users"],
vec!["author:belongs_to:users", "author_id:Uuid"],
vec!["author:belongs_to:users", "author:belongs_to:posts"],
vec!["author:String", "author:belongs_to:users"],
vec!["type:belongs_to:users"],
vec!["author:belongs_to:../users"],
vec!["author:belongs_to:users:extra"],
] {
let tokens: Vec<_> = fields.iter().map(|f| f.to_string()).collect();
assert!(
ModelSpec::parse("Post", &tokens).is_err(),
"accepted {fields:?}"
);
}
}
#[test]
fn validates_models_and_preserves_valid_relation_roundtrip() {
for name in [
"",
"post",
"Self",
"String",
"Vec",
"../Post",
"Post-Item",
"Post Item",
"Post_Item",
] {
assert!(ModelSpec::parse(name, &[]).is_err(), "accepted {name}");
}
let model = ModelSpec::parse(
"BlogPost",
&["title:String".into(), "user:belongs_to:users".into()],
)
.unwrap();
model.validate().unwrap();
let mut tokens = model.to_field_tokens();
tokens.extend(model.relations.iter().map(Relation::to_token));
assert_eq!(ModelSpec::parse("BlogPost", &tokens).unwrap(), model);
}
}