fraiseql_db/collation.rs
1//! Database-specific collation mapping.
2//!
3//! Maps user locales to database-specific collation strings, adapting to each
4//! database's collation capabilities.
5
6use fraiseql_error::{FraiseQLError, Result};
7
8use crate::{collation_config::CollationConfig, types::DatabaseType};
9
10/// Maps user locales to database-specific collation strings.
11///
12/// The mapper takes a global `CollationConfig` and database type, then translates
13/// user locales (e.g., "fr-FR") into the appropriate database-specific collation
14/// format (e.g., "fr-FR-x-icu" for PostgreSQL with ICU).
15///
16/// # Examples
17///
18/// ```
19/// use fraiseql_db::CollationConfig;
20/// use fraiseql_db::{DatabaseType, collation::CollationMapper};
21///
22/// // PostgreSQL with ICU
23/// let config = CollationConfig::default();
24/// let mapper = CollationMapper::new(config.clone(), DatabaseType::PostgreSQL);
25/// assert_eq!(mapper.map_locale("fr-FR").unwrap(), Some("fr-FR-x-icu".to_string()));
26///
27/// // MySQL (general collation, not locale-specific)
28/// let mapper = CollationMapper::new(config, DatabaseType::MySQL);
29/// assert_eq!(mapper.map_locale("fr-FR").unwrap(), Some("utf8mb4_unicode_ci".to_string()));
30/// ```
31pub struct CollationMapper {
32 config: CollationConfig,
33 database_type: DatabaseType,
34}
35
36impl CollationMapper {
37 /// Create a new collation mapper.
38 ///
39 /// # Arguments
40 ///
41 /// * `config` - Global collation configuration
42 /// * `database_type` - Target database type
43 #[must_use]
44 pub const fn new(config: CollationConfig, database_type: DatabaseType) -> Self {
45 Self {
46 config,
47 database_type,
48 }
49 }
50
51 /// Map user locale to database-specific collation string.
52 ///
53 /// # Arguments
54 ///
55 /// * `locale` - User locale (e.g., "fr-FR", "ja-JP")
56 ///
57 /// # Returns
58 ///
59 /// - `Ok(Some(collation))` - Database-specific collation string
60 /// - `Ok(None)` - Use database default (no COLLATE clause)
61 /// - `Err(_)` - Invalid locale when strategy is `Error`
62 ///
63 /// # Errors
64 ///
65 /// Returns `FraiseQLError::Validation` if locale is not in allowed list
66 /// and `on_invalid_locale` is set to `Error`.
67 ///
68 /// # Examples
69 ///
70 /// ```
71 /// use fraiseql_db::CollationConfig;
72 /// use fraiseql_db::{DatabaseType, collation::CollationMapper};
73 ///
74 /// let config = CollationConfig::default();
75 /// let mapper = CollationMapper::new(config, DatabaseType::PostgreSQL);
76 ///
77 /// // Valid locale
78 /// let collation = mapper.map_locale("fr-FR").unwrap();
79 /// assert_eq!(collation, Some("fr-FR-x-icu".to_string()));
80 ///
81 /// // Invalid locale (not in allowed list)
82 /// let result = mapper.map_locale("invalid");
83 /// assert!(result.is_ok(), "utf8 is a valid collation: {result:?}");
84 /// ```
85 ///
86 /// # Errors
87 ///
88 /// Returns [`FraiseQLError::Validation`] if `locale` is not in the allowed list
89 /// and the configured `InvalidLocaleStrategy` is `Reject`.
90 pub fn map_locale(&self, locale: &str) -> Result<Option<String>> {
91 if !self.config.enabled {
92 return Ok(None);
93 }
94
95 // Check if locale is allowed
96 if !self.config.allowed_locales.contains(&locale.to_string()) {
97 return self.handle_invalid_locale();
98 }
99
100 match self.database_type {
101 DatabaseType::PostgreSQL => Ok(self.map_postgres(locale)),
102 DatabaseType::MySQL => Ok(self.map_mysql(locale)),
103 DatabaseType::SQLite => Ok(self.map_sqlite(locale)),
104 DatabaseType::SQLServer => Ok(self.map_sqlserver(locale)),
105 }
106 }
107
108 /// Map locale for PostgreSQL.
109 ///
110 /// Supports both ICU and libc collations:
111 /// - ICU: "fr-FR-x-icu" (recommended, Unicode-aware)
112 /// - libc: "fr_FR.UTF-8" (system-dependent)
113 fn map_postgres(&self, locale: &str) -> Option<String> {
114 if let Some(overrides) = &self.config.database_overrides {
115 if let Some(pg_config) = &overrides.postgres {
116 if pg_config.use_icu {
117 return Some(format!("{locale}-x-icu"));
118 }
119 // libc format: en_US.UTF-8
120 let libc_locale = locale.replace('-', "_");
121 return Some(format!("{libc_locale}.UTF-8"));
122 }
123 }
124
125 // Default: ICU collation
126 Some(format!("{locale}-x-icu"))
127 }
128
129 /// Map locale for MySQL.
130 ///
131 /// MySQL collations are charset-based, not locale-specific.
132 /// All locales map to the same general-purpose collation.
133 fn map_mysql(&self, _locale: &str) -> Option<String> {
134 if let Some(overrides) = &self.config.database_overrides {
135 if let Some(mysql_config) = &overrides.mysql {
136 return Some(format!("{}{}", mysql_config.charset, mysql_config.suffix));
137 }
138 }
139
140 // Default: utf8mb4_unicode_ci (supports all languages)
141 Some("utf8mb4_unicode_ci".to_string())
142 }
143
144 /// Map locale for SQLite.
145 ///
146 /// SQLite has very limited collation support. Only NOCASE is built-in
147 /// for case-insensitive sorting.
148 fn map_sqlite(&self, _locale: &str) -> Option<String> {
149 if let Some(overrides) = &self.config.database_overrides {
150 if let Some(sqlite_config) = &overrides.sqlite {
151 return if sqlite_config.use_nocase {
152 Some("NOCASE".to_string())
153 } else {
154 None
155 };
156 }
157 }
158
159 // Default: NOCASE
160 Some("NOCASE".to_string())
161 }
162
163 /// Map locale for SQL Server.
164 ///
165 /// Maps common locales to SQL Server language-specific collations.
166 fn map_sqlserver(&self, locale: &str) -> Option<String> {
167 // Map common locales to SQL Server collations
168 let collation = match locale {
169 "en-US" | "en-GB" | "en-CA" | "en-AU" => "Latin1_General_100_CI_AI_SC_UTF8",
170 "fr-FR" | "fr-CA" => "French_100_CI_AI",
171 "de-DE" | "de-AT" | "de-CH" => "German_PhoneBook_100_CI_AI",
172 "es-ES" | "es-MX" => "Modern_Spanish_100_CI_AI",
173 "ja-JP" => "Japanese_XJIS_100_CI_AI",
174 "zh-CN" => "Chinese_PRC_100_CI_AI",
175 "pt-BR" => "Latin1_General_100_CI_AI_SC_UTF8",
176 "it-IT" => "Latin1_General_100_CI_AI_SC_UTF8",
177 _ => "Latin1_General_100_CI_AI_SC_UTF8", // Default
178 };
179
180 Some(collation.to_string())
181 }
182
183 /// Handle invalid locale based on configuration strategy.
184 fn handle_invalid_locale(&self) -> Result<Option<String>> {
185 use crate::collation_config::InvalidLocaleStrategy;
186
187 match self.config.on_invalid_locale {
188 InvalidLocaleStrategy::Fallback => self.map_locale(&self.config.fallback_locale),
189 InvalidLocaleStrategy::DatabaseDefault => Ok(None),
190 InvalidLocaleStrategy::Error => Err(FraiseQLError::Validation {
191 message: "Invalid locale: not in allowed list".to_string(),
192 path: None,
193 }),
194 }
195 }
196
197 /// Get the database type this mapper is configured for.
198 #[must_use]
199 pub const fn database_type(&self) -> DatabaseType {
200 self.database_type
201 }
202
203 /// Check if collation is enabled.
204 #[must_use]
205 pub const fn is_enabled(&self) -> bool {
206 self.config.enabled
207 }
208}
209
210/// Database collation capabilities.
211///
212/// Provides information about what collation features each database supports.
213pub struct CollationCapabilities;
214
215impl CollationCapabilities {
216 /// Check if database supports locale-specific collations.
217 ///
218 /// - PostgreSQL: ✅ Full support via ICU or libc
219 /// - MySQL: ❌ Only charset-based collations
220 /// - SQLite: ❌ Limited to NOCASE or custom functions
221 /// - SQL Server: ✅ Language-specific collations
222 #[must_use]
223 pub const fn supports_locale_collation(db_type: DatabaseType) -> bool {
224 matches!(db_type, DatabaseType::PostgreSQL | DatabaseType::SQLServer)
225 }
226
227 /// Check if database requires custom collation registration.
228 ///
229 /// SQLite requires custom collation functions to be registered for
230 /// locale-aware sorting beyond NOCASE.
231 #[must_use]
232 pub const fn requires_custom_collation(db_type: DatabaseType) -> bool {
233 matches!(db_type, DatabaseType::SQLite)
234 }
235
236 /// Get collation strategy description for database.
237 #[must_use]
238 pub const fn strategy(db_type: DatabaseType) -> &'static str {
239 match db_type {
240 DatabaseType::PostgreSQL => "ICU collations (locale-specific)",
241 DatabaseType::MySQL => "UTF8MB4 collations (general)",
242 DatabaseType::SQLite => "NOCASE (limited)",
243 DatabaseType::SQLServer => "Language-specific collations",
244 }
245 }
246
247 /// Get recommended collation provider for database.
248 #[must_use]
249 pub const fn recommended_provider(db_type: DatabaseType) -> Option<&'static str> {
250 match db_type {
251 DatabaseType::PostgreSQL => Some("icu"),
252 DatabaseType::MySQL => Some("utf8mb4_unicode_ci"),
253 DatabaseType::SQLite => Some("NOCASE"),
254 DatabaseType::SQLServer => Some("Latin1_General_100_CI_AI_SC_UTF8"),
255 }
256 }
257}
258
259#[cfg(test)]
260mod tests;