Skip to main content

postrust_graphql/
types.rs

1//! PostgreSQL to GraphQL type mapping.
2
3use std::fmt;
4
5/// Represents a GraphQL type.
6#[derive(Debug, Clone, PartialEq, Eq)]
7pub enum GraphQLType {
8    /// GraphQL Int (32-bit signed integer)
9    Int,
10    /// GraphQL Float (double-precision floating point)
11    Float,
12    /// GraphQL String
13    String,
14    /// GraphQL Boolean
15    Boolean,
16    /// GraphQL ID
17    Id,
18    /// Custom BigInt scalar (64-bit integer)
19    BigInt,
20    /// Custom BigDecimal scalar (arbitrary precision)
21    BigDecimal,
22    /// Custom JSON scalar
23    Json,
24    /// Custom UUID scalar
25    Uuid,
26    /// Custom Date scalar
27    Date,
28    /// Custom DateTime scalar
29    DateTime,
30    /// Custom Time scalar
31    Time,
32    /// List type wrapping another type
33    List(Box<GraphQLType>),
34    /// Custom/unknown type (falls back to String)
35    Custom(std::string::String),
36}
37
38impl fmt::Display for GraphQLType {
39    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
40        match self {
41            GraphQLType::Int => write!(f, "Int"),
42            GraphQLType::Float => write!(f, "Float"),
43            GraphQLType::String => write!(f, "String"),
44            GraphQLType::Boolean => write!(f, "Boolean"),
45            GraphQLType::Id => write!(f, "ID"),
46            // Named as PostgreSQL names them, which is how Hasura names them
47            // and therefore how a client's own queries declare their
48            // variables: `query ($x: jsonb!)` names a type that has to exist
49            // under exactly that spelling.
50            GraphQLType::BigInt => write!(f, "bigint"),
51            GraphQLType::BigDecimal => write!(f, "numeric"),
52            GraphQLType::Json => write!(f, "jsonb"),
53            GraphQLType::Uuid => write!(f, "uuid"),
54            GraphQLType::Date => write!(f, "date"),
55            GraphQLType::DateTime => write!(f, "timestamptz"),
56            GraphQLType::Time => write!(f, "time"),
57            GraphQLType::List(inner) => write!(f, "[{}]", inner),
58            GraphQLType::Custom(name) => write!(f, "{}", name),
59        }
60    }
61}
62
63/// Maps a PostgreSQL type name to a GraphQL type.
64pub fn pg_type_to_graphql(pg_type: &str) -> GraphQLType {
65    // Normalize the type name
66    let normalized = pg_type.to_lowercase().trim().to_string();
67
68    // Check for array types first
69    if let Some(inner_type) = normalized.strip_prefix('_') {
70        // PostgreSQL array types start with underscore (e.g., _int4)
71        return GraphQLType::List(Box::new(pg_type_to_graphql(inner_type)));
72    }
73
74    if normalized.ends_with("[]") {
75        // Alternative array syntax (e.g., integer[])
76        let inner_type = normalized.trim_end_matches("[]");
77        return GraphQLType::List(Box::new(pg_type_to_graphql(inner_type)));
78    }
79
80    match normalized.as_str() {
81        // Integer types
82        "integer" | "int" | "int4" | "smallint" | "int2" => GraphQLType::Int,
83
84        // BigInt types
85        "bigint" | "int8" => GraphQLType::BigInt,
86
87        // Float types
88        "real" | "float4" | "double precision" | "float8" => GraphQLType::Float,
89
90        // Numeric/Decimal types
91        "numeric" | "decimal" => GraphQLType::BigDecimal,
92
93        // Boolean
94        "boolean" | "bool" => GraphQLType::Boolean,
95
96        // String types
97        "text" | "varchar" | "character varying" | "char" | "character" | "bpchar" => {
98            GraphQLType::String
99        }
100
101        // `json` and `jsonb` are different types to PostgreSQL and to a
102        // client declaring a variable, however alike they look here.
103        "jsonb" => GraphQLType::Json,
104        "json" => GraphQLType::Custom("json".to_string()),
105
106        // UUID
107        "uuid" => GraphQLType::Uuid,
108
109        // Date/Time types
110        "timestamptz" | "timestamp with time zone" => GraphQLType::DateTime,
111        "timestamp" | "timestamp without time zone" => GraphQLType::Custom("timestamp".to_string()),
112        "date" => GraphQLType::Date,
113        "time" | "time without time zone" => GraphQLType::Time,
114        "timetz" | "time with time zone" => GraphQLType::Custom("timetz".to_string()),
115
116        // Types with no GraphQL equivalent keep their own name. A client that
117        // was generated against Hasura declares `$point: geometry!` and
118        // `$tags: _text`, and a scalar named anything else is a type that does
119        // not exist as far as that query is concerned. This also covers user
120        // types the database defines -- an enum arrives here as its own type
121        // name and becomes a scalar under it.
122        other => GraphQLType::Custom(other.to_string()),
123    }
124}
125
126/// Check if a PostgreSQL type is nullable in GraphQL context.
127pub fn is_nullable_type(nullable: bool, is_pk: bool) -> bool {
128    // Primary keys are never null in GraphQL
129    if is_pk {
130        return false;
131    }
132    nullable
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138    use pretty_assertions::assert_eq;
139
140    #[test]
141    fn test_pg_to_graphql_integer_types() {
142        assert_eq!(pg_type_to_graphql("integer"), GraphQLType::Int);
143        assert_eq!(pg_type_to_graphql("int4"), GraphQLType::Int);
144        assert_eq!(pg_type_to_graphql("int"), GraphQLType::Int);
145        assert_eq!(pg_type_to_graphql("smallint"), GraphQLType::Int);
146        assert_eq!(pg_type_to_graphql("int2"), GraphQLType::Int);
147    }
148
149    #[test]
150    fn test_pg_to_graphql_bigint() {
151        assert_eq!(pg_type_to_graphql("bigint"), GraphQLType::BigInt);
152        assert_eq!(pg_type_to_graphql("int8"), GraphQLType::BigInt);
153    }
154
155    #[test]
156    fn test_pg_to_graphql_float_types() {
157        assert_eq!(pg_type_to_graphql("real"), GraphQLType::Float);
158        assert_eq!(pg_type_to_graphql("float4"), GraphQLType::Float);
159        assert_eq!(pg_type_to_graphql("double precision"), GraphQLType::Float);
160        assert_eq!(pg_type_to_graphql("float8"), GraphQLType::Float);
161    }
162
163    #[test]
164    fn test_pg_to_graphql_numeric_types() {
165        assert_eq!(pg_type_to_graphql("numeric"), GraphQLType::BigDecimal);
166        assert_eq!(pg_type_to_graphql("decimal"), GraphQLType::BigDecimal);
167    }
168
169    #[test]
170    fn test_pg_to_graphql_string_types() {
171        assert_eq!(pg_type_to_graphql("text"), GraphQLType::String);
172        assert_eq!(pg_type_to_graphql("varchar"), GraphQLType::String);
173        assert_eq!(pg_type_to_graphql("character varying"), GraphQLType::String);
174        assert_eq!(pg_type_to_graphql("char"), GraphQLType::String);
175        assert_eq!(pg_type_to_graphql("bpchar"), GraphQLType::String);
176    }
177
178    #[test]
179    fn test_pg_to_graphql_boolean() {
180        assert_eq!(pg_type_to_graphql("boolean"), GraphQLType::Boolean);
181        assert_eq!(pg_type_to_graphql("bool"), GraphQLType::Boolean);
182    }
183
184    #[test]
185    fn test_pg_to_graphql_json() {
186        // Different types to PostgreSQL, and to a client declaring a variable.
187        assert_eq!(pg_type_to_graphql("jsonb"), GraphQLType::Json);
188        assert_eq!(
189            pg_type_to_graphql("json"),
190            GraphQLType::Custom("json".to_string())
191        );
192    }
193
194    #[test]
195    fn test_pg_to_graphql_uuid() {
196        assert_eq!(pg_type_to_graphql("uuid"), GraphQLType::Uuid);
197    }
198
199    #[test]
200    fn test_pg_to_graphql_datetime_types() {
201        assert_eq!(
202            pg_type_to_graphql("timestamp"),
203            GraphQLType::Custom("timestamp".to_string())
204        );
205        assert_eq!(pg_type_to_graphql("timestamptz"), GraphQLType::DateTime);
206        assert_eq!(
207            pg_type_to_graphql("timestamp with time zone"),
208            GraphQLType::DateTime
209        );
210        assert_eq!(
211            pg_type_to_graphql("timestamp without time zone"),
212            GraphQLType::Custom("timestamp".to_string())
213        );
214    }
215
216    #[test]
217    fn test_pg_to_graphql_date() {
218        assert_eq!(pg_type_to_graphql("date"), GraphQLType::Date);
219    }
220
221    #[test]
222    fn test_pg_to_graphql_time() {
223        assert_eq!(pg_type_to_graphql("time"), GraphQLType::Time);
224        assert_eq!(
225            pg_type_to_graphql("timetz"),
226            GraphQLType::Custom("timetz".to_string())
227        );
228        assert_eq!(
229            pg_type_to_graphql("time with time zone"),
230            GraphQLType::Custom("timetz".to_string())
231        );
232    }
233
234    #[test]
235    fn test_pg_to_graphql_array_types_underscore() {
236        assert_eq!(
237            pg_type_to_graphql("_int4"),
238            GraphQLType::List(Box::new(GraphQLType::Int))
239        );
240        assert_eq!(
241            pg_type_to_graphql("_text"),
242            GraphQLType::List(Box::new(GraphQLType::String))
243        );
244        assert_eq!(
245            pg_type_to_graphql("_uuid"),
246            GraphQLType::List(Box::new(GraphQLType::Uuid))
247        );
248    }
249
250    #[test]
251    fn test_pg_to_graphql_array_types_bracket() {
252        assert_eq!(
253            pg_type_to_graphql("integer[]"),
254            GraphQLType::List(Box::new(GraphQLType::Int))
255        );
256        assert_eq!(
257            pg_type_to_graphql("text[]"),
258            GraphQLType::List(Box::new(GraphQLType::String))
259        );
260    }
261
262    #[test]
263    fn test_pg_to_graphql_unknown_keeps_its_own_name() {
264        // A client generated against Hasura declares `$point: geometry!`, and
265        // a scalar named anything else is a type its query cannot name.
266        assert_eq!(
267            pg_type_to_graphql("customtype"),
268            GraphQLType::Custom("customtype".to_string())
269        );
270        assert_eq!(
271            pg_type_to_graphql("my_domain"),
272            GraphQLType::Custom("my_domain".to_string())
273        );
274    }
275
276    #[test]
277    fn test_pg_to_graphql_case_insensitive() {
278        assert_eq!(pg_type_to_graphql("INTEGER"), GraphQLType::Int);
279        assert_eq!(pg_type_to_graphql("Text"), GraphQLType::String);
280        assert_eq!(pg_type_to_graphql("BOOLEAN"), GraphQLType::Boolean);
281    }
282
283    #[test]
284    fn test_graphql_type_display() {
285        assert_eq!(format!("{}", GraphQLType::Int), "Int");
286        assert_eq!(format!("{}", GraphQLType::String), "String");
287        assert_eq!(
288            format!("{}", GraphQLType::List(Box::new(GraphQLType::Int))),
289            "[Int]"
290        );
291    }
292
293    #[test]
294    fn test_is_nullable_type() {
295        // PK is never nullable
296        assert!(!is_nullable_type(true, true));
297        assert!(!is_nullable_type(false, true));
298
299        // Non-PK follows the nullable flag
300        assert!(is_nullable_type(true, false));
301        assert!(!is_nullable_type(false, false));
302    }
303}