Skip to main content

anycms_i18n_sqlx/
lib.rs

1//! # anycms-i18n-sqlx
2//!
3//! SQLx database backend for [`anycms-i18n`].
4//!
5//! Loads translations from any SQLx-supported database (PostgreSQL, MySQL, SQLite)
6//! into an in-memory cache at startup, then serves translations synchronously
7//! via the [`Backend`] trait.
8//!
9//! ## Quick Start
10//!
11//! ```rust,ignore
12//! use anycms_i18n_sqlx::SqlxBackend;
13//!
14//! // PostgreSQL
15//! let pool = sqlx::PgPool::connect("postgres://...").await?;
16//! let backend = SqlxBackend::from_postgres(&pool).await?;
17//!
18//! // MySQL
19//! let pool = sqlx::MySqlPool::connect("mysql://...").await?;
20//! let backend = SqlxBackend::from_mysql(&pool).await?;
21//!
22//! // SQLite
23//! let pool = sqlx::SqlitePool::connect("sqlite:translations.db").await?;
24//! let backend = SqlxBackend::from_sqlite(&pool).await?;
25//!
26//! // Use as a Backend
27//! use anycms_i18n::Backend;
28//! assert!(backend.has_locale("en"));
29//! ```
30//!
31//! ## Custom Table / Column Names
32//!
33//! Use [`SqlxBackendBuilder`] to customize the table and column names:
34//!
35//! ```rust,ignore
36//! let backend = SqlxBackendBuilder::new()
37//!     .table("my_translations")
38//!     .locale_col("lang")
39//!     .key_col("msg_key")
40//!     .value_col("msg_value")
41//!     .build_postgres(&pool)
42//!     .await?;
43//! ```
44
45use 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
52// ---------------------------------------------------------------------------
53// SqlxBackend
54// ---------------------------------------------------------------------------
55
56/// Database-backed translation backend powered by SQLx.
57///
58/// Translations are loaded asynchronously into an in-memory [`DashMap`] cache.
59/// All [`Backend`] trait methods are synchronous and read from cache only.
60pub struct SqlxBackend {
61    cache: DashMap<String, HashMap<String, String>>,
62}
63
64impl SqlxBackend {
65    /// Create an empty backend with no translations.
66    pub fn new() -> Self {
67        Self {
68            cache: DashMap::new(),
69        }
70    }
71
72    /// Create from an iterator of `(locale, key, value)` tuples.
73    ///
74    /// This is the core constructor used by all database-specific loaders.
75    /// You can also call this directly if you have translations from another source.
76    ///
77    /// # Example
78    ///
79    /// ```
80    /// use anycms_i18n::Backend;
81    /// use anycms_i18n_sqlx::SqlxBackend;
82    ///
83    /// let backend = SqlxBackend::from_translations(vec![
84    ///     ("en".to_string(), "hello".to_string(), "Hello".to_string()),
85    ///     ("zh-CN".to_string(), "hello".to_string(), "你好".to_string()),
86    /// ]);
87    ///
88    /// assert_eq!(backend.get("en", "hello").as_deref(), Some("Hello"));
89    /// assert_eq!(backend.get("zh-CN", "hello").as_deref(), Some("你好"));
90    /// assert!(backend.has_locale("en"));
91    /// assert!(!backend.has_locale("ja"));
92    /// ```
93    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    /// Async reload: clear the cache and re-populate from an iterator.
107    ///
108    /// This is a convenience wrapper that clears the internal cache and rebuilds
109    /// it from the provided translations. In practice you would call one of the
110    /// database-specific reload methods instead.
111    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    // -- PostgreSQL -----------------------------------------------------------
122
123    /// Load all translations from a PostgreSQL pool.
124    ///
125    /// Queries `SELECT locale, key, value FROM i18n_translations`.
126    #[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    /// Reload translations from a PostgreSQL pool.
138    #[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    // -- MySQL ----------------------------------------------------------------
151
152    /// Load all translations from a MySQL pool.
153    ///
154    /// Queries `SELECT locale, key, value FROM i18n_translations`.
155    #[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    /// Reload translations from a MySQL pool.
167    #[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    // -- SQLite ---------------------------------------------------------------
180
181    /// Load all translations from a SQLite pool.
182    ///
183    /// Queries `SELECT locale, key, value FROM i18n_translations`.
184    #[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    /// Reload translations from a SQLite pool.
196    #[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
236// ---------------------------------------------------------------------------
237// SqlxBackendBuilder
238// ---------------------------------------------------------------------------
239
240/// Builder for [`SqlxBackend`] with custom table and column names.
241///
242/// ```rust,ignore
243/// let backend = SqlxBackendBuilder::new()
244///     .table("my_translations")
245///     .locale_col("lang")
246///     .build_postgres(&pool)
247///     .await?;
248/// ```
249pub struct SqlxBackendBuilder {
250    table: String,
251    locale_col: String,
252    key_col: String,
253    value_col: String,
254}
255
256impl SqlxBackendBuilder {
257    /// Create a new builder with default names:
258    /// - table: `i18n_translations`
259    /// - locale column: `locale`
260    /// - key column: `key`
261    /// - value column: `value`
262    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    /// Set a custom table name.
272    pub fn table(mut self, name: impl Into<String>) -> Self {
273        self.table = name.into();
274        self
275    }
276
277    /// Set a custom locale column name.
278    pub fn locale_col(mut self, name: impl Into<String>) -> Self {
279        self.locale_col = name.into();
280        self
281    }
282
283    /// Set a custom key column name.
284    pub fn key_col(mut self, name: impl Into<String>) -> Self {
285        self.key_col = name.into();
286        self
287    }
288
289    /// Set a custom value column name.
290    pub fn value_col(mut self, name: impl Into<String>) -> Self {
291        self.value_col = name.into();
292        self
293    }
294
295    /// Build the SQL query string from configured names.
296    #[allow(dead_code)] // used by feature-gated build_* methods
297    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    /// Build from a PostgreSQL pool.
305    #[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    /// Build from a MySQL pool.
317    #[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    /// Build from a SQLite pool.
329    #[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        // Missing locale -> empty map.
433        assert!(backend.dump("ja").is_empty());
434    }
435}