Skip to main content

forge_core/schema/
field.rs

1use serde::{Deserialize, Serialize};
2
3use super::types::{RustType, SqlType};
4
5/// Definition of a model field.
6#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct FieldDef {
8    /// Field name in Rust (snake_case).
9    pub name: String,
10
11    /// Column name in SQL (may differ from field name).
12    pub column_name: String,
13
14    /// Rust type.
15    pub rust_type: RustType,
16
17    /// SQL type.
18    pub sql_type: SqlType,
19
20    /// Whether the field is nullable.
21    pub nullable: bool,
22
23    /// Documentation comment.
24    pub doc: Option<String>,
25}
26
27impl FieldDef {
28    /// Create a new field definition.
29    pub fn new(name: &str, rust_type: RustType) -> Self {
30        let sql_type = rust_type.to_sql_type();
31        let nullable = rust_type.is_nullable();
32        let column_name = to_snake_case(name);
33
34        Self {
35            name: name.to_string(),
36            column_name,
37            rust_type,
38            sql_type,
39            nullable,
40            doc: None,
41        }
42    }
43
44    pub fn to_typescript(&self) -> String {
45        let (ts_type, optional) = if self.nullable {
46            let inner_type = match &self.rust_type {
47                super::types::RustType::Option(inner) => inner.to_typescript(),
48                other => other.to_typescript(),
49            };
50            (inner_type, "?")
51        } else {
52            (self.rust_type.to_typescript(), "")
53        };
54        format!("  {}{}: {};", self.name, optional, ts_type)
55    }
56}
57
58/// Convert a string to snake_case.
59fn to_snake_case(s: &str) -> String {
60    let mut result = String::new();
61    for (i, c) in s.chars().enumerate() {
62        if c.is_uppercase() {
63            if i > 0 {
64                result.push('_');
65            }
66            // to_lowercase always yields at least one char for uppercase input
67            for lc in c.to_lowercase() {
68                result.push(lc);
69            }
70        } else {
71            result.push(c);
72        }
73    }
74    result
75}
76
77#[cfg(test)]
78#[allow(clippy::unwrap_used, clippy::indexing_slicing)]
79mod tests {
80    use super::*;
81
82    #[test]
83    fn test_field_def_basic() {
84        let field = FieldDef::new("email", RustType::String);
85        assert_eq!(field.name, "email");
86        assert_eq!(field.column_name, "email");
87        assert!(!field.nullable);
88    }
89
90    #[test]
91    fn test_field_def_nullable() {
92        let field = FieldDef::new("avatar_url", RustType::Option(Box::new(RustType::String)));
93        assert!(field.nullable);
94    }
95
96    #[test]
97    fn test_to_snake_case() {
98        assert_eq!(to_snake_case("createdAt"), "created_at");
99        assert_eq!(to_snake_case("userId"), "user_id");
100        assert_eq!(to_snake_case("HTTPServer"), "h_t_t_p_server");
101    }
102}