drizzle-migrations 0.1.10

Migration infrastructure for drizzle-rs
Documentation
//! Common utilities for schema diffing and migration generation
//!
//! This module provides shared utilities used across `SQLite` and `PostgreSQL` dialects.

use std::collections::HashMap;

// =============================================================================
// Hash Function
// =============================================================================

const DICTIONARY: &[u8; 62] = b"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";

/// Generate a hash string from input, used for naming constraints
#[must_use]
pub fn hash(input: &str, len: usize) -> String {
    let dict_len = DICTIONARY.len() as u128;
    let len_u32 = u32::try_from(len).unwrap_or(u32::MAX);
    let combinations_count = dict_len.pow(len_u32);
    let p: u128 = 53;
    let mut power: u128 = 1;
    let mut hash_val: u128 = 0;

    for ch in input.chars() {
        let code = u128::from(u32::from(ch));
        hash_val = (hash_val + (code * power)) % combinations_count;
        power = (power * p) % combinations_count;
    }

    let mut result = Vec::with_capacity(len);
    let mut index = hash_val;

    for _ in 0..len {
        let idx = usize::try_from(index % dict_len).unwrap_or(0);
        result.push(DICTIONARY[idx] as char);
        index /= dict_len;
    }

    result.into_iter().rev().collect()
}

// =============================================================================
// String Utilities
// =============================================================================

/// Trim a specific character from both ends of a string
#[must_use]
pub fn trim_char(s: &str, c: char) -> String {
    s.trim_start_matches(c).trim_end_matches(c).to_string()
}

/// Trim multiple characters from both ends of a string
#[must_use]
pub fn trim_chars(s: &str, chars: &[char]) -> String {
    let mut result = s.to_string();
    for c in chars {
        result = result
            .trim_start_matches(*c)
            .trim_end_matches(*c)
            .to_string();
    }
    result
}

/// Escape a string for SQL default value
#[must_use]
pub fn escape_for_sql_default(input: &str, mode: EscapeMode) -> String {
    let mut value = input.replace('\\', "\\\\").replace('\'', "''");
    if matches!(mode, EscapeMode::PgArray) {
        value = value.replace('"', "\\\"");
    }
    value
}

/// Escape a string for use in a Rust string literal (within double quotes)
///
/// This escapes backslashes first, then double quotes, to produce valid Rust.
/// Used when generating Rust code that contains string literals.
#[must_use]
pub fn escape_for_rust_literal(input: &str) -> String {
    input.replace('\\', "\\\\").replace('"', "\\\"")
}

/// Unescape a string from SQL default value
#[must_use]
pub fn unescape_from_sql_default(input: &str, mode: EscapeMode) -> String {
    let mut res = input.replace("\\\"", "\"").replace("\\\\", "\\");
    if !matches!(mode, EscapeMode::Array) {
        res = res.replace("''", "'");
    }
    res
}

/// Escape mode for SQL values
#[derive(Debug, Clone, Copy)]
pub enum EscapeMode {
    Default,
    Array,
    PgArray,
}

/// Escape a string for TypeScript literal
#[must_use]
pub fn escape_for_ts_literal(input: &str) -> String {
    // JSON.stringify equivalent
    serde_json::to_string(input).unwrap_or_else(|_| format!("\"{input}\""))
}

/// Parse number for TypeScript representation
#[must_use]
pub fn number_for_ts(value: &str) -> (NumberMode, String) {
    // `i64::MAX` is not exactly representable in f64 (it rounds up to 2^63),
    // so use literal bounds directly to avoid lossy `as f64` casts.
    const I64_MIN_F64: f64 = -9_223_372_036_854_775_808.0;
    const I64_MAX_F64: f64 = 9_223_372_036_854_775_807.0;

    value.parse::<f64>().map_or_else(
        |_| (NumberMode::Number, format!("sql`{value}`")),
        |num| {
            if num.is_nan() {
                (NumberMode::Number, format!("sql`{value}`"))
            } else if (I64_MIN_F64..=I64_MAX_F64).contains(&num) {
                (NumberMode::Number, value.to_string())
            } else {
                (NumberMode::BigInt, format!("{value}n"))
            }
        },
    )
}

/// Number mode for TypeScript
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NumberMode {
    Number,
    BigInt,
}

