Skip to main content

gize_core/
field.rs

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