fraiseql-db 2.16.0

Database abstraction layer for FraiseQL v2
Documentation
//! Shared utility functions for the `fraiseql-db` crate.

use std::{
    collections::HashSet,
    sync::{LazyLock, OnceLock},
};

/// Built-in acronyms whose internal digit boundary is **not** split by
/// [`to_snake_case`], so they round-trip atomically (`s3` ↔ `s3`, not `s_3`).
/// Lowercased; only `<word><digit>` shapes are relevant. Extend per project via
/// the `[fraiseql.naming] acronyms` config — see [`set_runtime_acronyms`].
static DEFAULT_ACRONYMS: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
    [
        "s3", "ec2", "ipv4", "ipv6", "oauth1", "oauth2", "sha1", "sha256", "sha512", "md5",
        "base64", "utf8", "p256", "p384",
    ]
    .into_iter()
    .collect()
});

/// The effective acronym set (defaults ∪ project config), installed once at
/// startup by [`set_runtime_acronyms`]. When unset (tests, library use),
/// [`to_snake_case`] falls back to the built-in defaults.
static RUNTIME_ACRONYMS: OnceLock<HashSet<String>> = OnceLock::new();

/// Install the project's acronym additions (from `[fraiseql.naming] acronyms`) on
/// top of the built-in defaults.
///
/// Idempotent — only the first call wins, so the server (at boot) and the CLI (at
/// compile) each call it once. Terms are trimmed and lowercased; empties ignored.
pub fn set_runtime_acronyms(extra: &[String]) {
    let mut set: HashSet<String> = DEFAULT_ACRONYMS.iter().map(|s| (*s).to_string()).collect();
    for term in extra {
        let term = term.trim().to_ascii_lowercase();
        if !term.is_empty() {
            set.insert(term);
        }
    }
    let _ = RUNTIME_ACRONYMS.set(set);
}

/// Whether `candidate` (a lowercase `<word><digit>` token) is a registered acronym
/// in the effective set (runtime config if installed, else the built-in defaults).
fn is_registered_acronym(candidate: &str) -> bool {
    match RUNTIME_ACRONYMS.get() {
        Some(set) => set.contains(candidate),
        None => DEFAULT_ACRONYMS.contains(candidate),
    }
}

/// Does the lowercase word at `word_start` plus the digit run at `digit_start`
/// form a registered acronym (`s3`, `ipv4`, `oauth2`)? Used to suppress the
/// letter→digit split so the acronym stays whole.
fn acronym_spans_digit(chars: &[char], word_start: usize, digit_start: usize) -> bool {
    let mut end = digit_start;
    while end < chars.len() && chars[end].is_ascii_digit() {
        end += 1;
    }
    let candidate: String = chars[word_start..end].iter().collect::<String>().to_ascii_lowercase();
    is_registered_acronym(&candidate)
}

