Coverage for src/lexigram/graphql/dataloader/cache.py: 78%
79 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 04:37 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 04:37 +0800
1"""DataLoaderProtocol cache implementations.
3This module provides cache implementations for DataLoaderProtocol,
4including in-memory caching with TTL support.
5"""
7from __future__ import annotations
9from abc import ABC, abstractmethod
10from collections.abc import Hashable
11from dataclasses import dataclass
12import time
13from typing import Generic, TypeVar
15from lexigram.logging import get_logger
17logger = get_logger(__name__)
19K = TypeVar("K", bound=Hashable)
20V = TypeVar("V")
23class LoaderCache(ABC, Generic[K, V]):
24 """Abstract base class for DataLoaderProtocol caches.
26 Implement this interface to provide custom caching
27 behavior for DataLoaders.
28 """
30 @abstractmethod
31 def get(self, key: K) -> V | None:
32 """Get a cached value.
34 Args:
35 key: Cache key.
37 Returns:
38 Cached value or None.
39 """
40 ...
42 @abstractmethod
43 def set(self, key: K, value: V) -> None:
44 """Set a cached value.
46 Args:
47 key: Cache key.
48 value: Value to cache.
49 """
50 ...
52 @abstractmethod
53 def has(self, key: K) -> bool:
54 """Check if key exists in cache.
56 Args:
57 key: Cache key.
59 Returns:
60 True if key exists.
61 """
62 ...
64 @abstractmethod
65 def delete(self, key: K) -> None:
66 """Delete a cached value.
68 Args:
69 key: Cache key.
70 """
71 ...
73 @abstractmethod
74 def clear(self) -> None:
75 """Clear all cached values."""
76 ...
79@dataclass
80class LoaderCacheEntry(Generic[V]):
81 """Cache entry with expiration support.
83 Attributes:
84 value: Cached value.
85 expires_at: Expiration timestamp (or 0 for no expiry).
86 """
88 value: V
89 expires_at: float = 0
91 def is_expired(self) -> bool:
92 """Check if entry is expired."""
93 if self.expires_at <= 0:
94 return False
95 return time.time() > self.expires_at
98class InMemoryCache(LoaderCache[K, V]):
99 """In-memory cache implementation.
101 Simple in-memory cache with optional TTL support.
102 Suitable for single-request caching in DataLoaders.
104 Example:
105 ```python
106 cache = InMemoryCache[str, User](ttl_seconds=60)
108 cache.set("user:1", user)
109 user = cache.get("user:1")
110 ```
111 """
113 def __init__(
114 self,
115 ttl_seconds: float = 0,
116 max_size: int = 0,
117 ) -> None:
118 """Initialize the cache.
120 Args:
121 ttl_seconds: Time-to-live in seconds (0 for no TTL).
122 max_size: Maximum cache size (0 for unlimited).
123 """
124 self._ttl_seconds = ttl_seconds
125 self._max_size = max_size
126 self._cache: dict[K, LoaderCacheEntry[V]] = {}
128 @property
129 def size(self) -> int:
130 """Get current cache size."""
131 return len(self._cache)
133 def get(self, key: K) -> V | None:
134 """Get a cached value.
136 Args:
137 key: Cache key.
139 Returns:
140 Cached value or None if not found or expired.
141 """
142 entry = self._cache.get(key)
144 if entry is None:
145 return None
147 if entry.is_expired():
148 del self._cache[key]
149 return None
151 return entry.value
153 def set(self, key: K, value: V) -> None:
154 """Set a cached value.
156 Args:
157 key: Cache key.
158 value: Value to cache.
159 """
160 # Check max size
161 if self._max_size > 0 and len(self._cache) >= self._max_size and self._cache:
162 # Remove oldest entry (FIFO eviction)
163 oldest_key = next(iter(self._cache))
164 del self._cache[oldest_key]
166 # Calculate expiration
167 expires_at = 0.0
168 if self._ttl_seconds > 0:
169 expires_at = time.time() + self._ttl_seconds
171 self._cache[key] = LoaderCacheEntry(value=value, expires_at=expires_at)
173 def has(self, key: K) -> bool:
174 """Check if key exists and is not expired.
176 Args:
177 key: Cache key.
179 Returns:
180 True if key exists and is valid.
181 """
182 entry = self._cache.get(key)
184 if entry is None:
185 return False
187 if entry.is_expired():
188 del self._cache[key]
189 return False
191 return True
193 def delete(self, key: K) -> None:
194 """Delete a cached value.
196 Args:
197 key: Cache key.
198 """
199 self._cache.pop(key, None)
201 def clear(self) -> None:
202 """Clear all cached values."""
203 self._cache.clear()
205 def cleanup_expired(self) -> int:
206 """Remove expired entries.
208 Returns:
209 Number of entries removed.
210 """
211 expired_keys = [
212 kv[0] for kv in filter(lambda kv: kv[1].is_expired(), self._cache.items())
213 ]
215 for key in expired_keys:
216 del self._cache[key]
218 return len(expired_keys)
221class NoOpCache(LoaderCache[K, V]):
222 """No-operation cache (disables caching).
224 Use this when you want to disable DataLoaderProtocol caching
225 while keeping the batching behavior.
226 """
228 def get(self, key: K) -> V | None:
229 """Always returns None."""
230 return None
232 def set(self, key: K, value: V) -> None:
233 """Does nothing."""
235 def has(self, key: K) -> bool:
236 """Always returns False."""
237 return False
239 def delete(self, key: K) -> None:
240 """Does nothing."""
242 def clear(self) -> None:
243 """Does nothing."""
246__all__ = ["InMemoryCache", "LoaderCache", "LoaderCacheEntry", "NoOpCache"]