Skip to main content

typewriter_graphql/
mapper.rs

1//! GraphQL SDL type mapper implementation.
2
3use typewriter_core::ir::*;
4use typewriter_core::mapper::TypeMapper;
5use typewriter_core::naming::{FileStyle, to_file_style};
6
7use crate::emitter;
8
9/// GraphQL SDL language mapper.
10///
11/// Generates GraphQL Schema Definition Language types from Rust structs and enums.
12///
13/// ## Type Mappings
14///
15/// | Rust | GraphQL |
16/// |------|---------|
17/// | `struct` | `type TypeName { ... }` |
18/// | Simple enum | `enum TypeName { ... }` |
19/// | Data-carrying enum | `union TypeName = ...` + variant types |
20/// | `String` | `String` |
21/// | `bool` | `Boolean` |
22/// | `u8`–`u32`, `i8`–`i32` | `Int` |
23/// | `f32`, `f64` | `Float` |
24/// | `u64`, `i64` etc. | `String` (BigInt not in GraphQL spec) |
25/// | `Option<T>` | nullable (no `!`) |
26/// | `Vec<T>` | `[T!]` |
27/// | `HashMap<K,V>` | `JSON` (custom scalar) |
28/// | `Uuid` | `ID` |
29/// | `DateTime` | `DateTime` (custom scalar) |
30/// | `JsonValue` | `JSON` (custom scalar) |
31pub struct GraphQLMapper {
32    /// File naming style (default: `snake_case`)
33    pub file_style: FileStyle,
34}
35
36impl GraphQLMapper {
37    pub fn new() -> Self {
38        Self {
39            file_style: FileStyle::SnakeCase,
40        }
41    }
42
43    pub fn with_file_style(mut self, style: FileStyle) -> Self {
44        self.file_style = style;
45        self
46    }
47}
48
49impl Default for GraphQLMapper {
50    fn default() -> Self {
51        Self::new()
52    }
53}
54
55impl TypeMapper for GraphQLMapper {
56    fn map_primitive(&self, ty: &PrimitiveType) -> String {
57        match ty {
58            PrimitiveType::String => "String".to_string(),
59            PrimitiveType::Bool => "Boolean".to_string(),
60            PrimitiveType::U8 | PrimitiveType::U16 | PrimitiveType::U32 => "Int".to_string(),
61            PrimitiveType::I8 | PrimitiveType::I16 | PrimitiveType::I32 => "Int".to_string(),
62            PrimitiveType::F32 | PrimitiveType::F64 => "Float".to_string(),
63            // GraphQL has no bigint in the spec; use String for safe JSON transport
64            PrimitiveType::U64 | PrimitiveType::U128 => "String".to_string(),
65            PrimitiveType::I64 | PrimitiveType::I128 => "String".to_string(),
66            PrimitiveType::Uuid => "ID".to_string(),
67            PrimitiveType::DateTime | PrimitiveType::NaiveDate => "DateTime".to_string(),
68            PrimitiveType::JsonValue => "JSON".to_string(),
69        }
70    }
71
72    fn map_option(&self, inner: &TypeKind) -> String {
73        // In GraphQL, optional means the field is nullable (no `!` suffix).
74        // We return the inner type without the `!` marker.
75        self.map_type(inner)
76    }
77
78    fn map_vec(&self, inner: &TypeKind) -> String {
79        format!("[{}!]", self.map_type(inner))
80    }
81
82    fn map_hashmap(&self, _key: &TypeKind, _value: &TypeKind) -> String {
83        // GraphQL has no map type; use a JSON custom scalar
84        "JSON".to_string()
85    }
86
87    fn map_tuple(&self, _elements: &[TypeKind]) -> String {
88        // GraphQL has no tuple type; fall back to JSON
89        "JSON".to_string()
90    }
91
92    fn map_named(&self, name: &str) -> String {
93        name.to_string()
94    }
95
96    fn emit_struct(&self, def: &StructDef) -> String {
97        emitter::render_type(self, def)
98    }
99
100    fn emit_enum(&self, def: &EnumDef) -> String {
101        emitter::render_enum(self, def)
102    }
103
104    fn file_header(&self, type_name: &str) -> String {
105        format!(
106            "# Auto-generated by typewriter v0.4.1. DO NOT EDIT.\n\
107             # Source: {}\n\n",
108            type_name
109        )
110    }
111
112    fn file_extension(&self) -> &str {
113        "graphql"
114    }
115
116    fn emit_imports(&self, def: &TypeDef) -> String {
117        // Collect custom scalars that need to be declared
118        let mut needs_datetime = false;
119        let mut needs_json = false;
120
121        fn scan_type(ty: &TypeKind, needs_datetime: &mut bool, needs_json: &mut bool) {
122            match ty {
123                TypeKind::Primitive(p) => match p {
124                    PrimitiveType::DateTime | PrimitiveType::NaiveDate => {
125                        *needs_datetime = true;
126                    }
127                    PrimitiveType::JsonValue => {
128                        *needs_json = true;
129                    }
130                    _ => {}
131                },
132                TypeKind::Option(inner) | TypeKind::Vec(inner) => {
133                    scan_type(inner, needs_datetime, needs_json);
134                }
135                TypeKind::HashMap(_, _) => {
136                    *needs_json = true;
137                }
138                TypeKind::Tuple(_) => {
139                    *needs_json = true;
140                }
141                TypeKind::Generic(_, params) => {
142                    for p in params {
143                        scan_type(p, needs_datetime, needs_json);
144                    }
145                }
146                _ => {}
147            }
148        }
149
150        match def {
151            TypeDef::Struct(s) => {
152                for field in &s.fields {
153                    if !field.skip {
154                        scan_type(&field.ty, &mut needs_datetime, &mut needs_json);
155                    }
156                }
157            }
158            TypeDef::Enum(e) => {
159                for variant in &e.variants {
160                    match &variant.kind {
161                        VariantKind::Struct(fields) => {
162                            for field in fields {
163                                if !field.skip {
164                                    scan_type(&field.ty, &mut needs_datetime, &mut needs_json);
165                                }
166                            }
167                        }
168                        VariantKind::Tuple(types) => {
169                            for ty in types {
170                                scan_type(ty, &mut needs_datetime, &mut needs_json);
171                            }
172                        }
173                        VariantKind::Unit => {}
174                    }
175                }
176            }
177        }
178
179        let mut output = String::new();
180        if needs_datetime {
181            output.push_str("scalar DateTime\n");
182        }
183        if needs_json {
184            output.push_str("scalar JSON\n");
185        }
186        if !output.is_empty() {
187            output.push('\n');
188        }
189        output
190    }
191
192    fn file_naming(&self, type_name: &str) -> String {
193        to_file_style(type_name, self.file_style)
194    }
195
196    fn map_generic(&self, name: &str, _params: &[TypeKind]) -> String {
197        // GraphQL doesn't have generics — just reference the base type name
198        name.to_string()
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    fn mapper() -> GraphQLMapper {
207        GraphQLMapper::new()
208    }
209
210    #[test]
211    fn test_primitive_mappings() {
212        let m = mapper();
213        assert_eq!(m.map_primitive(&PrimitiveType::String), "String");
214        assert_eq!(m.map_primitive(&PrimitiveType::Bool), "Boolean");
215        assert_eq!(m.map_primitive(&PrimitiveType::U32), "Int");
216        assert_eq!(m.map_primitive(&PrimitiveType::I32), "Int");
217        assert_eq!(m.map_primitive(&PrimitiveType::F64), "Float");
218        assert_eq!(m.map_primitive(&PrimitiveType::U64), "String");
219        assert_eq!(m.map_primitive(&PrimitiveType::I64), "String");
220        assert_eq!(m.map_primitive(&PrimitiveType::Uuid), "ID");
221        assert_eq!(m.map_primitive(&PrimitiveType::DateTime), "DateTime");
222        assert_eq!(m.map_primitive(&PrimitiveType::JsonValue), "JSON");
223    }
224
225    #[test]
226    fn test_option_mapping() {
227        let m = mapper();
228        assert_eq!(
229            m.map_option(&TypeKind::Primitive(PrimitiveType::U32)),
230            "Int"
231        );
232    }
233
234    #[test]
235    fn test_vec_mapping() {
236        let m = mapper();
237        assert_eq!(
238            m.map_vec(&TypeKind::Primitive(PrimitiveType::String)),
239            "[String!]"
240        );
241    }
242
243    #[test]
244    fn test_hashmap_mapping() {
245        let m = mapper();
246        assert_eq!(
247            m.map_hashmap(
248                &TypeKind::Primitive(PrimitiveType::String),
249                &TypeKind::Primitive(PrimitiveType::U32)
250            ),
251            "JSON"
252        );
253    }
254
255    #[test]
256    fn test_tuple_mapping() {
257        let m = mapper();
258        assert_eq!(
259            m.map_tuple(&[
260                TypeKind::Primitive(PrimitiveType::String),
261                TypeKind::Primitive(PrimitiveType::U32)
262            ]),
263            "JSON"
264        );
265    }
266
267    #[test]
268    fn test_file_naming() {
269        let m = mapper();
270        assert_eq!(m.file_naming("UserProfile"), "user_profile");
271        assert_eq!(m.file_naming("User"), "user");
272    }
273
274    #[test]
275    fn test_output_filename() {
276        let m = mapper();
277        assert_eq!(m.output_filename("UserProfile"), "user_profile.graphql");
278    }
279
280    #[test]
281    fn test_file_naming_kebab() {
282        let m = GraphQLMapper::new().with_file_style(FileStyle::KebabCase);
283        assert_eq!(m.file_naming("UserProfile"), "user-profile");
284        assert_eq!(m.output_filename("UserProfile"), "user-profile.graphql");
285    }
286
287    #[test]
288    fn test_generic_mapped_as_base_name() {
289        let m = mapper();
290        assert_eq!(
291            m.map_generic("Pagination", &[TypeKind::Named("User".to_string())]),
292            "Pagination"
293        );
294    }
295}