Skip to main content

fraiseql_db/
collation_config.rs

1//! Collation configuration for user-aware sorting.
2//!
3//! Maps user locales to database-specific collation strings.
4
5use serde::{Deserialize, Serialize};
6
7/// Collation configuration for user-aware sorting.
8///
9/// This configuration enables automatic collation support based on user locale,
10/// adapting to database capabilities.
11#[derive(Debug, Clone, Serialize, Deserialize)]
12#[serde(default)]
13pub struct CollationConfig {
14    /// Enable automatic user-aware collation.
15    pub enabled: bool,
16
17    /// Fallback locale for unauthenticated users.
18    pub fallback_locale: String,
19
20    /// Allowed locales (whitelist for security).
21    pub allowed_locales: Vec<String>,
22
23    /// Strategy when user locale is not in allowed list.
24    pub on_invalid_locale: InvalidLocaleStrategy,
25
26    /// Database-specific overrides (optional).
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub database_overrides: Option<DatabaseCollationOverrides>,
29}
30
31impl Default for CollationConfig {
32    fn default() -> Self {
33        Self {
34            enabled:            true,
35            fallback_locale:    "en-US".to_string(),
36            allowed_locales:    vec![
37                "en-US".into(),
38                "en-GB".into(),
39                "fr-FR".into(),
40                "de-DE".into(),
41                "es-ES".into(),
42                "ja-JP".into(),
43                "zh-CN".into(),
44                "pt-BR".into(),
45                "it-IT".into(),
46            ],
47            on_invalid_locale:  InvalidLocaleStrategy::Fallback,
48            database_overrides: None,
49        }
50    }
51}
52
53/// Strategy when user locale is not in allowed list.
54#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
55#[serde(rename_all = "snake_case")]
56#[non_exhaustive]
57pub enum InvalidLocaleStrategy {
58    /// Use fallback locale.
59    #[default]
60    Fallback,
61    /// Use database default (no COLLATE clause).
62    DatabaseDefault,
63    /// Return error.
64    Error,
65}
66
67/// Database-specific collation overrides.
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct DatabaseCollationOverrides {
70    /// PostgreSQL-specific settings.
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub postgres: Option<PostgresCollationConfig>,
73
74    /// MySQL-specific settings.
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub mysql: Option<MySqlCollationConfig>,
77
78    /// SQLite-specific settings.
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub sqlite: Option<SqliteCollationConfig>,
81
82    /// SQL Server-specific settings.
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub sqlserver: Option<SqlServerCollationConfig>,
85}
86
87/// PostgreSQL-specific collation configuration.
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct PostgresCollationConfig {
90    /// Use ICU collations (recommended).
91    pub use_icu: bool,
92
93    /// Provider: "icu" or "libc".
94    pub provider: String,
95}
96
97impl Default for PostgresCollationConfig {
98    fn default() -> Self {
99        Self {
100            use_icu:  true,
101            provider: "icu".to_string(),
102        }
103    }
104}
105
106/// MySQL-specific collation configuration.
107#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct MySqlCollationConfig {
109    /// Charset (e.g., "utf8mb4").
110    pub charset: String,
111
112    /// Collation suffix (e.g., "_unicode_ci" or "_0900_ai_ci").
113    pub suffix: String,
114}
115
116impl Default for MySqlCollationConfig {
117    fn default() -> Self {
118        Self {
119            charset: "utf8mb4".to_string(),
120            suffix:  "_unicode_ci".to_string(),
121        }
122    }
123}
124
125/// SQLite-specific collation configuration.
126#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct SqliteCollationConfig {
128    /// Use COLLATE NOCASE for case-insensitive sorting.
129    pub use_nocase: bool,
130}
131
132impl Default for SqliteCollationConfig {
133    fn default() -> Self {
134        Self { use_nocase: true }
135    }
136}
137
138/// SQL Server-specific collation configuration.
139#[derive(Debug, Clone, Serialize, Deserialize)]
140pub struct SqlServerCollationConfig {
141    /// Case-insensitive (CI) collations.
142    pub case_insensitive: bool,
143
144    /// Accent-insensitive (AI) collations.
145    pub accent_insensitive: bool,
146}
147
148impl Default for SqlServerCollationConfig {
149    fn default() -> Self {
150        Self {
151            case_insensitive:   true,
152            accent_insensitive: true,
153        }
154    }
155}
156
157#[allow(clippy::unwrap_used)] // Reason: test code, panics are acceptable
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    #[test]
163    fn test_collation_config_default_fallback_locale_is_en_us() {
164        let config = CollationConfig::default();
165        assert_eq!(config.fallback_locale, "en-US");
166    }
167
168    #[test]
169    fn test_collation_config_default_is_enabled() {
170        let config = CollationConfig::default();
171        assert!(config.enabled, "CollationConfig should be enabled by default");
172    }
173
174    #[test]
175    fn test_collation_config_default_allowed_locales_contains_common_locales() {
176        let config = CollationConfig::default();
177        assert!(config.allowed_locales.contains(&"en-US".to_string()));
178        assert!(config.allowed_locales.contains(&"fr-FR".to_string()));
179        assert!(config.allowed_locales.contains(&"de-DE".to_string()));
180    }
181
182    #[test]
183    fn test_collation_config_round_trip_serde() {
184        let config = CollationConfig::default();
185        let json = serde_json::to_string(&config).unwrap();
186        let restored: CollationConfig = serde_json::from_str(&json).unwrap();
187
188        assert_eq!(restored.fallback_locale, config.fallback_locale);
189        assert_eq!(restored.enabled, config.enabled);
190        assert_eq!(restored.allowed_locales, config.allowed_locales);
191    }
192
193    #[test]
194    fn test_collation_config_custom_locale() {
195        let config = CollationConfig {
196            fallback_locale: "ja-JP".to_string(),
197            ..CollationConfig::default()
198        };
199        assert_eq!(config.fallback_locale, "ja-JP");
200    }
201
202    #[test]
203    fn test_invalid_locale_strategy_default_is_fallback() {
204        let strategy = InvalidLocaleStrategy::default();
205        assert_eq!(strategy, InvalidLocaleStrategy::Fallback);
206    }
207
208    #[test]
209    fn test_postgres_collation_config_default_uses_icu() {
210        let config = PostgresCollationConfig::default();
211        assert!(config.use_icu, "PostgreSQL default collation should use ICU");
212        assert_eq!(config.provider, "icu");
213    }
214
215    #[test]
216    fn test_mysql_collation_config_default_charset() {
217        let config = MySqlCollationConfig::default();
218        assert_eq!(config.charset, "utf8mb4");
219        assert_eq!(config.suffix, "_unicode_ci");
220    }
221
222    #[test]
223    fn test_sqlite_collation_config_default_nocase() {
224        let config = SqliteCollationConfig::default();
225        assert!(config.use_nocase, "SQLite default collation should use NOCASE");
226    }
227
228    #[test]
229    fn test_sqlserver_collation_config_default_case_and_accent_insensitive() {
230        let config = SqlServerCollationConfig::default();
231        assert!(config.case_insensitive);
232        assert!(config.accent_insensitive);
233    }
234}