Skip to main content

fraiseql_core/utils/
casing.rs

1//! Field name case conversion (camelCase → `snake_case`).
2//!
3//! This module handles converting GraphQL field names (typically camelCase)
4//! to PostgreSQL column names (typically `snake_case`).
5
6/// Convert camelCase or `PascalCase` to `snake_case`.
7///
8/// Follows the standard GraphQL-to-SQL field name convention used across all
9/// authoring languages: `camelCase` GraphQL names → `snake_case` column names.
10///
11/// # Examples
12///
13/// ```
14/// use fraiseql_core::utils::casing::to_snake_case;
15///
16/// assert_eq!(to_snake_case("userId"), "user_id");
17/// assert_eq!(to_snake_case("createdAt"), "created_at");
18/// assert_eq!(to_snake_case("HTTPResponse"), "http_response");
19/// assert_eq!(to_snake_case("already_snake"), "already_snake");
20/// ```
21#[must_use]
22pub fn to_snake_case(s: &str) -> String {
23    // If already snake_case (no uppercase letters), return as-is
24    if !s.chars().any(char::is_uppercase) {
25        return s.to_string();
26    }
27
28    let mut result = String::with_capacity(s.len() + 5);
29    let mut prev_was_upper = false;
30    let mut prev_was_lower = false;
31
32    for (i, c) in s.chars().enumerate() {
33        if c.is_uppercase() {
34            // Add underscore before uppercase if:
35            // 1. Not the first character
36            // 2. Previous was lowercase OR next is lowercase (handles "HTTPResponse" →
37            //    "http_response")
38            if i > 0 {
39                let next_is_lower = s.chars().nth(i + 1).is_some_and(char::is_lowercase);
40                if prev_was_lower || (prev_was_upper && next_is_lower) {
41                    result.push('_');
42                }
43            }
44            result.push(c.to_ascii_lowercase());
45            prev_was_upper = true;
46            prev_was_lower = false;
47        } else {
48            result.push(c);
49            prev_was_upper = false;
50            prev_was_lower = c.is_lowercase();
51        }
52    }
53
54    result
55}
56
57/// Convert `snake_case` to camelCase.
58///
59/// This is the reverse operation, used for output formatting.
60///
61/// # Examples
62///
63/// ```
64/// use fraiseql_core::utils::casing::to_camel_case;
65///
66/// assert_eq!(to_camel_case("user_id"), "userId");
67/// assert_eq!(to_camel_case("created_at"), "createdAt");
68/// assert_eq!(to_camel_case("http_response"), "httpResponse");
69/// assert_eq!(to_camel_case("alreadyCamel"), "alreadyCamel");
70/// ```
71#[must_use]
72pub fn to_camel_case(s: &str) -> String {
73    // If no underscores, assume already camelCase
74    if !s.contains('_') {
75        return s.to_string();
76    }
77
78    let mut result = String::with_capacity(s.len());
79    let mut capitalize_next = false;
80
81    for c in s.chars() {
82        if c == '_' {
83            capitalize_next = true;
84        } else if capitalize_next {
85            result.push(c.to_ascii_uppercase());
86            capitalize_next = false;
87        } else {
88            result.push(c);
89        }
90    }
91
92    result
93}
94
95/// Normalize a field path for database access.
96///
97/// This handles dotted paths like "user.profile.name" and converts each segment.
98///
99/// # Examples
100///
101/// ```
102/// use fraiseql_core::utils::casing::normalize_field_path;
103///
104/// assert_eq!(normalize_field_path("userId"), "user_id");
105/// assert_eq!(normalize_field_path("user.createdAt"), "user.created_at");
106/// assert_eq!(normalize_field_path("device.sensor.currentValue"), "device.sensor.current_value");
107/// ```
108#[must_use]
109pub fn normalize_field_path(path: &str) -> String {
110    if !path.contains('.') {
111        return to_snake_case(path);
112    }
113
114    path.split('.').map(to_snake_case).collect::<Vec<_>>().join(".")
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    #[test]
122    fn test_simple_camel_to_snake() {
123        assert_eq!(to_snake_case("userId"), "user_id");
124        assert_eq!(to_snake_case("userName"), "user_name");
125        assert_eq!(to_snake_case("firstName"), "first_name");
126    }
127
128    #[test]
129    fn test_pascal_to_snake() {
130        assert_eq!(to_snake_case("UserId"), "user_id");
131        assert_eq!(to_snake_case("FirstName"), "first_name");
132    }
133
134    #[test]
135    fn test_consecutive_capitals() {
136        assert_eq!(to_snake_case("HTTPResponse"), "http_response");
137        assert_eq!(to_snake_case("XMLParser"), "xml_parser");
138        assert_eq!(to_snake_case("IOError"), "io_error");
139    }
140
141    #[test]
142    fn test_already_snake_case() {
143        assert_eq!(to_snake_case("user_id"), "user_id");
144        assert_eq!(to_snake_case("first_name"), "first_name");
145        assert_eq!(to_snake_case("http_response"), "http_response");
146    }
147
148    #[test]
149    fn test_mixed_formats() {
150        assert_eq!(to_snake_case("user_Id"), "user_id"); // Convert mixed formats
151        assert_eq!(to_snake_case("HTTPStatus_Code"), "http_status_code");
152    }
153
154    #[test]
155    fn test_single_char() {
156        assert_eq!(to_snake_case("a"), "a");
157        assert_eq!(to_snake_case("A"), "a");
158    }
159
160    #[test]
161    fn test_empty_string() {
162        assert_eq!(to_snake_case(""), "");
163    }
164
165    #[test]
166    fn test_numbers() {
167        assert_eq!(to_snake_case("user2FA"), "user2fa"); // Numbers don't trigger underscore
168        assert_eq!(to_snake_case("level99Boss"), "level99boss");
169    }
170
171    #[test]
172    fn test_simple_snake_to_camel() {
173        assert_eq!(to_camel_case("user_id"), "userId");
174        assert_eq!(to_camel_case("first_name"), "firstName");
175        assert_eq!(to_camel_case("http_response"), "httpResponse");
176    }
177
178    #[test]
179    fn test_already_camel_case() {
180        assert_eq!(to_camel_case("userId"), "userId");
181        assert_eq!(to_camel_case("firstName"), "firstName");
182    }
183
184    #[test]
185    fn test_multiple_underscores() {
186        assert_eq!(to_camel_case("user__id"), "userId");
187        assert_eq!(to_camel_case("http___response"), "httpResponse");
188    }
189
190    #[test]
191    fn test_trailing_underscore() {
192        assert_eq!(to_camel_case("user_id_"), "userId");
193        assert_eq!(to_camel_case("first_name_"), "firstName");
194    }
195
196    #[test]
197    fn test_normalize_field_path_simple() {
198        assert_eq!(normalize_field_path("userId"), "user_id");
199        assert_eq!(normalize_field_path("createdAt"), "created_at");
200    }
201
202    #[test]
203    fn test_normalize_field_path_nested() {
204        assert_eq!(normalize_field_path("user.createdAt"), "user.created_at");
205        assert_eq!(
206            normalize_field_path("device.sensorData.currentValue"),
207            "device.sensor_data.current_value"
208        );
209    }
210
211    #[test]
212    fn test_normalize_field_path_already_snake() {
213        assert_eq!(normalize_field_path("user_id"), "user_id");
214        assert_eq!(normalize_field_path("user.created_at"), "user.created_at");
215    }
216
217    #[test]
218    fn test_roundtrip_conversion() {
219        let original = "userId";
220        let snake = to_snake_case(original);
221        let back = to_camel_case(&snake);
222        assert_eq!(back, original);
223
224        let original2 = "HTTPResponse";
225        let snake2 = to_snake_case(original2);
226        assert_eq!(snake2, "http_response");
227        let back2 = to_camel_case(&snake2);
228        assert_eq!(back2, "httpResponse"); // Note: loses the capitalization pattern
229    }
230
231    #[test]
232    fn test_real_world_examples() {
233        // Common GraphQL field names
234        assert_eq!(to_snake_case("createdAt"), "created_at");
235        assert_eq!(to_snake_case("updatedAt"), "updated_at");
236        assert_eq!(to_snake_case("deletedAt"), "deleted_at");
237        assert_eq!(to_snake_case("isActive"), "is_active");
238        assert_eq!(to_snake_case("isDeleted"), "is_deleted");
239        assert_eq!(to_snake_case("machineId"), "machine_id");
240        assert_eq!(to_snake_case("deviceType"), "device_type");
241    }
242}