fraiseql_db/utils.rs
1//! Shared utility functions for the `fraiseql-db` crate.
2
3use std::{
4 collections::HashSet,
5 sync::{LazyLock, OnceLock},
6};
7
8/// Built-in acronyms whose internal digit boundary is **not** split by
9/// [`to_snake_case`], so they round-trip atomically (`s3` ↔ `s3`, not `s_3`).
10/// Lowercased; only `<word><digit>` shapes are relevant. Extend per project via
11/// the `[fraiseql.naming] acronyms` config — see [`set_runtime_acronyms`].
12static DEFAULT_ACRONYMS: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
13 [
14 "s3", "ec2", "ipv4", "ipv6", "oauth1", "oauth2", "sha1", "sha256", "sha512", "md5",
15 "base64", "utf8", "p256", "p384",
16 ]
17 .into_iter()
18 .collect()
19});
20
21/// The effective acronym set (defaults ∪ project config), installed once at
22/// startup by [`set_runtime_acronyms`]. When unset (tests, library use),
23/// [`to_snake_case`] falls back to the built-in defaults.
24static RUNTIME_ACRONYMS: OnceLock<HashSet<String>> = OnceLock::new();
25
26/// Install the project's acronym additions (from `[fraiseql.naming] acronyms`) on
27/// top of the built-in defaults.
28///
29/// Idempotent — only the first call wins, so the server (at boot) and the CLI (at
30/// compile) each call it once. Terms are trimmed and lowercased; empties ignored.
31pub fn set_runtime_acronyms(extra: &[String]) {
32 let mut set: HashSet<String> = DEFAULT_ACRONYMS.iter().map(|s| (*s).to_string()).collect();
33 for term in extra {
34 let term = term.trim().to_ascii_lowercase();
35 if !term.is_empty() {
36 set.insert(term);
37 }
38 }
39 let _ = RUNTIME_ACRONYMS.set(set);
40}
41
42/// Whether `candidate` (a lowercase `<word><digit>` token) is a registered acronym
43/// in the effective set (runtime config if installed, else the built-in defaults).
44fn is_registered_acronym(candidate: &str) -> bool {
45 match RUNTIME_ACRONYMS.get() {
46 Some(set) => set.contains(candidate),
47 None => DEFAULT_ACRONYMS.contains(candidate),
48 }
49}
50
51/// Does the lowercase word at `word_start` plus the digit run at `digit_start`
52/// form a registered acronym (`s3`, `ipv4`, `oauth2`)? Used to suppress the
53/// letter→digit split so the acronym stays whole.
54fn acronym_spans_digit(chars: &[char], word_start: usize, digit_start: usize) -> bool {
55 let mut end = digit_start;
56 while end < chars.len() && chars[end].is_ascii_digit() {
57 end += 1;
58 }
59 let candidate: String = chars[word_start..end].iter().collect::<String>().to_ascii_lowercase();
60 is_registered_acronym(&candidate)
61}
62
63/// Convert a camelCase / `PascalCase` field name to `snake_case` for JSONB key
64/// lookup.
65///
66/// FraiseQL exposes schema field names as camelCase for GraphQL spec compliance,
67/// but the underlying JSONB keys (view `data` columns, mutation entity payloads)
68/// are stored in their original `snake_case` form. This function reverses that
69/// conversion so projection can read the stored key.
70///
71/// This is the **single, canonical** field-name → JSONB-key rule for the whole
72/// engine: the SQL projection generators (this crate) and the Rust entity
73/// projector (`fraiseql-core`) both call it, so they can never disagree on a
74/// source key. It is acronym-aware — `"HTTPResponse"` → `"http_response"`, not
75/// `"h_t_t_p_response"` — and idempotent: `to_snake_case("ip_address")` ==
76/// `"ip_address"`.
77///
78/// # Digit boundaries and acronyms
79///
80/// A digit segment is normally a word of its own, inverting `to_camel_case`'s
81/// collapse (`phone_1` → `phone1`): this function reinserts the boundary so the
82/// round trip is bijective — `"phone1"` → `"phone_1"`, `"dns1Id"` → `"dns_1_id"`.
83///
84/// Registered acronyms are the exception: a lowercase word plus a digit run that
85/// matches the acronym registry stays whole — `"s3"` → `"s3"`, `"ipv4"` → `"ipv4"`,
86/// `"s3Bucket"` → `"s3_bucket"`. The built-in defaults (`s3`, `ipv4`, `oauth2`, …)
87/// are extended per project via `[fraiseql.naming] acronyms` (see
88/// [`set_runtime_acronyms`]); an unregistered `oauth2`-shaped name still splits.
89///
90/// # Examples
91///
92/// ```
93/// use fraiseql_db::utils::to_snake_case;
94///
95/// assert_eq!(to_snake_case("userId"), "user_id");
96/// assert_eq!(to_snake_case("createdAt"), "created_at");
97/// assert_eq!(to_snake_case("HTTPResponse"), "http_response");
98/// assert_eq!(to_snake_case("phone1"), "phone_1");
99/// assert_eq!(to_snake_case("dns1Id"), "dns_1_id");
100/// assert_eq!(to_snake_case("s3"), "s3"); // built-in acronym, stays whole
101/// assert_eq!(to_snake_case("s3Bucket"), "s3_bucket");
102/// assert_eq!(to_snake_case("already_snake"), "already_snake");
103/// assert_eq!(to_snake_case("phone_1"), "phone_1"); // idempotent
104/// ```
105#[must_use]
106pub fn to_snake_case(name: &str) -> String {
107 let chars: Vec<char> = name.chars().collect();
108 let mut result = String::with_capacity(name.len() + 5);
109 let mut prev_was_upper = false;
110 let mut prev_was_lower = false;
111 let mut prev_was_digit = false;
112 // Start index (in `chars`) of the current run of consecutive lowercase letters,
113 // used to test a lowercase-word + digit run against the acronym registry.
114 let mut lower_run_start = 0usize;
115
116 for (i, &c) in chars.iter().enumerate() {
117 if c.is_uppercase() {
118 // Insert a boundary underscore before an uppercase char when leaving a
119 // lowercase letter, leaving a digit (e.g. "dns1Id"), or leaving an
120 // acronym run into a new word (prev upper, next lower) — e.g.
121 // "HTTPResponse".
122 if i > 0 {
123 let next_is_lower = chars.get(i + 1).is_some_and(|n| n.is_lowercase());
124 if prev_was_lower || prev_was_digit || (prev_was_upper && next_is_lower) {
125 result.push('_');
126 }
127 }
128 result.push(c.to_ascii_lowercase());
129 prev_was_upper = true;
130 prev_was_lower = false;
131 prev_was_digit = false;
132 } else if c.is_ascii_digit() {
133 // A digit after a lowercase letter normally opens a new word
134 // ("phone1" → "phone_1"). Suppress that split when the lowercase word
135 // plus this digit run is a registered acronym ("s3", "ipv4", "oauth2").
136 if prev_was_lower && !acronym_spans_digit(&chars, lower_run_start, i) {
137 result.push('_');
138 }
139 result.push(c);
140 prev_was_upper = false;
141 prev_was_lower = false;
142 prev_was_digit = true;
143 } else {
144 if c.is_lowercase() {
145 if !prev_was_lower {
146 lower_run_start = i;
147 }
148 prev_was_lower = true;
149 } else {
150 prev_was_lower = false;
151 }
152 result.push(c);
153 prev_was_upper = false;
154 prev_was_digit = false;
155 }
156 }
157
158 result
159}
160
161#[cfg(test)]
162mod tests;