Skip to main content

hydracache_sqlx/
lib.rs

1//! SQLx-facing integration crate for HydraCache database result caching.
2//!
3//! The database-neutral query cache API lives in `hydracache-db`. This crate
4//! keeps SQLx users on a convenient import path while avoiding a hard conceptual
5//! dependency between the generic adapter and SQLx itself.
6//!
7//! # Example
8//!
9//! ```no_run
10//! use hydracache::HydraCache;
11//! use hydracache_sqlx::{DbCache, HydraCacheEntity, PreparedQueryPolicy, SqlxQueryExt};
12//!
13//! #[derive(serde::Serialize, serde::Deserialize, HydraCacheEntity)]
14//! #[hydracache(entity = "user", collection = "users", id = i64)]
15//! struct User {
16//!     id: i64,
17//!     name: String,
18//! }
19//!
20//! # async fn example(pool: sqlx::PgPool) -> hydracache_sqlx::Result<()> {
21//! let local = HydraCache::local().build();
22//!
23//! // SQLx users may import DbCache from this crate, but the type itself is
24//! // database-neutral and comes from hydracache-db.
25//! let queries = DbCache::new(local, "db");
26//!
27//! let user: User = queries
28//!     .for_entity::<User>(42)
29//!     .fetch_with(move || async move {
30//!         let (id, name): (i64, String) =
31//!             sqlx::query_as("select id, name from users where id = $1")
32//!                 .bind(42_i64)
33//!                 .fetch_one(&pool)
34//!                 .await?;
35//!
36//!         Ok::<_, sqlx::Error>(User { id, name })
37//!     })
38//!     .await?;
39//!
40//! assert_eq!(user.id, 42);
41//! assert!(!user.name.is_empty());
42//! # Ok(())
43//! # }
44//! ```
45//!
46//! Prepared policies keep repeated repository methods cheap while still using
47//! ordinary SQLx query execution on cache misses:
48//!
49//! ```no_run
50//! use hydracache::HydraCache;
51//! use hydracache_sqlx::{DbCache, HydraCacheEntity, PreparedQueryPolicy, SqlxQueryExt};
52//!
53//! #[derive(serde::Serialize, serde::Deserialize, HydraCacheEntity)]
54//! #[hydracache(entity = "user", collection = "users", id = i64)]
55//! struct User {
56//!     id: i64,
57//!     name: String,
58//! }
59//!
60//! # async fn example(pool: sqlx::PgPool) -> hydracache_sqlx::Result<()> {
61//! let queries = DbCache::new(HydraCache::local().build(), "db");
62//! let load_user = queries.prepare::<(i64, String)>(
63//!     PreparedQueryPolicy::for_cache_entity::<User>().with_name("load-user"),
64//! );
65//!
66//! let (id, name) = load_user
67//!     .for_id(42)
68//!     .sqlx_one(
69//!         pool.clone(),
70//!         sqlx::query_as("select id, name from users where id = $1").bind(42_i64),
71//!     )
72//!     .await?;
73//!
74//! assert_eq!(id, 42);
75//! assert!(!name.is_empty());
76//! # Ok(())
77//! # }
78//! ```
79//!
80//! Use [`DbQuery::fetch_with`] when you need SQLx macros, transactions, or a
81//! repository function instead of a pool-like executor.
82//!
83//! [`QueryCachePolicy`] and [`PreparedQueryPolicy`] are also re-exported for
84//! SQLx users, but the policy types are database-neutral and live in
85//! `hydracache-db`.
86//! [`query_cache_policy!`] is re-exported for the same convenience.
87
88extern crate self as hydracache_sqlx;
89
90mod error;
91mod query_ext;
92
93pub use error::{Result, SqlxCacheError};
94pub use hydracache_db::{
95    query_cache_policy, CacheEntity, DbAdapterKind, DbCache, DbCacheError, DbOperationContext,
96    DbQuery, DbResultShape, HydraCacheEntity, PreparedDbQuery, PreparedQueryPolicy,
97    QueryCachePolicy, RefreshPolicy, Result as DbResult,
98};
99pub use query_ext::SqlxQueryExt;
100
101/// SQLx-specific compatibility name for [`DbCache`].
102pub type SqlxCache<C = hydracache::PostcardCodec> = DbCache<C>;
103
104/// SQLx-specific compatibility name for [`DbQuery`].
105pub type SqlxQuery<T, C = hydracache::PostcardCodec> = DbQuery<T, C>;
106
107/// Re-export the SQLx crate used by this adapter.
108///
109/// This lets downstream users keep one adapter-aligned SQLx version in examples
110/// and integration code without hiding SQLx behind HydraCache abstractions.
111pub use sqlx;
112
113#[cfg(test)]
114mod tests {
115    use hydracache::HydraCache;
116    use serde::{Deserialize, Serialize};
117    use sqlx::postgres::PgPoolOptions;
118
119    use crate::{
120        DbCache, PreparedQueryPolicy, QueryCachePolicy, RefreshPolicy, SqlxCache, SqlxQueryExt,
121    };
122
123    #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
124    struct User {
125        id: u64,
126    }
127
128    #[tokio::test]
129    async fn sqlx_cache_alias_matches_database_cache_api() {
130        let query = SqlxCache::new(HydraCache::local().build(), "sqlx")
131            .cached::<User>()
132            .key("user:1");
133
134        assert_eq!(query.physical_key(), Some("sqlx:user:1".to_owned()));
135    }
136
137    #[tokio::test]
138    async fn db_cache_reexport_is_available_from_sqlx_crate() {
139        let query = DbCache::new(HydraCache::local().build(), "db")
140            .cached::<User>()
141            .key("user:1");
142
143        assert_eq!(query.physical_key(), Some("db:user:1".to_owned()));
144    }
145
146    #[tokio::test]
147    async fn query_cache_policy_reexport_is_available_from_sqlx_crate() {
148        let refresh =
149            RefreshPolicy::new().stale_while_revalidate(std::time::Duration::from_secs(5));
150        let policy = QueryCachePolicy::new()
151            .key("user:1")
152            .tag("user:1")
153            .refresh_policy(refresh);
154        let query = DbCache::new(HydraCache::local().build(), "db").cached_with::<User>(policy);
155
156        assert_eq!(query.physical_key(), Some("db:user:1".to_owned()));
157        assert_eq!(query.tags_value(), &["user:1".to_owned()]);
158        assert_eq!(query.refresh_policy_value(), Some(refresh));
159    }
160
161    #[tokio::test]
162    async fn prepared_query_policy_reexport_is_available_from_sqlx_crate() {
163        let prepared = DbCache::new(HydraCache::local().build(), "db").prepare::<User>(
164            PreparedQueryPolicy::for_entity("user")
165                .with_name("load-user")
166                .collection_tag("users"),
167        );
168
169        let query = prepared.for_id(1);
170        assert_eq!(query.name(), Some("load-user"));
171        assert_eq!(query.physical_key(), Some("db:user:1".to_owned()));
172        assert_eq!(
173            query.tags_value(),
174            &["users".to_owned(), "user:1".to_owned()]
175        );
176    }
177
178    #[tokio::test]
179    async fn sqlx_helper_missing_key_returns_sqlx_cache_error() {
180        let pool = PgPoolOptions::new()
181            .connect_lazy("postgres://postgres:postgres@localhost/postgres")
182            .unwrap();
183
184        let result = DbCache::new(HydraCache::local().build(), "db")
185            .cached::<(i64,)>()
186            .sqlx_one(pool, sqlx::query_as("select 1"))
187            .await;
188
189        let error = result.unwrap_err();
190        assert_eq!(
191            error.to_string(),
192            "database cached operation `db:unnamed` is missing an explicit cache key (adapter=sqlx, namespace=db, result_shape=one)"
193        );
194    }
195
196    #[tokio::test]
197    async fn sqlx_cache_error_wraps_db_cache_errors() {
198        let error = hydracache_db::DbCacheError::MissingKey {
199            operation: "load-user".to_owned(),
200            adapter: hydracache_db::DbAdapterKind::Sqlx,
201            namespace: "db".to_owned(),
202            result_shape: hydracache_db::DbResultShape::One,
203        };
204        let error = crate::SqlxCacheError::from(error);
205
206        assert_eq!(
207            error.to_string(),
208            "database cached operation `load-user` is missing an explicit cache key (adapter=sqlx, namespace=db, result_shape=one)"
209        );
210    }
211}