/// Convert a camelCase / `PascalCase` field name to `snake_case` for JSONB key
/// lookup.
///
/// FraiseQL exposes schema field names as camelCase for GraphQL spec compliance,
/// but the underlying JSONB keys (view `data` columns, mutation entity payloads)
/// are stored in their original `snake_case` form. This function reverses that
/// conversion so projection can read the stored key.
///
/// This is the **single, canonical** field-name → JSONB-key rule for the whole
/// engine: the SQL projection generators (this crate) and the Rust entity
/// projector (`fraiseql-core`) both call it, so they can never disagree on a
/// source key. It is acronym-aware — `"HTTPResponse"` → `"http_response"`, not
/// `"h_t_t_p_response"` — and idempotent: `to_snake_case("ip_address")` ==
/// `"ip_address"`.
///
/// # Digit boundaries and acronyms
///
/// A digit segment is normally a word of its own, inverting `to_camel_case`'s
/// collapse (`phone_1` → `phone1`): this function reinserts the boundary so the
/// round trip is bijective — `"phone1"` → `"phone_1"`, `"dns1Id"` → `"dns_1_id"`.
///
/// Registered acronyms are the exception: a lowercase word plus a digit run that
/// matches the acronym registry stays whole — `"s3"` → `"s3"`, `"ipv4"` → `"ipv4"`,
/// `"s3Bucket"` → `"s3_bucket"`, and wherever it sits in the name:
/// `"hostIpv4"` → `"host_ipv4"`. The built-in defaults (`s3`, `ipv4`, `oauth2`, …)
/// are extended per project via `[fraiseql.naming] acronyms` (see
/// [`set_runtime_acronyms`]); an unregistered `oauth2`-shaped name still splits.
///
/// # Examples
///
/// ```
/// use fraiseql_db::utils::to_snake_case;
///
/// assert_eq!(to_snake_case("userId"), "user_id");
/// assert_eq!(to_snake_case("createdAt"), "created_at");
/// assert_eq!(to_snake_case("HTTPResponse"), "http_response");
/// assert_eq!(to_snake_case("phone1"), "phone_1");
/// assert_eq!(to_snake_case("dns1Id"), "dns_1_id");
/// assert_eq!(to_snake_case("s3"), "s3"); // built-in acronym, stays whole
/// assert_eq!(to_snake_case("s3Bucket"), "s3_bucket");
/// assert_eq!(to_snake_case("hostIpv4"), "host_ipv4"); // acronym after the first word
/// assert_eq!(to_snake_case("already_snake"), "already_snake");
/// assert_eq!(to_snake_case("phone_1"), "phone_1"); // idempotent
/// ```
#[must_use]
pub fn to_snake_case(name: &str) -> String {
    let chars: Vec<char> = name.chars().collect();
    let mut result = String::with_capacity(name.len() + 5);
    let mut prev_was_upper = false;
    let mut prev_was_lower = false;
    let mut prev_was_digit = false;
    // Start index (in `chars`) of the current word — the capital that opened it in
    // camelCase, else the first lowercase letter — used to test a word + digit run
    // against the acronym registry (`emissionCo2Kg` must test `Co2`, not `o2`).
    let mut word_start = 0usize;

    for (i, &c) in chars.iter().enumerate() {
        if c.is_uppercase() {
            // Insert a boundary underscore before an uppercase char when leaving a
            // lowercase letter, leaving a digit (e.g. "dns1Id"), or leaving an
            // acronym run into a new word (prev upper, next lower) — e.g.
            // "HTTPResponse".
            let next_is_lower = chars.get(i + 1).is_some_and(|n| n.is_lowercase());
            if i > 0 && (prev_was_lower || prev_was_digit || (prev_was_upper && next_is_lower)) {
                result.push('_');
            }
            // A capital opens a new word unless it continues an acronym run
            // ("HTTP" in "HTTPResponse"; the `R` that ends the run does open one).
            if !prev_was_upper || next_is_lower {
                word_start = i;
            }
            result.push(c.to_ascii_lowercase());
            prev_was_upper = true;
            prev_was_lower = false;
            prev_was_digit = false;
        } else if c.is_ascii_digit() {
            // A digit after a lowercase letter normally opens a new word
            // ("phone1" → "phone_1"). Suppress that split when the lowercase word
            // plus this digit run is a registered acronym ("s3", "ipv4", "oauth2").
            if prev_was_lower && !acronym_spans_digit(&chars, word_start, i) {
                result.push('_');
            }
            result.push(c);
            prev_was_upper = false;
            prev_was_lower = false;
            prev_was_digit = true;
        } else {
            if c.is_lowercase() {
                if !prev_was_lower && !prev_was_upper {
                    word_start = i;
                }
                prev_was_lower = true;
            } else {
                prev_was_lower = false;
            }
            result.push(c);
            prev_was_upper = false;
            prev_was_digit = false;
        }
    }

    result
}

/// Validate that a client-supplied field name matches the GraphQL identifier
/// pattern `[_A-Za-z][_0-9A-Za-z]*`.
///
/// This is a security boundary shared by the `orderBy` and `where` parsers:
/// field names are interpolated into SQL text (`data->>'field'`, MySQL/SQLite
/// JSON path literals), so any character outside the GraphQL identifier set —
/// quotes, backslashes, whitespace — must be rejected before a clause is
/// constructed (#833).
///
/// `context` names the argument being validated (`"orderBy"`, `"where"`) so the
/// error points at the offending input.
///
/// # Errors
///
/// Returns `FraiseQLError::Validation` if the field contains invalid characters.
pub fn validate_graphql_identifier(field: &str, context: &str) -> fraiseql_error::Result<()> {
    let mut chars = field.chars();
    let first_ok = chars.next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_');
    let rest_ok = chars.all(|c| c.is_ascii_alphanumeric() || c == '_');
    if first_ok && rest_ok {
        Ok(())
    } else {
        Err(fraiseql_error::FraiseQLError::Validation {
            message: format!(
                "{context} field name '{field}' contains invalid characters; \
                 only [_A-Za-z][_0-9A-Za-z]* is allowed"
            ),
            path:    None,
        })
    }
}

