1use crate::validation::{validate_column, validate_identifier, validate_model_name};
4use anyhow::{Context, Result, bail, ensure};
5use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum FieldType {
13 String,
14 Bool,
15 I32,
16 I64,
17 F64,
18 Uuid,
19 DateTime,
20}
21
22impl FieldType {
23 pub fn parse(raw: &str) -> Result<Self> {
25 let ty = match raw.to_ascii_lowercase().as_str() {
26 "string" | "str" => Self::String,
27 "bool" | "boolean" => Self::Bool,
28 "i32" | "int" => Self::I32,
29 "i64" | "bigint" | "long" => Self::I64,
30 "f64" | "float" | "double" => Self::F64,
31 "uuid" => Self::Uuid,
32 "datetime" | "timestamp" => Self::DateTime,
33 other => bail!(
34 "unknown field type `{other}` (supported: String, Bool, i32, i64, f64, Uuid, DateTime)"
35 ),
36 };
37 Ok(ty)
38 }
39
40 pub fn rust_type(self) -> &'static str {
42 match self {
43 Self::String => "String",
44 Self::Bool => "bool",
45 Self::I32 => "i32",
46 Self::I64 => "i64",
47 Self::F64 => "f64",
48 Self::Uuid => "uuid::Uuid",
49 Self::DateTime => "chrono::DateTime<chrono::Utc>",
50 }
51 }
52
53 pub fn is_copy(self) -> bool {
57 !matches!(self, Self::String)
60 }
61
62 pub fn as_token(self) -> &'static str {
66 match self {
67 Self::String => "String",
68 Self::Bool => "bool",
69 Self::I32 => "i32",
70 Self::I64 => "i64",
71 Self::F64 => "f64",
72 Self::Uuid => "Uuid",
73 Self::DateTime => "DateTime",
74 }
75 }
76
77 pub fn sql_type(self) -> &'static str {
79 match self {
80 Self::String => "TEXT",
81 Self::Bool => "BOOLEAN",
82 Self::I32 => "INTEGER",
83 Self::I64 => "BIGINT",
84 Self::F64 => "DOUBLE PRECISION",
85 Self::Uuid => "UUID",
86 Self::DateTime => "TIMESTAMPTZ",
87 }
88 }
89}
90
91#[derive(Debug, Clone, PartialEq, Eq)]
93pub struct Field {
94 pub name: String,
95 pub ty: FieldType,
96}
97
98impl Field {
99 pub fn to_token(&self) -> String {
102 format!("{}:{}", self.name, self.ty.as_token())
103 }
104
105 pub fn parse(token: &str) -> Result<Self> {
107 let (name, ty) = token
108 .split_once(':')
109 .with_context(|| format!("field `{token}` must be in the form name:Type"))?;
110 validate_column(name)?;
111 Ok(Self {
112 name: name.to_string(),
113 ty: FieldType::parse(ty)?,
114 })
115 }
116}
117
118#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
122pub struct Relation {
123 pub field: String,
125 pub target: String,
127}
128
129impl Relation {
130 pub fn fk_column(&self) -> String {
132 format!("{}_id", self.field)
133 }
134
135 pub fn to_token(&self) -> String {
138 format!("{}:belongs_to:{}", self.field, self.target)
139 }
140}
141
142#[derive(Debug, Clone, PartialEq, Eq)]
150pub struct ModelSpec {
151 pub name: String,
152 pub fields: Vec<Field>,
153 pub relations: Vec<Relation>,
154}
155
156impl ModelSpec {
157 pub fn parse(name: impl Into<String>, tokens: &[String]) -> Result<Self> {
161 let name = name.into();
162 validate_model_name(&name)?;
163 let mut fields = Vec::new();
164 let mut relations = Vec::new();
165 for token in tokens {
166 let parts: Vec<&str> = token.splitn(3, ':').collect();
167 if parts.len() == 3 && parts[1] == "belongs_to" {
168 let (field, target) = (parts[0], parts[2]);
169 if field.is_empty() || target.is_empty() {
170 bail!("relationship `{token}` must be in the form name:belongs_to:target");
171 }
172 relations.push(Relation {
173 field: field.to_string(),
174 target: target.to_string(),
175 });
176 } else {
177 fields.push(Field::parse(token)?);
178 }
179 }
180 for r in &relations {
182 fields.push(Field {
183 name: r.fk_column(),
184 ty: FieldType::Uuid,
185 });
186 }
187 let model = Self {
188 name,
189 fields,
190 relations,
191 };
192 model.validate()?;
193 Ok(model)
194 }
195
196 pub fn validate_for_dialect(&self, dialect: crate::Dialect) -> Result<()> {
198 self.validate()?;
199 let check = crate::validation::validate_sql_identifier_for_dialect;
200 check(
201 &crate::naming::table_name(&self.name),
202 "generated table",
203 dialect,
204 )?;
205 for field in &self.fields {
206 check(&field.name, "field", dialect)?;
207 }
208 for relation in &self.relations {
209 check(&relation.target, "relationship target", dialect)?;
210 }
211 Ok(())
212 }
213
214 pub fn validate(&self) -> Result<()> {
215 use std::collections::BTreeSet;
216 validate_model_name(&self.name)?;
217 let mut names = BTreeSet::new();
218 for field in &self.fields {
219 validate_column(&field.name)?;
220 ensure!(
221 names.insert(field.name.as_str()),
222 "duplicate field `{}` in model `{}` (including generated relationship columns)",
223 field.name,
224 self.name
225 );
226 }
227 let mut relations = BTreeSet::new();
228 for relation in &self.relations {
229 validate_identifier(&relation.field, "relationship")?;
230 validate_identifier(&relation.target, "relationship target")?;
231 ensure!(
232 relations.insert(relation.field.as_str()),
233 "duplicate relationship `{}` in model `{}`",
234 relation.field,
235 self.name
236 );
237 ensure!(
238 !names.contains(relation.field.as_str()),
239 "relationship `{}` collides with a field in model `{}`",
240 relation.field,
241 self.name
242 );
243 let fk = relation.fk_column();
244 ensure!(
245 self.fields
246 .iter()
247 .any(|f| f.name == fk && f.ty == FieldType::Uuid),
248 "relationship `{}` requires generated UUID field `{fk}`",
249 relation.field
250 );
251 }
252 Ok(())
253 }
254
255 pub fn to_field_tokens(&self) -> Vec<String> {
259 let fk: std::collections::BTreeSet<String> =
260 self.relations.iter().map(Relation::fk_column).collect();
261 self.fields
262 .iter()
263 .filter(|f| !fk.contains(&f.name))
264 .map(Field::to_token)
265 .collect()
266 }
267}
268
269#[cfg(test)]
270mod tests {
271 use super::*;
272
273 #[test]
274 fn parses_a_field() {
275 let f = Field::parse("email:String").unwrap();
276 assert_eq!(f.name, "email");
277 assert_eq!(f.ty, FieldType::String);
278 assert_eq!(f.ty.rust_type(), "String");
279 assert_eq!(f.ty.sql_type(), "TEXT");
280 }
281
282 #[test]
283 fn rejects_unknown_type() {
284 assert!(Field::parse("x:Blob").is_err());
285 }
286
287 #[test]
288 fn rejects_malformed_token() {
289 assert!(Field::parse("notype").is_err());
290 }
291
292 #[test]
293 fn parses_a_model() {
294 let m = ModelSpec::parse(
295 "User",
296 &["name:String".to_string(), "active:bool".to_string()],
297 )
298 .unwrap();
299 assert_eq!(m.name, "User");
300 assert_eq!(m.fields.len(), 2);
301 assert_eq!(m.fields[1].ty, FieldType::Bool);
302 assert!(m.relations.is_empty());
303 }
304
305 #[test]
306 fn parses_a_belongs_to_relationship() {
307 let m = ModelSpec::parse(
308 "Post",
309 &[
310 "title:String".to_string(),
311 "author:belongs_to:users".to_string(),
312 ],
313 )
314 .unwrap();
315 assert_eq!(m.relations.len(), 1);
317 assert_eq!(m.relations[0].field, "author");
318 assert_eq!(m.relations[0].target, "users");
319 assert_eq!(m.relations[0].fk_column(), "author_id");
320 let fk = m.fields.iter().find(|f| f.name == "author_id").unwrap();
322 assert_eq!(fk.ty, FieldType::Uuid);
323 assert_eq!(m.to_field_tokens(), vec!["title:String".to_string()]);
325 }
326
327 #[test]
328 fn rejects_malformed_relationship() {
329 assert!(ModelSpec::parse("Post", &["author:belongs_to:".to_string()]).is_err());
330 assert!(ModelSpec::parse("Post", &[":belongs_to:users".to_string()]).is_err());
331 }
332 #[test]
333 fn rejects_invalid_and_reserved_field_names() {
334 for name in [
335 "",
336 "_",
337 "9name",
338 "first-name",
339 "first name",
340 "Name",
341 "café",
342 "r#type",
343 "../field",
344 "type",
345 "async",
346 "gen",
347 "id",
348 "created_at",
349 "updated_at",
350 ] {
351 let error = Field::parse(&format!("{name}:String"))
352 .unwrap_err()
353 .to_string();
354 assert!(error.contains(name), "{error}");
355 }
356 assert!(Field::parse(&format!("{}:String", "a".repeat(64))).is_err());
357 for name in ["name", "first_name", "address2", "_internal"] {
358 Field::parse(&format!("{name}:String")).unwrap();
359 }
360 }
361
362 #[test]
363 fn rejects_duplicates_and_relationship_collisions() {
364 for fields in [
365 vec!["name:String", "name:i32"],
366 vec!["author_id:Uuid", "author:belongs_to:users"],
367 vec!["author:belongs_to:users", "author_id:Uuid"],
368 vec!["author:belongs_to:users", "author:belongs_to:posts"],
369 vec!["author:String", "author:belongs_to:users"],
370 vec!["type:belongs_to:users"],
371 vec!["author:belongs_to:../users"],
372 vec!["author:belongs_to:users:extra"],
373 ] {
374 let tokens: Vec<_> = fields.iter().map(|f| f.to_string()).collect();
375 assert!(
376 ModelSpec::parse("Post", &tokens).is_err(),
377 "accepted {fields:?}"
378 );
379 }
380 }
381
382 #[test]
383 fn validates_models_and_preserves_valid_relation_roundtrip() {
384 for name in [
385 "",
386 "post",
387 "Self",
388 "String",
389 "Vec",
390 "../Post",
391 "Post-Item",
392 "Post Item",
393 "Post_Item",
394 ] {
395 assert!(ModelSpec::parse(name, &[]).is_err(), "accepted {name}");
396 }
397 let model = ModelSpec::parse(
398 "BlogPost",
399 &["title:String".into(), "user:belongs_to:users".into()],
400 )
401 .unwrap();
402 model.validate().unwrap();
403 let mut tokens = model.to_field_tokens();
404 tokens.extend(model.relations.iter().map(Relation::to_token));
405 assert_eq!(ModelSpec::parse("BlogPost", &tokens).unwrap(), model);
406 }
407}