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}