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