/// Parse type parameters from SQL type like "varchar(255)" or "numeric(10,2)"
#[must_use]
pub fn parse_params(type_str: &str) -> Vec<String> {
    if let Some(start) = type_str.find('(')
        && let Some(end) = type_str.find(')')
    {
        let params = &type_str[start + 1..end];
        return params.split(',').map(|s| s.trim().to_string()).collect();
    }
    Vec::new()
}

// =============================================================================
// Resolver Types
// =============================================================================

/// Result from a rename resolver
#[derive(Debug, Clone)]
pub struct ResolverResult<T> {
    pub created: Vec<T>,
    pub deleted: Vec<T>,
    pub renamed_or_moved: Vec<Rename<T>>,
}

/// A rename operation
#[derive(Debug, Clone)]
pub struct Rename<T> {
    pub from: T,
    pub to: T,
}

/// Simple resolver that doesn't detect renames (everything is create/delete)
#[must_use]
pub const fn simple_resolver<T: Clone>(created: Vec<T>, deleted: Vec<T>) -> ResolverResult<T> {
    ResolverResult {
        created,
        deleted,
        renamed_or_moved: Vec::new(),
    }
}

// =============================================================================
// Inspect Utility
// =============================================================================

/// Inspect an object for debugging (simplified version)
#[must_use]
pub fn inspect<K, V, S>(map: &HashMap<K, V, S>) -> String
where
    K: std::fmt::Display,
    V: std::fmt::Display,
    S: std::hash::BuildHasher,
{
    if map.is_empty() {
        return String::new();
    }

    let pairs: Vec<String> = map.iter().map(|(k, v)| format!("{k}: '{v}'")).collect();

    format!("{{ {} }}", pairs.join(", "))
}

// =============================================================================
// Migration Rename Tracking
// =============================================================================

/// Prepare migration rename strings for storage
#[must_use]
pub fn prepare_migration_renames<T>(
    table_renames: &[(String, String)],
    column_renames: &[(String, String, String)], // (table, from, to)
) -> Vec<String> {
    let mut renames = Vec::new();

    for (from, to) in table_renames {
        renames.push(format!("table:{from}:{to}"));
    }

    for (table, from, to) in column_renames {
        renames.push(format!("column:{table}:{from}:{to}"));
    }

    renames
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_hash() {
        let h1 = hash("test", 12);
        let h2 = hash("test", 12);
        assert_eq!(h1, h2);
        assert_eq!(h1.len(), 12);

        let h3 = hash("different", 12);
        assert_ne!(h1, h3);
    }

    #[test]
    fn test_trim_char() {
        assert_eq!(trim_char("'hello'", '\''), "hello");
        assert_eq!(trim_char("hello", '\''), "hello");
    }

    #[test]
    fn test_parse_params() {
        assert_eq!(parse_params("varchar(255)"), vec!["255"]);
        assert_eq!(parse_params("numeric(10,2)"), vec!["10", "2"]);
        assert!(parse_params("text").is_empty());
    }

    #[test]
    fn test_number_for_ts() {
        let (mode, val) = number_for_ts("123");
        assert_eq!(mode, NumberMode::Number);
        assert_eq!(val, "123");

        // Use a value clearly outside f64's representation of i64 range
        // 1e20 is definitely greater than i64::MAX (~9.2e18)
        let (mode, val) = number_for_ts("100000000000000000000");
        assert_eq!(mode, NumberMode::BigInt);
        assert!(val.ends_with('n'));
    }

    #[test]
    fn test_escape_for_sql_default() {
        assert_eq!(
            escape_for_sql_default("it's a test", EscapeMode::Default),
            "it''s a test"
        );
        assert_eq!(
            escape_for_sql_default("path\\to\\file", EscapeMode::Default),
            "path\\\\to\\\\file"
        );
    }

    #[test]
    fn test_escape_for_rust_literal() {
        // Basic escaping
        assert_eq!(escape_for_rust_literal("hello"), "hello");

        // Escape double quotes
        assert_eq!(
            escape_for_rust_literal(r#"say "hello""#),
            r#"say \"hello\""#
        );

        // Escape backslashes
        assert_eq!(escape_for_rust_literal(r"path\to\file"), r"path\\to\\file");

        // Escape both
        assert_eq!(
            escape_for_rust_literal(r#"a "quoted" path\to\file"#),
            r#"a \"quoted\" path\\to\\file"#
        );

        // SQL query with quotes (typical view definition)
        assert_eq!(
            escape_for_rust_literal(r#"SELECT * FROM "users" WHERE name = 'test'"#),
            r#"SELECT * FROM \"users\" WHERE name = 'test'"#
        );
    }
}