Skip to main content

fraiseql_core/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 crate::{
7    config::CollationConfig,
8    db::types::DatabaseType,
9    error::{FraiseQLError, Result},
10};
11
12/// Maps user locales to database-specific collation strings.
13///
14/// The mapper takes a global `CollationConfig` and database type, then translates
15/// user locales (e.g., "fr-FR") into the appropriate database-specific collation
16/// format (e.g., "fr-FR-x-icu" for PostgreSQL with ICU).
17///
18/// # Examples
19///
20/// ```
21/// use fraiseql_core::config::CollationConfig;
22/// use fraiseql_core::db::{DatabaseType, collation::CollationMapper};
23///
24/// // PostgreSQL with ICU
25/// let config = CollationConfig::default();
26/// let mapper = CollationMapper::new(config.clone(), DatabaseType::PostgreSQL);
27/// assert_eq!(mapper.map_locale("fr-FR").unwrap(), Some("fr-FR-x-icu".to_string()));
28///
29/// // MySQL (general collation, not locale-specific)
30/// let mapper = CollationMapper::new(config, DatabaseType::MySQL);
31/// assert_eq!(mapper.map_locale("fr-FR").unwrap(), Some("utf8mb4_unicode_ci".to_string()));
32/// ```
33pub struct CollationMapper {
34    config:        CollationConfig,
35    database_type: DatabaseType,
36}
37
38impl CollationMapper {
39    /// Create a new collation mapper.
40    ///
41    /// # Arguments
42    ///
43    /// * `config` - Global collation configuration
44    /// * `database_type` - Target database type
45    #[must_use]
46    pub fn new(config: CollationConfig, database_type: DatabaseType) -> Self {
47        Self {
48            config,
49            database_type,
50        }
51    }
52
53    /// Map user locale to database-specific collation string.
54    ///
55    /// # Arguments
56    ///
57    /// * `locale` - User locale (e.g., "fr-FR", "ja-JP")
58    ///
59    /// # Returns
60    ///
61    /// - `Ok(Some(collation))` - Database-specific collation string
62    /// - `Ok(None)` - Use database default (no COLLATE clause)
63    /// - `Err(_)` - Invalid locale when strategy is `Error`
64    ///
65    /// # Errors
66    ///
67    /// Returns `FraiseQLError::Validation` if locale is not in allowed list
68    /// and `on_invalid_locale` is set to `Error`.
69    ///
70    /// # Examples
71    ///
72    /// ```
73    /// use fraiseql_core::config::CollationConfig;
74    /// use fraiseql_core::db::{DatabaseType, collation::CollationMapper};
75    ///
76    /// let config = CollationConfig::default();
77    /// let mapper = CollationMapper::new(config, DatabaseType::PostgreSQL);
78    ///
79    /// // Valid locale
80    /// let collation = mapper.map_locale("fr-FR").unwrap();
81    /// assert_eq!(collation, Some("fr-FR-x-icu".to_string()));
82    ///
83    /// // Invalid locale (not in allowed list)
84    /// let result = mapper.map_locale("invalid");
85    /// assert!(result.is_ok()); // Returns fallback by default
86    /// ```
87    pub fn map_locale(&self, locale: &str) -> Result<Option<String>> {
88        if !self.config.enabled {
89            return Ok(None);
90        }
91
92        // Check if locale is allowed
93        if !self.config.allowed_locales.contains(&locale.to_string()) {
94            return self.handle_invalid_locale();
95        }
96
97        match self.database_type {
98            DatabaseType::PostgreSQL => Ok(self.map_postgres(locale)),
99            DatabaseType::MySQL => Ok(self.map_mysql(locale)),
100            DatabaseType::SQLite => Ok(self.map_sqlite(locale)),
101            DatabaseType::SQLServer => Ok(self.map_sqlserver(locale)),
102        }
103    }
104
105    /// Map locale for PostgreSQL.
106    ///
107    /// Supports both ICU and libc collations:
108    /// - ICU: "fr-FR-x-icu" (recommended, Unicode-aware)
109    /// - libc: "fr_FR.UTF-8" (system-dependent)
110    fn map_postgres(&self, locale: &str) -> Option<String> {
111        if let Some(overrides) = &self.config.database_overrides {
112            if let Some(pg_config) = &overrides.postgres {
113                if pg_config.use_icu {
114                    return Some(format!("{locale}-x-icu"));
115                }
116                // libc format: en_US.UTF-8
117                let libc_locale = locale.replace('-', "_");
118                return Some(format!("{libc_locale}.UTF-8"));
119            }
120        }
121
122        // Default: ICU collation
123        Some(format!("{locale}-x-icu"))
124    }
125
126    /// Map locale for MySQL.
127    ///
128    /// MySQL collations are charset-based, not locale-specific.
129    /// All locales map to the same general-purpose collation.
130    fn map_mysql(&self, _locale: &str) -> Option<String> {
131        if let Some(overrides) = &self.config.database_overrides {
132            if let Some(mysql_config) = &overrides.mysql {
133                return Some(format!("{}{}", mysql_config.charset, mysql_config.suffix));
134            }
135        }
136
137        // Default: utf8mb4_unicode_ci (supports all languages)
138        Some("utf8mb4_unicode_ci".to_string())
139    }
140
141    /// Map locale for SQLite.
142    ///
143    /// SQLite has very limited collation support. Only NOCASE is built-in
144    /// for case-insensitive sorting.
145    fn map_sqlite(&self, _locale: &str) -> Option<String> {
146        if let Some(overrides) = &self.config.database_overrides {
147            if let Some(sqlite_config) = &overrides.sqlite {
148                return if sqlite_config.use_nocase {
149                    Some("NOCASE".to_string())
150                } else {
151                    None
152                };
153            }
154        }
155
156        // Default: NOCASE
157        Some("NOCASE".to_string())
158    }
159
160    /// Map locale for SQL Server.
161    ///
162    /// Maps common locales to SQL Server language-specific collations.
163    fn map_sqlserver(&self, locale: &str) -> Option<String> {
164        // Map common locales to SQL Server collations
165        let collation = match locale {
166            "en-US" | "en-GB" | "en-CA" | "en-AU" => "Latin1_General_100_CI_AI_SC_UTF8",
167            "fr-FR" | "fr-CA" => "French_100_CI_AI",
168            "de-DE" | "de-AT" | "de-CH" => "German_PhoneBook_100_CI_AI",
169            "es-ES" | "es-MX" => "Modern_Spanish_100_CI_AI",
170            "ja-JP" => "Japanese_XJIS_100_CI_AI",
171            "zh-CN" => "Chinese_PRC_100_CI_AI",
172            "pt-BR" => "Latin1_General_100_CI_AI_SC_UTF8",
173            "it-IT" => "Latin1_General_100_CI_AI_SC_UTF8",
174            _ => "Latin1_General_100_CI_AI_SC_UTF8", // Default
175        };
176
177        Some(collation.to_string())
178    }
179
180    /// Handle invalid locale based on configuration strategy.
181    fn handle_invalid_locale(&self) -> Result<Option<String>> {
182        use crate::config::InvalidLocaleStrategy;
183
184        match self.config.on_invalid_locale {
185            InvalidLocaleStrategy::Fallback => self.map_locale(&self.config.fallback_locale),
186            InvalidLocaleStrategy::DatabaseDefault => Ok(None),
187            InvalidLocaleStrategy::Error => Err(FraiseQLError::Validation {
188                message: "Invalid locale: not in allowed list".to_string(),
189                path:    None,
190            }),
191        }
192    }
193
194    /// Get the database type this mapper is configured for.
195    #[must_use]
196    pub const fn database_type(&self) -> DatabaseType {
197        self.database_type
198    }
199
200    /// Check if collation is enabled.
201    #[must_use]
202    pub const fn is_enabled(&self) -> bool {
203        self.config.enabled
204    }
205}
206
207/// Database collation capabilities.
208///
209/// Provides information about what collation features each database supports.
210pub struct CollationCapabilities;
211
212impl CollationCapabilities {
213    /// Check if database supports locale-specific collations.
214    ///
215    /// - PostgreSQL: ✅ Full support via ICU or libc
216    /// - MySQL: ❌ Only charset-based collations
217    /// - SQLite: ❌ Limited to NOCASE or custom functions
218    /// - SQL Server: ✅ Language-specific collations
219    #[must_use]
220    pub const fn supports_locale_collation(db_type: DatabaseType) -> bool {
221        matches!(db_type, DatabaseType::PostgreSQL | DatabaseType::SQLServer)
222    }
223
224    /// Check if database requires custom collation registration.
225    ///
226    /// SQLite requires custom collation functions to be registered for
227    /// locale-aware sorting beyond NOCASE.
228    #[must_use]
229    pub const fn requires_custom_collation(db_type: DatabaseType) -> bool {
230        matches!(db_type, DatabaseType::SQLite)
231    }
232
233    /// Get collation strategy description for database.
234    #[must_use]
235    pub const fn strategy(db_type: DatabaseType) -> &'static str {
236        match db_type {
237            DatabaseType::PostgreSQL => "ICU collations (locale-specific)",
238            DatabaseType::MySQL => "UTF8MB4 collations (general)",
239            DatabaseType::SQLite => "NOCASE (limited)",
240            DatabaseType::SQLServer => "Language-specific collations",
241        }
242    }
243
244    /// Get recommended collation provider for database.
245    #[must_use]
246    pub const fn recommended_provider(db_type: DatabaseType) -> Option<&'static str> {
247        match db_type {
248            DatabaseType::PostgreSQL => Some("icu"),
249            DatabaseType::MySQL => Some("utf8mb4_unicode_ci"),
250            DatabaseType::SQLite => Some("NOCASE"),
251            DatabaseType::SQLServer => Some("Latin1_General_100_CI_AI_SC_UTF8"),
252        }
253    }
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259    use crate::config::{
260        DatabaseCollationOverrides, InvalidLocaleStrategy, MySqlCollationConfig,
261        PostgresCollationConfig, SqliteCollationConfig,
262    };
263
264    fn test_config() -> CollationConfig {
265        CollationConfig {
266            enabled:            true,
267            fallback_locale:    "en-US".to_string(),
268            allowed_locales:    vec!["en-US".into(), "fr-FR".into(), "ja-JP".into()],
269            on_invalid_locale:  InvalidLocaleStrategy::Fallback,
270            database_overrides: None,
271        }
272    }
273
274    #[test]
275    fn test_postgres_icu_collation() {
276        let config = test_config();
277        let mapper = CollationMapper::new(config, DatabaseType::PostgreSQL);
278
279        assert_eq!(mapper.map_locale("fr-FR").unwrap(), Some("fr-FR-x-icu".to_string()));
280        assert_eq!(mapper.map_locale("ja-JP").unwrap(), Some("ja-JP-x-icu".to_string()));
281    }
282
283    #[test]
284    fn test_postgres_libc_collation() {
285        let mut config = test_config();
286        config.database_overrides = Some(DatabaseCollationOverrides {
287            postgres:  Some(PostgresCollationConfig {
288                use_icu:  false,
289                provider: "libc".to_string(),
290            }),
291            mysql:     None,
292            sqlite:    None,
293            sqlserver: None,
294        });
295
296        let mapper = CollationMapper::new(config, DatabaseType::PostgreSQL);
297
298        assert_eq!(mapper.map_locale("fr-FR").unwrap(), Some("fr_FR.UTF-8".to_string()));
299        assert_eq!(mapper.map_locale("en-US").unwrap(), Some("en_US.UTF-8".to_string()));
300    }
301
302    #[test]
303    fn test_mysql_collation() {
304        let config = test_config();
305        let mapper = CollationMapper::new(config, DatabaseType::MySQL);
306
307        // All locales map to same charset-based collation
308        assert_eq!(mapper.map_locale("fr-FR").unwrap(), Some("utf8mb4_unicode_ci".to_string()));
309        assert_eq!(mapper.map_locale("ja-JP").unwrap(), Some("utf8mb4_unicode_ci".to_string()));
310    }
311
312    #[test]
313    fn test_mysql_custom_collation() {
314        let mut config = test_config();
315        config.database_overrides = Some(DatabaseCollationOverrides {
316            postgres:  None,
317            mysql:     Some(MySqlCollationConfig {
318                charset: "utf8mb4".to_string(),
319                suffix:  "_0900_ai_ci".to_string(),
320            }),
321            sqlite:    None,
322            sqlserver: None,
323        });
324
325        let mapper = CollationMapper::new(config, DatabaseType::MySQL);
326
327        assert_eq!(mapper.map_locale("fr-FR").unwrap(), Some("utf8mb4_0900_ai_ci".to_string()));
328    }
329
330    #[test]
331    fn test_sqlite_collation() {
332        let config = test_config();
333        let mapper = CollationMapper::new(config, DatabaseType::SQLite);
334
335        assert_eq!(mapper.map_locale("fr-FR").unwrap(), Some("NOCASE".to_string()));
336    }
337
338    #[test]
339    fn test_sqlite_disabled_nocase() {
340        let mut config = test_config();
341        config.database_overrides = Some(DatabaseCollationOverrides {
342            postgres:  None,
343            mysql:     None,
344            sqlite:    Some(SqliteCollationConfig { use_nocase: false }),
345            sqlserver: None,
346        });
347
348        let mapper = CollationMapper::new(config, DatabaseType::SQLite);
349
350        assert_eq!(mapper.map_locale("fr-FR").unwrap(), None);
351    }
352
353    #[test]
354    fn test_sqlserver_collation() {
355        let config = test_config();
356        let mapper = CollationMapper::new(config, DatabaseType::SQLServer);
357
358        assert_eq!(mapper.map_locale("fr-FR").unwrap(), Some("French_100_CI_AI".to_string()));
359        assert_eq!(
360            mapper.map_locale("ja-JP").unwrap(),
361            Some("Japanese_XJIS_100_CI_AI".to_string())
362        );
363    }
364
365    #[test]
366    fn test_invalid_locale_fallback() {
367        let config = test_config();
368        let mapper = CollationMapper::new(config, DatabaseType::PostgreSQL);
369
370        // Invalid locale should use fallback
371        let result = mapper.map_locale("invalid-locale").unwrap();
372        assert_eq!(result, Some("en-US-x-icu".to_string()));
373    }
374
375    #[test]
376    fn test_invalid_locale_database_default() {
377        let mut config = test_config();
378        config.on_invalid_locale = InvalidLocaleStrategy::DatabaseDefault;
379        let mapper = CollationMapper::new(config, DatabaseType::PostgreSQL);
380
381        // Invalid locale should return None (use database default)
382        let result = mapper.map_locale("invalid-locale").unwrap();
383        assert_eq!(result, None);
384    }
385
386    #[test]
387    fn test_invalid_locale_error() {
388        let mut config = test_config();
389        config.on_invalid_locale = InvalidLocaleStrategy::Error;
390        let mapper = CollationMapper::new(config, DatabaseType::PostgreSQL);
391
392        // Invalid locale should return error
393        let result = mapper.map_locale("invalid-locale");
394        assert!(result.is_err());
395    }
396
397    #[test]
398    fn test_disabled_collation() {
399        let mut config = test_config();
400        config.enabled = false;
401        let mapper = CollationMapper::new(config, DatabaseType::PostgreSQL);
402
403        // Should always return None when disabled
404        assert_eq!(mapper.map_locale("fr-FR").unwrap(), None);
405        assert_eq!(mapper.map_locale("en-US").unwrap(), None);
406    }
407
408    #[test]
409    fn test_capabilities_locale_support() {
410        assert!(CollationCapabilities::supports_locale_collation(DatabaseType::PostgreSQL));
411        assert!(CollationCapabilities::supports_locale_collation(DatabaseType::SQLServer));
412        assert!(!CollationCapabilities::supports_locale_collation(DatabaseType::MySQL));
413        assert!(!CollationCapabilities::supports_locale_collation(DatabaseType::SQLite));
414    }
415
416    #[test]
417    fn test_capabilities_custom_collation() {
418        assert!(CollationCapabilities::requires_custom_collation(DatabaseType::SQLite));
419        assert!(!CollationCapabilities::requires_custom_collation(DatabaseType::PostgreSQL));
420        assert!(!CollationCapabilities::requires_custom_collation(DatabaseType::MySQL));
421        assert!(!CollationCapabilities::requires_custom_collation(DatabaseType::SQLServer));
422    }
423
424    #[test]
425    fn test_capabilities_strategy() {
426        assert_eq!(
427            CollationCapabilities::strategy(DatabaseType::PostgreSQL),
428            "ICU collations (locale-specific)"
429        );
430        assert_eq!(
431            CollationCapabilities::strategy(DatabaseType::MySQL),
432            "UTF8MB4 collations (general)"
433        );
434        assert_eq!(CollationCapabilities::strategy(DatabaseType::SQLite), "NOCASE (limited)");
435        assert_eq!(
436            CollationCapabilities::strategy(DatabaseType::SQLServer),
437            "Language-specific collations"
438        );
439    }
440}