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    pub(crate) 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    pub(crate) 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}