fraiseql_core/apq/storage.rs
1//! Storage trait and types for APQ backends
2//!
3//! This module defines the abstract storage interface that all APQ backends must implement,
4//! allowing for pluggable storage backends (memory, `PostgreSQL`, etc.).
5
6use async_trait::async_trait;
7use serde_json::json;
8
9/// Storage backend for persisted queries
10///
11/// Implementations of this trait provide different storage strategies:
12/// - Memory: In-process LRU cache (single instance, fast)
13/// - `PostgreSQL`: Distributed storage (multi-instance, persistent)
14// Reason: used as dyn Trait (Arc<dyn ApqStorage>); async_trait ensures Send bounds and
15// dyn-compatibility async_trait: dyn-dispatch required; remove when RTN + Send is stable (RFC 3425)
16#[async_trait]
17pub trait ApqStorage: Send + Sync {
18 /// Get query by hash
19 ///
20 /// # Arguments
21 ///
22 /// * `hash` - The SHA-256 hash of the query (hexadecimal)
23 ///
24 /// # Returns
25 ///
26 /// * `Ok(Some(query))` if query found
27 /// * `Ok(None)` if query not found
28 /// * `Err(e)` if storage access fails
29 async fn get(&self, hash: &str) -> Result<Option<String>, ApqError>;
30
31 /// Store query with hash
32 ///
33 /// # Arguments
34 ///
35 /// * `hash` - The SHA-256 hash of the query
36 /// * `query` - The full GraphQL query string
37 ///
38 /// # Returns
39 ///
40 /// * `Ok(())` on success
41 /// * `Err(e)` if storage fails
42 async fn set(&self, hash: String, query: String) -> Result<(), ApqError>;
43
44 /// Check if query exists
45 ///
46 /// # Arguments
47 ///
48 /// * `hash` - The SHA-256 hash to check
49 ///
50 /// # Returns
51 ///
52 /// * `Ok(true)` if query exists
53 /// * `Ok(false)` if not found
54 /// * `Err(e)` if check fails
55 async fn exists(&self, hash: &str) -> Result<bool, ApqError>;
56
57 /// Remove query from storage
58 ///
59 /// # Arguments
60 ///
61 /// * `hash` - The hash to remove
62 ///
63 /// # Returns
64 ///
65 /// * `Ok(())` on success
66 /// * `Err(e)` if removal fails
67 async fn remove(&self, hash: &str) -> Result<(), ApqError>;
68
69 /// Get storage statistics
70 ///
71 /// # Returns
72 ///
73 /// Statistics about the storage backend
74 async fn stats(&self) -> Result<ApqStats, ApqError>;
75
76 /// Clear all stored queries
77 ///
78 /// # Returns
79 ///
80 /// * `Ok(())` on success
81 /// * `Err(e)` if clear fails
82 async fn clear(&self) -> Result<(), ApqError>;
83}
84
85/// Statistics about APQ storage
86#[derive(Debug, Clone)]
87pub struct ApqStats {
88 /// Total number of stored queries
89 pub total_queries: usize,
90
91 /// Storage backend name
92 pub backend: String,
93
94 /// Additional backend-specific stats (as JSON)
95 pub extra: serde_json::Value,
96}
97
98impl ApqStats {
99 /// Create new statistics
100 #[must_use]
101 pub fn new(total_queries: usize, backend: String) -> Self {
102 Self {
103 total_queries,
104 backend,
105 extra: json!({}),
106 }
107 }
108
109 /// Create new statistics with extra data
110 #[must_use]
111 pub const fn with_extra(
112 total_queries: usize,
113 backend: String,
114 extra: serde_json::Value,
115 ) -> Self {
116 Self {
117 total_queries,
118 backend,
119 extra,
120 }
121 }
122}
123
124/// APQ errors
125#[derive(Debug, thiserror::Error)]
126#[non_exhaustive]
127pub enum ApqError {
128 /// Query not found in storage
129 #[error("Query not found")]
130 NotFound,
131
132 /// Query size exceeded limit (100KB)
133 #[error("Query size exceeds maximum limit (100KB)")]
134 QueryTooLarge,
135
136 /// Storage backend error
137 #[error("Storage error: {0}")]
138 StorageError(String),
139
140 /// Serialization/deserialization error
141 #[error("Serialization error: {0}")]
142 SerializationError(String),
143
144 /// Database error (for `PostgreSQL` backend)
145 #[error("Database error: {0}")]
146 DatabaseError(String),
147
148 /// Configuration error
149 #[error("Configuration error: {0}")]
150 ConfigError(String),
151}
152
153/// Type alias for arc-wrapped dynamic APQ storage.
154///
155/// Used for thread-safe, reference-counted storage of APQ backends.
156pub type ArcApqStorage = std::sync::Arc<dyn ApqStorage>;
157
158#[cfg(test)]
159mod tests {
160 use super::*;
161
162 #[test]
163 fn test_apq_stats_creation() {
164 let stats = ApqStats::new(100, "memory".to_string());
165 assert_eq!(stats.total_queries, 100);
166 assert_eq!(stats.backend, "memory");
167 assert_eq!(stats.extra, json!({}));
168 }
169
170 #[test]
171 fn test_apq_stats_with_extra() {
172 let extra = json!({
173 "hits": 500,
174 "misses": 50,
175 "hit_rate": 0.909
176 });
177
178 let stats = ApqStats::with_extra(100, "postgresql".to_string(), extra.clone());
179 assert_eq!(stats.total_queries, 100);
180 assert_eq!(stats.backend, "postgresql");
181 assert_eq!(stats.extra, extra);
182 }
183
184 #[test]
185 fn test_apq_error_display() {
186 let err = ApqError::QueryTooLarge;
187 assert_eq!(err.to_string(), "Query size exceeds maximum limit (100KB)");
188
189 let err = ApqError::StorageError("connection failed".to_string());
190 assert!(err.to_string().contains("connection failed"));
191 }
192}