1use anyhow::{Context, Result, bail};
4use serde::{Deserialize, Serialize};
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum FieldType {
12 String,
13 Bool,
14 I32,
15 I64,
16 F64,
17 Uuid,
18 DateTime,
19}
20
21impl FieldType {
22 pub fn parse(raw: &str) -> Result<Self> {
24 let ty = match raw.to_ascii_lowercase().as_str() {
25 "string" | "str" => Self::String,
26 "bool" | "boolean" => Self::Bool,
27 "i32" | "int" => Self::I32,
28 "i64" | "bigint" | "long" => Self::I64,
29 "f64" | "float" | "double" => Self::F64,
30 "uuid" => Self::Uuid,
31 "datetime" | "timestamp" => Self::DateTime,
32 other => bail!(
33 "unknown field type `{other}` (supported: String, Bool, i32, i64, f64, Uuid, DateTime)"
34 ),
35 };
36 Ok(ty)
37 }
38
39 pub fn rust_type(self) -> &'static str {
41 match self {
42 Self::String => "String",
43 Self::Bool => "bool",
44 Self::I32 => "i32",
45 Self::I64 => "i64",
46 Self::F64 => "f64",
47 Self::Uuid => "uuid::Uuid",
48 Self::DateTime => "chrono::DateTime<chrono::Utc>",
49 }
50 }
51
52 pub fn is_copy(self) -> bool {
56 !matches!(self, Self::String)
59 }
60
61 pub fn as_token(self) -> &'static str {
65 match self {
66 Self::String => "String",
67 Self::Bool => "bool",
68 Self::I32 => "i32",
69 Self::I64 => "i64",
70 Self::F64 => "f64",
71 Self::Uuid => "Uuid",
72 Self::DateTime => "DateTime",
73 }
74 }
75
76 pub fn sql_type(self) -> &'static str {
78 match self {
79 Self::String => "TEXT",
80 Self::Bool => "BOOLEAN",
81 Self::I32 => "INTEGER",
82 Self::I64 => "BIGINT",
83 Self::F64 => "DOUBLE PRECISION",
84 Self::Uuid => "UUID",
85 Self::DateTime => "TIMESTAMPTZ",
86 }
87 }
88}
89
90#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct Field {
93 pub name: String,
94 pub ty: FieldType,
95}
96
97impl Field {
98 pub fn to_token(&self) -> String {
101 format!("{}:{}", self.name, self.ty.as_token())
102 }
103
104 pub fn parse(token: &str) -> Result<Self> {
106 let (name, ty) = token
107 .split_once(':')
108 .with_context(|| format!("field `{token}` must be in the form name:Type"))?;
109 if name.is_empty() {
110 bail!("field `{token}` has an empty name");
111 }
112 Ok(Self {
113 name: name.to_string(),
114 ty: FieldType::parse(ty)?,
115 })
116 }
117}
118
119#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
123pub struct Relation {
124 pub field: String,
126 pub target: String,
128}
129
130impl Relation {
131 pub fn fk_column(&self) -> String {
133 format!("{}_id", self.field)
134 }
135
136 pub fn to_token(&self) -> String {
139 format!("{}:belongs_to:{}", self.field, self.target)
140 }
141}
142
143#[derive(Debug, Clone, PartialEq, Eq)]
151pub struct ModelSpec {
152 pub name: String,
153 pub fields: Vec<Field>,
154 pub relations: Vec<Relation>,
155}
156
157impl ModelSpec {
158 pub fn parse(name: impl Into<String>, tokens: &[String]) -> Result<Self> {
162 let mut fields = Vec::new();
163 let mut relations = Vec::new();
164 for token in tokens {
165 let parts: Vec<&str> = token.splitn(3, ':').collect();
166 if parts.len() == 3 && parts[1] == "belongs_to" {
167 let (field, target) = (parts[0], parts[2]);
168 if field.is_empty() || target.is_empty() {
169 bail!("relationship `{token}` must be in the form name:belongs_to:target");
170 }
171 relations.push(Relation {
172 field: field.to_string(),
173 target: target.to_string(),
174 });
175 } else {
176 fields.push(Field::parse(token)?);
177 }
178 }
179 for r in &relations {
181 fields.push(Field {
182 name: r.fk_column(),
183 ty: FieldType::Uuid,
184 });
185 }
186 Ok(Self {
187 name: name.into(),
188 fields,
189 relations,
190 })
191 }
192
193 pub fn to_field_tokens(&self) -> Vec<String> {
197 let fk: std::collections::BTreeSet<String> =
198 self.relations.iter().map(Relation::fk_column).collect();
199 self.fields
200 .iter()
201 .filter(|f| !fk.contains(&f.name))
202 .map(Field::to_token)
203 .collect()
204 }
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210
211 #[test]
212 fn parses_a_field() {
213 let f = Field::parse("email:String").unwrap();
214 assert_eq!(f.name, "email");
215 assert_eq!(f.ty, FieldType::String);
216 assert_eq!(f.ty.rust_type(), "String");
217 assert_eq!(f.ty.sql_type(), "TEXT");
218 }
219
220 #[test]
221 fn rejects_unknown_type() {
222 assert!(Field::parse("x:Blob").is_err());
223 }
224
225 #[test]
226 fn rejects_malformed_token() {
227 assert!(Field::parse("notype").is_err());
228 }
229
230 #[test]
231 fn parses_a_model() {
232 let m = ModelSpec::parse(
233 "User",
234 &["name:String".to_string(), "active:bool".to_string()],
235 )
236 .unwrap();
237 assert_eq!(m.name, "User");
238 assert_eq!(m.fields.len(), 2);
239 assert_eq!(m.fields[1].ty, FieldType::Bool);
240 assert!(m.relations.is_empty());
241 }
242
243 #[test]
244 fn parses_a_belongs_to_relationship() {
245 let m = ModelSpec::parse(
246 "Post",
247 &[
248 "title:String".to_string(),
249 "author:belongs_to:users".to_string(),
250 ],
251 )
252 .unwrap();
253 assert_eq!(m.relations.len(), 1);
255 assert_eq!(m.relations[0].field, "author");
256 assert_eq!(m.relations[0].target, "users");
257 assert_eq!(m.relations[0].fk_column(), "author_id");
258 let fk = m.fields.iter().find(|f| f.name == "author_id").unwrap();
260 assert_eq!(fk.ty, FieldType::Uuid);
261 assert_eq!(m.to_field_tokens(), vec!["title:String".to_string()]);
263 }
264
265 #[test]
266 fn rejects_malformed_relationship() {
267 assert!(ModelSpec::parse("Post", &["author:belongs_to:".to_string()]).is_err());
268 assert!(ModelSpec::parse("Post", &[":belongs_to:users".to_string()]).is_err());
269 }
270}