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