Skip to main content

gize_core/
field.rs

1//! Model and field definitions parsed from the CLI (`name:String email:String ...`).
2
3use crate::validation::{validate_column, validate_identifier, validate_model_name};
4use anyhow::{Context, Result, bail, ensure};
5use serde::{Deserialize, Serialize};
6
7/// The set of scalar field types Gize understands in the MVP.
8///
9/// Kept deliberately small and explicit (ADR-003 / ADR-011): each variant maps to a Rust
10/// type and a SQL column type. Unknown types are rejected early with a helpful error.
11#[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    /// Parse a field type from its CLI spelling (case-insensitive).
24    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    /// The idiomatic Rust type used in generated structs.
41    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    /// Whether the mapped Rust type is `Copy`. Used by generators to decide between
54    /// `.bind(x)` (Copy) and `.bind(x.clone())` (owned), keeping generated code
55    /// clippy-clean (`clone_on_copy`).
56    pub fn is_copy(self) -> bool {
57        // `String` is the only non-Copy type in the MVP set; `uuid::Uuid` and
58        // `chrono::DateTime<Utc>` are both Copy.
59        !matches!(self, Self::String)
60    }
61
62    /// The canonical CLI/manifest spelling of this type (the inverse of [`Self::parse`]).
63    /// Used to serialize a field back into a `name:Type` token for `gize.toml` so the
64    /// manifest is normalized regardless of the spelling the user typed (ADR-009 revision).
65    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    /// The PostgreSQL column type used in generated migrations (ADR-011).
78    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/// A single field of a model: `name:Type`.
92#[derive(Debug, Clone, PartialEq, Eq)]
93pub struct Field {
94    pub name: String,
95    pub ty: FieldType,
96}
97
98impl Field {
99    /// Serialize this field back into its canonical `name:Type` token (inverse of
100    /// [`Self::parse`] for the type part), for recording in `gize.toml`.
101    pub fn to_token(&self) -> String {
102        format!("{}:{}", self.name, self.ty.as_token())
103    }
104
105    /// Parse one `name:Type` token.
106    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/// A `belongs_to` relationship declared on a model (ADR-014): a foreign key from this model
119/// to `target`'s primary key. `field` is the local name (`author`), which yields the FK
120/// column `author_id`. Serialized under `[[module.belongs_to]]` in `gize.toml`.
121#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
122pub struct Relation {
123    /// The local relationship name, e.g. `author`.
124    pub field: String,
125    /// The referenced module/table, e.g. `users`.
126    pub target: String,
127}
128
129impl Relation {
130    /// The foreign-key column this relationship produces: `author` -> `author_id`.
131    pub fn fk_column(&self) -> String {
132        format!("{}_id", self.field)
133    }
134
135    /// The `name:belongs_to:target` token spelling, for reconstructing a model from the
136    /// manifest and for round-tripping.
137    pub fn to_token(&self) -> String {
138        format!("{}:belongs_to:{}", self.field, self.target)
139    }
140}
141
142/// A model: a name plus its fields and relationships, as produced by
143/// `gize make crud Name f:T author:belongs_to:users ...`.
144///
145/// Each relationship is also expanded into a synthetic `<name>_id: Uuid` [`Field`] appended
146/// to `fields`, so every field-driven template (model, dto, repository, …) picks up the
147/// foreign-key column for free; only the migration consults `relations` directly, to emit the
148/// `FOREIGN KEY` constraint.
149#[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    /// Build a model spec from a name and raw tokens. A token of the form
158    /// `name:belongs_to:target` is parsed as a relationship; anything else is a scalar
159    /// `name:Type` field.
160    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        // Expand each relationship into its foreign-key column so the code templates render it.
181        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    /// Validate an expanded model, including specs assembled programmatically.
197    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    /// Serialize the scalar fields back into canonical `name:Type` tokens, for recording the
256    /// model's shape in `gize.toml` (ADR-009 revision). Synthetic foreign-key columns are
257    /// omitted — relationships are recorded separately under `[[module.belongs_to]]`.
258    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        // The relationship is recorded...
316        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        // ...and expanded into a synthetic UUID foreign-key field for codegen.
321        let fk = m.fields.iter().find(|f| f.name == "author_id").unwrap();
322        assert_eq!(fk.ty, FieldType::Uuid);
323        // ...but the FK column is not re-emitted as a scalar field token.
324        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}