use fraiseql_error::{FraiseQLError, Result};
use crate::{collation_config::CollationConfig, types::DatabaseType};
pub struct CollationMapper {
config: CollationConfig,
database_type: DatabaseType,
}
impl CollationMapper {
#[must_use]
pub const fn new(config: CollationConfig, database_type: DatabaseType) -> Self {
Self {
config,
database_type,
}
}
pub fn map_locale(&self, locale: &str) -> Result<Option<String>> {
if !self.config.enabled {
return Ok(None);
}
if !self.config.allowed_locales.contains(&locale.to_string()) {
return self.handle_invalid_locale();
}
match self.database_type {
DatabaseType::PostgreSQL => Ok(self.map_postgres(locale)),
DatabaseType::MySQL => Ok(self.map_mysql(locale)),
DatabaseType::SQLite => Ok(self.map_sqlite(locale)),
DatabaseType::SQLServer => Ok(self.map_sqlserver(locale)),
}
}
fn map_postgres(&self, locale: &str) -> Option<String> {
if let Some(overrides) = &self.config.database_overrides {
if let Some(pg_config) = &overrides.postgres {
if pg_config.use_icu {
return Some(format!("{locale}-x-icu"));
}
let libc_locale = locale.replace('-', "_");
return Some(format!("{libc_locale}.UTF-8"));
}
}
Some(format!("{locale}-x-icu"))
}
fn map_mysql(&self, _locale: &str) -> Option<String> {
if let Some(overrides) = &self.config.database_overrides {
if let Some(mysql_config) = &overrides.mysql {
return Some(format!("{}{}", mysql_config.charset, mysql_config.suffix));
}
}
Some("utf8mb4_unicode_ci".to_string())
}
fn map_sqlite(&self, _locale: &str) -> Option<String> {
if let Some(overrides) = &self.config.database_overrides {
if let Some(sqlite_config) = &overrides.sqlite {
return if sqlite_config.use_nocase {
Some("NOCASE".to_string())
} else {
None
};
}
}
Some("NOCASE".to_string())
}
fn map_sqlserver(&self, locale: &str) -> Option<String> {
let collation = match locale {
"en-US" | "en-GB" | "en-CA" | "en-AU" => "Latin1_General_100_CI_AI_SC_UTF8",
"fr-FR" | "fr-CA" => "French_100_CI_AI",
"de-DE" | "de-AT" | "de-CH" => "German_PhoneBook_100_CI_AI",
"es-ES" | "es-MX" => "Modern_Spanish_100_CI_AI",
"ja-JP" => "Japanese_XJIS_100_CI_AI",
"zh-CN" => "Chinese_PRC_100_CI_AI",
"pt-BR" => "Latin1_General_100_CI_AI_SC_UTF8",
"it-IT" => "Latin1_General_100_CI_AI_SC_UTF8",
_ => "Latin1_General_100_CI_AI_SC_UTF8", };
Some(collation.to_string())
}
fn handle_invalid_locale(&self) -> Result<Option<String>> {
use crate::collation_config::InvalidLocaleStrategy;
match self.config.on_invalid_locale {
InvalidLocaleStrategy::Fallback => self.map_locale(&self.config.fallback_locale),
InvalidLocaleStrategy::DatabaseDefault => Ok(None),
InvalidLocaleStrategy::Error => Err(FraiseQLError::Validation {
message: "Invalid locale: not in allowed list".to_string(),
path: None,
}),
}
}
#[must_use]
pub const fn database_type(&self) -> DatabaseType {
self.database_type
}
#[must_use]
pub const fn is_enabled(&self) -> bool {
self.config.enabled
}
}
pub struct CollationCapabilities;
impl CollationCapabilities {
#[must_use]
pub const fn supports_locale_collation(db_type: DatabaseType) -> bool {
matches!(db_type, DatabaseType::PostgreSQL | DatabaseType::SQLServer)
}
#[must_use]
pub const fn requires_custom_collation(db_type: DatabaseType) -> bool {
matches!(db_type, DatabaseType::SQLite)
}
#[must_use]
pub const fn strategy(db_type: DatabaseType) -> &'static str {
match db_type {
DatabaseType::PostgreSQL => "ICU collations (locale-specific)",
DatabaseType::MySQL => "UTF8MB4 collations (general)",
DatabaseType::SQLite => "NOCASE (limited)",
DatabaseType::SQLServer => "Language-specific collations",
}
}
#[must_use]
pub const fn recommended_provider(db_type: DatabaseType) -> Option<&'static str> {
match db_type {
DatabaseType::PostgreSQL => Some("icu"),
DatabaseType::MySQL => Some("utf8mb4_unicode_ci"),
DatabaseType::SQLite => Some("NOCASE"),
DatabaseType::SQLServer => Some("Latin1_General_100_CI_AI_SC_UTF8"),
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)] mod tests {
use super::*;
use crate::collation_config::{
DatabaseCollationOverrides, InvalidLocaleStrategy, MySqlCollationConfig,
PostgresCollationConfig, SqliteCollationConfig,
};
fn test_config() -> CollationConfig {
CollationConfig {
enabled: true,
fallback_locale: "en-US".to_string(),
allowed_locales: vec!["en-US".into(), "fr-FR".into(), "ja-JP".into()],
on_invalid_locale: InvalidLocaleStrategy::Fallback,
database_overrides: None,
}
}
#[test]
fn test_postgres_icu_collation() {
let config = test_config();
let mapper = CollationMapper::new(config, DatabaseType::PostgreSQL);
assert_eq!(mapper.map_locale("fr-FR").unwrap(), Some("fr-FR-x-icu".to_string()));
assert_eq!(mapper.map_locale("ja-JP").unwrap(), Some("ja-JP-x-icu".to_string()));
}
#[test]
fn test_postgres_libc_collation() {
let mut config = test_config();
config.database_overrides = Some(DatabaseCollationOverrides {
postgres: Some(PostgresCollationConfig {
use_icu: false,
provider: "libc".to_string(),
}),
mysql: None,
sqlite: None,
sqlserver: None,
});
let mapper = CollationMapper::new(config, DatabaseType::PostgreSQL);
assert_eq!(mapper.map_locale("fr-FR").unwrap(), Some("fr_FR.UTF-8".to_string()));
assert_eq!(mapper.map_locale("en-US").unwrap(), Some("en_US.UTF-8".to_string()));
}
#[test]
fn test_mysql_collation() {
let config = test_config();
let mapper = CollationMapper::new(config, DatabaseType::MySQL);
assert_eq!(mapper.map_locale("fr-FR").unwrap(), Some("utf8mb4_unicode_ci".to_string()));
assert_eq!(mapper.map_locale("ja-JP").unwrap(), Some("utf8mb4_unicode_ci".to_string()));
}
#[test]
fn test_mysql_custom_collation() {
let mut config = test_config();
config.database_overrides = Some(DatabaseCollationOverrides {
postgres: None,
mysql: Some(MySqlCollationConfig {
charset: "utf8mb4".to_string(),
suffix: "_0900_ai_ci".to_string(),
}),
sqlite: None,
sqlserver: None,
});
let mapper = CollationMapper::new(config, DatabaseType::MySQL);
assert_eq!(mapper.map_locale("fr-FR").unwrap(), Some("utf8mb4_0900_ai_ci".to_string()));
}
#[test]
fn test_sqlite_collation() {
let config = test_config();
let mapper = CollationMapper::new(config, DatabaseType::SQLite);
assert_eq!(mapper.map_locale("fr-FR").unwrap(), Some("NOCASE".to_string()));
}
#[test]
fn test_sqlite_disabled_nocase() {
let mut config = test_config();
config.database_overrides = Some(DatabaseCollationOverrides {
postgres: None,
mysql: None,
sqlite: Some(SqliteCollationConfig { use_nocase: false }),
sqlserver: None,
});
let mapper = CollationMapper::new(config, DatabaseType::SQLite);
assert_eq!(mapper.map_locale("fr-FR").unwrap(), None);
}
#[test]
fn test_sqlserver_collation() {
let config = test_config();
let mapper = CollationMapper::new(config, DatabaseType::SQLServer);
assert_eq!(mapper.map_locale("fr-FR").unwrap(), Some("French_100_CI_AI".to_string()));
assert_eq!(
mapper.map_locale("ja-JP").unwrap(),
Some("Japanese_XJIS_100_CI_AI".to_string())
);
}
#[test]
fn test_invalid_locale_fallback() {
let config = test_config();
let mapper = CollationMapper::new(config, DatabaseType::PostgreSQL);
let result = mapper.map_locale("invalid-locale").unwrap();
assert_eq!(result, Some("en-US-x-icu".to_string()));
}
#[test]
fn test_invalid_locale_database_default() {
let mut config = test_config();
config.on_invalid_locale = InvalidLocaleStrategy::DatabaseDefault;
let mapper = CollationMapper::new(config, DatabaseType::PostgreSQL);
let result = mapper.map_locale("invalid-locale").unwrap();
assert_eq!(result, None);
}
#[test]
fn test_invalid_locale_error() {
let mut config = test_config();
config.on_invalid_locale = InvalidLocaleStrategy::Error;
let mapper = CollationMapper::new(config, DatabaseType::PostgreSQL);
let result = mapper.map_locale("invalid-locale");
assert!(
result.is_err(),
"expected Err for invalid locale with Error strategy, got: {result:?}"
);
}
#[test]
fn test_disabled_collation() {
let mut config = test_config();
config.enabled = false;
let mapper = CollationMapper::new(config, DatabaseType::PostgreSQL);
assert_eq!(mapper.map_locale("fr-FR").unwrap(), None);
assert_eq!(mapper.map_locale("en-US").unwrap(), None);
}
#[test]
fn test_capabilities_locale_support() {
assert!(CollationCapabilities::supports_locale_collation(DatabaseType::PostgreSQL));
assert!(CollationCapabilities::supports_locale_collation(DatabaseType::SQLServer));
assert!(!CollationCapabilities::supports_locale_collation(DatabaseType::MySQL));
assert!(!CollationCapabilities::supports_locale_collation(DatabaseType::SQLite));
}
#[test]
fn test_capabilities_custom_collation() {
assert!(CollationCapabilities::requires_custom_collation(DatabaseType::SQLite));
assert!(!CollationCapabilities::requires_custom_collation(DatabaseType::PostgreSQL));
assert!(!CollationCapabilities::requires_custom_collation(DatabaseType::MySQL));
assert!(!CollationCapabilities::requires_custom_collation(DatabaseType::SQLServer));
}
#[test]
fn test_capabilities_strategy() {
assert_eq!(
CollationCapabilities::strategy(DatabaseType::PostgreSQL),
"ICU collations (locale-specific)"
);
assert_eq!(
CollationCapabilities::strategy(DatabaseType::MySQL),
"UTF8MB4 collations (general)"
);
assert_eq!(CollationCapabilities::strategy(DatabaseType::SQLite), "NOCASE (limited)");
assert_eq!(
CollationCapabilities::strategy(DatabaseType::SQLServer),
"Language-specific collations"
);
}
}