Skip to main content

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)]
260#[allow(clippy::unwrap_used)] // Reason: test code, panics are acceptable
261mod tests {
262    use super::*;
263    use crate::collation_config::{
264        DatabaseCollationOverrides, InvalidLocaleStrategy, MySqlCollationConfig,
265        PostgresCollationConfig, SqliteCollationConfig,
266    };
267
268    fn test_config() -> CollationConfig {
269        CollationConfig {
270            enabled:            true,
271            fallback_locale:    "en-US".to_string(),
272            allowed_locales:    vec!["en-US".into(), "fr-FR".into(), "ja-JP".into()],
273            on_invalid_locale:  InvalidLocaleStrategy::Fallback,
274            database_overrides: None,
275        }
276    }
277
278    #[test]
279    fn test_postgres_icu_collation() {
280        let config = test_config();
281        let mapper = CollationMapper::new(config, DatabaseType::PostgreSQL);
282
283        assert_eq!(mapper.map_locale("fr-FR").unwrap(), Some("fr-FR-x-icu".to_string()));
284        assert_eq!(mapper.map_locale("ja-JP").unwrap(), Some("ja-JP-x-icu".to_string()));
285    }
286
287    #[test]
288    fn test_postgres_libc_collation() {
289        let mut config = test_config();
290        config.database_overrides = Some(DatabaseCollationOverrides {
291            postgres:  Some(PostgresCollationConfig {
292                use_icu:  false,
293                provider: "libc".to_string(),
294            }),
295            mysql:     None,
296            sqlite:    None,
297            sqlserver: None,
298        });
299
300        let mapper = CollationMapper::new(config, DatabaseType::PostgreSQL);
301
302        assert_eq!(mapper.map_locale("fr-FR").unwrap(), Some("fr_FR.UTF-8".to_string()));
303        assert_eq!(mapper.map_locale("en-US").unwrap(), Some("en_US.UTF-8".to_string()));
304    }
305
306    #[test]
307    fn test_mysql_collation() {
308        let config = test_config();
309        let mapper = CollationMapper::new(config, DatabaseType::MySQL);
310
311        // All locales map to same charset-based collation
312        assert_eq!(mapper.map_locale("fr-FR").unwrap(), Some("utf8mb4_unicode_ci".to_string()));
313        assert_eq!(mapper.map_locale("ja-JP").unwrap(), Some("utf8mb4_unicode_ci".to_string()));
314    }
315
316    #[test]
317    fn test_mysql_custom_collation() {
318        let mut config = test_config();
319        config.database_overrides = Some(DatabaseCollationOverrides {
320            postgres:  None,
321            mysql:     Some(MySqlCollationConfig {
322                charset: "utf8mb4".to_string(),
323                suffix:  "_0900_ai_ci".to_string(),
324            }),
325            sqlite:    None,
326            sqlserver: None,
327        });
328
329        let mapper = CollationMapper::new(config, DatabaseType::MySQL);
330
331        assert_eq!(mapper.map_locale("fr-FR").unwrap(), Some("utf8mb4_0900_ai_ci".to_string()));
332    }
333
334    #[test]
335    fn test_sqlite_collation() {
336        let config = test_config();
337        let mapper = CollationMapper::new(config, DatabaseType::SQLite);
338
339        assert_eq!(mapper.map_locale("fr-FR").unwrap(), Some("NOCASE".to_string()));
340    }
341
342    #[test]
343    fn test_sqlite_disabled_nocase() {
344        let mut config = test_config();
345        config.database_overrides = Some(DatabaseCollationOverrides {
346            postgres:  None,
347            mysql:     None,
348            sqlite:    Some(SqliteCollationConfig { use_nocase: false }),
349            sqlserver: None,
350        });
351
352        let mapper = CollationMapper::new(config, DatabaseType::SQLite);
353
354        assert_eq!(mapper.map_locale("fr-FR").unwrap(), None);
355    }
356
357    #[test]
358    fn test_sqlserver_collation() {
359        let config = test_config();
360        let mapper = CollationMapper::new(config, DatabaseType::SQLServer);
361
362        assert_eq!(mapper.map_locale("fr-FR").unwrap(), Some("French_100_CI_AI".to_string()));
363        assert_eq!(
364            mapper.map_locale("ja-JP").unwrap(),
365            Some("Japanese_XJIS_100_CI_AI".to_string())
366        );
367    }
368
369    #[test]
370    fn test_invalid_locale_fallback() {
371        let config = test_config();
372        let mapper = CollationMapper::new(config, DatabaseType::PostgreSQL);
373
374        // Invalid locale should use fallback
375        let result = mapper.map_locale("invalid-locale").unwrap();
376        assert_eq!(result, Some("en-US-x-icu".to_string()));
377    }
378
379    #[test]
380    fn test_invalid_locale_database_default() {
381        let mut config = test_config();
382        config.on_invalid_locale = InvalidLocaleStrategy::DatabaseDefault;
383        let mapper = CollationMapper::new(config, DatabaseType::PostgreSQL);
384
385        // Invalid locale should return None (use database default)
386        let result = mapper.map_locale("invalid-locale").unwrap();
387        assert_eq!(result, None);
388    }
389
390    #[test]
391    fn test_invalid_locale_error() {
392        let mut config = test_config();
393        config.on_invalid_locale = InvalidLocaleStrategy::Error;
394        let mapper = CollationMapper::new(config, DatabaseType::PostgreSQL);
395
396        // Invalid locale should return error
397        let result = mapper.map_locale("invalid-locale");
398        assert!(
399            result.is_err(),
400            "expected Err for invalid locale with Error strategy, got: {result:?}"
401        );
402    }
403
404    #[test]
405    fn test_disabled_collation() {
406        let mut config = test_config();
407        config.enabled = false;
408        let mapper = CollationMapper::new(config, DatabaseType::PostgreSQL);
409
410        // Should always return None when disabled
411        assert_eq!(mapper.map_locale("fr-FR").unwrap(), None);
412        assert_eq!(mapper.map_locale("en-US").unwrap(), None);
413    }
414
415    #[test]
416    fn test_capabilities_locale_support() {
417        assert!(CollationCapabilities::supports_locale_collation(DatabaseType::PostgreSQL));
418        assert!(CollationCapabilities::supports_locale_collation(DatabaseType::SQLServer));
419        assert!(!CollationCapabilities::supports_locale_collation(DatabaseType::MySQL));
420        assert!(!CollationCapabilities::supports_locale_collation(DatabaseType::SQLite));
421    }
422
423    #[test]
424    fn test_capabilities_custom_collation() {
425        assert!(CollationCapabilities::requires_custom_collation(DatabaseType::SQLite));
426        assert!(!CollationCapabilities::requires_custom_collation(DatabaseType::PostgreSQL));
427        assert!(!CollationCapabilities::requires_custom_collation(DatabaseType::MySQL));
428        assert!(!CollationCapabilities::requires_custom_collation(DatabaseType::SQLServer));
429    }
430
431    #[test]
432    fn test_capabilities_strategy() {
433        assert_eq!(
434            CollationCapabilities::strategy(DatabaseType::PostgreSQL),
435            "ICU collations (locale-specific)"
436        );
437        assert_eq!(
438            CollationCapabilities::strategy(DatabaseType::MySQL),
439            "UTF8MB4 collations (general)"
440        );
441        assert_eq!(CollationCapabilities::strategy(DatabaseType::SQLite), "NOCASE (limited)");
442        assert_eq!(
443            CollationCapabilities::strategy(DatabaseType::SQLServer),
444            "Language-specific collations"
445        );
446    }
447}