Skip to main content

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;