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")]
15//! struct User {
16//!     #[hydracache(id)]
17//!     id: i64,
18//!     name: String,
19//! }
20//!
21//! # async fn example(pool: sqlx::PgPool) -> hydracache_sqlx::Result<()> {
22//! let local = HydraCache::local().build();
23//!
24//! // SQLx users may import DbCache from this crate, but the type itself is
25//! // database-neutral and comes from hydracache-db.
26//! let queries = DbCache::new(local, "db");
27//!
28//! let user: User = queries
29//!     .for_entity::<User>(42)
30//!     .fetch_with(move || async move {
31//!         let (id, name): (i64, String) =
32//!             sqlx::query_as("select id, name from users where id = $1")
33//!                 .bind(42_i64)
34//!                 .fetch_one(&pool)
35//!                 .await?;
36//!
37//!         Ok::<_, sqlx::Error>(User { id, name })
38//!     })
39//!     .await?;
40//!
41//! assert_eq!(user.id, 42);
42//! assert!(!user.name.is_empty());
43//! # Ok(())
44//! # }
45//! ```
46//!
47//! Prepared policies keep repeated repository methods cheap while still using
48//! ordinary SQLx query execution on cache misses:
49//!
50//! ```no_run
51//! use hydracache::HydraCache;
52//! use hydracache_sqlx::{DbCache, HydraCacheEntity, PreparedQueryPolicy, SqlxQueryExt};
53//!
54//! #[derive(serde::Serialize, serde::Deserialize, HydraCacheEntity)]
55//! #[hydracache(entity = "user", collection = "users")]
56//! struct User {
57//!     #[hydracache(id)]
58//!     id: i64,
59//!     name: String,
60//! }
61//!
62//! # async fn example(pool: sqlx::PgPool) -> hydracache_sqlx::Result<()> {
63//! let queries = DbCache::new(HydraCache::local().build(), "db");
64//! let load_user = queries.prepare::<(i64, String)>(
65//!     PreparedQueryPolicy::for_cache_entity::<User>().with_name("load-user"),
66//! );
67//!
68//! let (id, name) = load_user
69//!     .for_id(42)
70//!     .sqlx_one(
71//!         pool.clone(),
72//!         sqlx::query_as("select id, name from users where id = $1").bind(42_i64),
73//!     )
74//!     .await?;
75//!
76//! assert_eq!(id, 42);
77//! assert!(!name.is_empty());
78//! # Ok(())
79//! # }
80//! ```
81//!
82//! Use [`DbQuery::fetch_with`] when you need SQLx macros, transactions, or a
83//! repository function instead of a pool-like executor.
84//!
85//! [`QueryCachePolicy`] and [`PreparedQueryPolicy`] are also re-exported for
86//! SQLx users, but the policy types are database-neutral and live in
87//! `hydracache-db`.
88//! [`query_cache_policy!`] is re-exported for the same convenience.
89
90extern crate self as hydracache_sqlx;
91
92mod error;
93mod query_ext;
94mod transaction;
95
96pub use error::{Result, SqlxCacheError, SqlxTransactionError, TransactionResult};
97pub use hydracache_db::{
98    prepared_query_policy, query_cache_policy, CacheEntity, CacheKeyBuilder, DbAdapterKind,
99    DbCache, DbCacheError, DbOperationContext, DbQuery, DbResultShape, HydraCacheEntity,
100    InvalidationCollector, InvalidationPlan, InvalidationReport, PgNotifyIntent,
101    PgNotifyIntentSource, PreparedDbQuery, PreparedQueryPolicy, QueryCachePolicy, RefreshPolicy,
102    Result as DbResult, SqlxInvalidationOutbox, OUTBOX_SCHEMA_VERSION,
103};
104pub use query_ext::SqlxQueryExt;
105pub use transaction::{
106    SqlxTransactionCompanion, SqlxTransactionDiagnostics, SqlxTransactionExt,
107    SqlxTransactionFuture, SqlxTransactionReport,
108};
109
110/// SQLx-specific compatibility name for [`DbCache`].
111pub type SqlxCache<C = hydracache::PostcardCodec> = DbCache<C>;
112
113/// SQLx-specific compatibility name for [`DbQuery`].
114pub type SqlxQuery<T, C = hydracache::PostcardCodec> = DbQuery<T, C>;
115
116/// Re-export the SQLx crate used by this adapter.
117///
118/// This lets downstream users keep one adapter-aligned SQLx version in examples
119/// and integration code without hiding SQLx behind HydraCache abstractions.
120pub use sqlx;
121
122#[cfg(test)]
123mod tests {
124    use hydracache::HydraCache;
125    use serde::{Deserialize, Serialize};
126    use sqlx::postgres::PgPoolOptions;
127
128    use crate::{
129        prepared_query_policy, query_cache_policy, CacheKeyBuilder, DbCache, InvalidationPlan,
130        PreparedQueryPolicy, QueryCachePolicy, RefreshPolicy, SqlxCache, SqlxQueryExt,
131    };
132
133    #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
134    struct User {
135        id: u64,
136    }
137
138    #[tokio::test]
139    async fn sqlx_cache_alias_matches_database_cache_api() {
140        let query = SqlxCache::new(HydraCache::local().build(), "sqlx")
141            .cached::<User>()
142            .key("user:1");
143
144        assert_eq!(query.physical_key(), Some("sqlx:user:1".to_owned()));
145    }
146
147    #[tokio::test]
148    async fn db_cache_reexport_is_available_from_sqlx_crate() {
149        let query = DbCache::new(HydraCache::local().build(), "db")
150            .cached::<User>()
151            .key("user:1");
152
153        assert_eq!(query.physical_key(), Some("db:user:1".to_owned()));
154
155        let pending = InvalidationPlan::new().tag("user:1");
156        assert_eq!(pending.tag_values().collect::<Vec<_>>(), vec!["user:1"]);
157    }
158
159    #[tokio::test]
160    async fn query_cache_policy_reexport_is_available_from_sqlx_crate() {
161        let refresh =
162            RefreshPolicy::new().stale_while_revalidate(std::time::Duration::from_secs(5));
163        let policy = QueryCachePolicy::new()
164            .key("user:1")
165            .tag("user:1")
166            .refresh_policy(refresh);
167        let query = DbCache::new(HydraCache::local().build(), "db").cached_with::<User>(policy);
168
169        assert_eq!(query.physical_key(), Some("db:user:1".to_owned()));
170        assert_eq!(query.tags_value(), &["user:1".to_owned()]);
171        assert_eq!(query.refresh_policy_value(), Some(refresh));
172    }
173
174    #[test]
175    fn query_policy_segment_macro_reexport_uses_sqlx_crate_paths() {
176        let policy = query_cache_policy!(
177            name = "search-users",
178            key_segments = ["tenant", 7_u64, "q", "ada:lovelace"],
179            tag_segments = [["tenant", 7_u64], ["users"]],
180            ttl_secs = 30,
181        );
182        let expected_key = CacheKeyBuilder::new()
183            .segment("tenant")
184            .segment(7_u64)
185            .segment("q")
186            .segment("ada:lovelace")
187            .build_string();
188
189        assert_eq!(policy.name(), Some("search-users"));
190        assert_eq!(policy.key_value(), Some(expected_key.as_str()));
191        assert_eq!(
192            policy.tags_value(),
193            &["tenant:7".to_owned(), "users".to_owned()]
194        );
195    }
196
197    #[tokio::test]
198    async fn prepared_query_policy_reexport_is_available_from_sqlx_crate() {
199        let policy = prepared_query_policy!(
200            entity = "user",
201            name = "load-user",
202            collection_tag = "users",
203        );
204        let expected = PreparedQueryPolicy::for_entity("user")
205            .with_name("load-user")
206            .collection_tag("users");
207        assert_eq!(policy, expected);
208
209        let prepared = DbCache::new(HydraCache::local().build(), "db").prepare::<User>(policy);
210
211        let query = prepared.for_id(1);
212        assert_eq!(query.name(), Some("load-user"));
213        assert_eq!(query.physical_key(), Some("db:user:1".to_owned()));
214        assert_eq!(
215            query.tags_value(),
216            &["users".to_owned(), "user:1".to_owned()]
217        );
218    }
219
220    #[tokio::test]
221    async fn sqlx_helper_missing_key_returns_sqlx_cache_error() {
222        let pool = PgPoolOptions::new()
223            .connect_lazy("postgres://postgres:postgres@localhost/postgres")
224            .unwrap();
225
226        let result = DbCache::new(HydraCache::local().build(), "db")
227            .cached::<(i64,)>()
228            .sqlx_one(pool, sqlx::query_as("select 1"))
229            .await;
230
231        let error = result.unwrap_err();
232        assert_eq!(
233            error.to_string(),
234            "database cached operation `db:unnamed` is missing an explicit cache key (adapter=sqlx, namespace=db, result_shape=one)"
235        );
236    }
237
238    #[tokio::test]
239    async fn sqlx_cache_error_wraps_db_cache_errors() {
240        let error = hydracache_db::DbCacheError::MissingKey {
241            operation: "load-user".to_owned(),
242            adapter: hydracache_db::DbAdapterKind::Sqlx,
243            namespace: "db".to_owned(),
244            result_shape: hydracache_db::DbResultShape::One,
245        };
246        let error = crate::SqlxCacheError::from(error);
247
248        assert_eq!(
249            error.to_string(),
250            "database cached operation `load-user` is missing an explicit cache key (adapter=sqlx, namespace=db, result_shape=one)"
251        );
252    }
253}