1use std::collections::HashMap;
46
47use anycms_i18n::Backend;
48#[cfg(any(feature = "postgres", feature = "mysql", feature = "sqlite"))]
49use anycms_i18n::I18nError;
50use dashmap::DashMap;
51
52pub struct SqlxBackend {
61 cache: DashMap<String, HashMap<String, String>>,
62}
63
64impl SqlxBackend {
65 pub fn new() -> Self {
67 Self {
68 cache: DashMap::new(),
69 }
70 }
71
72 pub fn from_translations(
94 translations: impl IntoIterator<Item = (String, String, String)>,
95 ) -> Self {
96 let cache = DashMap::new();
97 for (locale, key, value) in translations {
98 cache
99 .entry(locale)
100 .or_insert_with(HashMap::new)
101 .insert(key, value);
102 }
103 Self { cache }
104 }
105
106 pub fn reload_from_translations(
112 &self,
113 translations: impl IntoIterator<Item = (String, String, String)>,
114 ) {
115 self.cache.clear();
116 for (locale, key, value) in translations {
117 self.cache.entry(locale).or_default().insert(key, value);
118 }
119 }
120
121 #[cfg(feature = "postgres")]
127 pub async fn from_postgres(pool: &sqlx::PgPool) -> Result<Self, I18nError> {
128 let rows: Vec<(String, String, String)> =
129 sqlx::query_as("SELECT locale, key, value FROM i18n_translations")
130 .fetch_all(pool)
131 .await
132 .map_err(|e| I18nError::DatabaseError(e.to_string()))?;
133
134 Ok(Self::from_translations(rows))
135 }
136
137 #[cfg(feature = "postgres")]
139 pub async fn reload_postgres(&self, pool: &sqlx::PgPool) -> Result<(), I18nError> {
140 let rows: Vec<(String, String, String)> =
141 sqlx::query_as("SELECT locale, key, value FROM i18n_translations")
142 .fetch_all(pool)
143 .await
144 .map_err(|e| I18nError::DatabaseError(e.to_string()))?;
145
146 self.reload_from_translations(rows);
147 Ok(())
148 }
149
150 #[cfg(feature = "mysql")]
156 pub async fn from_mysql(pool: &sqlx::MySqlPool) -> Result<Self, I18nError> {
157 let rows: Vec<(String, String, String)> =
158 sqlx::query_as("SELECT locale, key, value FROM i18n_translations")
159 .fetch_all(pool)
160 .await
161 .map_err(|e| I18nError::DatabaseError(e.to_string()))?;
162
163 Ok(Self::from_translations(rows))
164 }
165
166 #[cfg(feature = "mysql")]
168 pub async fn reload_mysql(&self, pool: &sqlx::MySqlPool) -> Result<(), I18nError> {
169 let rows: Vec<(String, String, String)> =
170 sqlx::query_as("SELECT locale, key, value FROM i18n_translations")
171 .fetch_all(pool)
172 .await
173 .map_err(|e| I18nError::DatabaseError(e.to_string()))?;
174
175 self.reload_from_translations(rows);
176 Ok(())
177 }
178
179 #[cfg(feature = "sqlite")]
185 pub async fn from_sqlite(pool: &sqlx::SqlitePool) -> Result<Self, I18nError> {
186 let rows: Vec<(String, String, String)> =
187 sqlx::query_as("SELECT locale, key, value FROM i18n_translations")
188 .fetch_all(pool)
189 .await
190 .map_err(|e| I18nError::DatabaseError(e.to_string()))?;
191
192 Ok(Self::from_translations(rows))
193 }
194
195 #[cfg(feature = "sqlite")]
197 pub async fn reload_sqlite(&self, pool: &sqlx::SqlitePool) -> Result<(), I18nError> {
198 let rows: Vec<(String, String, String)> =
199 sqlx::query_as("SELECT locale, key, value FROM i18n_translations")
200 .fetch_all(pool)
201 .await
202 .map_err(|e| I18nError::DatabaseError(e.to_string()))?;
203
204 self.reload_from_translations(rows);
205 Ok(())
206 }
207}
208
209impl Default for SqlxBackend {
210 fn default() -> Self {
211 Self::new()
212 }
213}
214
215impl Backend for SqlxBackend {
216 fn get(&self, locale: &str, key: &str) -> Option<String> {
217 self.cache.get(locale).and_then(|map| map.get(key).cloned())
218 }
219
220 fn available_locales(&self) -> Vec<String> {
221 self.cache.iter().map(|r| r.key().clone()).collect()
222 }
223
224 fn has_locale(&self, locale: &str) -> bool {
225 self.cache.contains_key(locale)
226 }
227
228 fn dump(&self, locale: &str) -> HashMap<String, String> {
229 self.cache
230 .get(locale)
231 .map(|m| m.clone())
232 .unwrap_or_default()
233 }
234}
235
236pub struct SqlxBackendBuilder {
250 table: String,
251 locale_col: String,
252 key_col: String,
253 value_col: String,
254}
255
256impl SqlxBackendBuilder {
257 pub fn new() -> Self {
263 Self {
264 table: "i18n_translations".into(),
265 locale_col: "locale".into(),
266 key_col: "key".into(),
267 value_col: "value".into(),
268 }
269 }
270
271 pub fn table(mut self, name: impl Into<String>) -> Self {
273 self.table = name.into();
274 self
275 }
276
277 pub fn locale_col(mut self, name: impl Into<String>) -> Self {
279 self.locale_col = name.into();
280 self
281 }
282
283 pub fn key_col(mut self, name: impl Into<String>) -> Self {
285 self.key_col = name.into();
286 self
287 }
288
289 pub fn value_col(mut self, name: impl Into<String>) -> Self {
291 self.value_col = name.into();
292 self
293 }
294
295 #[allow(dead_code)] fn query(&self) -> String {
298 format!(
299 "SELECT {} AS locale, {} AS key, {} AS value FROM {}",
300 self.locale_col, self.key_col, self.value_col, self.table,
301 )
302 }
303
304 #[cfg(feature = "postgres")]
306 pub async fn build_postgres(&self, pool: &sqlx::PgPool) -> Result<SqlxBackend, I18nError> {
307 let sql = self.query();
308 let rows: Vec<(String, String, String)> = sqlx::query_as(sql.as_str())
309 .fetch_all(pool)
310 .await
311 .map_err(|e| I18nError::DatabaseError(e.to_string()))?;
312
313 Ok(SqlxBackend::from_translations(rows))
314 }
315
316 #[cfg(feature = "mysql")]
318 pub async fn build_mysql(&self, pool: &sqlx::MySqlPool) -> Result<SqlxBackend, I18nError> {
319 let sql = self.query();
320 let rows: Vec<(String, String, String)> = sqlx::query_as(sql.as_str())
321 .fetch_all(pool)
322 .await
323 .map_err(|e| I18nError::DatabaseError(e.to_string()))?;
324
325 Ok(SqlxBackend::from_translations(rows))
326 }
327
328 #[cfg(feature = "sqlite")]
330 pub async fn build_sqlite(&self, pool: &sqlx::SqlitePool) -> Result<SqlxBackend, I18nError> {
331 let sql = self.query();
332 let rows: Vec<(String, String, String)> = sqlx::query_as(sql.as_str())
333 .fetch_all(pool)
334 .await
335 .map_err(|e| I18nError::DatabaseError(e.to_string()))?;
336
337 Ok(SqlxBackend::from_translations(rows))
338 }
339}
340
341impl Default for SqlxBackendBuilder {
342 fn default() -> Self {
343 Self::new()
344 }
345}
346
347#[cfg(test)]
348mod tests {
349 use super::*;
350
351 #[test]
352 fn empty_backend() {
353 let backend = SqlxBackend::new();
354 assert!(!backend.has_locale("en"));
355 assert!(backend.available_locales().is_empty());
356 assert_eq!(backend.get("en", "hello"), None);
357 }
358
359 #[test]
360 fn from_translations() {
361 let backend = SqlxBackend::from_translations(vec![
362 ("en".into(), "hello".into(), "Hello".into()),
363 ("en".into(), "world".into(), "World".into()),
364 ("zh-CN".into(), "hello".into(), "你好".into()),
365 ]);
366
367 assert!(backend.has_locale("en"));
368 assert!(backend.has_locale("zh-CN"));
369 assert!(!backend.has_locale("ja"));
370 assert_eq!(backend.get("en", "hello"), Some("Hello".into()));
371 assert_eq!(backend.get("en", "world"), Some("World".into()));
372 assert_eq!(backend.get("zh-CN", "hello"), Some("你好".into()));
373 assert_eq!(backend.get("en", "missing"), None);
374
375 let mut locales = backend.available_locales();
376 locales.sort();
377 assert_eq!(locales, vec!["en", "zh-CN"]);
378 }
379
380 #[test]
381 fn reload_clears_old_data() {
382 let backend =
383 SqlxBackend::from_translations(vec![("en".into(), "hello".into(), "Hello".into())]);
384 assert_eq!(backend.get("en", "hello"), Some("Hello".into()));
385
386 backend.reload_from_translations(vec![("de".into(), "hello".into(), "Hallo".into())]);
387
388 assert_eq!(backend.get("en", "hello"), None);
389 assert_eq!(backend.get("de", "hello"), Some("Hallo".into()));
390 }
391
392 #[test]
393 fn builder_default_query() {
394 let builder = SqlxBackendBuilder::new();
395 assert_eq!(
396 builder.query(),
397 "SELECT locale AS locale, key AS key, value AS value FROM i18n_translations"
398 );
399 }
400
401 #[test]
402 fn builder_custom_query() {
403 let builder = SqlxBackendBuilder::new()
404 .table("my_table")
405 .locale_col("lang")
406 .key_col("msg_key")
407 .value_col("msg_val");
408
409 assert_eq!(
410 builder.query(),
411 "SELECT lang AS locale, msg_key AS key, msg_val AS value FROM my_table"
412 );
413 }
414
415 #[test]
416 fn dump_returns_all_keys_for_locale() {
417 let backend = SqlxBackend::from_translations(vec![
418 ("en".into(), "hello".into(), "Hello".into()),
419 ("en".into(), "world".into(), "World".into()),
420 ("zh-CN".into(), "hello".into(), "你好".into()),
421 ]);
422
423 let en = backend.dump("en");
424 assert_eq!(en.len(), 2);
425 assert_eq!(en.get("hello").unwrap(), "Hello");
426 assert_eq!(en.get("world").unwrap(), "World");
427
428 let zh = backend.dump("zh-CN");
429 assert_eq!(zh.len(), 1);
430 assert_eq!(zh.get("hello").unwrap(), "你好");
431
432 assert!(backend.dump("ja").is_empty());
434 }
435}