1use fraiseql_error::{FraiseQLError, Result};
7
8use crate::{collation_config::CollationConfig, types::DatabaseType};
9
10pub struct CollationMapper {
32 config: CollationConfig,
33 database_type: DatabaseType,
34}
35
36impl CollationMapper {
37 #[must_use]
44 pub const fn new(config: CollationConfig, database_type: DatabaseType) -> Self {
45 Self {
46 config,
47 database_type,
48 }
49 }
50
51 pub fn map_locale(&self, locale: &str) -> Result<Option<String>> {
91 if !self.config.enabled {
92 return Ok(None);
93 }
94
95 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 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 let libc_locale = locale.replace('-', "_");
121 return Some(format!("{libc_locale}.UTF-8"));
122 }
123 }
124
125 Some(format!("{locale}-x-icu"))
127 }
128
129 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 Some("utf8mb4_unicode_ci".to_string())
142 }
143
144 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 Some("NOCASE".to_string())
161 }
162
163 fn map_sqlserver(&self, locale: &str) -> Option<String> {
167 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", };
179
180 Some(collation.to_string())
181 }
182
183 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 #[must_use]
199 pub const fn database_type(&self) -> DatabaseType {
200 self.database_type
201 }
202
203 #[must_use]
205 pub const fn is_enabled(&self) -> bool {
206 self.config.enabled
207 }
208}
209
210pub struct CollationCapabilities;
214
215impl CollationCapabilities {
216 #[must_use]
223 pub const fn supports_locale_collation(db_type: DatabaseType) -> bool {
224 matches!(db_type, DatabaseType::PostgreSQL | DatabaseType::SQLServer)
225 }
226
227 #[must_use]
232 pub const fn requires_custom_collation(db_type: DatabaseType) -> bool {
233 matches!(db_type, DatabaseType::SQLite)
234 }
235
236 #[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 #[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)] mod 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 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 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 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 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 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}