Skip to main content

fraiseql_core/compiler/
enum_validator.rs

1//! Enum type validation and parsing for GraphQL schemas.
2//!
3//! Handles parsing of GraphQL enum type definitions from JSON schema and validates
4//! enum structure, naming conventions, and value uniqueness.
5
6use serde_json::Value;
7
8use crate::{
9    compiler::ir::{IREnum, IREnumValue},
10    error::{FraiseQLError, Result},
11};
12
13/// Enum type validator and parser.
14///
15/// Validates GraphQL enum definitions for:
16/// - Correct structure (name, values)
17/// - Unique enum values
18/// - Valid naming conventions
19/// - Proper descriptions
20#[derive(Debug)]
21pub struct EnumValidator;
22
23impl EnumValidator {
24    /// Parse enum definitions from JSON schema.
25    ///
26    /// # Arguments
27    ///
28    /// * `enums_value` - JSON array of enum definitions
29    ///
30    /// # Returns
31    ///
32    /// Vec of parsed IREnum definitions
33    ///
34    /// # Example JSON Structure
35    ///
36    /// ```json
37    /// {
38    ///   "enums": [
39    ///     {
40    ///       "name": "UserStatus",
41    ///       "description": "User account status",
42    ///       "values": [
43    ///         {
44    ///           "name": "ACTIVE",
45    ///           "description": "User is active",
46    ///           "deprecationReason": null
47    ///         },
48    ///         {
49    ///           "name": "INACTIVE"
50    ///         }
51    ///       ]
52    ///     }
53    ///   ]
54    /// }
55    /// ```
56    pub fn parse_enums(enums_value: &Value) -> Result<Vec<IREnum>> {
57        let enums_arr = enums_value.as_array().ok_or_else(|| FraiseQLError::Validation {
58            message: "enums must be an array".to_string(),
59            path:    Some("schema.enums".to_string()),
60        })?;
61
62        let mut enums = Vec::new();
63        for (idx, enum_def) in enums_arr.iter().enumerate() {
64            let enum_obj = enum_def.as_object().ok_or_else(|| FraiseQLError::Validation {
65                message: format!("enum at index {} must be an object", idx),
66                path:    Some(format!("schema.enums[{}]", idx)),
67            })?;
68
69            let enum_type = Self::parse_single_enum(enum_obj, idx)?;
70            enums.push(enum_type);
71        }
72
73        Ok(enums)
74    }
75
76    /// Parse a single enum definition from JSON object.
77    ///
78    /// # Arguments
79    ///
80    /// * `enum_obj` - JSON object containing enum definition
81    /// * `index` - Index in array for error reporting
82    fn parse_single_enum(
83        enum_obj: &serde_json::Map<String, Value>,
84        index: usize,
85    ) -> Result<IREnum> {
86        // Extract name
87        let name = enum_obj
88            .get("name")
89            .and_then(|v| v.as_str())
90            .ok_or_else(|| FraiseQLError::Validation {
91                message: "enum must have a name".to_string(),
92                path:    Some(format!("schema.enums[{}].name", index)),
93            })?
94            .to_string();
95
96        // Validate enum name
97        Self::validate_enum_name(&name)?;
98
99        // Extract description (optional)
100        let description =
101            enum_obj.get("description").and_then(|v| v.as_str()).map(|s| s.to_string());
102
103        // Parse enum values
104        let values_value = enum_obj.get("values").ok_or_else(|| FraiseQLError::Validation {
105            message: format!("enum '{}' must have 'values' field", name),
106            path:    Some(format!("schema.enums[{}].values", index)),
107        })?;
108
109        let values = Self::parse_enum_values(values_value, &name)?;
110
111        // Validate that enum has at least one value
112        if values.is_empty() {
113            return Err(FraiseQLError::Validation {
114                message: format!("enum '{}' must have at least one value", name),
115                path:    Some(format!("schema.enums[{}].values", index)),
116            });
117        }
118
119        Ok(IREnum {
120            name,
121            values,
122            description,
123        })
124    }
125
126    /// Parse enum values from JSON array.
127    ///
128    /// # Arguments
129    ///
130    /// * `values_value` - JSON array of enum values
131    /// * `enum_name` - Name of the enum (for error messages)
132    fn parse_enum_values(values_value: &Value, enum_name: &str) -> Result<Vec<IREnumValue>> {
133        let values_arr = values_value.as_array().ok_or_else(|| FraiseQLError::Validation {
134            message: format!("enum '{}' values must be an array", enum_name),
135            path:    Some(format!("schema.enums.{}.values", enum_name)),
136        })?;
137
138        let mut values = Vec::new();
139        let mut seen_names = std::collections::HashSet::new();
140
141        for (idx, value_def) in values_arr.iter().enumerate() {
142            let value_obj = value_def.as_object().ok_or_else(|| FraiseQLError::Validation {
143                message: format!("enum '{}' value at index {} must be an object", enum_name, idx),
144                path:    Some(format!("schema.enums.{}.values[{}]", enum_name, idx)),
145            })?;
146
147            // Extract value name
148            let value_name = value_obj
149                .get("name")
150                .and_then(|v| v.as_str())
151                .ok_or_else(|| FraiseQLError::Validation {
152                    message: format!(
153                        "enum '{}' value at index {} must have a name",
154                        enum_name, idx
155                    ),
156                    path:    Some(format!("schema.enums.{}.values[{}].name", enum_name, idx)),
157                })?
158                .to_string();
159
160            // Validate enum value name
161            Self::validate_enum_value_name(&value_name, enum_name)?;
162
163            // Check for duplicate values
164            if !seen_names.insert(value_name.clone()) {
165                return Err(FraiseQLError::Validation {
166                    message: format!("enum '{}' has duplicate value '{}'", enum_name, value_name),
167                    path:    Some(format!("schema.enums.{}.values", enum_name)),
168                });
169            }
170
171            // Extract description (optional)
172            let description =
173                value_obj.get("description").and_then(|v| v.as_str()).map(|s| s.to_string());
174
175            // Extract deprecation reason (optional)
176            let deprecation_reason = value_obj
177                .get("deprecationReason")
178                .and_then(|v| v.as_str())
179                .map(|s| s.to_string());
180
181            values.push(IREnumValue {
182                name: value_name,
183                description,
184                deprecation_reason,
185            });
186        }
187
188        Ok(values)
189    }
190
191    /// Validate enum type name follows GraphQL naming conventions.
192    ///
193    /// Valid names: PascalCase starting with letter, alphanumeric + underscore
194    fn validate_enum_name(name: &str) -> Result<()> {
195        if name.is_empty() {
196            return Err(FraiseQLError::Validation {
197                message: "enum name cannot be empty".to_string(),
198                path:    Some("schema.enums.name".to_string()),
199            });
200        }
201
202        if !name.chars().next().unwrap().is_alphabetic() {
203            return Err(FraiseQLError::Validation {
204                message: format!("enum name '{}' must start with a letter", name),
205                path:    Some("schema.enums.name".to_string()),
206            });
207        }
208
209        if !name.chars().all(|c| c.is_alphanumeric() || c == '_') {
210            return Err(FraiseQLError::Validation {
211                message: format!(
212                    "enum name '{}' contains invalid characters (use alphanumeric and underscore)",
213                    name
214                ),
215                path:    Some("schema.enums.name".to_string()),
216            });
217        }
218
219        Ok(())
220    }
221
222    /// Validate enum value name (should be SCREAMING_SNAKE_CASE).
223    ///
224    /// Valid names: UPPERCASE with underscores
225    fn validate_enum_value_name(name: &str, enum_name: &str) -> Result<()> {
226        if name.is_empty() {
227            return Err(FraiseQLError::Validation {
228                message: format!("enum '{}' value name cannot be empty", enum_name),
229                path:    Some(format!("schema.enums.{}.values.name", enum_name)),
230            });
231        }
232
233        if !name.chars().all(|c| c.is_uppercase() || c.is_numeric() || c == '_') {
234            return Err(FraiseQLError::Validation {
235                message: format!(
236                    "enum '{}' value '{}' should use SCREAMING_SNAKE_CASE (uppercase with underscores)",
237                    enum_name, name
238                ),
239                path:    Some(format!("schema.enums.{}.values.name", enum_name)),
240            });
241        }
242
243        // Check that it doesn't start with underscore
244        if name.starts_with('_') {
245            return Err(FraiseQLError::Validation {
246                message: format!(
247                    "enum '{}' value '{}' cannot start with underscore",
248                    enum_name, name
249                ),
250                path:    Some(format!("schema.enums.{}.values.name", enum_name)),
251            });
252        }
253
254        Ok(())
255    }
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261
262    #[test]
263    fn test_parse_simple_enum() {
264        let json = serde_json::json!([
265            {
266                "name": "Status",
267                "values": [
268                    {"name": "ACTIVE"},
269                    {"name": "INACTIVE"}
270                ]
271            }
272        ]);
273
274        let result = EnumValidator::parse_enums(&json);
275        assert!(result.is_ok());
276        let enums = result.unwrap();
277        assert_eq!(enums.len(), 1);
278        assert_eq!(enums[0].name, "Status");
279        assert_eq!(enums[0].values.len(), 2);
280    }
281
282    #[test]
283    fn test_parse_enum_with_description() {
284        let json = serde_json::json!([
285            {
286                "name": "UserStatus",
287                "description": "User account status",
288                "values": [
289                    {
290                        "name": "ACTIVE",
291                        "description": "User is active"
292                    }
293                ]
294            }
295        ]);
296
297        let result = EnumValidator::parse_enums(&json);
298        assert!(result.is_ok());
299        let enums = result.unwrap();
300        assert_eq!(enums[0].description, Some("User account status".to_string()));
301        assert_eq!(enums[0].values[0].description, Some("User is active".to_string()));
302    }
303
304    #[test]
305    fn test_parse_enum_with_deprecation() {
306        let json = serde_json::json!([
307            {
308                "name": "Status",
309                "values": [
310                    {
311                        "name": "OLD_STATUS",
312                        "deprecationReason": "Use NEW_STATUS instead"
313                    }
314                ]
315            }
316        ]);
317
318        let result = EnumValidator::parse_enums(&json);
319        assert!(result.is_ok());
320        let enums = result.unwrap();
321        assert_eq!(
322            enums[0].values[0].deprecation_reason,
323            Some("Use NEW_STATUS instead".to_string())
324        );
325    }
326
327    #[test]
328    fn test_parse_multiple_enums() {
329        let json = serde_json::json!([
330            {
331                "name": "Status",
332                "values": [{"name": "ACTIVE"}]
333            },
334            {
335                "name": "Priority",
336                "values": [{"name": "HIGH"}, {"name": "LOW"}]
337            }
338        ]);
339
340        let result = EnumValidator::parse_enums(&json);
341        assert!(result.is_ok());
342        let enums = result.unwrap();
343        assert_eq!(enums.len(), 2);
344    }
345
346    #[test]
347    fn test_enum_not_array() {
348        let json = serde_json::json!({"name": "Status"});
349        let result = EnumValidator::parse_enums(&json);
350        assert!(result.is_err());
351    }
352
353    #[test]
354    fn test_enum_missing_name() {
355        let json = serde_json::json!([
356            {
357                "values": [{"name": "ACTIVE"}]
358            }
359        ]);
360
361        let result = EnumValidator::parse_enums(&json);
362        assert!(result.is_err());
363    }
364
365    #[test]
366    fn test_enum_missing_values() {
367        let json = serde_json::json!([
368            {
369                "name": "Status"
370            }
371        ]);
372
373        let result = EnumValidator::parse_enums(&json);
374        assert!(result.is_err());
375    }
376
377    #[test]
378    fn test_enum_empty_values() {
379        let json = serde_json::json!([
380            {
381                "name": "Status",
382                "values": []
383            }
384        ]);
385
386        let result = EnumValidator::parse_enums(&json);
387        assert!(result.is_err());
388    }
389
390    #[test]
391    fn test_enum_duplicate_values() {
392        let json = serde_json::json!([
393            {
394                "name": "Status",
395                "values": [
396                    {"name": "ACTIVE"},
397                    {"name": "ACTIVE"}
398                ]
399            }
400        ]);
401
402        let result = EnumValidator::parse_enums(&json);
403        assert!(result.is_err());
404    }
405
406    #[test]
407    fn test_enum_value_missing_name() {
408        let json = serde_json::json!([
409            {
410                "name": "Status",
411                "values": [
412                    {"description": "Active status"}
413                ]
414            }
415        ]);
416
417        let result = EnumValidator::parse_enums(&json);
418        assert!(result.is_err());
419    }
420
421    #[test]
422    fn test_validate_enum_name_valid() {
423        assert!(EnumValidator::validate_enum_name("Status").is_ok());
424        assert!(EnumValidator::validate_enum_name("UserStatus").is_ok());
425        assert!(EnumValidator::validate_enum_name("Status2").is_ok());
426    }
427
428    #[test]
429    fn test_validate_enum_name_invalid_start() {
430        assert!(EnumValidator::validate_enum_name("2Status").is_err());
431    }
432
433    #[test]
434    fn test_validate_enum_name_invalid_chars() {
435        assert!(EnumValidator::validate_enum_name("Status-Type").is_err());
436        assert!(EnumValidator::validate_enum_name("Status Type").is_err());
437    }
438
439    #[test]
440    fn test_validate_enum_value_valid() {
441        assert!(EnumValidator::validate_enum_value_name("ACTIVE", "Status").is_ok());
442        assert!(EnumValidator::validate_enum_value_name("ACTIVE_STATUS", "Status").is_ok());
443        assert!(EnumValidator::validate_enum_value_name("ACTIVE_STATUS_2", "Status").is_ok());
444    }
445
446    #[test]
447    fn test_validate_enum_value_invalid_lowercase() {
448        assert!(EnumValidator::validate_enum_value_name("Active", "Status").is_err());
449    }
450
451    #[test]
452    fn test_validate_enum_value_invalid_start_underscore() {
453        assert!(EnumValidator::validate_enum_value_name("_ACTIVE", "Status").is_err());
454    }
455
456    #[test]
457    fn test_enum_name_empty() {
458        assert!(EnumValidator::validate_enum_name("").is_err());
459    }
460
461    #[test]
462    fn test_parse_complex_enum_scenario() {
463        let json = serde_json::json!([
464            {
465                "name": "OrderStatus",
466                "description": "Order processing status",
467                "values": [
468                    {
469                        "name": "PENDING",
470                        "description": "Order awaiting processing"
471                    },
472                    {
473                        "name": "PROCESSING",
474                        "description": "Order is being processed"
475                    },
476                    {
477                        "name": "COMPLETED",
478                        "description": "Order has been completed"
479                    },
480                    {
481                        "name": "CANCELLED",
482                        "description": "Order was cancelled",
483                        "deprecationReason": "Use VOID instead"
484                    }
485                ]
486            }
487        ]);
488
489        let result = EnumValidator::parse_enums(&json);
490        assert!(result.is_ok());
491        let enums = result.unwrap();
492        assert_eq!(enums[0].name, "OrderStatus");
493        assert_eq!(enums[0].values.len(), 4);
494        assert!(enums[0].values[3].deprecation_reason.is_some());
495    }
496
497    #[test]
498    fn test_serialization_roundtrip() {
499        let enum_val = IREnum {
500            name:        "Status".to_string(),
501            values:      vec![IREnumValue {
502                name:               "ACTIVE".to_string(),
503                description:        Some("Active status".to_string()),
504                deprecation_reason: None,
505            }],
506            description: Some("Status enum".to_string()),
507        };
508
509        let json = serde_json::to_string(&enum_val).expect("serialize should work");
510        let restored: IREnum = serde_json::from_str(&json).expect("deserialize should work");
511
512        assert_eq!(restored.name, enum_val.name);
513        assert_eq!(restored.values.len(), enum_val.values.len());
514    }
515}