#[cfg(test)]
mod tests;

// ── Name suggestions ──────────────────────────────────────────────────────────
//
// Lives here rather than in `fraiseql-core` because both crates need it and
// `fraiseql-core` depends on `fraiseql-db`, not the other way round: the WHERE
// parser suggests field names, and the runtime suggests argument and variable
// names. `fraiseql_core::runtime::suggest_similar` re-exports this one, so the
// two cannot drift.

/// Return candidates from `haystack` whose edit distance to `needle` is ≤ 2.
///
/// Uses a simple iterative Levenshtein implementation with a `2 * threshold`
/// early-exit so cost stays proportional to the length of the candidates rather
/// than `O(n * m)` for every comparison. At most three suggestions are returned,
/// ordered by increasing edit distance.
#[must_use]
pub fn suggest_similar<'a>(needle: &str, haystack: &[&'a str]) -> Vec<&'a str> {
    const MAX_DISTANCE: usize = 2;
    const MAX_SUGGESTIONS: usize = 3;

    let mut ranked: Vec<(usize, &str)> = haystack
        .iter()
        .filter_map(|&candidate| {
            let d = levenshtein(needle, candidate);
            if d <= MAX_DISTANCE {
                Some((d, candidate))
            } else {
                None
            }
        })
        .collect();

    ranked.sort_unstable_by_key(|&(d, _)| d);
    ranked.into_iter().take(MAX_SUGGESTIONS).map(|(_, s)| s).collect()
}

/// Compute the Levenshtein edit distance between two strings.
#[must_use]
pub fn levenshtein(a: &str, b: &str) -> usize {
    let a: Vec<char> = a.chars().collect();
    let b: Vec<char> = b.chars().collect();
    let m = a.len();
    let n = b.len();

    // Early exit: length difference alone exceeds threshold.
    if m.abs_diff(n) > 2 {
        return m.abs_diff(n);
    }

    let mut prev: Vec<usize> = (0..=n).collect();
    let mut curr = vec![0usize; n + 1];

    for i in 1..=m {
        curr[0] = i;
        for j in 1..=n {
            curr[j] = if a[i - 1] == b[j - 1] {
                prev[j - 1]
            } else {
                1 + prev[j - 1].min(prev[j]).min(curr[j - 1])
            };
        }
        std::mem::swap(&mut prev, &mut curr);
    }

    prev[n]
}

/// The native column a declared vector field is stored in, as a bare
/// `snake_case` SQL identifier.
///
/// The storage contract (`docs/operations/vector-search.md`) is that the backing
/// view exposes a declared vector field as a native `vector(N)` / `halfvec(N)` /
/// `sparsevec(N)` / `bit(N)` column named after the field in `snake_case`. Two
/// paths need that name — `nearest`'s ORDER BY lowering (#386) and the threshold
/// WHERE predicates (#1117) — and they derive it **here** rather than each
/// spelling the rule out. Two derivations of one stored name is precisely how
/// the two ends of a query come to disagree about where a value lives.
///
/// Returned unquoted: the ORDER BY path stores the quoted form in
/// `OrderByClause::native_column`, the WHERE path quotes through its dialect.
/// What must not diverge is the *name*, not the quoting.
///
/// # Errors
///
/// [`FraiseQLError::Validation`](fraiseql_error::FraiseQLError::Validation) when the
/// derived name is not a bare lower-case
/// identifier. This reaches SQL as an identifier rather than as a parameter, so
/// it is **validated rather than escaped**: a field whose `snake_case` form is
/// not `[a-z_][a-z0-9_]*` names no column this rule can construct, and guessing
/// one would be an injection seam.
pub fn vector_storage_column(field_name: &str) -> fraiseql_error::Result<String> {
    let column = to_snake_case(field_name);
    let valid = column.chars().next().is_some_and(|c| c.is_ascii_lowercase() || c == '_')
        && column.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_');
    if valid {
        Ok(column)
    } else {
        Err(fraiseql_error::FraiseQLError::validation(format!(
            "vector field name '{field_name}' does not resolve to a bare SQL identifier"
        )))
    }
}