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/// The canonical field-name → `snake_case` JSONB-key rule.
7///
8/// Re-exported from `fraiseql-db` so the SQL projection generators and this
9/// crate's Rust entity projector share **one** definition — eliminating the
10/// historical drift where two copies disagreed on acronym field names
11/// (`userID` → `user_id`, never `user_i_d`). See
12/// [`fraiseql_db::utils::to_snake_case`].
13pub use fraiseql_db::utils::to_snake_case;
14
15/// Convert `snake_case` to camelCase.
16///
17/// This is the reverse operation, used for output formatting.
18///
19/// # Examples
20///
21/// ```
22/// use fraiseql_core::utils::casing::to_camel_case;
23///
24/// assert_eq!(to_camel_case("user_id"), "userId");
25/// assert_eq!(to_camel_case("created_at"), "createdAt");
26/// assert_eq!(to_camel_case("http_response"), "httpResponse");
27/// assert_eq!(to_camel_case("alreadyCamel"), "alreadyCamel");
28/// ```
29#[must_use]
30pub fn to_camel_case(s: &str) -> String {
31 // If no underscores, assume already camelCase
32 if !s.contains('_') {
33 return s.to_string();
34 }
35
36 let mut result = String::with_capacity(s.len());
37 let mut capitalize_next = false;
38
39 for c in s.chars() {
40 if c == '_' {
41 capitalize_next = true;
42 } else if capitalize_next {
43 result.push(c.to_ascii_uppercase());
44 capitalize_next = false;
45 } else {
46 result.push(c);
47 }
48 }
49
50 result
51}
52
53/// Normalize a field path for database access.
54///
55/// This handles dotted paths like "user.profile.name" and converts each segment.
56///
57/// # Examples
58///
59/// ```
60/// use fraiseql_core::utils::casing::normalize_field_path;
61///
62/// assert_eq!(normalize_field_path("userId"), "user_id");
63/// assert_eq!(normalize_field_path("user.createdAt"), "user.created_at");
64/// assert_eq!(normalize_field_path("device.sensor.currentValue"), "device.sensor.current_value");
65/// ```
66#[must_use]
67pub fn normalize_field_path(path: &str) -> String {
68 if !path.contains('.') {
69 return to_snake_case(path);
70 }
71
72 path.split('.').map(to_snake_case).collect::<Vec<_>>().join(".")
73}