1use crate::{
7 config::CollationConfig,
8 db::types::DatabaseType,
9 error::{FraiseQLError, Result},
10};
11
12pub struct CollationMapper {
34 config: CollationConfig,
35 database_type: DatabaseType,
36}
37
38impl CollationMapper {
39 #[must_use]
46 pub fn new(config: CollationConfig, database_type: DatabaseType) -> Self {
47 Self {
48 config,
49 database_type,
50 }
51 }
52
53 pub fn map_locale(&self, locale: &str) -> Result<Option<String>> {
88 if !self.config.enabled {
89 return Ok(None);
90 }
91
92 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 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 let libc_locale = locale.replace('-', "_");
118 return Some(format!("{libc_locale}.UTF-8"));
119 }
120 }
121
122 Some(format!("{locale}-x-icu"))
124 }
125
126 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 Some("utf8mb4_unicode_ci".to_string())
139 }
140
141 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 Some("NOCASE".to_string())
158 }
159
160 fn map_sqlserver(&self, locale: &str) -> Option<String> {
164 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", };
176
177 Some(collation.to_string())
178 }
179
180 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 #[must_use]
196 pub const fn database_type(&self) -> DatabaseType {
197 self.database_type
198 }
199
200 #[must_use]
202 pub const fn is_enabled(&self) -> bool {
203 self.config.enabled
204 }
205}
206
207pub struct CollationCapabilities;
211
212impl CollationCapabilities {
213 #[must_use]
220 pub const fn supports_locale_collation(db_type: DatabaseType) -> bool {
221 matches!(db_type, DatabaseType::PostgreSQL | DatabaseType::SQLServer)
222 }
223
224 #[must_use]
229 pub const fn requires_custom_collation(db_type: DatabaseType) -> bool {
230 matches!(db_type, DatabaseType::SQLite)
231 }
232
233 #[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 #[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 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 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 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 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 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}