diff --git a/advanced_alchemy/_listeners.py b/advanced_alchemy/_listeners.py index a35c514e7..50e088915 100644 --- a/advanced_alchemy/_listeners.py +++ b/advanced_alchemy/_listeners.py @@ -11,13 +11,17 @@ from sqlalchemy.inspection import inspect if TYPE_CHECKING: - from sqlalchemy.orm import Session, UOWTransaction + from sqlalchemy.ext.asyncio import AsyncSession, async_scoped_session + from sqlalchemy.orm import Session, UOWTransaction, scoped_session from sqlalchemy.orm.state import InstanceState + from advanced_alchemy.cache import CacheManager from advanced_alchemy.types.file_object import FileObjectSessionTracker, StorageRegistry _active_file_operations: set[asyncio.Task[Any]] = set() """Stores active file operations to prevent them from being garbage collected.""" +_active_cache_operations: set[asyncio.Task[Any]] = set() +"""Stores active cache invalidation operations to prevent them from being garbage collected.""" # Context variable to hold the session tracker instance for the current session context _current_session_tracker: contextvars.ContextVar[Optional["FileObjectSessionTracker"]] = contextvars.ContextVar( "_current_session_tracker", @@ -444,6 +448,217 @@ def setup_file_object_listeners(registry: Optional["StorageRegistry"] = None) -> set_async_context(False) +# Cache invalidation support +_CACHE_TRACKER_KEY = "_aa_cache_tracker" + + +class CacheInvalidationTracker: + """Tracks pending cache invalidations for a session transaction. + + This tracker collects entity invalidations during a transaction and + processes them only after a successful commit. On rollback, the + pending invalidations are discarded. + + Note: + Model version bumps are also deferred to commit to ensure rollbacks + don't invalidate list caches when no DB change occurred. + """ + + __slots__ = ("_cache_manager", "_pending_invalidations", "_pending_model_bumps") + + def __init__(self, cache_manager: "CacheManager") -> None: + self._cache_manager = cache_manager + self._pending_invalidations: list[tuple[str, Any, Optional[str]]] = [] + self._pending_model_bumps: set[str] = set() + + def add_invalidation(self, model_name: str, entity_id: Any, bind_group: Optional[str] = None) -> None: + """Queue an entity for cache invalidation. + + The actual invalidation and model version bump are deferred until + commit() is called, ensuring rollbacks don't affect the cache. + + Args: + model_name: The model/table name. + entity_id: The entity's primary key value. + bind_group: Optional routing group for multi-master configurations. + When provided, only the cache entry for that bind_group is + invalidated. + """ + self._pending_invalidations.append((model_name, entity_id, bind_group)) + # Queue model version bump for list query invalidation (deferred to commit) + self._pending_model_bumps.add(model_name) + + def commit(self) -> None: + """Process all pending invalidations after successful commit.""" + # First bump model versions for list query invalidation + for model_name in self._pending_model_bumps: + self._cache_manager.bump_model_version_sync(model_name) + self._pending_model_bumps.clear() + + # Then invalidate individual entities + for model_name, entity_id, bind_group in self._pending_invalidations: + self._cache_manager.invalidate_entity_sync(model_name, entity_id, bind_group) + self._pending_invalidations.clear() + + def rollback(self) -> None: + """Discard pending invalidations on rollback.""" + self._pending_invalidations.clear() + self._pending_model_bumps.clear() + + async def commit_async(self) -> None: + """Process all pending invalidations after successful commit (async-safe). + + This method performs cache I/O using the CacheManager async APIs so that + dogpile backends (often sync network clients) never block the event loop. + """ + # First bump model versions for list query invalidation + for model_name in self._pending_model_bumps: + await self._cache_manager.bump_model_version_async(model_name) + self._pending_model_bumps.clear() + + # Then invalidate individual entities + for model_name, entity_id, bind_group in self._pending_invalidations: + await self._cache_manager.invalidate_entity_async(model_name, entity_id, bind_group) + self._pending_invalidations.clear() + + +def get_cache_tracker( + session: "Union[Session, AsyncSession, scoped_session[Session], async_scoped_session[AsyncSession]]", + cache_manager: Optional["CacheManager"] = None, + create: bool = True, +) -> Optional["CacheInvalidationTracker"]: + """Get or create a cache invalidation tracker for the session. + + The tracker is stored on session.info to ensure proper scoping + per session instance and avoid ContextVar collisions. + + Args: + session: The SQLAlchemy session instance (sync or async, including scoped sessions). + cache_manager: The CacheManager instance (required if create=True). + create: Whether to create a new tracker if one doesn't exist. + + Returns: + The cache tracker or None if not available. + """ + tracker: Optional[CacheInvalidationTracker] = session.info.get(_CACHE_TRACKER_KEY) + if tracker is None and create and cache_manager is not None: + tracker = CacheInvalidationTracker(cache_manager) + session.info[_CACHE_TRACKER_KEY] = tracker + return tracker + + +class CacheInvalidationListener: + """Manages cache invalidation during SQLAlchemy Session transactions. + + This listener hooks into the SQLAlchemy Session event lifecycle to + handle cache invalidation in a transaction-safe manner. + + How it Works: + + 1. **Event Registration (`setup_cache_listeners`):** + Registers `after_commit` and `after_rollback` listeners globally + on the Session class. + + 2. **Tracking Changes:** + During mutations (add, update, delete), repositories call + `get_cache_tracker()` and add invalidations via `add_invalidation()`. + + 3. **Processing (`after_commit`):** + After successful commit, all pending invalidations are processed + and the tracker is cleared. + + 4. **Discarding (`after_rollback`):** + On rollback, pending invalidations are discarded without processing. + """ + + @classmethod + def _is_listener_enabled(cls, session: "Session") -> bool: + """Check if cache listener is enabled for this session.""" + enable_listener = True + + session_info = getattr(session, "info", {}) + if "enable_cache_listener" in session_info: + return bool(session_info["enable_cache_listener"]) + + options_sources: list[Optional[Union[Callable[[], dict[str, Any]], dict[str, Any]]]] = [] + if session.bind: + options_sources.append(getattr(session.bind, "execution_options", None)) + sync_engine = getattr(session.bind, "sync_engine", None) + if sync_engine: + options_sources.append(getattr(sync_engine, "execution_options", None)) + options_sources.append(getattr(session, "execution_options", None)) + + for options_source in options_sources: + if options_source is None: + continue + + options: Optional[dict[str, Any]] = None + if callable(options_source): + try: + result = options_source() + if isinstance(result, dict): # pyright: ignore + options = result + except Exception as e: + logger.debug("Error calling execution_options source: %s", e) + else: + options = options_source + + if options is not None and "enable_cache_listener" in options: + enable_listener = bool(options["enable_cache_listener"]) + break + + return enable_listener + + @classmethod + def after_commit(cls, session: "Session") -> None: + """Process cache invalidations after a successful commit.""" + if not cls._is_listener_enabled(session): + return + + tracker = get_cache_tracker(session, create=False) + if tracker: + try: + asyncio.get_running_loop() + except RuntimeError: + # No running loop: sync usage, perform invalidation inline. + tracker.commit() + else: + # Running loop: schedule async invalidation so commit doesn't block. + task = asyncio.create_task(tracker.commit_async()) + _active_cache_operations.add(task) + task.add_done_callback(_active_cache_operations.discard) + session.info.pop(_CACHE_TRACKER_KEY, None) + + @classmethod + def after_rollback(cls, session: "Session") -> None: + """Discard pending cache invalidations after a rollback.""" + tracker = get_cache_tracker(session, create=False) + if tracker: + tracker.rollback() + session.info.pop(_CACHE_TRACKER_KEY, None) + + +def setup_cache_listeners() -> None: + """Register cache invalidation event listeners globally. + + This should be called once during application initialization to enable + automatic cache invalidation for repositories using a CacheManager. + """ + from sqlalchemy.event import contains + from sqlalchemy.orm import Session + + listeners = { + "after_commit": CacheInvalidationListener.after_commit, + "after_rollback": CacheInvalidationListener.after_rollback, + } + + for event_name, listener_func in listeners.items(): + if not contains(Session, event_name, listener_func): + event.listen(Session, event_name, listener_func) + + logger.debug("Cache invalidation listeners registered") + + # Existing listener (keep it) def touch_updated_timestamp(session: "Session", *_: Any) -> None: # pragma: no cover """Set timestamp on update. diff --git a/advanced_alchemy/cache/__init__.py b/advanced_alchemy/cache/__init__.py new file mode 100644 index 000000000..f9cba561f --- /dev/null +++ b/advanced_alchemy/cache/__init__.py @@ -0,0 +1,102 @@ +"""Dogpile.cache integration for Advanced Alchemy. + +This module provides optional caching support for SQLAlchemy repositories +using dogpile.cache. It supports multiple cache backends (Redis, Memcached, +file, memory) and provides automatic cache invalidation on model changes. + +Features: + - Multiple backend support (Redis, Memcached, file, memory, null) + - Commit-aware cache invalidation via SQLAlchemy events + - Version-based invalidation for list queries + - JSON serialization for cached models (configurable) + - Graceful degradation when dogpile.cache is not installed + - Per-process singleflight to reduce stampedes on cache miss + +Example: + Using the config system (recommended):: + + from advanced_alchemy.cache import CacheConfig + from advanced_alchemy.config import SQLAlchemyAsyncConfig + from advanced_alchemy.repository import ( + SQLAlchemyAsyncRepository, + ) + + # Configure database with caching + db_config = SQLAlchemyAsyncConfig( + connection_string="sqlite+aiosqlite:///app.db", + cache_config=CacheConfig( + backend="dogpile.cache.memory", + expiration_time=300, + ), + ) + + # Cache listeners are auto-registered, cache_manager is stored + # in session.info and auto-retrieved by repositories. + + + class UserRepository(SQLAlchemyAsyncRepository[User]): + model_type = User + + + async with db_config.get_session() as session: + repo = UserRepository(session=session) + user = await repo.get( + 1 + ) # First call hits DB and caches + user = await repo.get( + 1 + ) # Second call returns cached result + + Redis configuration:: + + cache_config = CacheConfig( + backend="dogpile.cache.redis", + expiration_time=3600, + arguments={ + "host": "localhost", + "port": 6379, + "db": 0, + "distributed_lock": True, + }, + ) + +Note: + This module requires the optional ``dogpile.cache`` dependency. + Install with: ``pip install advanced-alchemy[dogpile]`` + + Without dogpile.cache installed, the cache manager will use a + NullRegion that provides the same interface but doesn't cache. + +Manual Setup: + If not using the config system, call ``setup_cache_listeners()`` + during application initialization and pass cache_manager explicitly:: + + from advanced_alchemy.cache import ( + CacheConfig, + CacheManager, + setup_cache_listeners, + ) + + cache_manager = CacheManager( + CacheConfig(backend="dogpile.cache.memory") + ) + setup_cache_listeners() + + repo = UserRepository( + session=session, cache_manager=cache_manager + ) +""" + +from advanced_alchemy._listeners import setup_cache_listeners +from advanced_alchemy.cache.config import CacheConfig +from advanced_alchemy.cache.manager import DOGPILE_CACHE_INSTALLED, CacheManager +from advanced_alchemy.cache.serializers import default_deserializer, default_serializer + +__all__ = ( + "DOGPILE_CACHE_INSTALLED", + "CacheConfig", + "CacheManager", + "default_deserializer", + "default_serializer", + "setup_cache_listeners", +) diff --git a/advanced_alchemy/cache/_null.py b/advanced_alchemy/cache/_null.py new file mode 100644 index 000000000..c7ed0ff47 --- /dev/null +++ b/advanced_alchemy/cache/_null.py @@ -0,0 +1,171 @@ +"""Null cache region implementation for when dogpile.cache is not installed.""" + +from collections.abc import Awaitable +from typing import Any, Callable, Optional, Protocol, TypeVar + +__all__ = ( + "NO_VALUE", + "AsyncCacheRegionProtocol", + "NullRegion", + "SyncCacheRegionProtocol", +) + +T = TypeVar("T") + + +class SyncCacheRegionProtocol(Protocol): + """Protocol defining the synchronous cache region interface used by CacheManager. + + This protocol is compatible with both dogpile.cache.CacheRegion and NullRegion. + """ + + def get(self, key: str, expiration_time: Optional[int] = None) -> Any: ... + + def get_or_create( + self, + key: str, + creator: Callable[[], T], + expiration_time: Optional[int] = None, + ) -> T: ... + + def set(self, key: str, value: Any) -> None: ... + + def delete(self, key: str) -> None: ... + + def invalidate(self) -> None: ... + + def configure( + self, + backend: str, + expiration_time: Optional[int] = None, + arguments: Optional[dict[str, Any]] = None, + **kwargs: Any, + ) -> "SyncCacheRegionProtocol": ... + + +class AsyncCacheRegionProtocol(Protocol): + """Protocol defining the asynchronous cache region interface. + + This protocol defines async versions of cache region operations, + suitable for use with native async cache backends. + """ + + async def get(self, key: str, expiration_time: Optional[int] = None) -> Any: ... + + async def get_or_create( + self, + key: str, + creator: Callable[[], Awaitable[T]], + expiration_time: Optional[int] = None, + ) -> T: ... + + async def set(self, key: str, value: Any) -> None: ... + + async def delete(self, key: str) -> None: ... + + async def invalidate(self) -> None: ... + + async def configure( + self, + backend: str, + expiration_time: Optional[int] = None, + arguments: Optional[dict[str, Any]] = None, + **kwargs: Any, + ) -> "AsyncCacheRegionProtocol": ... + + +class _NoValue: + """Sentinel value to indicate a cache miss. + + This is compatible with ``dogpile.cache.api.NO_VALUE`` and is used + when dogpile.cache is not installed to maintain API compatibility. + """ + + __slots__ = () + + def __repr__(self) -> str: + return "" + + +NO_VALUE: Any = _NoValue() + + +class NullRegion: + """A no-op cache region that provides the same interface as dogpile.cache.CacheRegion. + + This class is used when dogpile.cache is not installed or when caching + is disabled. All operations are no-ops that don't actually cache anything. + + The interface matches the subset of dogpile.cache.CacheRegion methods + that are used by the CacheManager. + """ + + __slots__ = () + + def get(self, key: str, expiration_time: Optional[int] = None) -> Any: + """Get a value from the cache (always returns NO_VALUE). + + Args: + key: The cache key. + expiration_time: Ignored. + + Returns: + Always returns NO_VALUE to indicate a cache miss. + """ + return NO_VALUE + + def get_or_create( + self, + key: str, + creator: Callable[[], T], + expiration_time: Optional[int] = None, + ) -> T: + """Get or create a value (always calls the creator). + + Args: + key: The cache key. + creator: Function to create the value. + expiration_time: Ignored. + + Returns: + The result of calling the creator function. + """ + return creator() + + def set(self, key: str, value: Any) -> None: + """Set a value in the cache (no-op). + + Args: + key: The cache key. + value: The value to cache. + """ + + def delete(self, key: str) -> None: + """Delete a value from the cache (no-op). + + Args: + key: The cache key to delete. + """ + + def invalidate(self) -> None: + """Invalidate all cached values (no-op).""" + + def configure( + self, + backend: str, + expiration_time: Optional[int] = None, + arguments: Optional[dict[str, Any]] = None, + **kwargs: Any, + ) -> "NullRegion": + """Configure the region (no-op, returns self). + + Args: + backend: Ignored. + expiration_time: Ignored. + arguments: Ignored. + **kwargs: Ignored. + + Returns: + Self for method chaining. + """ + return self diff --git a/advanced_alchemy/cache/config.py b/advanced_alchemy/cache/config.py new file mode 100644 index 000000000..c10328a6c --- /dev/null +++ b/advanced_alchemy/cache/config.py @@ -0,0 +1,119 @@ +"""Configuration classes for dogpile.cache integration.""" + +from dataclasses import dataclass, field +from typing import Any, Callable, Optional + +__all__ = ("CacheConfig",) + + +def _default_arguments() -> dict[str, Any]: + return {} + + +@dataclass +class CacheConfig: + """Configuration for a dogpile.cache region. + + This dataclass holds configuration options for setting up a cache region + using dogpile.cache. It supports multiple backends (Redis, Memcached, file, memory) + and provides sensible defaults for getting started quickly. + + Example: + Basic memory cache configuration:: + + config = CacheConfig( + backend="dogpile.cache.memory", + expiration_time=300, + ) + + Redis cache configuration:: + + config = CacheConfig( + backend="dogpile.cache.redis", + expiration_time=3600, + arguments={ + "host": "localhost", + "port": 6379, + "db": 0, + }, + ) + """ + + backend: str = "dogpile.cache.null" + """Cache backend identifier. + + Common backends: + - ``dogpile.cache.null``: No-op cache (default, for development) + - ``dogpile.cache.memory``: In-process memory cache + - ``dogpile.cache.redis``: Redis backend + - ``dogpile.cache.memcached``: Memcached backend + - ``dogpile.cache.dbm``: DBM file-based cache + """ + + expiration_time: int = 3600 + """Default TTL (time-to-live) in seconds for cached items. + + Set to ``-1`` for no expiration. Default is 3600 (1 hour). + """ + + arguments: dict[str, Any] = field(default_factory=_default_arguments) + """Backend-specific configuration arguments. + + These are passed directly to the dogpile.cache backend. + See dogpile.cache documentation for backend-specific options. + + Example for Redis:: + + {"host": "localhost", "port": 6379, "db": 0} + + Example for Memcached:: + + {"url": ["127.0.0.1:11211"]} + """ + + key_prefix: str = "aa:" + """Prefix for all cache keys. + + This helps avoid key collisions when sharing a cache backend + with other applications. Default is ``aa:`` (advanced-alchemy). + """ + + enabled: bool = True + """Enable or disable caching globally. + + When ``False``, all cache operations are bypassed and + data is always fetched from the database. Useful for + debugging or testing. + """ + + serializer: Optional[Callable[[Any], bytes]] = None + """Custom serializer function. + + If ``None``, uses the default JSON serializer which handles + SQLAlchemy models, datetime objects, and UUIDs. + + The function should accept any value and return bytes. + """ + + deserializer: Optional[Callable[[bytes, "type[Any]"], Any]] = None + """Custom deserializer function. + + If ``None``, uses the default JSON deserializer. + + The function should accept bytes and a model class, + returning an instance of that class. + """ + + region_factory: Optional[Callable[["CacheConfig"], Any]] = None + """Optional hook to construct a cache region instance. + + This exists to keep the repository/service integration stable even if + Advanced Alchemy swaps the underlying cache backend in the future. + + If provided, :class:`~advanced_alchemy.cache.CacheManager` will call this + factory instead of using ``dogpile.cache.make_region()``. + + The returned object must implement the subset of dogpile's region API that + AA relies on (e.g. ``get()``, ``set()``, ``delete()``, ``invalidate()``, + and optionally ``get_or_create()``). + """ diff --git a/advanced_alchemy/cache/manager.py b/advanced_alchemy/cache/manager.py new file mode 100644 index 000000000..6bc6f0dab --- /dev/null +++ b/advanced_alchemy/cache/manager.py @@ -0,0 +1,756 @@ +"""Cache manager for dogpile.cache integration with SQLAlchemy repositories.""" + +import asyncio +import base64 +import concurrent.futures +import logging +import threading +import uuid +from collections.abc import Coroutine +from functools import partial +from typing import TYPE_CHECKING, Any, Callable, Optional, TypeVar, cast + +from advanced_alchemy.cache._null import NO_VALUE, NullRegion, SyncCacheRegionProtocol +from advanced_alchemy.cache.serializers import default_deserializer, default_serializer +from advanced_alchemy.utils.sync_tools import async_ + +if TYPE_CHECKING: + from advanced_alchemy.cache.config import CacheConfig + +__all__ = ( + "DOGPILE_CACHE_INSTALLED", + "DOGPILE_NO_VALUE", + "CacheManager", +) + +logger = logging.getLogger("advanced_alchemy.cache") + +T = TypeVar("T") + +# Type alias for the make_region factory function +MakeRegionFunc = Callable[[], SyncCacheRegionProtocol] + + +# Stub implementations for when dogpile.cache is not installed +def _make_region_stub() -> SyncCacheRegionProtocol: + """Stub make_region that returns a NullRegion when dogpile.cache is not installed.""" + return NullRegion() + + +_dogpile_no_value_stub: object = NO_VALUE +"""Stub NO_VALUE sentinel when dogpile.cache is not installed.""" + + +# Try to import real dogpile.cache implementation at runtime +_make_region: MakeRegionFunc +_dogpile_no_value: object +try: + from dogpile.cache import ( # pyright: ignore[reportMissingImports] + make_region as _make_region_real, # pyright: ignore[reportUnknownVariableType] + ) + from dogpile.cache.api import ( # pyright: ignore[reportMissingImports] + NO_VALUE as _dogpile_no_value_real, # noqa: N811 # pyright: ignore[reportUnknownVariableType] + ) + + _make_region = cast("MakeRegionFunc", _make_region_real) + _dogpile_no_value = cast("object", _dogpile_no_value_real) + DOGPILE_CACHE_INSTALLED = True # pyright: ignore[reportConstantRedefinition] + +except ImportError: # pragma: no cover + _make_region = _make_region_stub + _dogpile_no_value = _dogpile_no_value_stub + DOGPILE_CACHE_INSTALLED = False # pyright: ignore[reportConstantRedefinition] + +DOGPILE_NO_VALUE: object = _dogpile_no_value +"""Sentinel value indicating a cache miss (from dogpile or NullRegion).""" + + +class CacheManager: + """Manages dogpile.cache regions with model-aware invalidation. + + This class provides a high-level interface for caching SQLAlchemy model + instances using dogpile.cache. It handles serialization, cache key + generation, and model-version-based invalidation for list queries. + + All cache operations are available in both sync and async variants: + - Sync methods end with ``_sync`` (e.g., ``get_sync``, ``set_sync``) + - Async methods end with ``_async`` (e.g., ``get_async``, ``set_async``) + + Async methods use ``asyncio.to_thread()`` with capacity limiting to + prevent blocking the event loop when using network-based backends + like Redis or Memcached. + + Features: + - Lazy initialization of cache regions + - Model-aware cache key generation + - Version-based invalidation for list queries + - Graceful degradation when dogpile.cache is not installed + - Support for custom serializers + - Both sync and async operation support + + Example: + Sync usage:: + + from advanced_alchemy.cache import CacheConfig, CacheManager + + config = CacheConfig( + backend="dogpile.cache.memory", + expiration_time=300, + ) + manager = CacheManager(config) + + # Cache a value (sync) + result = manager.get_or_create_sync( + "users:1", + lambda: fetch_user_from_db(1), + ) + + Async usage:: + + # Cache a value (async) + result = await manager.get_or_create_async( + "users:1", + lambda: fetch_user_from_db(1), + ) + """ + + __slots__ = ( + "_async_inflight", + "_async_inflight_lock", + "_model_versions", + "_region", + "_sync_inflight", + "_sync_inflight_lock", + "config", + ) + + def __init__(self, config: "CacheConfig") -> None: + """Initialize the cache manager. + + Args: + config: Configuration for the cache region. + + Note: + Model version tokens are stored in-cache for cross-process consistency. + A random token is used per commit to avoid lost updates without requiring + backend-specific atomic increment support. + + Per-process singleflight registries (async and sync) are best-effort; + they reduce stampedes within a single process but do not provide + cross-process locking. + """ + self.config = config + # Model version tokens are stored in-cache for cross-process consistency. + # Use a random token per commit to avoid lost updates without requiring + # backend-specific atomic increment support. + self._region: Optional[SyncCacheRegionProtocol] = None + self._model_versions: dict[str, str] = {} + self._async_inflight: dict[str, asyncio.Task[Any]] = {} + self._async_inflight_lock: Optional[asyncio.Lock] = None + self._sync_inflight: dict[str, concurrent.futures.Future[Any]] = {} + self._sync_inflight_lock = threading.Lock() + + @property + def _inflight_lock_async(self) -> asyncio.Lock: + """Lazily create the asyncio lock used for async singleflight.""" + if self._async_inflight_lock is None: + self._async_inflight_lock = asyncio.Lock() + return self._async_inflight_lock + + @property + def region(self) -> SyncCacheRegionProtocol: + """Get the cache region, creating it if necessary. + + The region is lazily initialized on first access. If dogpile.cache + is not installed or initialization fails, returns a NullRegion that + provides the same interface but doesn't actually cache anything. + + Returns: + The configured cache region or a NullRegion fallback. + """ + if self._region is None: + self._region = self._create_region() + return self._region + + def _create_region(self) -> SyncCacheRegionProtocol: + """Create and configure the dogpile.cache region. + + Returns: + A configured CacheRegion or NullRegion if dogpile.cache + is not installed or configuration fails. + """ + if self.config.region_factory is not None: + try: + created_region = cast("SyncCacheRegionProtocol", self.config.region_factory(self.config)) + except Exception: + logger.exception("Failed to construct cache region via region_factory, using NullRegion") + return NullRegion() + else: + logger.debug("Configured cache region via region_factory") + return created_region + + if not DOGPILE_CACHE_INSTALLED: + logger.info("dogpile.cache is not installed, using NullRegion") + return NullRegion() + + if not self.config.enabled: + logger.debug("Caching is disabled, using NullRegion") + return NullRegion() + + try: + region: SyncCacheRegionProtocol = _make_region().configure( + self.config.backend, + expiration_time=self.config.expiration_time, + arguments=self.config.arguments, + ) + except Exception: + logger.exception("Failed to configure cache region, using NullRegion") + return NullRegion() + else: + logger.debug("Configured cache region with backend: %s", self.config.backend) + return region + + def _singleflight_async_cleanup(self, key: str, task: asyncio.Task[Any], *_: Any) -> None: + """Cleanup callback for async singleflight tasks. + + This runs in the event loop thread; we can safely mutate the in-flight + dict directly without scheduling another task. + """ + if self._async_inflight.get(key) is task: + self._async_inflight.pop(key, None) + + def _make_key(self, key: str) -> str: + """Generate a full cache key with the configured prefix. + + Args: + key: The base cache key. + + Returns: + The prefixed cache key. + """ + return f"{self.config.key_prefix}{key}" + + # ========================================================================= + # Sync Methods (canonical implementations) + # ========================================================================= + + def get_sync(self, key: str) -> object: + """Get a value from the cache (sync). + + Args: + key: The cache key (without prefix). + + Returns: + The cached value or NO_VALUE if not found. + """ + if not self.config.enabled: + return DOGPILE_NO_VALUE + return self.region.get(self._make_key(key)) + + def set_sync(self, key: str, value: Any) -> None: + """Set a value in the cache (sync). + + Args: + key: The cache key (without prefix). + value: The value to cache. + """ + if not self.config.enabled: + return + self.region.set(self._make_key(key), value) + + def delete_sync(self, key: str) -> None: + """Delete a value from the cache (sync). + + Args: + key: The cache key (without prefix). + """ + self.region.delete(self._make_key(key)) + + def get_or_create_sync( + self, + key: str, + creator: Callable[[], T], + expiration_time: Optional[int] = None, + ) -> T: + """Get a value from cache or create it using the creator function (sync). + + This method uses dogpile.cache's get_or_create which provides + mutex-based protection against the "thundering herd" problem + when multiple requests try to create the same value simultaneously. + + Note: + The creator function must be synchronous. For async creators, + you must await them before passing to this method. + + Args: + key: The cache key (without prefix). + creator: A synchronous callable that returns the value to cache on miss. + expiration_time: Optional override for the default TTL. + + Returns: + The cached or newly created value. + """ + if not self.config.enabled: + return creator() + region = self.region + full_key = self._make_key(key) + if hasattr(region, "get_or_create"): + return region.get_or_create( + full_key, + creator, + expiration_time=expiration_time or self.config.expiration_time, + ) + cached = region.get(full_key) + if cached is not DOGPILE_NO_VALUE and cached is not NO_VALUE: + return cast("T", cached) + value = creator() + region.set(full_key, value) + return value + + def get_entity_sync( + self, + model_name: str, + entity_id: Any, + model_class: type[T], + bind_group: Optional[str] = None, + ) -> Optional[T]: + """Get a cached entity by model name and ID (sync). + + Args: + model_name: The model/table name. + entity_id: The entity's primary key value. + model_class: The SQLAlchemy model class for deserialization. + bind_group: Optional routing group for multi-master configurations. + When provided, entity caches are namespaced by bind_group to + prevent data leaks between database shards/replicas. + + Returns: + The cached model instance or None if not found. + """ + key = f"{model_name}:{bind_group}:get:{entity_id}" if bind_group else f"{model_name}:get:{entity_id}" + cached = self.get_sync(key) + + if cached is DOGPILE_NO_VALUE or cached is NO_VALUE: + return None + if not isinstance(cached, (bytes, bytearray)): + self.delete_sync(key) + return None + + deserializer = self.config.deserializer or default_deserializer + try: + payload = bytes(cached) if isinstance(cached, bytearray) else cached + result: T = deserializer(payload, model_class) + except Exception: + logger.exception("Failed to deserialize cached entity %s:%s", model_name, entity_id) + # Remove corrupted cache entry + self.delete_sync(key) + return None + else: + return result + + def set_entity_sync( + self, + model_name: str, + entity_id: Any, + entity: Any, + bind_group: Optional[str] = None, + ) -> None: + """Cache an entity (sync). + + Args: + model_name: The model/table name. + entity_id: The entity's primary key value. + entity: The SQLAlchemy model instance to cache. + bind_group: Optional routing group for multi-master configurations. + When provided, entity caches are namespaced by bind_group to + prevent data leaks between database shards/replicas. + """ + key = f"{model_name}:{bind_group}:get:{entity_id}" if bind_group else f"{model_name}:get:{entity_id}" + serializer = self.config.serializer or default_serializer + + try: + serialized = serializer(entity) + self.set_sync(key, serialized) + except Exception: + logger.exception("Failed to serialize entity %s:%s", model_name, entity_id) + + def invalidate_entity_sync(self, model_name: str, entity_id: Any, bind_group: Optional[str] = None) -> None: + """Invalidate the cache for a specific entity (sync). + + This should be called after an entity is created, updated, or deleted + to ensure the cache doesn't serve stale data. + + Args: + model_name: The model/table name. + entity_id: The entity's primary key value. + bind_group: Optional routing group for multi-master configurations. + When provided, only the cache entry for that bind_group is + invalidated. + """ + key = f"{model_name}:{bind_group}:get:{entity_id}" if bind_group else f"{model_name}:get:{entity_id}" + self.delete_sync(key) + logger.debug("Invalidated cache for %s:%s (bind_group=%s)", model_name, entity_id, bind_group) + + def bump_model_version_sync(self, model_name: str) -> str: + """Bump the version token for a model (sync). + + This is used for version-based invalidation of list queries. + When a model is created, updated, or deleted, the version is + bumped to a new random token, which effectively invalidates all + list query caches that include that token in their cache key. + + Args: + model_name: The model/table name. + + Returns: + The new version token. + """ + token = uuid.uuid4().hex + self._model_versions[model_name] = token + + # Store in cache for distributed consistency + self.set_sync(f"{model_name}:version", token) + logger.debug("Bumped version token for %s to %s", model_name, token) + return token + + def get_model_version_sync(self, model_name: str) -> str: + """Get the current version token for a model (sync). + + This is used to include the version in list query cache keys, + ensuring that list caches are invalidated when models change. + + Args: + model_name: The model/table name. + + Returns: + The current version token ("0" if not set). + """ + # Check local cache first + if model_name in self._model_versions: + return self._model_versions[model_name] + + # Check distributed cache + cached = self.get_sync(f"{model_name}:version") + if cached is not DOGPILE_NO_VALUE and cached is not NO_VALUE and isinstance(cached, str): + self._model_versions[model_name] = cached + return cached + + return "0" + + def get_list_sync(self, key: str, model_class: type[T]) -> Optional[list[T]]: + """Get a cached list of entities (sync). + + The list is stored as base64-encoded serialized entity payloads. + + Args: + key: Cache key (without prefix). + model_class: Model class for deserialization. + + Returns: + A list of detached model instances or None if not found. + """ + cached = self.get_sync(key) + if cached is DOGPILE_NO_VALUE or cached is NO_VALUE: + return None + if not isinstance(cached, list): + return None + + cached_list = cast("list[Any]", cached) # type: ignore[redundant-cast] + deserializer = self.config.deserializer or default_deserializer + results: list[T] = [] + try: + for item in cached_list: + if not isinstance(item, str): + return None + raw = base64.b64decode(item.encode("ascii")) + results.append(deserializer(raw, model_class)) + except Exception: + logger.exception("Failed to deserialize cached list for key %s", key) + self.delete_sync(key) + return None + return results + + def set_list_sync(self, key: str, items: list[Any]) -> None: + """Cache a list of entities (sync). + + Args: + key: Cache key (without prefix). + items: List of entities to cache. + """ + serializer = self.config.serializer or default_serializer + try: + payload = [base64.b64encode(serializer(item)).decode("ascii") for item in items] + self.set_sync(key, payload) + except Exception: + logger.exception("Failed to serialize cached list for key %s", key) + + def get_list_and_count_sync(self, key: str, model_class: type[T]) -> Optional[tuple[list[T], int]]: + """Get a cached list+count payload (sync).""" + cached = self.get_sync(key) + if cached is DOGPILE_NO_VALUE or cached is NO_VALUE: + return None + if not isinstance(cached, dict): + return None + + cached_payload: dict[str, Any] = cast("dict[str, Any]", cached) + items_raw = cached_payload.get("items") + count_raw = cached_payload.get("count") + if not isinstance(items_raw, list) or not isinstance(count_raw, int): + return None + + items_list = cast("list[Any]", items_raw) # type: ignore[redundant-cast] + deserializer = self.config.deserializer or default_deserializer + results: list[T] = [] + try: + for item in items_list: + if not isinstance(item, str): + return None + raw = base64.b64decode(item.encode("ascii")) + results.append(deserializer(raw, model_class)) + except Exception: + logger.exception("Failed to deserialize cached list_and_count for key %s", key) + self.delete_sync(key) + return None + return results, count_raw + + def set_list_and_count_sync(self, key: str, items: list[Any], count: int) -> None: + """Cache a list+count payload (sync).""" + serializer = self.config.serializer or default_serializer + try: + payload = { + "items": [base64.b64encode(serializer(item)).decode("ascii") for item in items], + "count": count, + } + self.set_sync(key, payload) + except Exception: + logger.exception("Failed to serialize cached list_and_count for key %s", key) + + def singleflight_sync(self, key: str, creator: Callable[[], T]) -> T: + """Coalesce concurrent sync cache misses per-process. + + This reduces stampedes in thread-based sync apps. It does not provide + cross-process locking. + """ + with self._sync_inflight_lock: + future: Optional[concurrent.futures.Future[Any]] = self._sync_inflight.get(key) + if future is None: + future = concurrent.futures.Future() + self._sync_inflight[key] = future + is_owner = True + else: + is_owner = False + + if not is_owner: + return cast("T", future.result()) + + try: + result = creator() + except Exception as e: + future.set_exception(e) + raise + else: + future.set_result(result) + return result + finally: + with self._sync_inflight_lock: + if self._sync_inflight.get(key) is future: + self._sync_inflight.pop(key, None) + + def invalidate_all_sync(self) -> None: + """Invalidate all cached values (sync). + + Warning: + This invalidates the entire region, not just keys with + the configured prefix. Use with caution in shared cache + environments. + """ + self.region.invalidate() + self._model_versions.clear() + logger.info("Invalidated entire cache region") + + # ========================================================================= + # Async Methods (thin wrappers using async_() for thread offloading) + # ========================================================================= + + async def get_async(self, key: str) -> object: + """Get a value from the cache (async). + + Args: + key: The cache key (without prefix). + + Returns: + The cached value or NO_VALUE if not found. + """ + return await async_(self.get_sync)(key) + + async def set_async(self, key: str, value: Any) -> None: + """Set a value in the cache (async). + + Args: + key: The cache key (without prefix). + value: The value to cache. + """ + await async_(self.set_sync)(key, value) + + async def delete_async(self, key: str) -> None: + """Delete a value from the cache (async). + + Args: + key: The cache key (without prefix). + """ + await async_(self.delete_sync)(key) + + async def get_or_create_async( + self, + key: str, + creator: Callable[[], T], + expiration_time: Optional[int] = None, + ) -> T: + """Get a value from cache or create it using the creator function (async). + + This method uses dogpile.cache's get_or_create which provides + mutex-based protection against the "thundering herd" problem + when multiple requests try to create the same value simultaneously. + + Note: + The creator function must be synchronous since dogpile.cache + runs in a thread pool. For async creators, you must await + them and wrap the result before passing to this method. + + Args: + key: The cache key (without prefix). + creator: A synchronous callable that returns the value to cache on miss. + expiration_time: Optional override for the default TTL. + + Returns: + The cached or newly created value. + """ + return await async_(self.get_or_create_sync)(key, creator, expiration_time) + + async def get_entity_async( + self, + model_name: str, + entity_id: Any, + model_class: type[T], + bind_group: Optional[str] = None, + ) -> Optional[T]: + """Get a cached entity by model name and ID (async). + + Args: + model_name: The model/table name. + entity_id: The entity's primary key value. + model_class: The SQLAlchemy model class for deserialization. + bind_group: Optional routing group for multi-master configurations. + When provided, entity caches are namespaced by bind_group to + prevent data leaks between database shards/replicas. + + Returns: + The cached model instance or None if not found. + """ + return await async_(self.get_entity_sync)(model_name, entity_id, model_class, bind_group) + + async def set_entity_async( + self, + model_name: str, + entity_id: Any, + entity: Any, + bind_group: Optional[str] = None, + ) -> None: + """Cache an entity (async). + + Args: + model_name: The model/table name. + entity_id: The entity's primary key value. + entity: The SQLAlchemy model instance to cache. + bind_group: Optional routing group for multi-master configurations. + When provided, entity caches are namespaced by bind_group to + prevent data leaks between database shards/replicas. + """ + await async_(self.set_entity_sync)(model_name, entity_id, entity, bind_group) + + async def invalidate_entity_async(self, model_name: str, entity_id: Any, bind_group: Optional[str] = None) -> None: + """Invalidate the cache for a specific entity (async). + + This should be called after an entity is created, updated, or deleted + to ensure the cache doesn't serve stale data. + + Args: + model_name: The model/table name. + entity_id: The entity's primary key value. + bind_group: Optional routing group for multi-master configurations. + When provided, only the cache entry for that bind_group is + invalidated. + """ + await async_(self.invalidate_entity_sync)(model_name, entity_id, bind_group) + + async def bump_model_version_async(self, model_name: str) -> str: + """Bump the version token for a model (async). + + This is used for version-based invalidation of list queries. + When a model is created, updated, or deleted, the version is + bumped, which effectively invalidates all list query caches + that include that model's version in their cache key. + + Args: + model_name: The model/table name. + + Returns: + The new version token. + """ + return await async_(self.bump_model_version_sync)(model_name) + + async def get_model_version_async(self, model_name: str) -> str: + """Get the current version token for a model (async). + + This is used to include the version in list query cache keys, + ensuring that list caches are invalidated when models change. + + Args: + model_name: The model/table name. + + Returns: + The current version token ("0" if not set). + """ + return await async_(self.get_model_version_sync)(model_name) + + async def get_list_async(self, key: str, model_class: type[T]) -> Optional[list[T]]: + """Get a cached list of entities (async).""" + return await async_(self.get_list_sync)(key, model_class) + + async def set_list_async(self, key: str, items: list[Any]) -> None: + """Cache a list of entities (async).""" + await async_(self.set_list_sync)(key, items) + + async def get_list_and_count_async(self, key: str, model_class: type[T]) -> Optional[tuple[list[T], int]]: + """Get a cached list+count payload (async).""" + return await async_(self.get_list_and_count_sync)(key, model_class) + + async def set_list_and_count_async(self, key: str, items: list[Any], count: int) -> None: + """Cache a list+count payload (async).""" + await async_(self.set_list_and_count_sync)(key, items, count) + + async def singleflight_async(self, key: str, creator: Callable[[], Coroutine[Any, Any, T]]) -> T: + """Coalesce concurrent async cache misses per-process. + + The creator is invoked once per key at a time; concurrent callers + await the same in-flight task. This does not provide cross-process + locking. + """ + async with self._inflight_lock_async: + task = self._async_inflight.get(key) + if task is None: + task = asyncio.create_task(creator()) + self._async_inflight[key] = task + task.add_done_callback(partial(self._singleflight_async_cleanup, key)) + + return await asyncio.shield(task) + + async def invalidate_all_async(self) -> None: + """Invalidate all cached values (async). + + Warning: + This invalidates the entire region, not just keys with + the configured prefix. Use with caution in shared cache + environments. + """ + await async_(self.invalidate_all_sync)() diff --git a/advanced_alchemy/cache/serializers.py b/advanced_alchemy/cache/serializers.py new file mode 100644 index 000000000..c70fd3301 --- /dev/null +++ b/advanced_alchemy/cache/serializers.py @@ -0,0 +1,218 @@ +"""Serialization utilities for caching SQLAlchemy models.""" + +from datetime import date, datetime, time, timedelta +from decimal import Decimal +from typing import Any, TypeVar, Union, cast +from uuid import UUID + +from sqlalchemy import inspect as sa_inspect + +from advanced_alchemy._serialization import decode_json, encode_json + +__all__ = ( + "default_deserializer", + "default_serializer", +) + +T = TypeVar("T") + +_MODEL_KEY = "__aa_model__" +"""Metadata key for the model class name in serialized data.""" + +_TABLE_KEY = "__aa_table__" +"""Metadata key for the table name in serialized data.""" + + +def _json_encoder(obj: Any) -> Any: # noqa: PLR0911 + """Custom JSON encoder for SQLAlchemy model attributes. + + Handles types that are not natively JSON serializable: + - datetime, date, time: ISO format strings + - timedelta: total seconds as float + - Decimal: string representation + - bytes: hex string + - UUID: string representation + - set, frozenset: list + + Args: + obj: The object to encode. + + Returns: + A JSON-serializable representation of the object. + + Raises: + TypeError: If the object type is not supported. + """ + if isinstance(obj, datetime): + return {"__type__": "datetime", "value": obj.isoformat()} + if isinstance(obj, date): + return {"__type__": "date", "value": obj.isoformat()} + if isinstance(obj, time): + return {"__type__": "time", "value": obj.isoformat()} + if isinstance(obj, timedelta): + return {"__type__": "timedelta", "value": obj.total_seconds()} + if isinstance(obj, Decimal): + return {"__type__": "decimal", "value": str(obj)} + if isinstance(obj, bytes): + return {"__type__": "bytes", "value": obj.hex()} + if isinstance(obj, UUID): + return {"__type__": "uuid", "value": str(obj)} + if isinstance(obj, (set, frozenset)): + items: list[Any] = list(cast("Union[set[Any], frozenset[Any]]", obj)) # type: ignore[redundant-cast] + return {"__type__": "set", "value": items} + msg = f"Object of type {type(obj).__name__} is not JSON serializable" + raise TypeError(msg) + + +def _json_decoder(obj: dict[str, Any]) -> Any: # noqa: PLR0911 + """Custom JSON decoder for special types. + + Args: + obj: The dictionary to decode. + + Returns: + The decoded object, or the original dict if not a special type. + """ + if "__type__" not in obj: + return obj + + type_name = obj["__type__"] + value = obj["value"] + + if type_name == "datetime": + return datetime.fromisoformat(value) + if type_name == "date": + return date.fromisoformat(value) + if type_name == "time": + return time.fromisoformat(value) + if type_name == "timedelta": + return timedelta(seconds=value) + if type_name == "decimal": + return Decimal(value) + if type_name == "bytes": + return bytes.fromhex(value) + if type_name == "uuid": + return UUID(value) + if type_name == "set": + return set(value) + + return obj + + +def _decode_special_types(value: Any) -> Any: + """Recursively decode special type markers. + + When using ``encode_json`` (msgspec/orjson/json fallback), we can't rely on + stdlib json's ``object_hook`` callback. This helper decodes the special + ``{"__type__": ..., "value": ...}`` structures produced by ``_json_encoder``. + """ + if isinstance(value, list): + value_list = cast("list[Any]", value) # type: ignore[redundant-cast] + return [_decode_special_types(v) for v in value_list] + + if not isinstance(value, dict): + return value + + # Decode any nested values first + value_dict = cast("dict[Any, Any]", value) # type: ignore[redundant-cast] + decoded: dict[str, Any] = {str(k): _decode_special_types(v) for k, v in value_dict.items()} + + # Then decode "typed" marker dicts + if "__type__" in decoded and "value" in decoded: + return _json_decoder(decoded) + + return decoded + + +def default_serializer(model: Any) -> bytes: + """Serialize a SQLAlchemy model instance to JSON bytes. + + This function extracts column values from a SQLAlchemy model and + serializes them to JSON format. The serialized data includes metadata + about the model class for validation during deserialization. + + Note: + Relationships are NOT serialized. Only column values are included. + The deserialized object will be a detached instance without + relationship data loaded. + + Args: + model: The SQLAlchemy model instance to serialize. + + Returns: + JSON-encoded bytes representation of the model. + + Example: + Serializing a model:: + + user = User(id=1, name="John", email="john@example.com") + data = default_serializer(user) + # b'{"__aa_model__": "User", "__aa_table__": "users", "id": 1, ...}' + """ + mapper = sa_inspect(model.__class__) + data: dict[str, Any] = { + _MODEL_KEY: model.__class__.__name__, + _TABLE_KEY: model.__class__.__tablename__, + } + + for column in mapper.columns: + # Skip internal SQLAlchemy sentinel columns (e.g., sa_orm_sentinel) + if getattr(column, "_insert_sentinel", False): + continue + value = getattr(model, column.key) + try: + # Encode special types into JSON-friendly marker structures. + data[column.key] = _json_encoder(value) + except TypeError: + # Leave unknown types alone; encode_json has its own hooks/fallbacks. + data[column.key] = value + + return encode_json(data).encode("utf-8") + + +def default_deserializer(data: bytes, model_class: type[T]) -> T: + """Deserialize JSON bytes to a SQLAlchemy model instance. + + Creates a new, detached instance of the model class populated with + the serialized column values. The instance is NOT attached to any + session and should be treated as a read-only snapshot. + + Warning: + The returned instance is detached and does not have relationships + loaded. Accessing lazy-loaded relationships will raise + DetachedInstanceError. Use ``session.merge()`` if you need to + work with relationships. + + Args: + data: JSON bytes to deserialize. + model_class: The SQLAlchemy model class to instantiate. + + Returns: + A new, detached instance of the model class. + + Raises: + ValueError: If the serialized data is for a different model class. + + Example: + Deserializing data:: + + data = b'{"__aa_model__": "User", "id": 1, "name": "John"}' + user = default_deserializer(data, User) + # user is a detached User instance + """ + parsed_raw = decode_json(data) + parsed = _decode_special_types(parsed_raw) + + # Validate model class matches + serialized_model = parsed.pop(_MODEL_KEY, None) + parsed.pop(_TABLE_KEY, None) # Remove table key, not needed for instantiation + + if serialized_model and serialized_model != model_class.__name__: + msg = f"Cannot deserialize {serialized_model} data as {model_class.__name__}" + raise ValueError(msg) + + # Create detached instance using constructor + # This properly initializes SQLAlchemy's ORM state + instance: T = model_class(**parsed) + + return instance diff --git a/advanced_alchemy/repository/_async.py b/advanced_alchemy/repository/_async.py index 68b894980..11fc4dee1 100644 --- a/advanced_alchemy/repository/_async.py +++ b/advanced_alchemy/repository/_async.py @@ -1,9 +1,12 @@ import contextlib +import dataclasses import datetime import decimal +import hashlib import random import string -from collections.abc import Iterable, Sequence +from collections.abc import Iterable, Mapping, Sequence +from functools import partial from typing import ( TYPE_CHECKING, Any, @@ -39,8 +42,10 @@ from sqlalchemy.orm.strategy_options import _AbstractLoad # pyright: ignore[reportPrivateUsage] from sqlalchemy.sql import ColumnElement from sqlalchemy.sql.dml import ReturningDelete, ReturningUpdate +from sqlalchemy.sql.elements import UnaryExpression from sqlalchemy.sql.selectable import ForUpdateArg, ForUpdateParameter +from advanced_alchemy._serialization import encode_json from advanced_alchemy.exceptions import ErrorMessages, NotFoundError, RepositoryError, wrap_sqlalchemy_exception from advanced_alchemy.filters import StatementFilter, StatementTypeT from advanced_alchemy.repository._util import ( @@ -62,6 +67,8 @@ if TYPE_CHECKING: from sqlalchemy.engine.interfaces import _CoreSingleExecuteParams # pyright: ignore[reportPrivateUsage] + from advanced_alchemy.cache.manager import CacheManager + DEFAULT_INSERTMANYVALUES_MAX_PARAMETERS: Final = 950 POSTGRES_VERSION_SUPPORTING_MERGE: Final = 15 DEFAULT_SAFE_TYPES: Final[set[type[Any]]] = { @@ -78,6 +85,128 @@ } +def _sort_kv_by_key_str(kv: tuple[object, object]) -> str: + return str(kv[0]) + + +def _sort_normalized_value(value: Any) -> str: + return encode_json(_canonicalize_cache_key_value(value)) + + +def _normalize_cache_key_value(value: Any) -> Any: + """Normalize values into a deterministic JSON-serializable form. + + Used for list/list_and_count cache keys. + """ + if value is None or isinstance(value, (int, float, str, bool)): + return value + + result: Any + if isinstance(value, bytes): + result = {"__bytes__": value.hex()} + elif isinstance(value, (datetime.datetime, datetime.date, datetime.time)): + result = {"__datetime__": value.isoformat()} + elif isinstance(value, datetime.timedelta): + result = {"__timedelta__": value.total_seconds()} + elif isinstance(value, decimal.Decimal): + result = {"__decimal__": str(value)} + elif isinstance(value, set): + value_set = cast("set[Any]", value) # type: ignore[redundant-cast] + normalized = [_normalize_cache_key_value(v) for v in value_set] + normalized.sort(key=_sort_normalized_value) + result = normalized + elif isinstance(value, (list, tuple)): + value_seq = cast("Sequence[Any]", value) + result = [_normalize_cache_key_value(v) for v in value_seq] + elif isinstance(value, dict): + value_dict = cast("dict[object, object]", value) + normalized_dict: dict[str, Any] = {} + for k, v in sorted(value_dict.items(), key=_sort_kv_by_key_str): + normalized_dict[str(k)] = _normalize_cache_key_value(v) + result = normalized_dict + elif dataclasses.is_dataclass(value) and not isinstance(value, type): + result = _normalize_cache_key_value(dataclasses.asdict(value)) + elif isinstance(value, InstrumentedAttribute): + result = {"__attr__": value.key} + elif isinstance(value, ColumnElement): + # Safe fallback for non-dataclass expressions in ordering/kwargs. + value_expr = cast("ColumnElement[Any]", value) # type: ignore[redundant-cast] + result = {"__sql__": str(value_expr)} + else: + result = {"__repr__": repr(value)} + + return result + + +def _canonicalize_cache_key_value(value: Any) -> Any: + """Convert dict-like objects into a stable, ordered representation.""" + if isinstance(value, Mapping): + value_map = cast("Mapping[object, object]", value) + return [ + [str(k), _canonicalize_cache_key_value(v)] for k, v in sorted(value_map.items(), key=_sort_kv_by_key_str) + ] + if isinstance(value, list): + value_list = cast("list[Any]", value) # type: ignore[redundant-cast] + return [_canonicalize_cache_key_value(v) for v in value_list] + return value + + +def _build_list_cache_key( + *, + model_name: str, + version_token: str, + method: str, + filters: Sequence[Union[StatementFilter, ColumnElement[bool]]], + kwargs: dict[str, Any], + order_by: Optional[Union[list[OrderingPair], OrderingPair]], + execution_options: dict[str, Any], + uniquify: bool, + count_with_window_function: Optional[bool] = None, +) -> Optional[str]: + """Build a stable cache key for list/list_and_count operations. + + Returns None if the query specification includes non-cacheable filter + expressions (e.g., raw SQLAlchemy boolean expressions). + """ + normalized_filters: list[dict[str, Any]] = [] + for filter_ in filters: + if isinstance(filter_, ColumnElement): + return None + normalized_filters.append({"type": filter_.__class__.__name__, "data": _normalize_cache_key_value(filter_)}) + + normalized_order_by: Optional[list[Any]] = None + if order_by is not None: + order_items = order_by if isinstance(order_by, list) else [order_by] + normalized_order_by = [] + for item in order_items: + if isinstance(item, UnaryExpression): + normalized_order_by.append({"expr": str(item)}) + else: + col, desc = item + normalized_order_by.append({"col": _normalize_cache_key_value(col), "desc": bool(desc)}) + + payload: dict[str, Any] = { + "method": method, + "model": model_name, + "version": version_token, + "filters": normalized_filters, + "kwargs": _normalize_cache_key_value(kwargs), + "order_by": normalized_order_by, + "execution_options": _normalize_cache_key_value(execution_options), + "uniquify": uniquify, + } + if count_with_window_function is not None: + payload["count_with_window_function"] = bool(count_with_window_function) + + try: + encoded = encode_json(_canonicalize_cache_key_value(payload)).encode("utf-8") + except TypeError: # pragma: no cover + return None + + digest = hashlib.sha256(encoded).hexdigest() + return f"{model_name}:{method}:{digest}" + + @runtime_checkable class SQLAlchemyAsyncRepositoryProtocol(FilterableRepositoryProtocol[ModelT], Protocol[ModelT]): """Base Protocol""" @@ -135,6 +264,7 @@ async def add( auto_expunge: Optional[bool] = None, auto_refresh: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, + bind_group: Optional[str] = None, ) -> ModelT: ... async def add_many( @@ -144,6 +274,7 @@ async def add_many( auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, + bind_group: Optional[str] = None, ) -> Sequence[ModelT]: ... async def delete( @@ -156,6 +287,7 @@ async def delete( error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, + bind_group: Optional[str] = None, ) -> ModelT: ... async def delete_many( @@ -169,6 +301,7 @@ async def delete_many( error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, + bind_group: Optional[str] = None, ) -> Sequence[ModelT]: ... async def delete_where( @@ -180,6 +313,7 @@ async def delete_where( error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, execution_options: Optional[dict[str, Any]] = None, sanity_check: bool = True, + bind_group: Optional[str] = None, **kwargs: Any, ) -> Sequence[ModelT]: ... @@ -246,6 +380,7 @@ async def get_or_upsert( error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, + bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[ModelT, bool]: ... @@ -261,6 +396,7 @@ async def get_and_update( error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, + bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[ModelT, bool]: ... @@ -288,6 +424,7 @@ async def update( error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, + bind_group: Optional[str] = None, ) -> ModelT: ... async def update_many( @@ -299,6 +436,7 @@ async def update_many( error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, + bind_group: Optional[str] = None, ) -> list[ModelT]: ... def _get_update_many_statement( @@ -322,6 +460,7 @@ async def upsert( error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, + bind_group: Optional[str] = None, ) -> ModelT: ... async def upsert_many( @@ -335,6 +474,7 @@ async def upsert_many( error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, + bind_group: Optional[str] = None, ) -> list[ModelT]: ... async def list_and_count( @@ -347,6 +487,7 @@ async def list_and_count( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, order_by: Optional[Union[list[OrderingPair], OrderingPair]] = None, + use_cache: bool = True, bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[list[ModelT], int]: ... @@ -360,6 +501,7 @@ async def list( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, order_by: Optional[Union[list[OrderingPair], OrderingPair]] = None, + use_cache: bool = True, bind_group: Optional[str] = None, **kwargs: Any, ) -> list[ModelT]: ... @@ -459,6 +601,10 @@ class SQLAlchemyAsyncRepository(SQLAlchemyAsyncRepositoryProtocol[ModelT], Filte count_with_window_function: bool = True """Use an analytical window function to count results. This allows the count to be performed in a single query. """ + _cache_manager: Optional["CacheManager"] = None + """Cache manager instance for repository-level caching. Set via ``cache_manager`` kwarg or retrieved from ``session.info``.""" + _bind_group: Optional[str] = None + """Default bind group for routing operations (e.g., to read replicas). Can be overridden per-method.""" def __init__( self, @@ -475,6 +621,8 @@ def __init__( wrap_exceptions: bool = True, uniquify: Optional[bool] = None, count_with_window_function: Optional[bool] = None, + cache_manager: Optional["CacheManager"] = None, + bind_group: Optional[str] = None, **kwargs: Any, ) -> None: """Repository for SQLAlchemy models. @@ -492,6 +640,8 @@ def __init__( wrap_exceptions: Wrap SQLAlchemy exceptions in a ``RepositoryError``. When set to ``False``, the original exception will be raised. uniquify: Optionally apply the ``unique()`` method to results before returning. count_with_window_function: When false, list and count will use two queries instead of an analytical window function. + cache_manager: Optional cache manager for repository-level caching. If not provided, retrieved from ``session.info``. + bind_group: Optional default routing group to use for all operations. Can be overridden per-method. **kwargs: Additional arguments. """ @@ -518,6 +668,10 @@ def __init__( self.statement = select(self.model_type) if statement is None else statement self._dialect = self.session.bind.dialect if self.session.bind is not None else self.session.get_bind().dialect self._prefer_any = any(self._dialect.name == engine_type for engine_type in self.prefer_any_dialects or ()) + # Cache manager: from explicit param or session.info (set by SQLAlchemyAsyncConfig) + self._cache_manager = cache_manager if cache_manager is not None else session.info.get("cache_manager") + # Default bind group for all operations (can be overridden per-method) + self._bind_group = bind_group def _get_uniquify(self, uniquify: Optional[bool] = None) -> bool: """Get the uniquify value, preferring the method parameter over instance setting. @@ -530,6 +684,43 @@ def _get_uniquify(self, uniquify: Optional[bool] = None) -> bool: """ return bool(uniquify) if uniquify is not None else self._uniquify + def _resolve_bind_group(self, bind_group: Optional[str] = None) -> Optional[str]: + """Resolve the bind_group to use, preferring method parameter over instance default. + + Args: + bind_group: Optional override for the bind_group setting. + + Returns: + The bind_group to use, or None if not set. + """ + return bind_group if bind_group is not None else self._bind_group + + def _queue_cache_invalidation(self, entity_id: Any, bind_group: Optional[str] = None) -> None: + """Queue a cache invalidation for an entity. + + The invalidation will be processed after the transaction commits. + If the transaction rolls back, the pending invalidation is discarded. + + This uses the global CacheInvalidationListener which must be set up + via setup_cache_listeners() during application initialization. + + Args: + entity_id: The primary key value of the entity to invalidate. + bind_group: Optional routing group for multi-master configurations. + When provided, only the cache entry for that bind_group is + invalidated. + """ + if self._cache_manager is not None: + from advanced_alchemy._listeners import get_cache_tracker + + # Check if model_type has __tablename__ (may not exist in mock scenarios) + model_name = getattr(self.model_type, "__tablename__", None) + if model_name is None: + return + tracker = get_cache_tracker(self.session, self._cache_manager) + if tracker is not None: + tracker.add_invalidation(cast("str", model_name), entity_id, bind_group) + def _type_must_use_in_instead_of_any(self, matched_values: "list[Any]", field_type: "Any" = None) -> bool: """Determine if field.in_() should be used instead of any_() for compatibility. @@ -697,6 +888,7 @@ async def add( auto_expunge: Optional[bool] = None, auto_refresh: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, + bind_group: Optional[str] = None, ) -> ModelT: """Add ``data`` to the collection. @@ -707,10 +899,12 @@ async def add( auto_commit: Commit objects before returning. error_messages: An optional dictionary of templates to use for friendlier error messages to clients + bind_group: Optional routing group for multi-master configurations. Returns: The added instance. """ + _ = bind_group # Reserved for future multi-master routing error_messages = self._get_error_messages( error_messages=error_messages, default_messages=self.error_messages, @@ -731,6 +925,7 @@ async def add_many( auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, + bind_group: Optional[str] = None, ) -> Sequence[ModelT]: """Add many `data` to the collection. @@ -740,10 +935,12 @@ async def add_many( auto_commit: Commit objects before returning. error_messages: An optional dictionary of templates to use for friendlier error messages to clients + bind_group: Optional routing group for multi-master configurations. Returns: The added instances. """ + _ = bind_group # Reserved for future multi-master routing error_messages = self._get_error_messages( error_messages=error_messages, default_messages=self.error_messages, @@ -768,6 +965,7 @@ async def delete( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, ) -> ModelT: """Delete instance identified by ``item_id``. @@ -782,6 +980,7 @@ async def delete( load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. + bind_group: Optional routing group for multi-master configurations. Returns: The deleted instance. @@ -795,16 +994,23 @@ async def delete( with wrap_sqlalchemy_exception( error_messages=error_messages, dialect_name=self._dialect.name, wrap_exceptions=self.wrap_exceptions ): + resolved_bind_group = self._resolve_bind_group(bind_group) + if resolved_bind_group: + execution_options = dict(execution_options) if execution_options else {} + execution_options["bind_group"] = resolved_bind_group execution_options = self._get_execution_options(execution_options) instance = await self.get( item_id, id_attribute=id_attribute, load=load, execution_options=execution_options, + bind_group=bind_group, ) await self.session.delete(instance) await self._flush_or_commit(auto_commit=auto_commit) self._expunge(instance, auto_expunge=auto_expunge) + # Queue cache invalidation (processed on commit) + self._queue_cache_invalidation(item_id, bind_group) return instance async def delete_many( @@ -819,6 +1025,7 @@ async def delete_many( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, ) -> Sequence[ModelT]: """Delete instance identified by `item_id`. @@ -835,6 +1042,7 @@ async def delete_many( load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. + bind_group: Optional routing group for multi-master configurations. Returns: The deleted instances. @@ -848,6 +1056,10 @@ async def delete_many( with wrap_sqlalchemy_exception( error_messages=error_messages, dialect_name=self._dialect.name, wrap_exceptions=self.wrap_exceptions ): + resolved_bind_group = self._resolve_bind_group(bind_group) + if resolved_bind_group: + execution_options = dict(execution_options) if execution_options else {} + execution_options["bind_group"] = resolved_bind_group execution_options = self._get_execution_options(execution_options) loader_options, _loader_options_have_wildcard = self._get_loader_options(load) id_attribute = get_instrumented_attr( @@ -902,6 +1114,8 @@ async def delete_many( await self._flush_or_commit(auto_commit=auto_commit) for instance in instances: self._expunge(instance, auto_expunge=auto_expunge) + # Queue cache invalidation (processed on commit) + self._queue_cache_invalidation(self.get_id_attribute_value(instance), bind_group) return instances @staticmethod @@ -918,6 +1132,7 @@ async def delete_where( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, **kwargs: Any, ) -> Sequence[ModelT]: """Delete instances specified by referenced kwargs and filters. @@ -932,6 +1147,7 @@ async def delete_where( load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. + bind_group: Optional routing group for multi-master configurations. **kwargs: Arguments to apply to a delete Raises: @@ -949,6 +1165,10 @@ async def delete_where( with wrap_sqlalchemy_exception( error_messages=error_messages, dialect_name=self._dialect.name, wrap_exceptions=self.wrap_exceptions ): + resolved_bind_group = self._resolve_bind_group(bind_group) + if resolved_bind_group: + execution_options = dict(execution_options) if execution_options else {} + execution_options["bind_group"] = resolved_bind_group execution_options = self._get_execution_options(execution_options) loader_options, _loader_options_have_wildcard = self._get_loader_options(load) model_type = self.model_type @@ -969,6 +1189,8 @@ async def delete_where( load=load, execution_options=execution_options, auto_expunge=auto_expunge, + use_cache=False, # Always fetch from DB for delete_where + bind_group=bind_group, **kwargs, ), ) @@ -983,6 +1205,8 @@ async def delete_where( await self._flush_or_commit(auto_commit=auto_commit) for instance in instances: self._expunge(instance, auto_expunge=auto_expunge) + # Queue cache invalidation (processed on commit) + self._queue_cache_invalidation(self.get_id_attribute_value(instance), resolved_bind_group) return instances async def exists( @@ -1100,6 +1324,279 @@ def _get_delete_many_statement( return statement.where(id_attribute.in_(id_chunk)) # pyright: ignore[reportUnknownMemberType,reportUnknownVariableType] return statement.where(any_(id_chunk) == id_attribute) # type: ignore[arg-type] + async def _get_from_db( + self, + item_id: Any, + *, + auto_expunge: Optional[bool], + statement: Optional[Select[tuple[ModelT]]], + id_attribute: Optional[Union[str, InstrumentedAttribute[Any]]], + error_messages: Optional[ErrorMessages], + load: Optional[LoadSpec], + execution_options: Optional[dict[str, Any]], + with_for_update: ForUpdateParameter, + bind_group: Optional[str] = None, + ) -> ModelT: + """Fetch an entity from the database without using cache.""" + with wrap_sqlalchemy_exception( + error_messages=error_messages, dialect_name=self._dialect.name, wrap_exceptions=self.wrap_exceptions + ): + resolved_bind_group = self._resolve_bind_group(bind_group) + if resolved_bind_group: + execution_options = dict(execution_options) if execution_options else {} + execution_options["bind_group"] = resolved_bind_group + resolved_execution_options = self._get_execution_options(execution_options) + resolved_statement = self.statement if statement is None else statement + loader_options, loader_options_have_wildcard = self._get_loader_options(load) + resolved_id_attribute = id_attribute if id_attribute is not None else self.id_attribute + resolved_statement = self._get_base_stmt( + statement=resolved_statement, + loader_options=loader_options, + execution_options=resolved_execution_options, + ) + resolved_statement = self._filter_select_by_kwargs(resolved_statement, [(resolved_id_attribute, item_id)]) + resolved_statement = self._apply_for_update_options(resolved_statement, with_for_update) + instance = ( + await self._execute(resolved_statement, uniquify=loader_options_have_wildcard) + ).scalar_one_or_none() + instance = self.check_not_found(instance) + self._expunge(instance, auto_expunge=auto_expunge) + return instance + + async def _get_cached_creator( + self, + model_name: str, + item_id: Any, + *, + auto_expunge: Optional[bool], + statement: Optional[Select[tuple[ModelT]]], + id_attribute: Optional[Union[str, InstrumentedAttribute[Any]]], + error_messages: Optional[ErrorMessages], + load: Optional[LoadSpec], + execution_options: Optional[dict[str, Any]], + with_for_update: ForUpdateParameter, + bind_group: Optional[str] = None, + ) -> ModelT: + """Singleflight creator for get(id) caching (async).""" + if self._cache_manager is None: + return await self._get_from_db( + item_id, + auto_expunge=auto_expunge, + statement=statement, + id_attribute=id_attribute, + error_messages=error_messages, + load=load, + execution_options=execution_options, + with_for_update=with_for_update, + bind_group=bind_group, + ) + + existing = await self._cache_manager.get_entity_async( + model_name, item_id, self.model_type, bind_group=bind_group + ) + if existing is not None: + return existing + + instance = await self._get_from_db( + item_id, + auto_expunge=auto_expunge, + statement=statement, + id_attribute=id_attribute, + error_messages=error_messages, + load=load, + execution_options=execution_options, + with_for_update=with_for_update, + bind_group=bind_group, + ) + await self._cache_manager.set_entity_async(model_name, item_id, instance, bind_group=bind_group) + return instance + + async def _list_from_db( + self, + *, + filters: Sequence[Union[StatementFilter, ColumnElement[bool]]], + auto_expunge: Optional[bool], + statement: Optional[Select[tuple[ModelT]]], + order_by: Optional[Union[list[OrderingPair], OrderingPair]], + error_messages: Optional[ErrorMessages], + load: Optional[LoadSpec], + execution_options: Optional[dict[str, Any]], + kwargs: dict[str, Any], + uniquify: Optional[bool], + bind_group: Optional[str] = None, + ) -> list[ModelT]: + """Fetch a list of entities from the database without using cache.""" + self._uniquify = self._get_uniquify(uniquify) + with wrap_sqlalchemy_exception( + error_messages=error_messages, dialect_name=self._dialect.name, wrap_exceptions=self.wrap_exceptions + ): + resolved_bind_group = self._resolve_bind_group(bind_group) + if resolved_bind_group: + execution_options = dict(execution_options) if execution_options else {} + execution_options["bind_group"] = resolved_bind_group + resolved_execution_options = self._get_execution_options(execution_options) + resolved_statement = self.statement if statement is None else statement + loader_options, loader_options_have_wildcard = self._get_loader_options(load) + resolved_statement = self._get_base_stmt( + statement=resolved_statement, + loader_options=loader_options, + execution_options=resolved_execution_options, + ) + if order_by is None: + order_by = self.order_by if self.order_by is not None else [] + resolved_statement = self._apply_order_by(statement=resolved_statement, order_by=order_by) + resolved_statement = self._apply_filters(*filters, statement=resolved_statement) + resolved_statement = self._filter_select_by_kwargs(resolved_statement, kwargs) + result = await self._execute(resolved_statement, uniquify=loader_options_have_wildcard) + instances = list(result.scalars()) + for instance in instances: + self._expunge(instance, auto_expunge=auto_expunge) + return cast("list[ModelT]", instances) + + async def _list_cached_creator( + self, + cache_key: str, + *, + filters: Sequence[Union[StatementFilter, ColumnElement[bool]]], + auto_expunge: Optional[bool], + statement: Optional[Select[tuple[ModelT]]], + order_by: Optional[Union[list[OrderingPair], OrderingPair]], + error_messages: Optional[ErrorMessages], + load: Optional[LoadSpec], + execution_options: Optional[dict[str, Any]], + kwargs: dict[str, Any], + uniquify: Optional[bool], + bind_group: Optional[str] = None, + ) -> list[ModelT]: + """Singleflight creator for list caching (async).""" + if self._cache_manager is None: + return await self._list_from_db( + filters=filters, + auto_expunge=auto_expunge, + statement=statement, + order_by=order_by, + error_messages=error_messages, + load=load, + execution_options=execution_options, + kwargs=kwargs, + uniquify=uniquify, + bind_group=bind_group, + ) + + existing = await self._cache_manager.get_list_async(cache_key, self.model_type) + if existing is not None: + return existing + + instances = await self._list_from_db( + filters=filters, + auto_expunge=auto_expunge, + statement=statement, + order_by=order_by, + error_messages=error_messages, + load=load, + execution_options=execution_options, + kwargs=kwargs, + uniquify=uniquify, + bind_group=bind_group, + ) + await self._cache_manager.set_list_async(cache_key, list(instances)) + return list(instances) + + async def _list_and_count_from_db( + self, + *, + filters: Sequence[Union[StatementFilter, ColumnElement[bool]]], + auto_expunge: Optional[bool], + statement: Optional[Select[tuple[ModelT]]], + count_with_window_function: bool, + order_by: Optional[Union[list[OrderingPair], OrderingPair]], + error_messages: Optional[ErrorMessages], + load: Optional[LoadSpec], + execution_options: Optional[dict[str, Any]], + kwargs: dict[str, Any], + uniquify: Optional[bool], + bind_group: Optional[str] = None, + ) -> tuple[list[ModelT], int]: + """Fetch a list+count payload from the database without using cache.""" + self._uniquify = self._get_uniquify(uniquify) + resolved_bind_group = self._resolve_bind_group(bind_group) + if resolved_bind_group: + execution_options = dict(execution_options) if execution_options else {} + execution_options["bind_group"] = resolved_bind_group + if self._dialect.name in {"spanner", "spanner+spanner"} or not count_with_window_function: + return await self._list_and_count_basic( + *filters, + auto_expunge=auto_expunge, + statement=statement, + load=load, + execution_options=execution_options, + order_by=order_by, + error_messages=error_messages, + **kwargs, + ) + return await self._list_and_count_window( + *filters, + auto_expunge=auto_expunge, + statement=statement, + load=load, + execution_options=execution_options, + error_messages=error_messages, + order_by=order_by, + **kwargs, + ) + + async def _list_and_count_cached_creator( + self, + cache_key: str, + *, + filters: Sequence[Union[StatementFilter, ColumnElement[bool]]], + auto_expunge: Optional[bool], + statement: Optional[Select[tuple[ModelT]]], + count_with_window_function: bool, + order_by: Optional[Union[list[OrderingPair], OrderingPair]], + error_messages: Optional[ErrorMessages], + load: Optional[LoadSpec], + execution_options: Optional[dict[str, Any]], + kwargs: dict[str, Any], + uniquify: Optional[bool], + bind_group: Optional[str] = None, + ) -> tuple[list[ModelT], int]: + """Singleflight creator for list_and_count caching (async).""" + if self._cache_manager is None: + return await self._list_and_count_from_db( + filters=filters, + auto_expunge=auto_expunge, + statement=statement, + count_with_window_function=count_with_window_function, + order_by=order_by, + error_messages=error_messages, + load=load, + execution_options=execution_options, + kwargs=kwargs, + uniquify=uniquify, + bind_group=bind_group, + ) + + existing = await self._cache_manager.get_list_and_count_async(cache_key, self.model_type) + if existing is not None: + return existing + + instances, count = await self._list_and_count_from_db( + filters=filters, + auto_expunge=auto_expunge, + statement=statement, + count_with_window_function=count_with_window_function, + order_by=order_by, + error_messages=error_messages, + load=load, + execution_options=execution_options, + kwargs=kwargs, + uniquify=uniquify, + bind_group=bind_group, + ) + await self._cache_manager.set_list_and_count_async(cache_key, list(instances), count) + return list(instances), count + async def get( self, item_id: Any, @@ -1112,6 +1609,7 @@ async def get( execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, with_for_update: ForUpdateParameter = None, + use_cache: bool = True, bind_group: Optional[str] = None, ) -> ModelT: """Get instance identified by `item_id`. @@ -1128,37 +1626,80 @@ async def get( execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. with_for_update: Optional FOR UPDATE clause / parameters to apply to the SELECT statement. + use_cache: Whether to use caching for this query (default True). bind_group: Optional routing group to use for the operation. Returns: The retrieved instance. """ self._uniquify = self._get_uniquify(uniquify) - error_messages = self._get_error_messages( + resolved_error_messages = self._get_error_messages( error_messages=error_messages, default_messages=self.error_messages, ) - with wrap_sqlalchemy_exception( - error_messages=error_messages, dialect_name=self._dialect.name, wrap_exceptions=self.wrap_exceptions + + resolved_auto_expunge = self.auto_expunge if auto_expunge is None else auto_expunge + resolved_id_attribute: Optional[Union[str, InstrumentedAttribute[Any]]] = id_attribute + if isinstance(resolved_id_attribute, InstrumentedAttribute): + resolved_id_attribute = resolved_id_attribute.key + + cache_manager = self._cache_manager + # Resolve bind_group for cache key namespacing + resolved_bind_group = self._resolve_bind_group(bind_group) + + if ( + use_cache + and cache_manager is not None + and bool(resolved_auto_expunge) + and statement is None + and load is None + and with_for_update is None + and (resolved_id_attribute is None or resolved_id_attribute == self.id_attribute) + and not self._default_loader_options + and not self._default_execution_options + and execution_options is None ): - if bind_group: - execution_options = dict(execution_options) if execution_options else {} - execution_options["bind_group"] = bind_group - execution_options = self._get_execution_options(execution_options) - statement = self.statement if statement is None else statement - loader_options, loader_options_have_wildcard = self._get_loader_options(load) - id_attribute = id_attribute if id_attribute is not None else self.id_attribute - statement = self._get_base_stmt( - statement=statement, - loader_options=loader_options, - execution_options=execution_options, + model_name = cast("str", self.model_type.__tablename__) # type: ignore[attr-defined] + cached = await cache_manager.get_entity_async( + model_name, item_id, self.model_type, bind_group=resolved_bind_group + ) + if cached is not None: + return cached + + # Include bind_group in singleflight key to prevent cross-shard cache pollution + singleflight_key = ( + f"{model_name}:{resolved_bind_group}:get:{item_id}" + if resolved_bind_group + else f"{model_name}:get:{item_id}" + ) + return await cache_manager.singleflight_async( + singleflight_key, + partial( + self._get_cached_creator, + model_name, + item_id, + auto_expunge=auto_expunge, + statement=statement, + id_attribute=resolved_id_attribute, + error_messages=resolved_error_messages, + load=load, + execution_options=execution_options, + with_for_update=with_for_update, + bind_group=resolved_bind_group, + ), ) - statement = self._filter_select_by_kwargs(statement, [(id_attribute, item_id)]) - statement = self._apply_for_update_options(statement, with_for_update) - instance = (await self._execute(statement, uniquify=loader_options_have_wildcard)).scalar_one_or_none() - instance = self.check_not_found(instance) - self._expunge(instance, auto_expunge=auto_expunge) - return instance + + return await self._get_from_db( + item_id, + auto_expunge=auto_expunge, + statement=statement, + id_attribute=id_attribute, + error_messages=resolved_error_messages, + load=load, + execution_options=execution_options, + with_for_update=with_for_update, + bind_group=bind_group, + ) async def get_one( self, @@ -1294,6 +1835,7 @@ async def get_or_upsert( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[ModelT, bool]: """Get instance identified by ``kwargs`` or create if it doesn't exist. @@ -1317,6 +1859,7 @@ async def get_or_upsert( load: Set relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. + bind_group: Optional routing group for multi-master configurations. **kwargs: Identifier of the instance to be retrieved. Returns: @@ -1345,6 +1888,7 @@ async def get_or_upsert( **match_filter, load=load, execution_options=execution_options, + bind_group=bind_group, ) if not existing: return ( @@ -1353,6 +1897,7 @@ async def get_or_upsert( auto_commit=auto_commit, auto_expunge=auto_expunge, auto_refresh=auto_refresh, + bind_group=bind_group, ), True, ) @@ -1385,6 +1930,7 @@ async def get_and_update( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[ModelT, bool]: """Get instance identified by ``kwargs`` and update the model if the arguments are different. @@ -1406,6 +1952,7 @@ async def get_and_update( load: Set relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. + bind_group: Optional routing group for multi-master configurations. **kwargs: Identifier of the instance to be retrieved. Returns: @@ -1429,7 +1976,9 @@ async def get_and_update( } else: match_filter = kwargs - existing = await self.get_one(*filters, **match_filter, load=load, execution_options=execution_options) + existing = await self.get_one( + *filters, **match_filter, load=load, execution_options=execution_options, bind_group=bind_group + ) updated = False for field_name, new_field_value in kwargs.items(): field = getattr(existing, field_name, MISSING) @@ -1517,6 +2066,7 @@ async def update( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, ) -> ModelT: """Update instance with the attribute values present on `data`. @@ -1538,6 +2088,7 @@ async def update( load: Set relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. + bind_group: Optional routing group for multi-master configurations. Returns: The updated instance. @@ -1560,6 +2111,7 @@ async def update( load=load, execution_options=execution_options, with_for_update=with_for_update, + bind_group=bind_group, ) mapper = None with ( @@ -1621,6 +2173,8 @@ async def update( auto_refresh=auto_refresh, ) self._expunge(instance, auto_expunge=auto_expunge) + # Queue cache invalidation (processed on commit) + self._queue_cache_invalidation(self.get_id_attribute_value(instance), bind_group) return instance async def update_many( @@ -1633,6 +2187,7 @@ async def update_many( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, ) -> list[ModelT]: """Update one or more instances with the attribute values present on `data`. @@ -1650,6 +2205,7 @@ async def update_many( load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. + bind_group: Optional routing group for multi-master configurations. Returns: The updated instances. @@ -1673,6 +2229,10 @@ async def update_many( with wrap_sqlalchemy_exception( error_messages=error_messages, dialect_name=self._dialect.name, wrap_exceptions=self.wrap_exceptions ): + resolved_bind_group = self._resolve_bind_group(bind_group) + if resolved_bind_group: + execution_options = dict(execution_options) if execution_options else {} + execution_options["bind_group"] = resolved_bind_group execution_options = self._get_execution_options(execution_options) loader_options = self._get_loader_options(load)[0] supports_returning = self._dialect.update_executemany_returning and self._dialect.name != "oracle" @@ -1705,9 +2265,12 @@ async def update_many( getattr(self.model_type, self.id_attribute).in_(updated_ids), load=loader_options, execution_options=execution_options, + bind_group=bind_group, ) for instance in updated_instances: self._expunge(instance, auto_expunge=auto_expunge) + # Queue cache invalidation (processed on commit) + self._queue_cache_invalidation(self.get_id_attribute_value(instance), bind_group) return updated_instances def _get_update_many_statement( @@ -1737,6 +2300,7 @@ async def list_and_count( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + use_cache: bool = True, bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[list[ModelT], int]: @@ -1753,6 +2317,7 @@ async def list_and_count( load: Set relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. + use_cache: Whether to use the cache for this query. Defaults to ``True``. bind_group: Optional routing group to use for the operation. **kwargs: Instance attribute value filters. @@ -1763,32 +2328,87 @@ async def list_and_count( count_with_window_function if count_with_window_function is not None else self.count_with_window_function ) self._uniquify = self._get_uniquify(uniquify) - error_messages = self._get_error_messages( + resolved_error_messages = self._get_error_messages( error_messages=error_messages, default_messages=self.error_messages, ) - if self._dialect.name in {"spanner", "spanner+spanner"} or not count_with_window_function: - return await self._list_and_count_basic( - *filters, + + resolved_auto_expunge = self.auto_expunge if auto_expunge is None else auto_expunge + resolved_execution_options = self._get_execution_options(execution_options) + resolved_order_by = order_by if order_by is not None else (self.order_by if self.order_by is not None else []) + + cache_manager = self._cache_manager + if not ( + use_cache + and bool(resolved_auto_expunge) + and cache_manager is not None + and statement is None + and load is None + and not self._default_loader_options + ): + return await self._list_and_count_from_db( + filters=filters, auto_expunge=auto_expunge, statement=statement, + count_with_window_function=count_with_window_function, + order_by=order_by, + error_messages=resolved_error_messages, load=load, execution_options=execution_options, + kwargs=kwargs, + uniquify=uniquify, + bind_group=bind_group, + ) + + model_name = cast("str", self.model_type.__tablename__) # type: ignore[attr-defined] + version_token = await cache_manager.get_model_version_async(model_name) + cache_key = _build_list_cache_key( + model_name=model_name, + version_token=version_token, + method="list_and_count", + filters=filters, + kwargs=kwargs, + order_by=resolved_order_by, + execution_options=resolved_execution_options, + uniquify=self._uniquify, + count_with_window_function=count_with_window_function, + ) + if cache_key is None: + return await self._list_and_count_from_db( + filters=filters, + auto_expunge=auto_expunge, + statement=statement, + count_with_window_function=count_with_window_function, order_by=order_by, - error_messages=error_messages, + error_messages=resolved_error_messages, + load=load, + execution_options=execution_options, + kwargs=kwargs, + uniquify=uniquify, bind_group=bind_group, - **kwargs, ) - return await self._list_and_count_window( - *filters, - auto_expunge=auto_expunge, - statement=statement, - load=load, - execution_options=execution_options, - error_messages=error_messages, - order_by=order_by, - bind_group=bind_group, - **kwargs, + + cached = await cache_manager.get_list_and_count_async(cache_key, self.model_type) + if cached is not None: + return cached + + return await cache_manager.singleflight_async( + cache_key, + partial( + self._list_and_count_cached_creator, + cache_key, + filters=filters, + auto_expunge=auto_expunge, + statement=statement, + count_with_window_function=count_with_window_function, + order_by=order_by, + error_messages=resolved_error_messages, + load=load, + execution_options=execution_options, + kwargs=kwargs, + uniquify=uniquify, + bind_group=bind_group, + ), ) def _expunge(self, instance: "ModelT", auto_expunge: "Optional[bool]") -> None: @@ -2009,6 +2629,7 @@ async def upsert( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, ) -> ModelT: """Modify or create instance. @@ -2033,6 +2654,7 @@ async def upsert( load: Set relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. + bind_group: Optional routing group for multi-master configurations. Returns: The updated or created instance. @@ -2052,13 +2674,16 @@ async def upsert( match_filter = {self.id_attribute: getattr(data, self.id_attribute, None)} else: match_filter = data.to_dict(exclude={self.id_attribute}) - existing = await self.get_one_or_none(load=load, execution_options=execution_options, **match_filter) + existing = await self.get_one_or_none( + load=load, execution_options=execution_options, bind_group=bind_group, **match_filter + ) if not existing: return await self.add( data, auto_commit=auto_commit, auto_expunge=auto_expunge, auto_refresh=auto_refresh, + bind_group=bind_group, ) with wrap_sqlalchemy_exception( error_messages=error_messages, dialect_name=self._dialect.name, wrap_exceptions=self.wrap_exceptions @@ -2076,6 +2701,8 @@ async def upsert( auto_refresh=auto_refresh, ) self._expunge(instance, auto_expunge=auto_expunge) + # Queue cache invalidation (processed on commit) + self._queue_cache_invalidation(self.get_id_attribute_value(instance), bind_group) return instance async def upsert_many( @@ -2090,6 +2717,7 @@ async def upsert_many( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, ) -> list[ModelT]: """Modify or create multiple instances. @@ -2113,6 +2741,7 @@ async def upsert_many( load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. + bind_group: Optional routing group to use for the operation. Returns: The updated or created instance. @@ -2147,6 +2776,7 @@ async def upsert_many( load=load, execution_options=execution_options, auto_expunge=False, + bind_group=bind_group, ) for field_name in match_fields: field = get_instrumented_attr(self.model_type, field_name) @@ -2165,7 +2795,7 @@ async def upsert_many( data_to_insert.append(datum) if data_to_insert: instances.extend( - await self.add_many(data_to_insert, auto_commit=False, auto_expunge=False), + await self.add_many(data_to_insert, auto_commit=False, auto_expunge=False, bind_group=bind_group), ) if data_to_update: instances.extend( @@ -2175,6 +2805,7 @@ async def upsert_many( auto_expunge=False, load=load, execution_options=execution_options, + bind_group=bind_group, ), ) await self._flush_or_commit(auto_commit=auto_commit) @@ -2224,6 +2855,7 @@ async def list( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + use_cache: bool = True, bind_group: Optional[str] = None, **kwargs: Any, ) -> list[ModelT]: @@ -2239,6 +2871,7 @@ async def list( load: Set relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. + use_cache: Whether to use the cache for this query. Defaults to ``True``. bind_group: Optional routing group to use for the operation. **kwargs: Instance attribute value filters. @@ -2246,34 +2879,84 @@ async def list( The list of instances, after filtering applied. """ self._uniquify = self._get_uniquify(uniquify) - error_messages = self._get_error_messages( + resolved_error_messages = self._get_error_messages( error_messages=error_messages, default_messages=self.error_messages, ) - with wrap_sqlalchemy_exception( - error_messages=error_messages, dialect_name=self._dialect.name, wrap_exceptions=self.wrap_exceptions + + resolved_auto_expunge = self.auto_expunge if auto_expunge is None else auto_expunge + resolved_execution_options = self._get_execution_options(execution_options) + resolved_order_by = order_by if order_by is not None else (self.order_by if self.order_by is not None else []) + + cache_manager = self._cache_manager + if not ( + use_cache + and bool(resolved_auto_expunge) + and cache_manager is not None + and statement is None + and load is None + and not self._default_loader_options ): - if bind_group: - execution_options = dict(execution_options) if execution_options else {} - execution_options["bind_group"] = bind_group - execution_options = self._get_execution_options(execution_options) - statement = self.statement if statement is None else statement - loader_options, loader_options_have_wildcard = self._get_loader_options(load) - statement = self._get_base_stmt( + return await self._list_from_db( + filters=filters, + auto_expunge=auto_expunge, statement=statement, - loader_options=loader_options, + order_by=order_by, + error_messages=resolved_error_messages, + load=load, execution_options=execution_options, + kwargs=kwargs, + uniquify=uniquify, + bind_group=bind_group, ) - if order_by is None: - order_by = self.order_by if self.order_by is not None else [] - statement = self._apply_order_by(statement=statement, order_by=order_by) - statement = self._apply_filters(*filters, statement=statement) - statement = self._filter_select_by_kwargs(statement, kwargs) - result = await self._execute(statement, uniquify=loader_options_have_wildcard) - instances = list(result.scalars()) - for instance in instances: - self._expunge(instance, auto_expunge=auto_expunge) - return cast("list[ModelT]", instances) + + model_name = cast("str", self.model_type.__tablename__) # type: ignore[attr-defined] + version_token = await cache_manager.get_model_version_async(model_name) + cache_key = _build_list_cache_key( + model_name=model_name, + version_token=version_token, + method="list", + filters=filters, + kwargs=kwargs, + order_by=resolved_order_by, + execution_options=resolved_execution_options, + uniquify=self._uniquify, + ) + if cache_key is None: + return await self._list_from_db( + filters=filters, + auto_expunge=auto_expunge, + statement=statement, + order_by=order_by, + error_messages=resolved_error_messages, + load=load, + execution_options=execution_options, + kwargs=kwargs, + uniquify=uniquify, + bind_group=bind_group, + ) + + cached = await cache_manager.get_list_async(cache_key, self.model_type) + if cached is not None: + return cached + + return await cache_manager.singleflight_async( + cache_key, + partial( + self._list_cached_creator, + cache_key, + filters=filters, + auto_expunge=auto_expunge, + statement=statement, + order_by=order_by, + error_messages=resolved_error_messages, + load=load, + execution_options=execution_options, + kwargs=kwargs, + uniquify=uniquify, + bind_group=bind_group, + ), + ) @classmethod async def check_health(cls, session: Union[AsyncSession, async_scoped_session[AsyncSession]]) -> bool: diff --git a/advanced_alchemy/repository/_sync.py b/advanced_alchemy/repository/_sync.py index d433d39a8..0d9b6351e 100644 --- a/advanced_alchemy/repository/_sync.py +++ b/advanced_alchemy/repository/_sync.py @@ -1,11 +1,14 @@ # Do not edit this file directly. It has been autogenerated from # advanced_alchemy/repository/_async.py import contextlib +import dataclasses import datetime import decimal +import hashlib import random import string -from collections.abc import Iterable, Sequence +from collections.abc import Iterable, Mapping, Sequence +from functools import partial from typing import ( TYPE_CHECKING, Any, @@ -40,8 +43,10 @@ from sqlalchemy.orm.strategy_options import _AbstractLoad # pyright: ignore[reportPrivateUsage] from sqlalchemy.sql import ColumnElement from sqlalchemy.sql.dml import ReturningDelete, ReturningUpdate +from sqlalchemy.sql.elements import UnaryExpression from sqlalchemy.sql.selectable import ForUpdateArg, ForUpdateParameter +from advanced_alchemy._serialization import encode_json from advanced_alchemy.exceptions import ErrorMessages, NotFoundError, RepositoryError, wrap_sqlalchemy_exception from advanced_alchemy.filters import StatementFilter, StatementTypeT from advanced_alchemy.repository._util import ( @@ -63,6 +68,8 @@ if TYPE_CHECKING: from sqlalchemy.engine.interfaces import _CoreSingleExecuteParams # pyright: ignore[reportPrivateUsage] + from advanced_alchemy.cache.manager import CacheManager + DEFAULT_INSERTMANYVALUES_MAX_PARAMETERS: Final = 950 POSTGRES_VERSION_SUPPORTING_MERGE: Final = 15 DEFAULT_SAFE_TYPES: Final[set[type[Any]]] = { @@ -79,6 +86,128 @@ } +def _sort_kv_by_key_str(kv: tuple[object, object]) -> str: + return str(kv[0]) + + +def _sort_normalized_value(value: Any) -> str: + return encode_json(_canonicalize_cache_key_value(value)) + + +def _normalize_cache_key_value(value: Any) -> Any: + """Normalize values into a deterministic JSON-serializable form. + + Used for list/list_and_count cache keys. + """ + if value is None or isinstance(value, (int, float, str, bool)): + return value + + result: Any + if isinstance(value, bytes): + result = {"__bytes__": value.hex()} + elif isinstance(value, (datetime.datetime, datetime.date, datetime.time)): + result = {"__datetime__": value.isoformat()} + elif isinstance(value, datetime.timedelta): + result = {"__timedelta__": value.total_seconds()} + elif isinstance(value, decimal.Decimal): + result = {"__decimal__": str(value)} + elif isinstance(value, set): + value_set = cast("set[Any]", value) # type: ignore[redundant-cast] + normalized = [_normalize_cache_key_value(v) for v in value_set] + normalized.sort(key=_sort_normalized_value) + result = normalized + elif isinstance(value, (list, tuple)): + value_seq = cast("Sequence[Any]", value) + result = [_normalize_cache_key_value(v) for v in value_seq] + elif isinstance(value, dict): + value_dict = cast("dict[object, object]", value) + normalized_dict: dict[str, Any] = {} + for k, v in sorted(value_dict.items(), key=_sort_kv_by_key_str): + normalized_dict[str(k)] = _normalize_cache_key_value(v) + result = normalized_dict + elif dataclasses.is_dataclass(value) and not isinstance(value, type): + result = _normalize_cache_key_value(dataclasses.asdict(value)) + elif isinstance(value, InstrumentedAttribute): + result = {"__attr__": value.key} + elif isinstance(value, ColumnElement): + # Safe fallback for non-dataclass expressions in ordering/kwargs. + value_expr = cast("ColumnElement[Any]", value) # type: ignore[redundant-cast] + result = {"__sql__": str(value_expr)} + else: + result = {"__repr__": repr(value)} + + return result + + +def _canonicalize_cache_key_value(value: Any) -> Any: + """Convert dict-like objects into a stable, ordered representation.""" + if isinstance(value, Mapping): + value_map = cast("Mapping[object, object]", value) + return [ + [str(k), _canonicalize_cache_key_value(v)] for k, v in sorted(value_map.items(), key=_sort_kv_by_key_str) + ] + if isinstance(value, list): + value_list = cast("list[Any]", value) # type: ignore[redundant-cast] + return [_canonicalize_cache_key_value(v) for v in value_list] + return value + + +def _build_list_cache_key( + *, + model_name: str, + version_token: str, + method: str, + filters: Sequence[Union[StatementFilter, ColumnElement[bool]]], + kwargs: dict[str, Any], + order_by: Optional[Union[list[OrderingPair], OrderingPair]], + execution_options: dict[str, Any], + uniquify: bool, + count_with_window_function: Optional[bool] = None, +) -> Optional[str]: + """Build a stable cache key for list/list_and_count operations. + + Returns None if the query specification includes non-cacheable filter + expressions (e.g., raw SQLAlchemy boolean expressions). + """ + normalized_filters: list[dict[str, Any]] = [] + for filter_ in filters: + if isinstance(filter_, ColumnElement): + return None + normalized_filters.append({"type": filter_.__class__.__name__, "data": _normalize_cache_key_value(filter_)}) + + normalized_order_by: Optional[list[Any]] = None + if order_by is not None: + order_items = order_by if isinstance(order_by, list) else [order_by] + normalized_order_by = [] + for item in order_items: + if isinstance(item, UnaryExpression): + normalized_order_by.append({"expr": str(item)}) + else: + col, desc = item + normalized_order_by.append({"col": _normalize_cache_key_value(col), "desc": bool(desc)}) + + payload: dict[str, Any] = { + "method": method, + "model": model_name, + "version": version_token, + "filters": normalized_filters, + "kwargs": _normalize_cache_key_value(kwargs), + "order_by": normalized_order_by, + "execution_options": _normalize_cache_key_value(execution_options), + "uniquify": uniquify, + } + if count_with_window_function is not None: + payload["count_with_window_function"] = bool(count_with_window_function) + + try: + encoded = encode_json(_canonicalize_cache_key_value(payload)).encode("utf-8") + except TypeError: # pragma: no cover + return None + + digest = hashlib.sha256(encoded).hexdigest() + return f"{model_name}:{method}:{digest}" + + @runtime_checkable class SQLAlchemySyncRepositoryProtocol(FilterableRepositoryProtocol[ModelT], Protocol[ModelT]): """Base Protocol""" @@ -136,6 +265,7 @@ def add( auto_expunge: Optional[bool] = None, auto_refresh: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, + bind_group: Optional[str] = None, ) -> ModelT: ... def add_many( @@ -145,6 +275,7 @@ def add_many( auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, + bind_group: Optional[str] = None, ) -> Sequence[ModelT]: ... def delete( @@ -157,6 +288,7 @@ def delete( error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, + bind_group: Optional[str] = None, ) -> ModelT: ... def delete_many( @@ -170,6 +302,7 @@ def delete_many( error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, + bind_group: Optional[str] = None, ) -> Sequence[ModelT]: ... def delete_where( @@ -181,6 +314,7 @@ def delete_where( error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, execution_options: Optional[dict[str, Any]] = None, sanity_check: bool = True, + bind_group: Optional[str] = None, **kwargs: Any, ) -> Sequence[ModelT]: ... @@ -247,6 +381,7 @@ def get_or_upsert( error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, + bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[ModelT, bool]: ... @@ -262,6 +397,7 @@ def get_and_update( error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, + bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[ModelT, bool]: ... @@ -289,6 +425,7 @@ def update( error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, + bind_group: Optional[str] = None, ) -> ModelT: ... def update_many( @@ -300,6 +437,7 @@ def update_many( error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, + bind_group: Optional[str] = None, ) -> list[ModelT]: ... def _get_update_many_statement( @@ -323,6 +461,7 @@ def upsert( error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, + bind_group: Optional[str] = None, ) -> ModelT: ... def upsert_many( @@ -336,6 +475,7 @@ def upsert_many( error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, + bind_group: Optional[str] = None, ) -> list[ModelT]: ... def list_and_count( @@ -348,6 +488,7 @@ def list_and_count( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, order_by: Optional[Union[list[OrderingPair], OrderingPair]] = None, + use_cache: bool = True, bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[list[ModelT], int]: ... @@ -361,6 +502,7 @@ def list( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, order_by: Optional[Union[list[OrderingPair], OrderingPair]] = None, + use_cache: bool = True, bind_group: Optional[str] = None, **kwargs: Any, ) -> list[ModelT]: ... @@ -460,6 +602,10 @@ class SQLAlchemySyncRepository(SQLAlchemySyncRepositoryProtocol[ModelT], Filtera count_with_window_function: bool = True """Use an analytical window function to count results. This allows the count to be performed in a single query. """ + _cache_manager: Optional["CacheManager"] = None + """Cache manager instance for repository-level caching. Set via ``cache_manager`` kwarg or retrieved from ``session.info``.""" + _bind_group: Optional[str] = None + """Default bind group for routing operations (e.g., to read replicas). Can be overridden per-method.""" def __init__( self, @@ -476,6 +622,8 @@ def __init__( wrap_exceptions: bool = True, uniquify: Optional[bool] = None, count_with_window_function: Optional[bool] = None, + cache_manager: Optional["CacheManager"] = None, + bind_group: Optional[str] = None, **kwargs: Any, ) -> None: """Repository for SQLAlchemy models. @@ -493,6 +641,8 @@ def __init__( wrap_exceptions: Wrap SQLAlchemy exceptions in a ``RepositoryError``. When set to ``False``, the original exception will be raised. uniquify: Optionally apply the ``unique()`` method to results before returning. count_with_window_function: When false, list and count will use two queries instead of an analytical window function. + cache_manager: Optional cache manager for repository-level caching. If not provided, retrieved from ``session.info``. + bind_group: Optional default routing group to use for all operations. Can be overridden per-method. **kwargs: Additional arguments. """ @@ -519,6 +669,10 @@ def __init__( self.statement = select(self.model_type) if statement is None else statement self._dialect = self.session.bind.dialect if self.session.bind is not None else self.session.get_bind().dialect self._prefer_any = any(self._dialect.name == engine_type for engine_type in self.prefer_any_dialects or ()) + # Cache manager: from explicit param or session.info (set by SQLAlchemyAsyncConfig) + self._cache_manager = cache_manager if cache_manager is not None else session.info.get("cache_manager") + # Default bind group for all operations (can be overridden per-method) + self._bind_group = bind_group def _get_uniquify(self, uniquify: Optional[bool] = None) -> bool: """Get the uniquify value, preferring the method parameter over instance setting. @@ -531,6 +685,43 @@ def _get_uniquify(self, uniquify: Optional[bool] = None) -> bool: """ return bool(uniquify) if uniquify is not None else self._uniquify + def _resolve_bind_group(self, bind_group: Optional[str] = None) -> Optional[str]: + """Resolve the bind_group to use, preferring method parameter over instance default. + + Args: + bind_group: Optional override for the bind_group setting. + + Returns: + The bind_group to use, or None if not set. + """ + return bind_group if bind_group is not None else self._bind_group + + def _queue_cache_invalidation(self, entity_id: Any, bind_group: Optional[str] = None) -> None: + """Queue a cache invalidation for an entity. + + The invalidation will be processed after the transaction commits. + If the transaction rolls back, the pending invalidation is discarded. + + This uses the global CacheInvalidationListener which must be set up + via setup_cache_listeners() during application initialization. + + Args: + entity_id: The primary key value of the entity to invalidate. + bind_group: Optional routing group for multi-master configurations. + When provided, only the cache entry for that bind_group is + invalidated. + """ + if self._cache_manager is not None: + from advanced_alchemy._listeners import get_cache_tracker + + # Check if model_type has __tablename__ (may not exist in mock scenarios) + model_name = getattr(self.model_type, "__tablename__", None) + if model_name is None: + return + tracker = get_cache_tracker(self.session, self._cache_manager) + if tracker is not None: + tracker.add_invalidation(cast("str", model_name), entity_id, bind_group) + def _type_must_use_in_instead_of_any(self, matched_values: "list[Any]", field_type: "Any" = None) -> bool: """Determine if field.in_() should be used instead of any_() for compatibility. @@ -698,6 +889,7 @@ def add( auto_expunge: Optional[bool] = None, auto_refresh: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, + bind_group: Optional[str] = None, ) -> ModelT: """Add ``data`` to the collection. @@ -708,10 +900,12 @@ def add( auto_commit: Commit objects before returning. error_messages: An optional dictionary of templates to use for friendlier error messages to clients + bind_group: Optional routing group for multi-master configurations. Returns: The added instance. """ + _ = bind_group # Reserved for future multi-master routing error_messages = self._get_error_messages( error_messages=error_messages, default_messages=self.error_messages, @@ -732,6 +926,7 @@ def add_many( auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, + bind_group: Optional[str] = None, ) -> Sequence[ModelT]: """Add many `data` to the collection. @@ -741,10 +936,12 @@ def add_many( auto_commit: Commit objects before returning. error_messages: An optional dictionary of templates to use for friendlier error messages to clients + bind_group: Optional routing group for multi-master configurations. Returns: The added instances. """ + _ = bind_group # Reserved for future multi-master routing error_messages = self._get_error_messages( error_messages=error_messages, default_messages=self.error_messages, @@ -769,6 +966,7 @@ def delete( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, ) -> ModelT: """Delete instance identified by ``item_id``. @@ -783,6 +981,7 @@ def delete( load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. + bind_group: Optional routing group for multi-master configurations. Returns: The deleted instance. @@ -796,16 +995,23 @@ def delete( with wrap_sqlalchemy_exception( error_messages=error_messages, dialect_name=self._dialect.name, wrap_exceptions=self.wrap_exceptions ): + resolved_bind_group = self._resolve_bind_group(bind_group) + if resolved_bind_group: + execution_options = dict(execution_options) if execution_options else {} + execution_options["bind_group"] = resolved_bind_group execution_options = self._get_execution_options(execution_options) instance = self.get( item_id, id_attribute=id_attribute, load=load, execution_options=execution_options, + bind_group=bind_group, ) self.session.delete(instance) self._flush_or_commit(auto_commit=auto_commit) self._expunge(instance, auto_expunge=auto_expunge) + # Queue cache invalidation (processed on commit) + self._queue_cache_invalidation(item_id, bind_group) return instance def delete_many( @@ -820,6 +1026,7 @@ def delete_many( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, ) -> Sequence[ModelT]: """Delete instance identified by `item_id`. @@ -836,6 +1043,7 @@ def delete_many( load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. + bind_group: Optional routing group for multi-master configurations. Returns: The deleted instances. @@ -849,6 +1057,10 @@ def delete_many( with wrap_sqlalchemy_exception( error_messages=error_messages, dialect_name=self._dialect.name, wrap_exceptions=self.wrap_exceptions ): + resolved_bind_group = self._resolve_bind_group(bind_group) + if resolved_bind_group: + execution_options = dict(execution_options) if execution_options else {} + execution_options["bind_group"] = resolved_bind_group execution_options = self._get_execution_options(execution_options) loader_options, _loader_options_have_wildcard = self._get_loader_options(load) id_attribute = get_instrumented_attr( @@ -903,6 +1115,8 @@ def delete_many( self._flush_or_commit(auto_commit=auto_commit) for instance in instances: self._expunge(instance, auto_expunge=auto_expunge) + # Queue cache invalidation (processed on commit) + self._queue_cache_invalidation(self.get_id_attribute_value(instance), bind_group) return instances @staticmethod @@ -919,6 +1133,7 @@ def delete_where( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, **kwargs: Any, ) -> Sequence[ModelT]: """Delete instances specified by referenced kwargs and filters. @@ -933,6 +1148,7 @@ def delete_where( load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. + bind_group: Optional routing group for multi-master configurations. **kwargs: Arguments to apply to a delete Raises: @@ -950,6 +1166,10 @@ def delete_where( with wrap_sqlalchemy_exception( error_messages=error_messages, dialect_name=self._dialect.name, wrap_exceptions=self.wrap_exceptions ): + resolved_bind_group = self._resolve_bind_group(bind_group) + if resolved_bind_group: + execution_options = dict(execution_options) if execution_options else {} + execution_options["bind_group"] = resolved_bind_group execution_options = self._get_execution_options(execution_options) loader_options, _loader_options_have_wildcard = self._get_loader_options(load) model_type = self.model_type @@ -970,6 +1190,8 @@ def delete_where( load=load, execution_options=execution_options, auto_expunge=auto_expunge, + use_cache=False, # Always fetch from DB for delete_where + bind_group=bind_group, **kwargs, ), ) @@ -984,6 +1206,8 @@ def delete_where( self._flush_or_commit(auto_commit=auto_commit) for instance in instances: self._expunge(instance, auto_expunge=auto_expunge) + # Queue cache invalidation (processed on commit) + self._queue_cache_invalidation(self.get_id_attribute_value(instance), resolved_bind_group) return instances def exists( @@ -1101,6 +1325,275 @@ def _get_delete_many_statement( return statement.where(id_attribute.in_(id_chunk)) # pyright: ignore[reportUnknownMemberType,reportUnknownVariableType] return statement.where(any_(id_chunk) == id_attribute) # type: ignore[arg-type] + def _get_from_db( + self, + item_id: Any, + *, + auto_expunge: Optional[bool], + statement: Optional[Select[tuple[ModelT]]], + id_attribute: Optional[Union[str, InstrumentedAttribute[Any]]], + error_messages: Optional[ErrorMessages], + load: Optional[LoadSpec], + execution_options: Optional[dict[str, Any]], + with_for_update: ForUpdateParameter, + bind_group: Optional[str] = None, + ) -> ModelT: + """Fetch an entity from the database without using cache.""" + with wrap_sqlalchemy_exception( + error_messages=error_messages, dialect_name=self._dialect.name, wrap_exceptions=self.wrap_exceptions + ): + resolved_bind_group = self._resolve_bind_group(bind_group) + if resolved_bind_group: + execution_options = dict(execution_options) if execution_options else {} + execution_options["bind_group"] = resolved_bind_group + resolved_execution_options = self._get_execution_options(execution_options) + resolved_statement = self.statement if statement is None else statement + loader_options, loader_options_have_wildcard = self._get_loader_options(load) + resolved_id_attribute = id_attribute if id_attribute is not None else self.id_attribute + resolved_statement = self._get_base_stmt( + statement=resolved_statement, + loader_options=loader_options, + execution_options=resolved_execution_options, + ) + resolved_statement = self._filter_select_by_kwargs(resolved_statement, [(resolved_id_attribute, item_id)]) + resolved_statement = self._apply_for_update_options(resolved_statement, with_for_update) + instance = (self._execute(resolved_statement, uniquify=loader_options_have_wildcard)).scalar_one_or_none() + instance = self.check_not_found(instance) + self._expunge(instance, auto_expunge=auto_expunge) + return instance + + def _get_cached_creator( + self, + model_name: str, + item_id: Any, + *, + auto_expunge: Optional[bool], + statement: Optional[Select[tuple[ModelT]]], + id_attribute: Optional[Union[str, InstrumentedAttribute[Any]]], + error_messages: Optional[ErrorMessages], + load: Optional[LoadSpec], + execution_options: Optional[dict[str, Any]], + with_for_update: ForUpdateParameter, + bind_group: Optional[str] = None, + ) -> ModelT: + """Singleflight creator for get(id) caching (async).""" + if self._cache_manager is None: + return self._get_from_db( + item_id, + auto_expunge=auto_expunge, + statement=statement, + id_attribute=id_attribute, + error_messages=error_messages, + load=load, + execution_options=execution_options, + with_for_update=with_for_update, + bind_group=bind_group, + ) + + existing = self._cache_manager.get_entity_sync(model_name, item_id, self.model_type, bind_group=bind_group) + if existing is not None: + return existing + + instance = self._get_from_db( + item_id, + auto_expunge=auto_expunge, + statement=statement, + id_attribute=id_attribute, + error_messages=error_messages, + load=load, + execution_options=execution_options, + with_for_update=with_for_update, + bind_group=bind_group, + ) + self._cache_manager.set_entity_sync(model_name, item_id, instance, bind_group=bind_group) + return instance + + def _list_from_db( + self, + *, + filters: Sequence[Union[StatementFilter, ColumnElement[bool]]], + auto_expunge: Optional[bool], + statement: Optional[Select[tuple[ModelT]]], + order_by: Optional[Union[list[OrderingPair], OrderingPair]], + error_messages: Optional[ErrorMessages], + load: Optional[LoadSpec], + execution_options: Optional[dict[str, Any]], + kwargs: dict[str, Any], + uniquify: Optional[bool], + bind_group: Optional[str] = None, + ) -> list[ModelT]: + """Fetch a list of entities from the database without using cache.""" + self._uniquify = self._get_uniquify(uniquify) + with wrap_sqlalchemy_exception( + error_messages=error_messages, dialect_name=self._dialect.name, wrap_exceptions=self.wrap_exceptions + ): + resolved_bind_group = self._resolve_bind_group(bind_group) + if resolved_bind_group: + execution_options = dict(execution_options) if execution_options else {} + execution_options["bind_group"] = resolved_bind_group + resolved_execution_options = self._get_execution_options(execution_options) + resolved_statement = self.statement if statement is None else statement + loader_options, loader_options_have_wildcard = self._get_loader_options(load) + resolved_statement = self._get_base_stmt( + statement=resolved_statement, + loader_options=loader_options, + execution_options=resolved_execution_options, + ) + if order_by is None: + order_by = self.order_by if self.order_by is not None else [] + resolved_statement = self._apply_order_by(statement=resolved_statement, order_by=order_by) + resolved_statement = self._apply_filters(*filters, statement=resolved_statement) + resolved_statement = self._filter_select_by_kwargs(resolved_statement, kwargs) + result = self._execute(resolved_statement, uniquify=loader_options_have_wildcard) + instances = list(result.scalars()) + for instance in instances: + self._expunge(instance, auto_expunge=auto_expunge) + return cast("list[ModelT]", instances) + + def _list_cached_creator( + self, + cache_key: str, + *, + filters: Sequence[Union[StatementFilter, ColumnElement[bool]]], + auto_expunge: Optional[bool], + statement: Optional[Select[tuple[ModelT]]], + order_by: Optional[Union[list[OrderingPair], OrderingPair]], + error_messages: Optional[ErrorMessages], + load: Optional[LoadSpec], + execution_options: Optional[dict[str, Any]], + kwargs: dict[str, Any], + uniquify: Optional[bool], + bind_group: Optional[str] = None, + ) -> list[ModelT]: + """Singleflight creator for list caching (async).""" + if self._cache_manager is None: + return self._list_from_db( + filters=filters, + auto_expunge=auto_expunge, + statement=statement, + order_by=order_by, + error_messages=error_messages, + load=load, + execution_options=execution_options, + kwargs=kwargs, + uniquify=uniquify, + bind_group=bind_group, + ) + + existing = self._cache_manager.get_list_sync(cache_key, self.model_type) + if existing is not None: + return existing + + instances = self._list_from_db( + filters=filters, + auto_expunge=auto_expunge, + statement=statement, + order_by=order_by, + error_messages=error_messages, + load=load, + execution_options=execution_options, + kwargs=kwargs, + uniquify=uniquify, + bind_group=bind_group, + ) + self._cache_manager.set_list_sync(cache_key, list(instances)) + return list(instances) + + def _list_and_count_from_db( + self, + *, + filters: Sequence[Union[StatementFilter, ColumnElement[bool]]], + auto_expunge: Optional[bool], + statement: Optional[Select[tuple[ModelT]]], + count_with_window_function: bool, + order_by: Optional[Union[list[OrderingPair], OrderingPair]], + error_messages: Optional[ErrorMessages], + load: Optional[LoadSpec], + execution_options: Optional[dict[str, Any]], + kwargs: dict[str, Any], + uniquify: Optional[bool], + bind_group: Optional[str] = None, + ) -> tuple[list[ModelT], int]: + """Fetch a list+count payload from the database without using cache.""" + self._uniquify = self._get_uniquify(uniquify) + resolved_bind_group = self._resolve_bind_group(bind_group) + if resolved_bind_group: + execution_options = dict(execution_options) if execution_options else {} + execution_options["bind_group"] = resolved_bind_group + if self._dialect.name in {"spanner", "spanner+spanner"} or not count_with_window_function: + return self._list_and_count_basic( + *filters, + auto_expunge=auto_expunge, + statement=statement, + load=load, + execution_options=execution_options, + order_by=order_by, + error_messages=error_messages, + **kwargs, + ) + return self._list_and_count_window( + *filters, + auto_expunge=auto_expunge, + statement=statement, + load=load, + execution_options=execution_options, + error_messages=error_messages, + order_by=order_by, + **kwargs, + ) + + def _list_and_count_cached_creator( + self, + cache_key: str, + *, + filters: Sequence[Union[StatementFilter, ColumnElement[bool]]], + auto_expunge: Optional[bool], + statement: Optional[Select[tuple[ModelT]]], + count_with_window_function: bool, + order_by: Optional[Union[list[OrderingPair], OrderingPair]], + error_messages: Optional[ErrorMessages], + load: Optional[LoadSpec], + execution_options: Optional[dict[str, Any]], + kwargs: dict[str, Any], + uniquify: Optional[bool], + bind_group: Optional[str] = None, + ) -> tuple[list[ModelT], int]: + """Singleflight creator for list_and_count caching (async).""" + if self._cache_manager is None: + return self._list_and_count_from_db( + filters=filters, + auto_expunge=auto_expunge, + statement=statement, + count_with_window_function=count_with_window_function, + order_by=order_by, + error_messages=error_messages, + load=load, + execution_options=execution_options, + kwargs=kwargs, + uniquify=uniquify, + bind_group=bind_group, + ) + + existing = self._cache_manager.get_list_and_count_sync(cache_key, self.model_type) + if existing is not None: + return existing + + instances, count = self._list_and_count_from_db( + filters=filters, + auto_expunge=auto_expunge, + statement=statement, + count_with_window_function=count_with_window_function, + order_by=order_by, + error_messages=error_messages, + load=load, + execution_options=execution_options, + kwargs=kwargs, + uniquify=uniquify, + bind_group=bind_group, + ) + self._cache_manager.set_list_and_count_sync(cache_key, list(instances), count) + return list(instances), count + def get( self, item_id: Any, @@ -1113,6 +1606,7 @@ def get( execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, with_for_update: ForUpdateParameter = None, + use_cache: bool = True, bind_group: Optional[str] = None, ) -> ModelT: """Get instance identified by `item_id`. @@ -1129,37 +1623,78 @@ def get( execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. with_for_update: Optional FOR UPDATE clause / parameters to apply to the SELECT statement. + use_cache: Whether to use caching for this query (default True). bind_group: Optional routing group to use for the operation. Returns: The retrieved instance. """ self._uniquify = self._get_uniquify(uniquify) - error_messages = self._get_error_messages( + resolved_error_messages = self._get_error_messages( error_messages=error_messages, default_messages=self.error_messages, ) - with wrap_sqlalchemy_exception( - error_messages=error_messages, dialect_name=self._dialect.name, wrap_exceptions=self.wrap_exceptions + + resolved_auto_expunge = self.auto_expunge if auto_expunge is None else auto_expunge + resolved_id_attribute: Optional[Union[str, InstrumentedAttribute[Any]]] = id_attribute + if isinstance(resolved_id_attribute, InstrumentedAttribute): + resolved_id_attribute = resolved_id_attribute.key + + cache_manager = self._cache_manager + # Resolve bind_group for cache key namespacing + resolved_bind_group = self._resolve_bind_group(bind_group) + + if ( + use_cache + and cache_manager is not None + and bool(resolved_auto_expunge) + and statement is None + and load is None + and with_for_update is None + and (resolved_id_attribute is None or resolved_id_attribute == self.id_attribute) + and not self._default_loader_options + and not self._default_execution_options + and execution_options is None ): - if bind_group: - execution_options = dict(execution_options) if execution_options else {} - execution_options["bind_group"] = bind_group - execution_options = self._get_execution_options(execution_options) - statement = self.statement if statement is None else statement - loader_options, loader_options_have_wildcard = self._get_loader_options(load) - id_attribute = id_attribute if id_attribute is not None else self.id_attribute - statement = self._get_base_stmt( - statement=statement, - loader_options=loader_options, - execution_options=execution_options, + model_name = cast("str", self.model_type.__tablename__) # type: ignore[attr-defined] + cached = cache_manager.get_entity_sync(model_name, item_id, self.model_type, bind_group=resolved_bind_group) + if cached is not None: + return cached + + # Include bind_group in singleflight key to prevent cross-shard cache pollution + singleflight_key = ( + f"{model_name}:{resolved_bind_group}:get:{item_id}" + if resolved_bind_group + else f"{model_name}:get:{item_id}" + ) + return cache_manager.singleflight_sync( + singleflight_key, + partial( + self._get_cached_creator, + model_name, + item_id, + auto_expunge=auto_expunge, + statement=statement, + id_attribute=resolved_id_attribute, + error_messages=resolved_error_messages, + load=load, + execution_options=execution_options, + with_for_update=with_for_update, + bind_group=resolved_bind_group, + ), ) - statement = self._filter_select_by_kwargs(statement, [(id_attribute, item_id)]) - statement = self._apply_for_update_options(statement, with_for_update) - instance = (self._execute(statement, uniquify=loader_options_have_wildcard)).scalar_one_or_none() - instance = self.check_not_found(instance) - self._expunge(instance, auto_expunge=auto_expunge) - return instance + + return self._get_from_db( + item_id, + auto_expunge=auto_expunge, + statement=statement, + id_attribute=id_attribute, + error_messages=resolved_error_messages, + load=load, + execution_options=execution_options, + with_for_update=with_for_update, + bind_group=bind_group, + ) def get_one( self, @@ -1295,6 +1830,7 @@ def get_or_upsert( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[ModelT, bool]: """Get instance identified by ``kwargs`` or create if it doesn't exist. @@ -1318,6 +1854,7 @@ def get_or_upsert( load: Set relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. + bind_group: Optional routing group for multi-master configurations. **kwargs: Identifier of the instance to be retrieved. Returns: @@ -1346,6 +1883,7 @@ def get_or_upsert( **match_filter, load=load, execution_options=execution_options, + bind_group=bind_group, ) if not existing: return ( @@ -1354,6 +1892,7 @@ def get_or_upsert( auto_commit=auto_commit, auto_expunge=auto_expunge, auto_refresh=auto_refresh, + bind_group=bind_group, ), True, ) @@ -1386,6 +1925,7 @@ def get_and_update( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[ModelT, bool]: """Get instance identified by ``kwargs`` and update the model if the arguments are different. @@ -1407,6 +1947,7 @@ def get_and_update( load: Set relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. + bind_group: Optional routing group for multi-master configurations. **kwargs: Identifier of the instance to be retrieved. Returns: @@ -1430,7 +1971,9 @@ def get_and_update( } else: match_filter = kwargs - existing = self.get_one(*filters, **match_filter, load=load, execution_options=execution_options) + existing = self.get_one( + *filters, **match_filter, load=load, execution_options=execution_options, bind_group=bind_group + ) updated = False for field_name, new_field_value in kwargs.items(): field = getattr(existing, field_name, MISSING) @@ -1518,6 +2061,7 @@ def update( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, ) -> ModelT: """Update instance with the attribute values present on `data`. @@ -1539,6 +2083,7 @@ def update( load: Set relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. + bind_group: Optional routing group for multi-master configurations. Returns: The updated instance. @@ -1561,6 +2106,7 @@ def update( load=load, execution_options=execution_options, with_for_update=with_for_update, + bind_group=bind_group, ) mapper = None with ( @@ -1622,6 +2168,8 @@ def update( auto_refresh=auto_refresh, ) self._expunge(instance, auto_expunge=auto_expunge) + # Queue cache invalidation (processed on commit) + self._queue_cache_invalidation(self.get_id_attribute_value(instance), bind_group) return instance def update_many( @@ -1634,6 +2182,7 @@ def update_many( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, ) -> list[ModelT]: """Update one or more instances with the attribute values present on `data`. @@ -1651,6 +2200,7 @@ def update_many( load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. + bind_group: Optional routing group for multi-master configurations. Returns: The updated instances. @@ -1674,6 +2224,10 @@ def update_many( with wrap_sqlalchemy_exception( error_messages=error_messages, dialect_name=self._dialect.name, wrap_exceptions=self.wrap_exceptions ): + resolved_bind_group = self._resolve_bind_group(bind_group) + if resolved_bind_group: + execution_options = dict(execution_options) if execution_options else {} + execution_options["bind_group"] = resolved_bind_group execution_options = self._get_execution_options(execution_options) loader_options = self._get_loader_options(load)[0] supports_returning = self._dialect.update_executemany_returning and self._dialect.name != "oracle" @@ -1706,9 +2260,12 @@ def update_many( getattr(self.model_type, self.id_attribute).in_(updated_ids), load=loader_options, execution_options=execution_options, + bind_group=bind_group, ) for instance in updated_instances: self._expunge(instance, auto_expunge=auto_expunge) + # Queue cache invalidation (processed on commit) + self._queue_cache_invalidation(self.get_id_attribute_value(instance), bind_group) return updated_instances def _get_update_many_statement( @@ -1738,6 +2295,7 @@ def list_and_count( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + use_cache: bool = True, bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[list[ModelT], int]: @@ -1754,6 +2312,7 @@ def list_and_count( load: Set relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. + use_cache: Whether to use the cache for this query. Defaults to ``True``. bind_group: Optional routing group to use for the operation. **kwargs: Instance attribute value filters. @@ -1764,32 +2323,87 @@ def list_and_count( count_with_window_function if count_with_window_function is not None else self.count_with_window_function ) self._uniquify = self._get_uniquify(uniquify) - error_messages = self._get_error_messages( + resolved_error_messages = self._get_error_messages( error_messages=error_messages, default_messages=self.error_messages, ) - if self._dialect.name in {"spanner", "spanner+spanner"} or not count_with_window_function: - return self._list_and_count_basic( - *filters, + + resolved_auto_expunge = self.auto_expunge if auto_expunge is None else auto_expunge + resolved_execution_options = self._get_execution_options(execution_options) + resolved_order_by = order_by if order_by is not None else (self.order_by if self.order_by is not None else []) + + cache_manager = self._cache_manager + if not ( + use_cache + and bool(resolved_auto_expunge) + and cache_manager is not None + and statement is None + and load is None + and not self._default_loader_options + ): + return self._list_and_count_from_db( + filters=filters, auto_expunge=auto_expunge, statement=statement, + count_with_window_function=count_with_window_function, + order_by=order_by, + error_messages=resolved_error_messages, load=load, execution_options=execution_options, + kwargs=kwargs, + uniquify=uniquify, + bind_group=bind_group, + ) + + model_name = cast("str", self.model_type.__tablename__) # type: ignore[attr-defined] + version_token = cache_manager.get_model_version_sync(model_name) + cache_key = _build_list_cache_key( + model_name=model_name, + version_token=version_token, + method="list_and_count", + filters=filters, + kwargs=kwargs, + order_by=resolved_order_by, + execution_options=resolved_execution_options, + uniquify=self._uniquify, + count_with_window_function=count_with_window_function, + ) + if cache_key is None: + return self._list_and_count_from_db( + filters=filters, + auto_expunge=auto_expunge, + statement=statement, + count_with_window_function=count_with_window_function, order_by=order_by, - error_messages=error_messages, + error_messages=resolved_error_messages, + load=load, + execution_options=execution_options, + kwargs=kwargs, + uniquify=uniquify, bind_group=bind_group, - **kwargs, ) - return self._list_and_count_window( - *filters, - auto_expunge=auto_expunge, - statement=statement, - load=load, - execution_options=execution_options, - error_messages=error_messages, - order_by=order_by, - bind_group=bind_group, - **kwargs, + + cached = cache_manager.get_list_and_count_sync(cache_key, self.model_type) + if cached is not None: + return cached + + return cache_manager.singleflight_sync( + cache_key, + partial( + self._list_and_count_cached_creator, + cache_key, + filters=filters, + auto_expunge=auto_expunge, + statement=statement, + count_with_window_function=count_with_window_function, + order_by=order_by, + error_messages=resolved_error_messages, + load=load, + execution_options=execution_options, + kwargs=kwargs, + uniquify=uniquify, + bind_group=bind_group, + ), ) def _expunge(self, instance: "ModelT", auto_expunge: "Optional[bool]") -> None: @@ -2008,6 +2622,7 @@ def upsert( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, ) -> ModelT: """Modify or create instance. @@ -2032,6 +2647,7 @@ def upsert( load: Set relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. + bind_group: Optional routing group for multi-master configurations. Returns: The updated or created instance. @@ -2051,13 +2667,16 @@ def upsert( match_filter = {self.id_attribute: getattr(data, self.id_attribute, None)} else: match_filter = data.to_dict(exclude={self.id_attribute}) - existing = self.get_one_or_none(load=load, execution_options=execution_options, **match_filter) + existing = self.get_one_or_none( + load=load, execution_options=execution_options, bind_group=bind_group, **match_filter + ) if not existing: return self.add( data, auto_commit=auto_commit, auto_expunge=auto_expunge, auto_refresh=auto_refresh, + bind_group=bind_group, ) with wrap_sqlalchemy_exception( error_messages=error_messages, dialect_name=self._dialect.name, wrap_exceptions=self.wrap_exceptions @@ -2075,6 +2694,8 @@ def upsert( auto_refresh=auto_refresh, ) self._expunge(instance, auto_expunge=auto_expunge) + # Queue cache invalidation (processed on commit) + self._queue_cache_invalidation(self.get_id_attribute_value(instance), bind_group) return instance def upsert_many( @@ -2089,6 +2710,7 @@ def upsert_many( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, ) -> list[ModelT]: """Modify or create multiple instances. @@ -2112,6 +2734,7 @@ def upsert_many( load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. + bind_group: Optional routing group to use for the operation. Returns: The updated or created instance. @@ -2146,6 +2769,7 @@ def upsert_many( load=load, execution_options=execution_options, auto_expunge=False, + bind_group=bind_group, ) for field_name in match_fields: field = get_instrumented_attr(self.model_type, field_name) @@ -2164,7 +2788,7 @@ def upsert_many( data_to_insert.append(datum) if data_to_insert: instances.extend( - self.add_many(data_to_insert, auto_commit=False, auto_expunge=False), + self.add_many(data_to_insert, auto_commit=False, auto_expunge=False, bind_group=bind_group), ) if data_to_update: instances.extend( @@ -2174,6 +2798,7 @@ def upsert_many( auto_expunge=False, load=load, execution_options=execution_options, + bind_group=bind_group, ), ) self._flush_or_commit(auto_commit=auto_commit) @@ -2223,6 +2848,7 @@ def list( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + use_cache: bool = True, bind_group: Optional[str] = None, **kwargs: Any, ) -> list[ModelT]: @@ -2238,6 +2864,7 @@ def list( load: Set relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. + use_cache: Whether to use the cache for this query. Defaults to ``True``. bind_group: Optional routing group to use for the operation. **kwargs: Instance attribute value filters. @@ -2245,34 +2872,84 @@ def list( The list of instances, after filtering applied. """ self._uniquify = self._get_uniquify(uniquify) - error_messages = self._get_error_messages( + resolved_error_messages = self._get_error_messages( error_messages=error_messages, default_messages=self.error_messages, ) - with wrap_sqlalchemy_exception( - error_messages=error_messages, dialect_name=self._dialect.name, wrap_exceptions=self.wrap_exceptions + + resolved_auto_expunge = self.auto_expunge if auto_expunge is None else auto_expunge + resolved_execution_options = self._get_execution_options(execution_options) + resolved_order_by = order_by if order_by is not None else (self.order_by if self.order_by is not None else []) + + cache_manager = self._cache_manager + if not ( + use_cache + and bool(resolved_auto_expunge) + and cache_manager is not None + and statement is None + and load is None + and not self._default_loader_options ): - if bind_group: - execution_options = dict(execution_options) if execution_options else {} - execution_options["bind_group"] = bind_group - execution_options = self._get_execution_options(execution_options) - statement = self.statement if statement is None else statement - loader_options, loader_options_have_wildcard = self._get_loader_options(load) - statement = self._get_base_stmt( + return self._list_from_db( + filters=filters, + auto_expunge=auto_expunge, statement=statement, - loader_options=loader_options, + order_by=order_by, + error_messages=resolved_error_messages, + load=load, execution_options=execution_options, + kwargs=kwargs, + uniquify=uniquify, + bind_group=bind_group, ) - if order_by is None: - order_by = self.order_by if self.order_by is not None else [] - statement = self._apply_order_by(statement=statement, order_by=order_by) - statement = self._apply_filters(*filters, statement=statement) - statement = self._filter_select_by_kwargs(statement, kwargs) - result = self._execute(statement, uniquify=loader_options_have_wildcard) - instances = list(result.scalars()) - for instance in instances: - self._expunge(instance, auto_expunge=auto_expunge) - return cast("list[ModelT]", instances) + + model_name = cast("str", self.model_type.__tablename__) # type: ignore[attr-defined] + version_token = cache_manager.get_model_version_sync(model_name) + cache_key = _build_list_cache_key( + model_name=model_name, + version_token=version_token, + method="list", + filters=filters, + kwargs=kwargs, + order_by=resolved_order_by, + execution_options=resolved_execution_options, + uniquify=self._uniquify, + ) + if cache_key is None: + return self._list_from_db( + filters=filters, + auto_expunge=auto_expunge, + statement=statement, + order_by=order_by, + error_messages=resolved_error_messages, + load=load, + execution_options=execution_options, + kwargs=kwargs, + uniquify=uniquify, + bind_group=bind_group, + ) + + cached = cache_manager.get_list_sync(cache_key, self.model_type) + if cached is not None: + return cached + + return cache_manager.singleflight_sync( + cache_key, + partial( + self._list_cached_creator, + cache_key, + filters=filters, + auto_expunge=auto_expunge, + statement=statement, + order_by=order_by, + error_messages=resolved_error_messages, + load=load, + execution_options=execution_options, + kwargs=kwargs, + uniquify=uniquify, + bind_group=bind_group, + ), + ) @classmethod def check_health(cls, session: Union[Session, scoped_session[Session]]) -> bool: diff --git a/advanced_alchemy/repository/memory/_async.py b/advanced_alchemy/repository/memory/_async.py index d183de455..4aa925e89 100644 --- a/advanced_alchemy/repository/memory/_async.py +++ b/advanced_alchemy/repository/memory/_async.py @@ -456,6 +456,7 @@ async def get( execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, with_for_update: ForUpdateParameter = None, + use_cache: bool = True, bind_group: Optional[str] = None, ) -> ModelT: return self._find_or_raise_not_found(item_id) @@ -506,6 +507,7 @@ async def get_or_upsert( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[ModelT, bool]: kwargs_ = self._exclude_unused_kwargs(kwargs) @@ -542,6 +544,7 @@ async def get_and_update( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[ModelT, bool]: kwargs_ = self._exclude_unused_kwargs(kwargs) @@ -592,6 +595,7 @@ async def add( auto_expunge: Optional[bool] = None, auto_refresh: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, + bind_group: Optional[str] = None, ) -> ModelT: try: self.__database__.add(self.model_type, data) @@ -607,6 +611,7 @@ async def add_many( auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, + bind_group: Optional[str] = None, ) -> list[ModelT]: for obj in data: await self.add(obj) # pyright: ignore[reportCallIssue] @@ -626,6 +631,7 @@ async def update( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, ) -> ModelT: self._find_or_raise_not_found(self.__collection__().key(data)) return self.__collection__().update(data) @@ -640,6 +646,7 @@ async def update_many( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, ) -> list[ModelT]: return [self.__collection__().update(obj) for obj in data if obj in self.__collection__()] @@ -654,6 +661,7 @@ async def delete( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, ) -> ModelT: try: return self._find_or_raise_not_found(item_id) @@ -672,6 +680,7 @@ async def delete_many( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, ) -> list[ModelT]: deleted: list[ModelT] = [] for id_ in item_ids: @@ -690,6 +699,7 @@ async def delete_where( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, **kwargs: Any, ) -> list[ModelT]: result = self.__collection__().list() @@ -712,6 +722,7 @@ async def upsert( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, ) -> ModelT: # sourcery skip: assign-if-exp, reintroduce-else if data in self.__collection__(): @@ -730,6 +741,7 @@ async def upsert_many( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, ) -> list[ModelT]: return [await self.upsert(item) for item in data] @@ -744,6 +756,7 @@ async def list_and_count( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + use_cache: bool = True, bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[list[ModelT], int]: @@ -753,6 +766,7 @@ async def list( self, *filters: Union[StatementFilter, ColumnElement[bool]], uniquify: Optional[bool] = None, + use_cache: bool = True, bind_group: Optional[str] = None, **kwargs: Any, ) -> list[ModelT]: diff --git a/advanced_alchemy/repository/memory/_sync.py b/advanced_alchemy/repository/memory/_sync.py index d82fa354b..3966a70f3 100644 --- a/advanced_alchemy/repository/memory/_sync.py +++ b/advanced_alchemy/repository/memory/_sync.py @@ -457,6 +457,7 @@ def get( execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, with_for_update: ForUpdateParameter = None, + use_cache: bool = True, bind_group: Optional[str] = None, ) -> ModelT: return self._find_or_raise_not_found(item_id) @@ -507,6 +508,7 @@ def get_or_upsert( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[ModelT, bool]: kwargs_ = self._exclude_unused_kwargs(kwargs) @@ -543,6 +545,7 @@ def get_and_update( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[ModelT, bool]: kwargs_ = self._exclude_unused_kwargs(kwargs) @@ -593,6 +596,7 @@ def add( auto_expunge: Optional[bool] = None, auto_refresh: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, + bind_group: Optional[str] = None, ) -> ModelT: try: self.__database__.add(self.model_type, data) @@ -608,6 +612,7 @@ def add_many( auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, + bind_group: Optional[str] = None, ) -> list[ModelT]: for obj in data: self.add(obj) # pyright: ignore[reportCallIssue] @@ -627,6 +632,7 @@ def update( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, ) -> ModelT: self._find_or_raise_not_found(self.__collection__().key(data)) return self.__collection__().update(data) @@ -641,6 +647,7 @@ def update_many( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, ) -> list[ModelT]: return [self.__collection__().update(obj) for obj in data if obj in self.__collection__()] @@ -655,6 +662,7 @@ def delete( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, ) -> ModelT: try: return self._find_or_raise_not_found(item_id) @@ -673,6 +681,7 @@ def delete_many( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, ) -> list[ModelT]: deleted: list[ModelT] = [] for id_ in item_ids: @@ -691,6 +700,7 @@ def delete_where( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, **kwargs: Any, ) -> list[ModelT]: result = self.__collection__().list() @@ -713,6 +723,7 @@ def upsert( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, ) -> ModelT: # sourcery skip: assign-if-exp, reintroduce-else if data in self.__collection__(): @@ -731,6 +742,7 @@ def upsert_many( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, ) -> list[ModelT]: return [self.upsert(item) for item in data] @@ -745,6 +757,7 @@ def list_and_count( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + use_cache: bool = True, bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[list[ModelT], int]: @@ -754,6 +767,7 @@ def list( self, *filters: Union[StatementFilter, ColumnElement[bool]], uniquify: Optional[bool] = None, + use_cache: bool = True, bind_group: Optional[str] = None, **kwargs: Any, ) -> list[ModelT]: diff --git a/advanced_alchemy/service/_async.py b/advanced_alchemy/service/_async.py index c8cf640df..cbe191033 100644 --- a/advanced_alchemy/service/_async.py +++ b/advanced_alchemy/service/_async.py @@ -514,6 +514,7 @@ async def list_and_count( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + use_cache: bool = True, bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[Sequence[ModelT], int]: @@ -530,7 +531,8 @@ async def list_and_count( load: Set relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. - bind_group: The bind group to use for the operation. + use_cache: Whether to use the repository cache for this query. + bind_group: Optional routing group to use for the operation. **kwargs: Instance attribute value filters. Returns: @@ -548,6 +550,7 @@ async def list_and_count( load=load, execution_options=execution_options, uniquify=self._get_uniquify(uniquify), + use_cache=use_cache, bind_group=bind_group, **kwargs, ), @@ -611,6 +614,7 @@ async def list( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + use_cache: bool = True, bind_group: Optional[str] = None, **kwargs: Any, ) -> Sequence[ModelT]: @@ -626,7 +630,8 @@ async def list( load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. - bind_group: The bind group to use for the operation. + use_cache: Whether to use the repository cache for this query. + bind_group: Optional routing group to use for the operation. **kwargs: Instance attribute value filters. Returns: @@ -643,6 +648,7 @@ async def list( load=load, execution_options=execution_options, uniquify=self._get_uniquify(uniquify), + use_cache=use_cache, bind_group=bind_group, **kwargs, ), @@ -663,6 +669,7 @@ async def create( auto_expunge: Optional[bool] = None, auto_refresh: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, + bind_group: Optional[str] = None, ) -> "ModelT": """Wrap repository instance creation. @@ -673,6 +680,7 @@ async def create( auto_commit: Commit objects before returning. error_messages: An optional dictionary of templates to use for friendlier error messages to clients + bind_group: Optional routing group to use for the operation. Returns: Representation of created instance. @@ -686,6 +694,7 @@ async def create( auto_expunge=auto_expunge, auto_refresh=auto_refresh, error_messages=error_messages, + bind_group=bind_group, ), ) @@ -696,6 +705,7 @@ async def create_many( auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, + bind_group: Optional[str] = None, ) -> Sequence[ModelT]: """Wrap repository bulk instance creation. @@ -705,6 +715,7 @@ async def create_many( auto_commit: Commit objects before returning. error_messages: An optional dictionary of templates to use for friendlier error messages to clients + bind_group: Optional routing group to use for the operation. Returns: Representation of created instances. @@ -719,6 +730,7 @@ async def create_many( auto_commit=auto_commit, auto_expunge=auto_expunge, error_messages=error_messages, + bind_group=bind_group, ), ) @@ -737,6 +749,7 @@ async def update( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, ) -> "ModelT": """Wrap repository update operation. @@ -758,6 +771,7 @@ async def update( load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. + bind_group: Optional routing group to use for the operation. Raises: RepositoryError: If no configuration or session is provided. @@ -779,6 +793,7 @@ async def update( load=load, execution_options=execution_options, with_for_update=with_for_update, + bind_group=bind_group, ) # Extract attributes from converted model to update existing instance @@ -819,6 +834,7 @@ async def update( load=load, execution_options=execution_options, uniquify=self._get_uniquify(uniquify), + bind_group=bind_group, ), ) @@ -832,6 +848,7 @@ async def update_many( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, ) -> Sequence[ModelT]: """Wrap repository bulk instance update. @@ -844,6 +861,7 @@ async def update_many( load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. + bind_group: Optional routing group to use for the operation. Returns: Representation of updated instances. @@ -861,6 +879,7 @@ async def update_many( load=load, execution_options=execution_options, uniquify=self._get_uniquify(uniquify), + bind_group=bind_group, ), ) @@ -879,6 +898,7 @@ async def upsert( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, ) -> ModelT: """Wrap repository upsert operation. @@ -901,6 +921,7 @@ async def upsert( load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. + bind_group: Optional routing group to use for the operation. Returns: Updated or created representation. @@ -923,6 +944,7 @@ async def upsert( load=load, execution_options=execution_options, uniquify=self._get_uniquify(uniquify), + bind_group=bind_group, ), ) @@ -938,6 +960,7 @@ async def upsert_many( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, ) -> Sequence[ModelT]: """Wrap repository upsert operation. @@ -953,6 +976,7 @@ async def upsert_many( load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. + bind_group: Optional routing group to use for the operation. Returns: Updated or created representation. @@ -972,6 +996,7 @@ async def upsert_many( load=load, execution_options=execution_options, uniquify=self._get_uniquify(uniquify), + bind_group=bind_group, ), ) @@ -989,6 +1014,7 @@ async def get_or_upsert( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[ModelT, bool]: """Wrap repository instance creation. @@ -1013,6 +1039,7 @@ async def get_or_upsert( load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. + bind_group: Optional routing group to use for the operation. **kwargs: Identifier of the instance to be retrieved. Returns: @@ -1035,6 +1062,7 @@ async def get_or_upsert( load=load, execution_options=execution_options, uniquify=self._get_uniquify(uniquify), + bind_group=bind_group, **validated_model.to_dict(), ), ) @@ -1052,6 +1080,7 @@ async def get_and_update( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[ModelT, bool]: """Wrap repository instance creation. @@ -1073,6 +1102,7 @@ async def get_and_update( load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. + bind_group: Optional routing group to use for the operation. **kwargs: Identifier of the instance to be retrieved. Returns: @@ -1094,6 +1124,7 @@ async def get_and_update( load=load, execution_options=execution_options, uniquify=self._get_uniquify(uniquify), + bind_group=bind_group, **validated_model.to_dict(), ), ) @@ -1109,6 +1140,7 @@ async def delete( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, ) -> ModelT: """Wrap repository delete operation. @@ -1123,6 +1155,7 @@ async def delete( load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. + bind_group: Optional routing group to use for the operation. Returns: Representation of the deleted instance. @@ -1138,6 +1171,7 @@ async def delete( load=load, execution_options=execution_options, uniquify=self._get_uniquify(uniquify), + bind_group=bind_group, ), ) @@ -1153,6 +1187,7 @@ async def delete_many( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, ) -> Sequence[ModelT]: """Wrap repository bulk instance deletion. @@ -1169,6 +1204,7 @@ async def delete_many( load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. + bind_group: Optional routing group to use for the operation. Returns: Representation of removed instances. @@ -1185,6 +1221,7 @@ async def delete_many( load=load, execution_options=execution_options, uniquify=self._get_uniquify(uniquify), + bind_group=bind_group, ), ) @@ -1198,6 +1235,7 @@ async def delete_where( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, **kwargs: Any, ) -> Sequence[ModelT]: """Wrap repository scalars operation. @@ -1212,6 +1250,7 @@ async def delete_where( load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. + bind_group: Optional routing group to use for the operation. **kwargs: Instance attribute value filters. Returns: @@ -1228,6 +1267,7 @@ async def delete_where( load=load, execution_options=execution_options, uniquify=self._get_uniquify(uniquify), + bind_group=bind_group, **kwargs, ), ) diff --git a/advanced_alchemy/service/_sync.py b/advanced_alchemy/service/_sync.py index 643fa9cd0..9ca1384f9 100644 --- a/advanced_alchemy/service/_sync.py +++ b/advanced_alchemy/service/_sync.py @@ -513,6 +513,7 @@ def list_and_count( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + use_cache: bool = True, bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[Sequence[ModelT], int]: @@ -529,7 +530,8 @@ def list_and_count( load: Set relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. - bind_group: The bind group to use for the operation. + use_cache: Whether to use the repository cache for this query. + bind_group: Optional routing group to use for the operation. **kwargs: Instance attribute value filters. Returns: @@ -547,6 +549,7 @@ def list_and_count( load=load, execution_options=execution_options, uniquify=self._get_uniquify(uniquify), + use_cache=use_cache, bind_group=bind_group, **kwargs, ), @@ -610,6 +613,7 @@ def list( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + use_cache: bool = True, bind_group: Optional[str] = None, **kwargs: Any, ) -> Sequence[ModelT]: @@ -625,7 +629,8 @@ def list( load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. - bind_group: The bind group to use for the operation. + use_cache: Whether to use the repository cache for this query. + bind_group: Optional routing group to use for the operation. **kwargs: Instance attribute value filters. Returns: @@ -642,6 +647,7 @@ def list( load=load, execution_options=execution_options, uniquify=self._get_uniquify(uniquify), + use_cache=use_cache, bind_group=bind_group, **kwargs, ), @@ -662,6 +668,7 @@ def create( auto_expunge: Optional[bool] = None, auto_refresh: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, + bind_group: Optional[str] = None, ) -> "ModelT": """Wrap repository instance creation. @@ -672,6 +679,7 @@ def create( auto_commit: Commit objects before returning. error_messages: An optional dictionary of templates to use for friendlier error messages to clients + bind_group: Optional routing group to use for the operation. Returns: Representation of created instance. @@ -685,6 +693,7 @@ def create( auto_expunge=auto_expunge, auto_refresh=auto_refresh, error_messages=error_messages, + bind_group=bind_group, ), ) @@ -695,6 +704,7 @@ def create_many( auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, + bind_group: Optional[str] = None, ) -> Sequence[ModelT]: """Wrap repository bulk instance creation. @@ -704,6 +714,7 @@ def create_many( auto_commit: Commit objects before returning. error_messages: An optional dictionary of templates to use for friendlier error messages to clients + bind_group: Optional routing group to use for the operation. Returns: Representation of created instances. @@ -718,6 +729,7 @@ def create_many( auto_commit=auto_commit, auto_expunge=auto_expunge, error_messages=error_messages, + bind_group=bind_group, ), ) @@ -736,6 +748,7 @@ def update( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, ) -> "ModelT": """Wrap repository update operation. @@ -757,6 +770,7 @@ def update( load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. + bind_group: Optional routing group to use for the operation. Raises: RepositoryError: If no configuration or session is provided. @@ -778,6 +792,7 @@ def update( load=load, execution_options=execution_options, with_for_update=with_for_update, + bind_group=bind_group, ) # Extract attributes from converted model to update existing instance @@ -818,6 +833,7 @@ def update( load=load, execution_options=execution_options, uniquify=self._get_uniquify(uniquify), + bind_group=bind_group, ), ) @@ -831,6 +847,7 @@ def update_many( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, ) -> Sequence[ModelT]: """Wrap repository bulk instance update. @@ -843,6 +860,7 @@ def update_many( load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. + bind_group: Optional routing group to use for the operation. Returns: Representation of updated instances. @@ -860,6 +878,7 @@ def update_many( load=load, execution_options=execution_options, uniquify=self._get_uniquify(uniquify), + bind_group=bind_group, ), ) @@ -878,6 +897,7 @@ def upsert( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, ) -> ModelT: """Wrap repository upsert operation. @@ -900,6 +920,7 @@ def upsert( load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. + bind_group: Optional routing group to use for the operation. Returns: Updated or created representation. @@ -922,6 +943,7 @@ def upsert( load=load, execution_options=execution_options, uniquify=self._get_uniquify(uniquify), + bind_group=bind_group, ), ) @@ -937,6 +959,7 @@ def upsert_many( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, ) -> Sequence[ModelT]: """Wrap repository upsert operation. @@ -952,6 +975,7 @@ def upsert_many( load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. + bind_group: Optional routing group to use for the operation. Returns: Updated or created representation. @@ -971,6 +995,7 @@ def upsert_many( load=load, execution_options=execution_options, uniquify=self._get_uniquify(uniquify), + bind_group=bind_group, ), ) @@ -988,6 +1013,7 @@ def get_or_upsert( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[ModelT, bool]: """Wrap repository instance creation. @@ -1012,6 +1038,7 @@ def get_or_upsert( load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. + bind_group: Optional routing group to use for the operation. **kwargs: Identifier of the instance to be retrieved. Returns: @@ -1034,6 +1061,7 @@ def get_or_upsert( load=load, execution_options=execution_options, uniquify=self._get_uniquify(uniquify), + bind_group=bind_group, **validated_model.to_dict(), ), ) @@ -1051,6 +1079,7 @@ def get_and_update( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[ModelT, bool]: """Wrap repository instance creation. @@ -1072,6 +1101,7 @@ def get_and_update( load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. + bind_group: Optional routing group to use for the operation. **kwargs: Identifier of the instance to be retrieved. Returns: @@ -1093,6 +1123,7 @@ def get_and_update( load=load, execution_options=execution_options, uniquify=self._get_uniquify(uniquify), + bind_group=bind_group, **validated_model.to_dict(), ), ) @@ -1108,6 +1139,7 @@ def delete( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, ) -> ModelT: """Wrap repository delete operation. @@ -1122,6 +1154,7 @@ def delete( load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. + bind_group: Optional routing group to use for the operation. Returns: Representation of the deleted instance. @@ -1137,6 +1170,7 @@ def delete( load=load, execution_options=execution_options, uniquify=self._get_uniquify(uniquify), + bind_group=bind_group, ), ) @@ -1152,6 +1186,7 @@ def delete_many( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, ) -> Sequence[ModelT]: """Wrap repository bulk instance deletion. @@ -1168,6 +1203,7 @@ def delete_many( load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. + bind_group: Optional routing group to use for the operation. Returns: Representation of removed instances. @@ -1184,6 +1220,7 @@ def delete_many( load=load, execution_options=execution_options, uniquify=self._get_uniquify(uniquify), + bind_group=bind_group, ), ) @@ -1197,6 +1234,7 @@ def delete_where( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, **kwargs: Any, ) -> Sequence[ModelT]: """Wrap repository scalars operation. @@ -1211,6 +1249,7 @@ def delete_where( load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. + bind_group: Optional routing group to use for the operation. **kwargs: Instance attribute value filters. Returns: @@ -1227,6 +1266,7 @@ def delete_where( load=load, execution_options=execution_options, uniquify=self._get_uniquify(uniquify), + bind_group=bind_group, **kwargs, ), ) diff --git a/docs/reference/cache.rst b/docs/reference/cache.rst new file mode 100644 index 000000000..46c2fc658 --- /dev/null +++ b/docs/reference/cache.rst @@ -0,0 +1,55 @@ +===== +Cache +===== + +.. module:: advanced_alchemy.cache + +The cache module provides optional integration with `dogpile.cache`_ for caching +SQLAlchemy model instances. It supports multiple backends and provides automatic +cache invalidation when models are modified. + +.. _dogpile.cache: https://dogpilecache.sqlalchemy.org/ + +Installation +------------ + +The cache module requires the optional ``dogpile.cache`` dependency: + +.. code-block:: bash + + pip install advanced-alchemy[dogpile] + +Without this dependency, the cache manager will use a ``NullRegion`` that provides +the same interface but doesn't actually cache anything. + +Configuration +------------- + +.. autoclass:: advanced_alchemy.cache.CacheConfig + :members: + :show-inheritance: + +Cache Manager +------------- + +.. autoclass:: advanced_alchemy.cache.CacheManager + :members: + :show-inheritance: + +Serialization +------------- + +.. autofunction:: advanced_alchemy.cache.default_serializer + +.. autofunction:: advanced_alchemy.cache.default_deserializer + +Setup +----- + +.. autofunction:: advanced_alchemy._listeners.setup_cache_listeners + +Constants +--------- + +.. autodata:: advanced_alchemy.cache.DOGPILE_CACHE_INSTALLED + :annotation: diff --git a/docs/reference/index.rst b/docs/reference/index.rst index 51b08489f..c94391e5a 100644 --- a/docs/reference/index.rst +++ b/docs/reference/index.rst @@ -16,6 +16,7 @@ Core Components base mixins/index config/index + cache operations types exceptions diff --git a/docs/usage/caching.rst b/docs/usage/caching.rst new file mode 100644 index 000000000..8bccc0f70 --- /dev/null +++ b/docs/usage/caching.rst @@ -0,0 +1,346 @@ +======= +Caching +======= + +Advanced Alchemy provides optional caching support through integration with +`dogpile.cache`_. This allows you to cache SQLAlchemy model instances using +various backends (Redis, Memcached, file, memory) with automatic cache +invalidation when models are modified. + +.. _dogpile.cache: https://dogpilecache.sqlalchemy.org/ + +Installation +------------ + +Install the optional caching dependency: + +.. code-block:: bash + + pip install advanced-alchemy[dogpile] + +Quick Start +----------- + +Basic setup with in-memory caching using the config system: + +.. code-block:: python + + from advanced_alchemy.cache import CacheConfig + from advanced_alchemy.config import SQLAlchemyAsyncConfig + from advanced_alchemy.repository import SQLAlchemyAsyncRepository + + # Configure caching via SQLAlchemy config + db_config = SQLAlchemyAsyncConfig( + connection_string="sqlite+aiosqlite:///app.db", + cache_config=CacheConfig( + backend="dogpile.cache.memory", + expiration_time=300, # 5 minutes + ), + ) + + # Cache listeners are automatically registered when cache_config is set. + # The cache_manager is stored in session.info and auto-retrieved by repositories. + + + class UserRepository(SQLAlchemyAsyncRepository[User]): + model_type = User + + + # Repository automatically uses cache_manager from session.info + async with db_config.get_session() as session: + repo = UserRepository(session=session) + + # First call hits database and caches the result + user = await repo.get(user_id) + + # Second call returns cached result + user = await repo.get(user_id) + +Configuration Options +--------------------- + +The ``CacheConfig`` dataclass provides several configuration options: + +.. code-block:: python + + from advanced_alchemy.cache import CacheConfig + + config = CacheConfig( + # Cache backend (see Backend Configuration below) + backend="dogpile.cache.redis", + + # Default TTL in seconds (default: 3600) + expiration_time=3600, + + # Backend-specific arguments + arguments={ + "host": "localhost", + "port": 6379, + "db": 0, + }, + + # Key prefix to avoid collisions (default: "aa:") + key_prefix="myapp:", + + # Enable/disable caching globally (default: True) + enabled=True, + ) + +Backend Configuration +--------------------- + +Memory Backend +~~~~~~~~~~~~~~ + +Best for development and testing: + +.. code-block:: python + + config = CacheConfig( + backend="dogpile.cache.memory", + expiration_time=300, + ) + +Redis Backend +~~~~~~~~~~~~~ + +Recommended for production with distributed systems: + +.. code-block:: python + + config = CacheConfig( + backend="dogpile.cache.redis", + expiration_time=3600, + arguments={ + "host": "localhost", + "port": 6379, + "db": 0, + "distributed_lock": True, # Enable distributed locking + }, + ) + +Memcached Backend +~~~~~~~~~~~~~~~~~ + +Alternative for high-performance caching: + +.. code-block:: python + + config = CacheConfig( + backend="dogpile.cache.memcached", + expiration_time=3600, + arguments={ + "url": ["127.0.0.1:11211"], + }, + ) + +Null Backend +~~~~~~~~~~~~ + +Disables caching (useful for testing): + +.. code-block:: python + + config = CacheConfig( + backend="dogpile.cache.null", + ) + + # Or simply disable caching + config = CacheConfig(enabled=False) + +Repository Integration +---------------------- + +The cache manager integrates with repositories through the ``cache_manager`` parameter: + +.. code-block:: python + + from advanced_alchemy.repository import SQLAlchemyAsyncRepository + + class UserRepository(SQLAlchemyAsyncRepository[User]): + model_type = User + + + # Create repository with caching + repo = UserRepository( + session=session, + cache_manager=cache_manager, + auto_expunge=True, # Recommended with caching + ) + + # These methods support caching: + user = await repo.get(user_id) # Cached by entity ID + users = await repo.list() # Cached with version-based invalidation + users, count = await repo.list_and_count() # Cached with version-based invalidation + +Bypassing the Cache +~~~~~~~~~~~~~~~~~~~ + +You can bypass the cache for specific queries: + +.. code-block:: python + + # Force database fetch, skip cache + user = await repo.get(user_id, use_cache=False) + users = await repo.list(use_cache=False) + +Automatic Cache Invalidation +---------------------------- + +When using the config system with ``cache_config``, cache listeners are +automatically registered (controlled by ``enable_cache_listener=True``, the default). +Cache entries are automatically invalidated when models are created, updated, or deleted. + +The invalidation is transaction-aware: + +- Invalidations are deferred until the transaction commits +- If the transaction rolls back, invalidations are discarded +- Entity caches are invalidated by ID +- List caches use version-based invalidation (bumps a version token) + +Version-Based List Invalidation +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +List queries use version-based invalidation. When any entity of a model type +is modified, a version token is bumped, which invalidates all list caches +for that model: + +.. code-block:: python + + # First call caches with version token "abc123" + users = await repo.list() + + # Modify any user + user.name = "New Name" + await repo.update(user) + await session.commit() # Version token bumped to "def456" + + # Next call sees new version, fetches fresh data + users = await repo.list() + +Singleflight (Stampede Protection) +---------------------------------- + +The cache manager includes per-process singleflight to prevent cache stampedes. +When multiple concurrent requests try to fetch the same uncached data, only +one request hits the database: + +.. code-block:: python + + import asyncio + + # All 10 concurrent calls result in only 1 database query + results = await asyncio.gather(*[ + repo.get(user_id) for _ in range(10) + ]) + +Custom Serialization +-------------------- + +By default, models are serialized to JSON. You can provide custom serializers +for different serialization formats: + +.. code-block:: python + + import msgpack + + def msgpack_serializer(model): + # Convert model to dict and serialize with msgpack + from sqlalchemy import inspect + mapper = inspect(model.__class__) + data = {col.key: getattr(model, col.key) for col in mapper.columns} + return msgpack.packb(data) + + def msgpack_deserializer(data, model_class): + unpacked = msgpack.unpackb(data) + return model_class(**unpacked) + + config = CacheConfig( + backend="dogpile.cache.redis", + serializer=msgpack_serializer, + deserializer=msgpack_deserializer, + ) + +.. warning:: + + The default JSON serializer only serializes column values, not relationships. + Cached instances are detached and accessing lazy-loaded relationships will + raise ``DetachedInstanceError``. Use ``session.merge()`` if you need + relationship access. + +Performance Considerations +-------------------------- + +1. **Use auto_expunge=True**: When using caching, set ``auto_expunge=True`` on + your repository to ensure cached entities are properly detached. + +2. **Choose the right backend**: Use Redis or Memcached for production with + multiple application instances. Memory backend is only suitable for + single-instance deployments or development. + +3. **Set appropriate TTLs**: Balance between cache hit rate and data freshness. + Shorter TTLs mean more database queries but fresher data. + +4. **Key prefix**: Use unique key prefixes when sharing a cache backend with + other applications to avoid key collisions. + +5. **Graceful degradation**: If dogpile.cache is not installed, the cache + manager automatically falls back to a no-op implementation. + +Example: Full Application Setup +------------------------------- + +.. code-block:: python + + from litestar import Litestar + from litestar.contrib.sqlalchemy.plugins import SQLAlchemyPlugin + + from advanced_alchemy.cache import CacheConfig + from advanced_alchemy.config import SQLAlchemyAsyncConfig + from advanced_alchemy.repository import SQLAlchemyAsyncRepository + + + # Configure database with caching + db_config = SQLAlchemyAsyncConfig( + connection_string="postgresql+asyncpg://user:pass@localhost/db", + cache_config=CacheConfig( + backend="dogpile.cache.redis", + expiration_time=3600, + arguments={"host": "localhost", "port": 6379, "db": 0}, + key_prefix="myapp:", + ), + ) + + + class UserRepository(SQLAlchemyAsyncRepository[User]): + model_type = User + + + async def get_user(session: AsyncSession, user_id: int) -> User: + # Repository auto-retrieves cache_manager from session.info + repo = UserRepository(session=session, auto_expunge=True) + return await repo.get(user_id) + + + app = Litestar( + plugins=[SQLAlchemyPlugin(config=db_config)], + ... + ) + +Manual Setup (Without Config) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If you're not using the config system, you can set up caching manually: + +.. code-block:: python + + from advanced_alchemy.cache import CacheConfig, CacheManager, setup_cache_listeners + + # Create cache manager + cache_manager = CacheManager(CacheConfig(backend="dogpile.cache.memory")) + + # Register listeners once at startup + setup_cache_listeners() + + # Pass cache_manager explicitly to repositories + repo = UserRepository(session=session, cache_manager=cache_manager) diff --git a/docs/usage/index.rst b/docs/usage/index.rst index b6c9638e2..5c5d8e7df 100644 --- a/docs/usage/index.rst +++ b/docs/usage/index.rst @@ -19,6 +19,7 @@ This guide demonstrates building a complete blog system using Advanced Alchemy's services routing types + caching cli database_seeding diff --git a/pyproject.toml b/pyproject.toml index c1f10a178..3bc7a3061 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,6 +60,7 @@ Source = "https://github.com/litestar-org/advanced-alchemy" [project.optional-dependencies] argon2 = ["argon2-cffi"] cli = ["rich-click"] +dogpile = ["dogpile.cache"] fsspec = ["fsspec"] nanoid = ["fastnanoid>=0.4.1"] obstore = ["obstore"] @@ -150,6 +151,7 @@ test = [ "attrs", "cattrs", "dishka ; python_version >= \"3.10\"", + "dogpile.cache", "pydantic-extra-types", "numpy", "pgvector", @@ -467,12 +469,7 @@ module = [ [[tool.mypy.overrides]] follow_imports = "skip" ignore_missing_imports = true -module = [ - "pytest", - "pytest.*", - "_pytest", - "_pytest.*", -] +module = ["pytest", "pytest.*", "_pytest", "_pytest.*"] [[tool.mypy.overrides]] module = "advanced_alchemy._serialization" @@ -563,6 +560,16 @@ SQLAlchemyAsyncRepositoryProtocol = "SQLAlchemySyncRepositoryProtocol" "SQLAlchemyAsyncSlugRepository" = "SQLAlchemySyncSlugRepository" SQLAlchemyAsyncSlugRepositoryProtocol = "SQLAlchemySyncSlugRepositoryProtocol" "async_scoped_session" = "scoped_session" +"bump_model_version_async" = "bump_model_version_sync" +"get_entity_async" = "get_entity_sync" +"get_list_and_count_async" = "get_list_and_count_sync" +"get_list_async" = "get_list_sync" +"get_model_version_async" = "get_model_version_sync" +"invalidate_entity_async" = "invalidate_entity_sync" +"set_entity_async" = "set_entity_sync" +"set_list_and_count_async" = "set_list_and_count_sync" +"set_list_async" = "set_list_sync" +"singleflight_async" = "singleflight_sync" "sqlalchemy.ext.asyncio.AsyncSession" = "sqlalchemy.orm.Session" "sqlalchemy.ext.asyncio.scoping.async_scoped_session" = "sqlalchemy.orm.scoping.scoped_session" diff --git a/tests/integration/test_cache_bind_group.py b/tests/integration/test_cache_bind_group.py new file mode 100644 index 000000000..3df0a896b --- /dev/null +++ b/tests/integration/test_cache_bind_group.py @@ -0,0 +1,852 @@ +"""Integration tests for repository caching with bind_group support. + +These tests verify that cache keys properly include bind_group for multi-master +database configurations, preventing data leaks between database shards. +""" + +import asyncio +from collections.abc import Generator +from typing import TYPE_CHECKING, Any, Optional, cast + +import pytest +from sqlalchemy import Engine, String, event +from sqlalchemy.ext.asyncio import AsyncEngine +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + +from advanced_alchemy.base import UUIDBase +from advanced_alchemy.cache import setup_cache_listeners +from advanced_alchemy.cache.config import CacheConfig +from advanced_alchemy.cache.manager import DOGPILE_CACHE_INSTALLED, CacheManager +from advanced_alchemy.repository import SQLAlchemyAsyncRepository, SQLAlchemySyncRepository + +if TYPE_CHECKING: + pass + +pytestmark = [ + pytest.mark.integration, + pytest.mark.xdist_group("cache"), +] + + +@pytest.fixture(scope="module", autouse=True) +def _setup_cache_listeners() -> Generator[None, None, None]: + """Set up global cache listeners for all tests in this module.""" + setup_cache_listeners() + yield + + +# Module-level cache for model and counter for unique names +_model_cache: dict[str, type] = {} +_class_counter = 0 + + +def get_bind_group_author_model(engine_dialect_name: str, worker_id: str) -> type[DeclarativeBase]: + """Create appropriate model for bind_group tests.""" + global _class_counter + cache_key = f"bind_group_author_{worker_id}_{engine_dialect_name}" + + if cache_key not in _model_cache: + + class TestBase(DeclarativeBase): + pass + + _class_counter += 1 + unique_suffix = f"{_class_counter}_{worker_id}_{engine_dialect_name}" + + class_name = f"BindGroupAuthor_{unique_suffix}" + + BindGroupAuthor = type( + class_name, + (UUIDBase, TestBase), + { + "__tablename__": f"test_bind_group_authors_{worker_id}_{engine_dialect_name}", + "__mapper_args__": {"concrete": True}, + "__module__": __name__, + "name": mapped_column(String(length=100)), + "bio": mapped_column(String(length=500), nullable=True), + "__annotations__": {"name": Mapped[str], "bio": Mapped[Optional[str]]}, + }, + ) + + _model_cache[cache_key] = BindGroupAuthor + + return _model_cache[cache_key] + + +def get_worker_id(request: pytest.FixtureRequest) -> str: + """Get worker ID for pytest-xdist or 'master' for single process.""" + workerinput = getattr(request.config, "workerinput", None) + if isinstance(workerinput, dict): + return cast("str", workerinput.get("workerid", "master")) + return "master" + + +@pytest.fixture +def memory_cache_manager() -> CacheManager: + """Create a CacheManager with memory backend for testing.""" + config = CacheConfig( + backend="dogpile.cache.memory", + expiration_time=300, + key_prefix="test_bind_group:", + ) + return CacheManager(config) + + +# ============================================================================ +# Cache Invalidation Tracker Tests +# ============================================================================ + + +@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed") +def test_cache_invalidation_tracker_add_invalidation_with_bind_group(memory_cache_manager: CacheManager) -> None: + """Test CacheInvalidationTracker stores bind_group with invalidations.""" + from advanced_alchemy._listeners import CacheInvalidationTracker + + tracker = CacheInvalidationTracker(memory_cache_manager) + + # Add invalidations with different bind_groups + tracker.add_invalidation("model_a", "id1", bind_group=None) + tracker.add_invalidation("model_a", "id2", bind_group="shard_a") + tracker.add_invalidation("model_b", "id3", bind_group="shard_b") + + # Verify pending invalidations stored correctly + assert len(tracker._pending_invalidations) == 3 + assert ("model_a", "id1", None) in tracker._pending_invalidations + assert ("model_a", "id2", "shard_a") in tracker._pending_invalidations + assert ("model_b", "id3", "shard_b") in tracker._pending_invalidations + + # Verify model bumps queued + assert "model_a" in tracker._pending_model_bumps + assert "model_b" in tracker._pending_model_bumps + + +@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed") +def test_cache_invalidation_tracker_rollback_clears_pending(memory_cache_manager: CacheManager) -> None: + """Test CacheInvalidationTracker rollback clears all pending invalidations.""" + from advanced_alchemy._listeners import CacheInvalidationTracker + + tracker = CacheInvalidationTracker(memory_cache_manager) + + tracker.add_invalidation("model", "id1", bind_group="shard_a") + tracker.add_invalidation("model", "id2", bind_group="shard_b") + + # Verify pending invalidations exist + assert len(tracker._pending_invalidations) == 2 + assert len(tracker._pending_model_bumps) == 1 + + # Rollback should clear everything + tracker.rollback() + + assert len(tracker._pending_invalidations) == 0 + assert len(tracker._pending_model_bumps) == 0 + + +# ============================================================================ +# Async Repository Tests with bind_group +# ============================================================================ + + +@pytest.mark.asyncio +@pytest.mark.aiosqlite +@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed") +async def test_async_repository_get_with_bind_group_uses_separate_cache( + aiosqlite_engine: AsyncEngine, + memory_cache_manager: CacheManager, + request: pytest.FixtureRequest, +) -> None: + """Test async repository get() with bind_group uses separate cache entries.""" + from sqlalchemy.ext.asyncio import AsyncSession as AS + from sqlalchemy.ext.asyncio import async_sessionmaker + + worker_id = get_worker_id(request) + BindGroupAuthor = get_bind_group_author_model("aiosqlite_bind", worker_id) + + async with aiosqlite_engine.begin() as conn: + await conn.run_sync(BindGroupAuthor.metadata.create_all) + + try: + async_session_factory = async_sessionmaker(aiosqlite_engine, class_=AS, expire_on_commit=False) + async with async_session_factory() as session: + + class BindGroupAuthorRepository(SQLAlchemyAsyncRepository[Any]): + model_type = BindGroupAuthor + + repo = BindGroupAuthorRepository(session=session, cache_manager=memory_cache_manager, auto_expunge=True) + + # Create an author + author = BindGroupAuthor(name="Test Author", bio="Bio") + await repo.add(author) + await session.commit() + + author_id = author.id + table_name = BindGroupAuthor.__tablename__ + + # Get without bind_group - should cache to default key + author1 = await repo.get(author_id) + assert author1.name == "Test Author" + + # Verify default cache was populated + cached_default = memory_cache_manager.get_entity_sync( + table_name, author_id, BindGroupAuthor, bind_group=None + ) + assert cached_default is not None + + # Get with bind_group="shard_a" - should cache to shard_a key + author2 = await repo.get(author_id, bind_group="shard_a") + assert author2.name == "Test Author" + + # Verify shard_a cache was populated + cached_shard_a = memory_cache_manager.get_entity_sync( + table_name, author_id, BindGroupAuthor, bind_group="shard_a" + ) + assert cached_shard_a is not None + + # Invalidate only shard_a cache + memory_cache_manager.invalidate_entity_sync(table_name, author_id, bind_group="shard_a") + + # Verify default cache still exists + cached_default_after = memory_cache_manager.get_entity_sync( + table_name, author_id, BindGroupAuthor, bind_group=None + ) + assert cached_default_after is not None, "Default cache should still exist" + + # Verify shard_a cache was invalidated + cached_shard_a_after = memory_cache_manager.get_entity_sync( + table_name, author_id, BindGroupAuthor, bind_group="shard_a" + ) + assert cached_shard_a_after is None, "shard_a cache should be invalidated" + + finally: + async with aiosqlite_engine.begin() as conn: + await conn.run_sync(BindGroupAuthor.metadata.drop_all) + + +@pytest.mark.asyncio +@pytest.mark.aiosqlite +@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed") +async def test_async_repository_singleflight_key_includes_bind_group( + aiosqlite_engine: AsyncEngine, + memory_cache_manager: CacheManager, + request: pytest.FixtureRequest, +) -> None: + """Test that singleflight deduplication includes bind_group in key.""" + from sqlalchemy.ext.asyncio import AsyncSession as AS + from sqlalchemy.ext.asyncio import async_sessionmaker + + worker_id = get_worker_id(request) + BindGroupAuthor = get_bind_group_author_model("aiosqlite_sf", worker_id) + + async with aiosqlite_engine.begin() as conn: + await conn.run_sync(BindGroupAuthor.metadata.create_all) + + query_count = 0 + + def before_cursor_execute(_conn: object, _cursor: object, statement: str, *_: object) -> None: + nonlocal query_count + if statement.lstrip().upper().startswith("SELECT"): + query_count += 1 + + event.listen(aiosqlite_engine.sync_engine, "before_cursor_execute", before_cursor_execute) + + try: + async_session_factory = async_sessionmaker(aiosqlite_engine, class_=AS, expire_on_commit=False) + async with async_session_factory() as session: + + class BindGroupAuthorRepository(SQLAlchemyAsyncRepository[Any]): + model_type = BindGroupAuthor + + repo = BindGroupAuthorRepository(session=session, cache_manager=memory_cache_manager, auto_expunge=True) + + author = BindGroupAuthor(name="SF Author") + await repo.add(author) + await session.commit() + + author_id = author.id + table_name = BindGroupAuthor.__tablename__ + + # Invalidate cache to force misses + memory_cache_manager.invalidate_entity_sync(table_name, author_id, bind_group=None) + memory_cache_manager.invalidate_entity_sync(table_name, author_id, bind_group="shard_a") + + # Concurrent gets without bind_group should coalesce + query_count = 0 + results_default = await asyncio.gather(*[repo.get(author_id) for _ in range(5)]) + assert all(r.id == author_id for r in results_default) + queries_default = query_count + + # Concurrent gets with bind_group="shard_a" should coalesce separately + query_count = 0 + results_shard_a = await asyncio.gather(*[repo.get(author_id, bind_group="shard_a") for _ in range(5)]) + assert all(r.id == author_id for r in results_shard_a) + queries_shard_a = query_count + + # Both should have coalesced to 1 query each + assert queries_default == 1, "Default queries should coalesce" + assert queries_shard_a == 1, "shard_a queries should coalesce" + + finally: + event.remove(aiosqlite_engine.sync_engine, "before_cursor_execute", before_cursor_execute) + async with aiosqlite_engine.begin() as conn: + await conn.run_sync(BindGroupAuthor.metadata.drop_all) + + +@pytest.mark.asyncio +@pytest.mark.aiosqlite +@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed") +async def test_async_repository_get_bypasses_cache_when_disabled( + aiosqlite_engine: AsyncEngine, + memory_cache_manager: CacheManager, + request: pytest.FixtureRequest, +) -> None: + """Test async repository get() with use_cache=False bypasses cache regardless of bind_group.""" + from sqlalchemy.ext.asyncio import AsyncSession as AS + from sqlalchemy.ext.asyncio import async_sessionmaker + + worker_id = get_worker_id(request) + BindGroupAuthor = get_bind_group_author_model("aiosqlite_bypass", worker_id) + + async with aiosqlite_engine.begin() as conn: + await conn.run_sync(BindGroupAuthor.metadata.create_all) + + try: + async_session_factory = async_sessionmaker(aiosqlite_engine, class_=AS, expire_on_commit=False) + async with async_session_factory() as session: + + class BindGroupAuthorRepository(SQLAlchemyAsyncRepository[Any]): + model_type = BindGroupAuthor + + repo = BindGroupAuthorRepository(session=session, cache_manager=memory_cache_manager, auto_expunge=True) + + author = BindGroupAuthor(name="No Cache Author") + await repo.add(author) + await session.commit() + + author_id = author.id + table_name = BindGroupAuthor.__tablename__ + + # Get with cache disabled and bind_group + await repo.get(author_id, use_cache=False, bind_group="shard_a") + + # Cache should not be populated + cached = memory_cache_manager.get_entity_sync(table_name, author_id, BindGroupAuthor, bind_group="shard_a") + assert cached is None, "Cache should not be populated when use_cache=False" + + finally: + async with aiosqlite_engine.begin() as conn: + await conn.run_sync(BindGroupAuthor.metadata.drop_all) + + +# ============================================================================ +# Sync Repository Tests with bind_group +# ============================================================================ + + +@pytest.mark.sqlite +@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed") +def test_sync_repository_get_with_bind_group_uses_separate_cache( + sqlite_engine: Engine, + memory_cache_manager: CacheManager, + request: pytest.FixtureRequest, +) -> None: + """Test sync repository get() with bind_group uses separate cache entries.""" + from sqlalchemy.orm import sessionmaker + + worker_id = get_worker_id(request) + BindGroupAuthor = get_bind_group_author_model("sqlite_bind", worker_id) + + BindGroupAuthor.metadata.create_all(sqlite_engine) + + try: + session_factory = sessionmaker(sqlite_engine) + with session_factory() as session: + + class BindGroupAuthorRepository(SQLAlchemySyncRepository[Any]): + model_type = BindGroupAuthor + + repo = BindGroupAuthorRepository(session=session, cache_manager=memory_cache_manager, auto_expunge=True) + + # Create an author + author = BindGroupAuthor(name="Test Author", bio="Bio") + repo.add(author) + session.commit() + + author_id = author.id + table_name = BindGroupAuthor.__tablename__ + + # Get without bind_group + author1 = repo.get(author_id) + assert author1.name == "Test Author" + + # Verify default cache was populated + cached_default = memory_cache_manager.get_entity_sync( + table_name, author_id, BindGroupAuthor, bind_group=None + ) + assert cached_default is not None + + # Get with bind_group="shard_a" + author2 = repo.get(author_id, bind_group="shard_a") + assert author2.name == "Test Author" + + # Verify shard_a cache was populated + cached_shard_a = memory_cache_manager.get_entity_sync( + table_name, author_id, BindGroupAuthor, bind_group="shard_a" + ) + assert cached_shard_a is not None + + # Invalidate only shard_a + memory_cache_manager.invalidate_entity_sync(table_name, author_id, bind_group="shard_a") + + # Verify default still cached, shard_a invalidated + assert ( + memory_cache_manager.get_entity_sync(table_name, author_id, BindGroupAuthor, bind_group=None) + is not None + ) + assert ( + memory_cache_manager.get_entity_sync(table_name, author_id, BindGroupAuthor, bind_group="shard_a") + is None + ) + + finally: + BindGroupAuthor.metadata.drop_all(sqlite_engine) + + +@pytest.mark.sqlite +@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed") +def test_sync_repository_get_bypasses_cache_when_disabled( + sqlite_engine: Engine, + memory_cache_manager: CacheManager, + request: pytest.FixtureRequest, +) -> None: + """Test sync repository get() with use_cache=False bypasses cache regardless of bind_group.""" + from sqlalchemy.orm import sessionmaker + + worker_id = get_worker_id(request) + BindGroupAuthor = get_bind_group_author_model("sqlite_bypass", worker_id) + + BindGroupAuthor.metadata.create_all(sqlite_engine) + + try: + session_factory = sessionmaker(sqlite_engine) + with session_factory() as session: + + class BindGroupAuthorRepository(SQLAlchemySyncRepository[Any]): + model_type = BindGroupAuthor + + repo = BindGroupAuthorRepository(session=session, cache_manager=memory_cache_manager, auto_expunge=True) + + author = BindGroupAuthor(name="No Cache") + repo.add(author) + session.commit() + + author_id = author.id + table_name = BindGroupAuthor.__tablename__ + + # Get with cache disabled and bind_group + repo.get(author_id, use_cache=False, bind_group="shard_a") + + # Cache should not be populated + cached = memory_cache_manager.get_entity_sync(table_name, author_id, BindGroupAuthor, bind_group="shard_a") + assert cached is None, "Cache should not be populated when use_cache=False" + + finally: + BindGroupAuthor.metadata.drop_all(sqlite_engine) + + +# ============================================================================ +# Repository default bind_group Tests +# ============================================================================ + + +@pytest.mark.asyncio +@pytest.mark.aiosqlite +@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed") +async def test_async_repository_uses_default_bind_group_for_cache( + aiosqlite_engine: AsyncEngine, + memory_cache_manager: CacheManager, + request: pytest.FixtureRequest, +) -> None: + """Test that repository uses default bind_group from constructor for cache keys.""" + from sqlalchemy.ext.asyncio import AsyncSession as AS + from sqlalchemy.ext.asyncio import async_sessionmaker + + worker_id = get_worker_id(request) + BindGroupAuthor = get_bind_group_author_model("aiosqlite_default", worker_id) + + async with aiosqlite_engine.begin() as conn: + await conn.run_sync(BindGroupAuthor.metadata.create_all) + + try: + async_session_factory = async_sessionmaker(aiosqlite_engine, class_=AS, expire_on_commit=False) + async with async_session_factory() as session: + + class BindGroupAuthorRepository(SQLAlchemyAsyncRepository[Any]): + model_type = BindGroupAuthor + + # Create repo with default bind_group via constructor parameter + repo = BindGroupAuthorRepository( + session=session, + cache_manager=memory_cache_manager, + auto_expunge=True, + bind_group="default_shard", + ) + + author = BindGroupAuthor(name="Default Shard Author") + await repo.add(author) + await session.commit() + + author_id = author.id + table_name = BindGroupAuthor.__tablename__ + + # Get without explicit bind_group - should use default from constructor + await repo.get(author_id) + + # Verify cache was populated for default_shard bind_group + cached = memory_cache_manager.get_entity_sync( + table_name, author_id, BindGroupAuthor, bind_group="default_shard" + ) + assert cached is not None, "Cache should use default bind_group from constructor" + + # Verify no cache for None bind_group + cached_none = memory_cache_manager.get_entity_sync(table_name, author_id, BindGroupAuthor, bind_group=None) + assert cached_none is None, "No cache should exist for None bind_group" + + finally: + async with aiosqlite_engine.begin() as conn: + await conn.run_sync(BindGroupAuthor.metadata.drop_all) + + +@pytest.mark.asyncio +@pytest.mark.aiosqlite +@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed") +async def test_async_repository_explicit_bind_group_overrides_default( + aiosqlite_engine: AsyncEngine, + memory_cache_manager: CacheManager, + request: pytest.FixtureRequest, +) -> None: + """Test that explicit bind_group parameter overrides repository default.""" + from sqlalchemy.ext.asyncio import AsyncSession as AS + from sqlalchemy.ext.asyncio import async_sessionmaker + + worker_id = get_worker_id(request) + BindGroupAuthor = get_bind_group_author_model("aiosqlite_override", worker_id) + + async with aiosqlite_engine.begin() as conn: + await conn.run_sync(BindGroupAuthor.metadata.create_all) + + try: + async_session_factory = async_sessionmaker(aiosqlite_engine, class_=AS, expire_on_commit=False) + async with async_session_factory() as session: + + class BindGroupAuthorRepository(SQLAlchemyAsyncRepository[Any]): + model_type = BindGroupAuthor + + # Create repo with default bind_group via constructor + repo = BindGroupAuthorRepository( + session=session, + cache_manager=memory_cache_manager, + auto_expunge=True, + bind_group="default_shard", + ) + + author = BindGroupAuthor(name="Override Test") + await repo.add(author) + await session.commit() + + author_id = author.id + table_name = BindGroupAuthor.__tablename__ + + # Get with explicit bind_group override + await repo.get(author_id, bind_group="override_shard") + + # Verify cache was populated for override_shard, not default_shard + cached_override = memory_cache_manager.get_entity_sync( + table_name, author_id, BindGroupAuthor, bind_group="override_shard" + ) + cached_default = memory_cache_manager.get_entity_sync( + table_name, author_id, BindGroupAuthor, bind_group="default_shard" + ) + + assert cached_override is not None, "Cache should be for override_shard" + assert cached_default is None, "No cache for default_shard when overridden" + + finally: + async with aiosqlite_engine.begin() as conn: + await conn.run_sync(BindGroupAuthor.metadata.drop_all) + + +# ============================================================================ +# Cache Manager Direct Tests (using model instances) +# ============================================================================ + + +@pytest.mark.asyncio +@pytest.mark.aiosqlite +@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed") +async def test_cache_manager_entity_methods_with_bind_group( + aiosqlite_engine: AsyncEngine, + memory_cache_manager: CacheManager, + request: pytest.FixtureRequest, +) -> None: + """Test CacheManager entity methods correctly handle bind_group in cache keys.""" + from sqlalchemy.ext.asyncio import AsyncSession as AS + from sqlalchemy.ext.asyncio import async_sessionmaker + + worker_id = get_worker_id(request) + BindGroupAuthor = get_bind_group_author_model("aiosqlite_cm", worker_id) + + async with aiosqlite_engine.begin() as conn: + await conn.run_sync(BindGroupAuthor.metadata.create_all) + + try: + async_session_factory = async_sessionmaker(aiosqlite_engine, class_=AS, expire_on_commit=False) + async with async_session_factory() as session: + # Create a model instance + author = BindGroupAuthor(name="Cache Manager Test") + session.add(author) + await session.commit() + + author_id = author.id + table_name = BindGroupAuthor.__tablename__ + + # Test set_entity_sync with different bind_groups + memory_cache_manager.set_entity_sync(table_name, author_id, author, bind_group=None) + + # Modify for shard_a (simulate different data in shard) + author.name = "Shard A Data" + memory_cache_manager.set_entity_sync(table_name, author_id, author, bind_group="shard_a") + + # Modify for shard_b + author.name = "Shard B Data" + memory_cache_manager.set_entity_sync(table_name, author_id, author, bind_group="shard_b") + + # Test get_entity_sync returns correct cached entity per bind_group + cached_default = memory_cache_manager.get_entity_sync( + table_name, author_id, BindGroupAuthor, bind_group=None + ) + cached_shard_a = memory_cache_manager.get_entity_sync( + table_name, author_id, BindGroupAuthor, bind_group="shard_a" + ) + cached_shard_b = memory_cache_manager.get_entity_sync( + table_name, author_id, BindGroupAuthor, bind_group="shard_b" + ) + + assert cached_default is not None + assert cached_shard_a is not None + assert cached_shard_b is not None + + # Names should reflect what was cached at the time + assert cached_default.name == "Cache Manager Test" + assert cached_shard_a.name == "Shard A Data" + assert cached_shard_b.name == "Shard B Data" + + # Test invalidate_entity_sync only affects specific bind_group + memory_cache_manager.invalidate_entity_sync(table_name, author_id, bind_group="shard_a") + + # Verify only shard_a was invalidated + assert ( + memory_cache_manager.get_entity_sync(table_name, author_id, BindGroupAuthor, bind_group=None) + is not None + ) + assert ( + memory_cache_manager.get_entity_sync(table_name, author_id, BindGroupAuthor, bind_group="shard_a") + is None + ) + assert ( + memory_cache_manager.get_entity_sync(table_name, author_id, BindGroupAuthor, bind_group="shard_b") + is not None + ) + + finally: + async with aiosqlite_engine.begin() as conn: + await conn.run_sync(BindGroupAuthor.metadata.drop_all) + + +@pytest.mark.asyncio +@pytest.mark.aiosqlite +@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed") +async def test_cache_manager_async_entity_methods_with_bind_group( + aiosqlite_engine: AsyncEngine, + memory_cache_manager: CacheManager, + request: pytest.FixtureRequest, +) -> None: + """Test CacheManager async entity methods correctly handle bind_group in cache keys.""" + from sqlalchemy.ext.asyncio import AsyncSession as AS + from sqlalchemy.ext.asyncio import async_sessionmaker + + worker_id = get_worker_id(request) + BindGroupAuthor = get_bind_group_author_model("aiosqlite_cm_async", worker_id) + + async with aiosqlite_engine.begin() as conn: + await conn.run_sync(BindGroupAuthor.metadata.create_all) + + try: + async_session_factory = async_sessionmaker(aiosqlite_engine, class_=AS, expire_on_commit=False) + async with async_session_factory() as session: + author = BindGroupAuthor(name="Async Cache Test") + session.add(author) + await session.commit() + + author_id = author.id + table_name = BindGroupAuthor.__tablename__ + + # Test async set/get with bind_groups + await memory_cache_manager.set_entity_async(table_name, author_id, author, bind_group=None) + + author.name = "Shard A Async" + await memory_cache_manager.set_entity_async(table_name, author_id, author, bind_group="shard_a") + + # Verify both cached correctly + cached_default = await memory_cache_manager.get_entity_async( + table_name, author_id, BindGroupAuthor, bind_group=None + ) + cached_shard_a = await memory_cache_manager.get_entity_async( + table_name, author_id, BindGroupAuthor, bind_group="shard_a" + ) + + assert cached_default is not None + assert cached_shard_a is not None + assert cached_default.name == "Async Cache Test" + assert cached_shard_a.name == "Shard A Async" + + # Test async invalidation + await memory_cache_manager.invalidate_entity_async(table_name, author_id, bind_group="shard_a") + + # Verify only shard_a was invalidated + assert ( + await memory_cache_manager.get_entity_async(table_name, author_id, BindGroupAuthor, bind_group=None) + is not None + ) + assert ( + await memory_cache_manager.get_entity_async( + table_name, author_id, BindGroupAuthor, bind_group="shard_a" + ) + is None + ) + + finally: + async with aiosqlite_engine.begin() as conn: + await conn.run_sync(BindGroupAuthor.metadata.drop_all) + + +# ============================================================================ +# Cache Invalidation Tracker with Commit Tests +# ============================================================================ + + +@pytest.mark.asyncio +@pytest.mark.aiosqlite +@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed") +async def test_cache_invalidation_tracker_commit_with_bind_group( + aiosqlite_engine: AsyncEngine, + memory_cache_manager: CacheManager, + request: pytest.FixtureRequest, +) -> None: + """Test CacheInvalidationTracker commit properly invalidates with bind_group.""" + from sqlalchemy.ext.asyncio import AsyncSession as AS + from sqlalchemy.ext.asyncio import async_sessionmaker + + from advanced_alchemy._listeners import CacheInvalidationTracker + + worker_id = get_worker_id(request) + BindGroupAuthor = get_bind_group_author_model("aiosqlite_tracker", worker_id) + + async with aiosqlite_engine.begin() as conn: + await conn.run_sync(BindGroupAuthor.metadata.create_all) + + try: + async_session_factory = async_sessionmaker(aiosqlite_engine, class_=AS, expire_on_commit=False) + async with async_session_factory() as session: + author = BindGroupAuthor(name="Tracker Test") + session.add(author) + await session.commit() + + author_id = author.id + table_name = BindGroupAuthor.__tablename__ + + # Pre-populate cache for different bind_groups + memory_cache_manager.set_entity_sync(table_name, author_id, author, bind_group=None) + memory_cache_manager.set_entity_sync(table_name, author_id, author, bind_group="shard_a") + memory_cache_manager.set_entity_sync(table_name, author_id, author, bind_group="shard_b") + + # Create tracker and add invalidation for shard_a only + tracker = CacheInvalidationTracker(memory_cache_manager) + tracker.add_invalidation(table_name, author_id, bind_group="shard_a") + + # Commit the tracker (sync) + tracker.commit() + + # Verify only shard_a was invalidated + assert ( + memory_cache_manager.get_entity_sync(table_name, author_id, BindGroupAuthor, bind_group=None) + is not None + ) + assert ( + memory_cache_manager.get_entity_sync(table_name, author_id, BindGroupAuthor, bind_group="shard_a") + is None + ) + assert ( + memory_cache_manager.get_entity_sync(table_name, author_id, BindGroupAuthor, bind_group="shard_b") + is not None + ) + + finally: + async with aiosqlite_engine.begin() as conn: + await conn.run_sync(BindGroupAuthor.metadata.drop_all) + + +@pytest.mark.asyncio +@pytest.mark.aiosqlite +@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed") +async def test_cache_invalidation_tracker_async_commit_with_bind_group( + aiosqlite_engine: AsyncEngine, + memory_cache_manager: CacheManager, + request: pytest.FixtureRequest, +) -> None: + """Test CacheInvalidationTracker async commit properly invalidates with bind_group.""" + from sqlalchemy.ext.asyncio import AsyncSession as AS + from sqlalchemy.ext.asyncio import async_sessionmaker + + from advanced_alchemy._listeners import CacheInvalidationTracker + + worker_id = get_worker_id(request) + BindGroupAuthor = get_bind_group_author_model("aiosqlite_tracker_async", worker_id) + + async with aiosqlite_engine.begin() as conn: + await conn.run_sync(BindGroupAuthor.metadata.create_all) + + try: + async_session_factory = async_sessionmaker(aiosqlite_engine, class_=AS, expire_on_commit=False) + async with async_session_factory() as session: + author = BindGroupAuthor(name="Async Tracker Test") + session.add(author) + await session.commit() + + author_id = author.id + table_name = BindGroupAuthor.__tablename__ + + # Pre-populate cache + await memory_cache_manager.set_entity_async(table_name, author_id, author, bind_group=None) + await memory_cache_manager.set_entity_async(table_name, author_id, author, bind_group="shard_a") + + # Create tracker and add invalidation + tracker = CacheInvalidationTracker(memory_cache_manager) + tracker.add_invalidation(table_name, author_id, bind_group="shard_a") + + # Async commit + await tracker.commit_async() + + # Verify only shard_a was invalidated + assert ( + await memory_cache_manager.get_entity_async(table_name, author_id, BindGroupAuthor, bind_group=None) + is not None + ) + assert ( + await memory_cache_manager.get_entity_async( + table_name, author_id, BindGroupAuthor, bind_group="shard_a" + ) + is None + ) + + finally: + async with aiosqlite_engine.begin() as conn: + await conn.run_sync(BindGroupAuthor.metadata.drop_all) diff --git a/tests/integration/test_cache_repository.py b/tests/integration/test_cache_repository.py new file mode 100644 index 000000000..605edb8d0 --- /dev/null +++ b/tests/integration/test_cache_repository.py @@ -0,0 +1,555 @@ +"""Integration tests for repository caching with dogpile.cache.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Generator +from typing import TYPE_CHECKING, Any, Optional, cast + +import pytest +from sqlalchemy import Engine, String, event +from sqlalchemy.ext.asyncio import AsyncEngine +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + +from advanced_alchemy.base import UUIDBase +from advanced_alchemy.cache import setup_cache_listeners +from advanced_alchemy.cache.config import CacheConfig +from advanced_alchemy.cache.manager import DOGPILE_CACHE_INSTALLED, CacheManager +from advanced_alchemy.repository import SQLAlchemyAsyncRepository, SQLAlchemySyncRepository + +if TYPE_CHECKING: + pass + +pytestmark = [ + pytest.mark.integration, + pytest.mark.xdist_group("cache"), +] + + +@pytest.fixture(scope="module", autouse=True) +def _setup_cache_listeners() -> Generator[None, None, None]: + """Set up global cache listeners for all tests in this module.""" + setup_cache_listeners() + yield + + +# Module-level cache for model and counter for unique names +_model_cache: dict[str, type] = {} +_class_counter = 0 + + +def get_cached_author_model(engine_dialect_name: str, worker_id: str) -> type[DeclarativeBase]: + """Create appropriate CachedAuthor model based on engine dialect.""" + global _class_counter + cache_key = f"cached_author_{worker_id}_{engine_dialect_name}" + + if cache_key not in _model_cache: + + class TestBase(DeclarativeBase): + pass + + _class_counter += 1 + unique_suffix = f"{_class_counter}_{worker_id}_{engine_dialect_name}" + + class_name = f"CachedAuthor_{unique_suffix}" + + CachedAuthor = type( + class_name, + (UUIDBase, TestBase), + { + "__tablename__": f"test_cached_authors_{worker_id}_{engine_dialect_name}", + "__mapper_args__": {"concrete": True}, + "__module__": __name__, + "name": mapped_column(String(length=100)), + "bio": mapped_column(String(length=500), nullable=True), + "__annotations__": {"name": Mapped[str], "bio": Mapped[Optional[str]]}, + }, + ) + + _model_cache[cache_key] = CachedAuthor + + return _model_cache[cache_key] + + +def get_worker_id(request: pytest.FixtureRequest) -> str: + """Get worker ID for pytest-xdist or 'master' for single process.""" + workerinput = getattr(request.config, "workerinput", None) + if isinstance(workerinput, dict): + return cast("str", workerinput.get("workerid", "master")) + return "master" + + +@pytest.fixture +def memory_cache_manager() -> CacheManager: + """Create a CacheManager with memory backend for testing.""" + config = CacheConfig( + backend="dogpile.cache.memory", + expiration_time=300, + key_prefix="test:", + ) + return CacheManager(config) + + +@pytest.fixture +def disabled_cache_manager() -> CacheManager: + """Create a CacheManager with caching disabled.""" + config = CacheConfig(enabled=False) + return CacheManager(config) + + +# Async repository tests + + +@pytest.mark.asyncio +@pytest.mark.aiosqlite +@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed") +async def test_async_repository_get_uses_cache( + aiosqlite_engine: AsyncEngine, + memory_cache_manager: CacheManager, + request: pytest.FixtureRequest, +) -> None: + """Test async repository get() uses cache on second call.""" + from sqlalchemy.ext.asyncio import AsyncSession as AS + from sqlalchemy.ext.asyncio import async_sessionmaker + + worker_id = get_worker_id(request) + CachedAuthor = get_cached_author_model("aiosqlite", worker_id) + + # Create tables + async with aiosqlite_engine.begin() as conn: + await conn.run_sync(CachedAuthor.metadata.create_all) + + try: + async_session_factory = async_sessionmaker(aiosqlite_engine, class_=AS, expire_on_commit=False) + async with async_session_factory() as session: + + class CachedAuthorRepository(SQLAlchemyAsyncRepository[Any]): + model_type = CachedAuthor + + repo = CachedAuthorRepository(session=session, cache_manager=memory_cache_manager, auto_expunge=True) + + # Create an author + author = CachedAuthor(name="John Doe", bio="Author bio") + await repo.add(author) + await session.commit() + + author_id = author.id + + # First get - should hit database and populate cache + author1 = await repo.get(author_id) + assert author1.name == "John Doe" + + # Verify cache was populated + table_name = CachedAuthor.__tablename__ + cached = memory_cache_manager.get_entity_sync(table_name, author_id, CachedAuthor) + assert cached is not None + assert cached.name == "John Doe" + + # Get again - should use cache + author2 = await repo.get(author_id) + assert author2.name == "John Doe" + + finally: + async with aiosqlite_engine.begin() as conn: + await conn.run_sync(CachedAuthor.metadata.drop_all) + + +@pytest.mark.asyncio +@pytest.mark.aiosqlite +@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed") +async def test_async_repository_get_use_cache_false_bypasses_cache( + aiosqlite_engine: AsyncEngine, + memory_cache_manager: CacheManager, + request: pytest.FixtureRequest, +) -> None: + """Test async repository get() with use_cache=False bypasses cache.""" + from sqlalchemy.ext.asyncio import AsyncSession as AS + from sqlalchemy.ext.asyncio import async_sessionmaker + + worker_id = get_worker_id(request) + CachedAuthor = get_cached_author_model("aiosqlite_bypass", worker_id) + + async with aiosqlite_engine.begin() as conn: + await conn.run_sync(CachedAuthor.metadata.create_all) + + try: + async_session_factory = async_sessionmaker(aiosqlite_engine, class_=AS, expire_on_commit=False) + async with async_session_factory() as session: + + class CachedAuthorRepository(SQLAlchemyAsyncRepository[Any]): + model_type = CachedAuthor + + repo = CachedAuthorRepository(session=session, cache_manager=memory_cache_manager, auto_expunge=True) + + # Create an author + author = CachedAuthor(name="Jane Doe") + await repo.add(author) + await session.commit() + + author_id = author.id + + # Get with cache disabled + author1 = await repo.get(author_id, use_cache=False) + assert author1.name == "Jane Doe" + + # Cache should be empty since we used use_cache=False + table_name = CachedAuthor.__tablename__ + cached = memory_cache_manager.get_entity_sync(table_name, author_id, CachedAuthor) + assert cached is None + + finally: + async with aiosqlite_engine.begin() as conn: + await conn.run_sync(CachedAuthor.metadata.drop_all) + + +@pytest.mark.asyncio +@pytest.mark.aiosqlite +async def test_async_repository_without_cache_manager_works( + aiosqlite_engine: AsyncEngine, + request: pytest.FixtureRequest, +) -> None: + """Test async repository works without cache_manager (cache disabled).""" + from sqlalchemy.ext.asyncio import AsyncSession as AS + from sqlalchemy.ext.asyncio import async_sessionmaker + + worker_id = get_worker_id(request) + CachedAuthor = get_cached_author_model("aiosqlite_nocache", worker_id) + + async with aiosqlite_engine.begin() as conn: + await conn.run_sync(CachedAuthor.metadata.create_all) + + try: + async_session_factory = async_sessionmaker(aiosqlite_engine, class_=AS, expire_on_commit=False) + async with async_session_factory() as session: + + class CachedAuthorRepository(SQLAlchemyAsyncRepository[Any]): + model_type = CachedAuthor + + repo = CachedAuthorRepository(session=session) # No cache_manager + + # Should work normally + author = CachedAuthor(name="No Cache") + await repo.add(author) + await session.commit() + + retrieved = await repo.get(author.id) + assert retrieved.name == "No Cache" + + finally: + async with aiosqlite_engine.begin() as conn: + await conn.run_sync(CachedAuthor.metadata.drop_all) + + +@pytest.mark.asyncio +@pytest.mark.aiosqlite +async def test_async_repository_cache_disabled_config( + aiosqlite_engine: AsyncEngine, + disabled_cache_manager: CacheManager, + request: pytest.FixtureRequest, +) -> None: + """Test async repository with disabled cache config.""" + from sqlalchemy.ext.asyncio import AsyncSession as AS + from sqlalchemy.ext.asyncio import async_sessionmaker + + worker_id = get_worker_id(request) + CachedAuthor = get_cached_author_model("aiosqlite_disabled", worker_id) + + async with aiosqlite_engine.begin() as conn: + await conn.run_sync(CachedAuthor.metadata.create_all) + + try: + async_session_factory = async_sessionmaker(aiosqlite_engine, class_=AS, expire_on_commit=False) + async with async_session_factory() as session: + + class CachedAuthorRepository(SQLAlchemyAsyncRepository[Any]): + model_type = CachedAuthor + + repo = CachedAuthorRepository(session=session, cache_manager=disabled_cache_manager, auto_expunge=True) + + # Should work but not cache anything + author = CachedAuthor(name="Test") + await repo.add(author) + await session.commit() + + retrieved = await repo.get(author.id) + assert retrieved.name == "Test" + + finally: + async with aiosqlite_engine.begin() as conn: + await conn.run_sync(CachedAuthor.metadata.drop_all) + + +@pytest.mark.asyncio +@pytest.mark.aiosqlite +@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed") +async def test_async_repository_list_uses_cache_and_invalidates_on_commit( + aiosqlite_engine: AsyncEngine, + memory_cache_manager: CacheManager, + request: pytest.FixtureRequest, +) -> None: + """Test async repository list() caching and version-token invalidation.""" + from sqlalchemy.ext.asyncio import AsyncSession as AS + from sqlalchemy.ext.asyncio import async_sessionmaker + + worker_id = get_worker_id(request) + CachedAuthor = get_cached_author_model("aiosqlite_list", worker_id) + + async with aiosqlite_engine.begin() as conn: + await conn.run_sync(CachedAuthor.metadata.create_all) + + query_count = 0 + + def before_cursor_execute(_conn: object, _cursor: object, statement: str, *_: object) -> None: + nonlocal query_count + if statement.lstrip().upper().startswith("SELECT"): + query_count += 1 + + event.listen(aiosqlite_engine.sync_engine, "before_cursor_execute", before_cursor_execute) + + try: + async_session_factory = async_sessionmaker(aiosqlite_engine, class_=AS, expire_on_commit=False) + async with async_session_factory() as session: + + class CachedAuthorRepository(SQLAlchemyAsyncRepository[Any]): + model_type = CachedAuthor + + repo = CachedAuthorRepository(session=session, cache_manager=memory_cache_manager, auto_expunge=True) + + author = CachedAuthor(name="List Test", bio="Bio") + await repo.add(author) + await session.commit() + + # Ensure we start from a known version token + model_name = CachedAuthor.__tablename__ + version_before = memory_cache_manager.get_model_version_sync(model_name) + + query_count = 0 + authors_1 = await repo.list() + assert len(authors_1) == 1 + assert query_count > 0 + + query_count = 0 + authors_2 = await repo.list() + assert len(authors_2) == 1 + assert query_count == 0 + + # Mutate + commit should bump model version token (invalidating list caches) + author = await repo.get(author.id, use_cache=False) + author.bio = "Updated" + await repo.update(author) + await session.commit() + + # Wait for eventual async invalidation tasks to complete + import advanced_alchemy._listeners as listeners + + if listeners._active_cache_operations: + await asyncio.gather(*list(listeners._active_cache_operations)) + + version_after = memory_cache_manager.get_model_version_sync(model_name) + assert version_after != version_before + + query_count = 0 + authors_3 = await repo.list() + assert len(authors_3) == 1 + assert query_count > 0 + + finally: + event.remove(aiosqlite_engine.sync_engine, "before_cursor_execute", before_cursor_execute) + async with aiosqlite_engine.begin() as conn: + await conn.run_sync(CachedAuthor.metadata.drop_all) + + +@pytest.mark.asyncio +@pytest.mark.aiosqlite +@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed") +async def test_async_repository_list_and_count_uses_cache( + aiosqlite_engine: AsyncEngine, + memory_cache_manager: CacheManager, + request: pytest.FixtureRequest, +) -> None: + """Test async repository list_and_count() caching.""" + from sqlalchemy.ext.asyncio import AsyncSession as AS + from sqlalchemy.ext.asyncio import async_sessionmaker + + worker_id = get_worker_id(request) + CachedAuthor = get_cached_author_model("aiosqlite_list_and_count", worker_id) + + async with aiosqlite_engine.begin() as conn: + await conn.run_sync(CachedAuthor.metadata.create_all) + + query_count = 0 + + def before_cursor_execute(_conn: object, _cursor: object, statement: str, *_: object) -> None: + nonlocal query_count + if statement.lstrip().upper().startswith("SELECT"): + query_count += 1 + + event.listen(aiosqlite_engine.sync_engine, "before_cursor_execute", before_cursor_execute) + + try: + async_session_factory = async_sessionmaker(aiosqlite_engine, class_=AS, expire_on_commit=False) + async with async_session_factory() as session: + + class CachedAuthorRepository(SQLAlchemyAsyncRepository[Any]): + model_type = CachedAuthor + + repo = CachedAuthorRepository(session=session, cache_manager=memory_cache_manager, auto_expunge=True) + + await repo.add(CachedAuthor(name="A1")) + await repo.add(CachedAuthor(name="A2")) + await session.commit() + + query_count = 0 + items_1, count_1 = await repo.list_and_count() + assert count_1 == 2 + assert len(items_1) == 2 + assert query_count > 0 + + query_count = 0 + items_2, count_2 = await repo.list_and_count() + assert count_2 == 2 + assert len(items_2) == 2 + assert query_count == 0 + + finally: + event.remove(aiosqlite_engine.sync_engine, "before_cursor_execute", before_cursor_execute) + async with aiosqlite_engine.begin() as conn: + await conn.run_sync(CachedAuthor.metadata.drop_all) + + +@pytest.mark.asyncio +@pytest.mark.aiosqlite +@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed") +async def test_async_repository_get_singleflight_coalesces_concurrent_misses( + aiosqlite_engine: AsyncEngine, + memory_cache_manager: CacheManager, + request: pytest.FixtureRequest, +) -> None: + """Test per-process async singleflight reduces stampedes on cache miss.""" + from sqlalchemy.ext.asyncio import AsyncSession as AS + from sqlalchemy.ext.asyncio import async_sessionmaker + + worker_id = get_worker_id(request) + CachedAuthor = get_cached_author_model("aiosqlite_singleflight", worker_id) + + async with aiosqlite_engine.begin() as conn: + await conn.run_sync(CachedAuthor.metadata.create_all) + + query_count = 0 + + def before_cursor_execute(_conn: object, _cursor: object, statement: str, *_: object) -> None: + nonlocal query_count + if statement.lstrip().upper().startswith("SELECT"): + query_count += 1 + + event.listen(aiosqlite_engine.sync_engine, "before_cursor_execute", before_cursor_execute) + + try: + async_session_factory = async_sessionmaker(aiosqlite_engine, class_=AS, expire_on_commit=False) + async with async_session_factory() as session: + + class CachedAuthorRepository(SQLAlchemyAsyncRepository[Any]): + model_type = CachedAuthor + + repo = CachedAuthorRepository(session=session, cache_manager=memory_cache_manager, auto_expunge=True) + + author = CachedAuthor(name="SF") + await repo.add(author) + await session.commit() + + author_id = author.id + model_name = CachedAuthor.__tablename__ + + # Force cache miss for this entity + memory_cache_manager.invalidate_entity_sync(model_name, author_id) + + query_count = 0 + results = await asyncio.gather(*[repo.get(author_id) for _ in range(10)]) + assert all(r.id == author_id for r in results) + assert query_count == 1 + + finally: + event.remove(aiosqlite_engine.sync_engine, "before_cursor_execute", before_cursor_execute) + async with aiosqlite_engine.begin() as conn: + await conn.run_sync(CachedAuthor.metadata.drop_all) + + +# Sync repository tests + + +@pytest.mark.sqlite +@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed") +def test_sync_repository_get_uses_cache( + sqlite_engine: Engine, + memory_cache_manager: CacheManager, + request: pytest.FixtureRequest, +) -> None: + """Test sync repository get() uses cache on second call.""" + from sqlalchemy.orm import sessionmaker + + worker_id = get_worker_id(request) + CachedAuthor = get_cached_author_model("sqlite", worker_id) + + CachedAuthor.metadata.create_all(sqlite_engine) + + try: + session_factory = sessionmaker(sqlite_engine) + with session_factory() as session: + + class CachedAuthorRepository(SQLAlchemySyncRepository[Any]): + model_type = CachedAuthor + + repo = CachedAuthorRepository(session=session, cache_manager=memory_cache_manager, auto_expunge=True) + + # Create an author + author = CachedAuthor(name="John Doe", bio="Author bio") + repo.add(author) + session.commit() + + author_id = author.id + + # First get - should hit database and populate cache + author1 = repo.get(author_id) + assert author1.name == "John Doe" + + # Verify cache was populated + table_name = CachedAuthor.__tablename__ + cached = memory_cache_manager.get_entity_sync(table_name, author_id, CachedAuthor) + assert cached is not None + assert cached.name == "John Doe" + + finally: + CachedAuthor.metadata.drop_all(sqlite_engine) + + +@pytest.mark.sqlite +def test_sync_repository_without_cache_manager_works( + sqlite_engine: Engine, + request: pytest.FixtureRequest, +) -> None: + """Test sync repository works without cache_manager.""" + from sqlalchemy.orm import sessionmaker + + worker_id = get_worker_id(request) + CachedAuthor = get_cached_author_model("sqlite_nocache", worker_id) + + CachedAuthor.metadata.create_all(sqlite_engine) + + try: + session_factory = sessionmaker(sqlite_engine) + with session_factory() as session: + + class CachedAuthorRepository(SQLAlchemySyncRepository[Any]): + model_type = CachedAuthor + + repo = CachedAuthorRepository(session=session) # No cache_manager + + author = CachedAuthor(name="No Cache Sync") + repo.add(author) + session.commit() + + retrieved = repo.get(author.id) + assert retrieved.name == "No Cache Sync" + + finally: + CachedAuthor.metadata.drop_all(sqlite_engine) diff --git a/tests/unit/test_cache/__init__.py b/tests/unit/test_cache/__init__.py new file mode 100644 index 000000000..d65b8afca --- /dev/null +++ b/tests/unit/test_cache/__init__.py @@ -0,0 +1 @@ +# ruff: noqa diff --git a/tests/unit/test_cache/test_cache_config.py b/tests/unit/test_cache/test_cache_config.py new file mode 100644 index 000000000..4c56f7d0e --- /dev/null +++ b/tests/unit/test_cache/test_cache_config.py @@ -0,0 +1,87 @@ +"""Unit tests for CacheConfig dataclass.""" + +from __future__ import annotations + +from advanced_alchemy.cache.config import CacheConfig + + +def test_cache_config_defaults() -> None: + """Test CacheConfig has sensible defaults.""" + config = CacheConfig() + + assert config.backend == "dogpile.cache.null" + assert config.expiration_time == 3600 + assert config.arguments == {} + assert config.key_prefix == "aa:" + assert config.enabled is True + assert config.serializer is None + assert config.deserializer is None + assert config.region_factory is None + + +def test_cache_config_custom_backend() -> None: + """Test CacheConfig with custom backend.""" + config = CacheConfig( + backend="dogpile.cache.memory", + expiration_time=300, + ) + + assert config.backend == "dogpile.cache.memory" + assert config.expiration_time == 300 + + +def test_cache_config_redis_arguments() -> None: + """Test CacheConfig with Redis-specific arguments.""" + config = CacheConfig( + backend="dogpile.cache.redis", + expiration_time=600, + arguments={ + "host": "localhost", + "port": 6379, + "db": 0, + }, + ) + + assert config.backend == "dogpile.cache.redis" + assert config.arguments["host"] == "localhost" + assert config.arguments["port"] == 6379 + assert config.arguments["db"] == 0 + + +def test_cache_config_disabled() -> None: + """Test CacheConfig can be disabled.""" + config = CacheConfig(enabled=False) + + assert config.enabled is False + + +def test_cache_config_custom_key_prefix() -> None: + """Test CacheConfig with custom key prefix.""" + config = CacheConfig(key_prefix="myapp:") + + assert config.key_prefix == "myapp:" + + +def test_cache_config_custom_serializers() -> None: + """Test CacheConfig with custom serializer/deserializer.""" + + def custom_serializer(obj: object) -> bytes: + return b"serialized" + + def custom_deserializer(data: bytes, model_class: type) -> object: + return model_class() + + config = CacheConfig( + serializer=custom_serializer, + deserializer=custom_deserializer, + ) + + assert config.serializer is custom_serializer + assert config.deserializer is custom_deserializer + + +def test_cache_config_no_expiration() -> None: + """Test CacheConfig with no expiration (set to -1).""" + config = CacheConfig(expiration_time=-1) + + assert config.expiration_time == -1 diff --git a/tests/unit/test_cache/test_cache_manager.py b/tests/unit/test_cache/test_cache_manager.py new file mode 100644 index 000000000..3e823270d --- /dev/null +++ b/tests/unit/test_cache/test_cache_manager.py @@ -0,0 +1,429 @@ +"""Unit tests for CacheManager.""" + +from __future__ import annotations + +import asyncio +import time +from typing import Any +from unittest.mock import MagicMock + +import pytest + +from advanced_alchemy.cache.config import CacheConfig +from advanced_alchemy.cache.manager import DOGPILE_CACHE_INSTALLED, CacheManager + + +@pytest.fixture +def memory_config() -> CacheConfig: + """Create a memory cache configuration.""" + return CacheConfig(backend="dogpile.cache.memory", expiration_time=300) + + +@pytest.fixture +def disabled_config() -> CacheConfig: + """Create a disabled cache configuration.""" + return CacheConfig(enabled=False) + + +@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed") +def test_cache_manager_lazy_initialization(memory_config: CacheConfig) -> None: + """Test CacheManager lazy initializes the region.""" + manager = CacheManager(memory_config) + + # Region should be None initially + assert manager._region is None + + # Accessing region should initialize it + region = manager.region + assert region is not None + assert manager._region is region + + +@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed") +def test_cache_manager_creates_region_with_config(memory_config: CacheConfig) -> None: + """Test CacheManager creates region with correct configuration.""" + manager = CacheManager(memory_config) + + region = manager.region + + # Verify region is configured (dogpile.cache doesn't expose config easily, + # but we can test it works) + assert region is not None + + +def test_cache_manager_disabled_returns_null_region(disabled_config: CacheConfig) -> None: + """Test CacheManager returns NullRegion when disabled.""" + manager = CacheManager(disabled_config) + + region = manager.region + + # Should be NullRegion + from advanced_alchemy.cache._null import NullRegion + + assert isinstance(region, NullRegion) + + +@pytest.mark.skipif(DOGPILE_CACHE_INSTALLED, reason="Test requires dogpile.cache NOT installed") +def test_cache_manager_without_dogpile_returns_null_region() -> None: + """Test CacheManager returns NullRegion when dogpile.cache not installed.""" + config = CacheConfig(backend="dogpile.cache.memory") + manager = CacheManager(config) + + region = manager.region + + from advanced_alchemy.cache._null import NullRegion + + assert isinstance(region, NullRegion) + + +@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed") +def test_cache_manager_get_or_create_caches_value(memory_config: CacheConfig) -> None: + """Test get_or_create caches the created value.""" + manager = CacheManager(memory_config) + call_count = 0 + + def creator() -> str: + nonlocal call_count + call_count += 1 + return "test_value" + + # First call should invoke creator + result1 = manager.get_or_create_sync("test_key", creator) + assert result1 == "test_value" + assert call_count == 1 + + # Second call should use cached value + result2 = manager.get_or_create_sync("test_key", creator) + assert result2 == "test_value" + assert call_count == 1 # Creator not called again + + +@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed") +def test_cache_manager_get_or_create_with_custom_expiration(memory_config: CacheConfig) -> None: + """Test get_or_create with custom expiration time.""" + manager = CacheManager(memory_config) + + result = manager.get_or_create_sync("key", lambda: "value", expiration_time=60) + + assert result == "value" + + +def test_cache_manager_get_or_create_disabled_always_calls_creator(disabled_config: CacheConfig) -> None: + """Test get_or_create bypasses cache when disabled.""" + manager = CacheManager(disabled_config) + call_count = 0 + + def creator() -> str: + nonlocal call_count + call_count += 1 + return "value" + + # Both calls should invoke creator + result1 = manager.get_or_create_sync("key", creator) + result2 = manager.get_or_create_sync("key", creator) + + assert result1 == "value" + assert result2 == "value" + assert call_count == 2 + + +@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed") +def test_cache_manager_get_set_delete(memory_config: CacheConfig) -> None: + """Test get, set, and delete operations.""" + from advanced_alchemy.cache.manager import DOGPILE_NO_VALUE + + manager = CacheManager(memory_config) + + # Initially empty + result_initial = manager.get_sync("test_key") + assert result_initial is DOGPILE_NO_VALUE or result_initial is None + + # Set value + manager.set_sync("test_key", "test_value") + + # Get value + result = manager.get_sync("test_key") + assert result == "test_value" + + # Delete value + manager.delete_sync("test_key") + + # Should be empty again + result_after_delete = manager.get_sync("test_key") + assert result_after_delete is DOGPILE_NO_VALUE or result_after_delete is None + + +def test_cache_manager_get_disabled_returns_no_value(disabled_config: CacheConfig) -> None: + """Test get returns NO_VALUE when disabled.""" + manager = CacheManager(disabled_config) + + result = manager.get_sync("any_key") + + # Should return dogpile's NO_VALUE + from advanced_alchemy.cache.manager import DOGPILE_NO_VALUE + + assert result is DOGPILE_NO_VALUE + + +def test_cache_manager_set_disabled_is_noop(disabled_config: CacheConfig) -> None: + """Test set does nothing when disabled.""" + manager = CacheManager(disabled_config) + + # Should not raise + manager.set_sync("key", "value") + + +@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed") +def test_cache_manager_make_key_adds_prefix(memory_config: CacheConfig) -> None: + """Test _make_key adds the configured prefix.""" + manager = CacheManager(memory_config) + + key = manager._make_key("test_key") + + assert key == "aa:test_key" + + +@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed") +def test_cache_manager_get_entity(memory_config: CacheConfig) -> None: + """Test get_entity retrieves and deserializes cached entity.""" + from advanced_alchemy._serialization import encode_json + + manager = CacheManager(memory_config) + + # Manually cache a serialized entity (using JSON directly to avoid SQLAlchemy complexity) + fake_entity_data = { + "__aa_model__": "TestModel", + "__aa_table__": "test_model", + "id": "12345678-1234-5678-1234-567812345678", + "name": "Test Entity", + } + serialized = encode_json(fake_entity_data).encode("utf-8") + manager.set_sync("test_model:get:1", serialized) + + # Note: This test is simplified - we skip deserialization test because it requires + # a real SQLAlchemy model. The integration tests cover full serialization/deserialization. + + +@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed") +def test_cache_manager_get_entity_not_found_returns_none(memory_config: CacheConfig) -> None: + """Test get_entity returns None when entity not in cache.""" + + manager = CacheManager(memory_config) + + # Use a mock class to avoid SQLAlchemy model creation complexity + MockModel = MagicMock() + + cached = manager.get_entity_sync("test_model", 999, MockModel) + + assert cached is None + + +@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed") +def test_cache_manager_get_entity_deserialization_error_returns_none(memory_config: CacheConfig) -> None: + """Test get_entity returns None and deletes corrupted cache on deserialization error.""" + + from advanced_alchemy.cache.manager import DOGPILE_NO_VALUE + + MockModel = MagicMock() + + manager = CacheManager(memory_config) + + # Put corrupted data in cache + manager.set_sync("test_model:get:1", b"corrupted_data") + + # Should return None and log error + cached = manager.get_entity_sync("test_model", 1, MockModel) + + assert cached is None + + # Corrupted entry should be deleted + result = manager.get_sync("test_model:get:1") + assert result is DOGPILE_NO_VALUE or result is None + + +@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed") +def test_cache_manager_set_entity_serialization_succeeds() -> None: + """Test set_entity can cache data (serialization tested in integration).""" + # Note: Full serialization test requires real SQLAlchemy models in a session. + # This is covered by integration tests. Here we just test the basic flow works. + pass + + +@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed") +def test_cache_manager_set_entity_serialization_error_logs_and_continues(memory_config: CacheConfig) -> None: + """Test set_entity logs error and continues on serialization failure.""" + + class UnserializableModel: + """Model that cannot be serialized.""" + + def __init__(self) -> None: + self.data = lambda: None # Functions can't be serialized + + manager = CacheManager(memory_config) + + # Should not raise, just log error + manager.set_entity_sync("test_model", 1, UnserializableModel()) + + +@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed") +def test_cache_manager_invalidate_entity(memory_config: CacheConfig) -> None: + """Test invalidate_entity removes entity from cache.""" + from advanced_alchemy.cache.manager import DOGPILE_NO_VALUE + + manager = CacheManager(memory_config) + + # Set a value + manager.set_sync("users:get:1", b"test_data") + + # Invalidate + manager.invalidate_entity_sync("users", 1) + + # Should be gone + result = manager.get_sync("users:get:1") + assert result is DOGPILE_NO_VALUE or result is None + + +@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed") +def test_cache_manager_bump_model_version(memory_config: CacheConfig) -> None: + """Test bump_model_version changes version token.""" + manager = CacheManager(memory_config) + + # Initial version should be 0 + assert manager.get_model_version_sync("users") == "0" + + # Bump version + version1 = manager.bump_model_version_sync("users") + assert version1 != "0" + assert manager.get_model_version_sync("users") == version1 + + # Bump again + version2 = manager.bump_model_version_sync("users") + assert version2 != version1 + assert manager.get_model_version_sync("users") == version2 + + +@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed") +def test_cache_manager_get_model_version_from_cache(memory_config: CacheConfig) -> None: + """Test get_model_version retrieves version from distributed cache.""" + manager = CacheManager(memory_config) + + # Manually set version in cache + manager.set_sync("users:version", "token") + + # Should retrieve from cache + version = manager.get_model_version_sync("users") + assert version == "token" + + +@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed") +def test_cache_manager_invalidate_all(memory_config: CacheConfig) -> None: + """Test invalidate_all clears entire cache.""" + from advanced_alchemy.cache.manager import DOGPILE_NO_VALUE + + manager = CacheManager(memory_config) + + # Set some values + manager.set_sync("key1", "value1") + manager.set_sync("key2", "value2") + manager._model_versions["users"] = "token" + + # Invalidate all + manager.invalidate_all_sync() + + # All should be cleared + result1 = manager.get_sync("key1") + result2 = manager.get_sync("key2") + assert result1 is DOGPILE_NO_VALUE or result1 is None + assert result2 is DOGPILE_NO_VALUE or result2 is None + assert manager._model_versions == {} + + +@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed") +def test_cache_manager_custom_serializers(memory_config: CacheConfig) -> None: + """Test CacheManager with custom serializer/deserializer.""" + + def custom_serializer(obj: Any) -> bytes: + return b"custom" + + def custom_deserializer(data: bytes, model_class: type) -> Any: + return "deserialized" + + memory_config.serializer = custom_serializer + memory_config.deserializer = custom_deserializer + + manager = CacheManager(memory_config) + + # set_entity should use custom serializer + manager.set_entity_sync("test", 1, {"data": "test"}) + + # get_entity should use custom deserializer + result = manager.get_entity_sync("test", 1, str) + assert result == "deserialized" + + +def test_cache_manager_handles_region_creation_failure() -> None: + """Test CacheManager handles region creation failure gracefully.""" + config = CacheConfig(backend="invalid.backend.that.does.not.exist") + + manager = CacheManager(config) + + # Should return NullRegion on failure + region = manager.region + + from advanced_alchemy.cache._null import NullRegion + + assert isinstance(region, NullRegion) + + +@pytest.mark.asyncio +async def test_cache_manager_get_async_does_not_block_event_loop() -> None: + """Ensure cache I/O is offloaded and doesn't block the loop.""" + + class SlowRegion: + def get(self, key: str, expiration_time: int | None = None) -> Any: + time.sleep(0.2) + return "value" + + def set(self, key: str, value: Any) -> None: + return + + def delete(self, key: str) -> None: + return + + def invalidate(self) -> None: + return + + config = CacheConfig(region_factory=lambda _cfg: SlowRegion()) + manager = CacheManager(config) + + ticks = 0 + + async def ticker() -> None: + nonlocal ticks + for _ in range(5): + await asyncio.sleep(0.05) + ticks += 1 + + result, _ = await asyncio.gather(manager.get_async("key"), ticker()) + + assert result == "value" + assert ticks >= 3 + + +@pytest.mark.asyncio +async def test_cache_manager_singleflight_async_coalesces() -> None: + """Ensure async singleflight invokes creator only once per key.""" + manager = CacheManager(CacheConfig(backend="dogpile.cache.null")) + + call_count = 0 + + async def creator() -> str: + nonlocal call_count + call_count += 1 + await asyncio.sleep(0.05) + return "ok" + + results = await asyncio.gather(*[manager.singleflight_async("k", creator) for _ in range(25)]) + + assert call_count == 1 + assert results == ["ok"] * 25 diff --git a/tests/unit/test_cache/test_cache_serializers.py b/tests/unit/test_cache/test_cache_serializers.py new file mode 100644 index 000000000..af1f1544d --- /dev/null +++ b/tests/unit/test_cache/test_cache_serializers.py @@ -0,0 +1,217 @@ +"""Unit tests for cache serialization utilities.""" + +from __future__ import annotations + +import datetime +from decimal import Decimal +from uuid import UUID + +import pytest + +from advanced_alchemy._serialization import decode_json, encode_json +from advanced_alchemy.cache.serializers import _decode_special_types, _json_decoder, _json_encoder + + +def test_json_encoder_datetime() -> None: + """Test encoding datetime objects.""" + dt = datetime.datetime(2025, 12, 14, 10, 30, 0) + + result = _json_encoder(dt) + + assert result == {"__type__": "datetime", "value": "2025-12-14T10:30:00"} + + +def test_json_encoder_date() -> None: + """Test encoding date objects.""" + d = datetime.date(2025, 12, 14) + + result = _json_encoder(d) + + assert result == {"__type__": "date", "value": "2025-12-14"} + + +def test_json_encoder_time() -> None: + """Test encoding time objects.""" + t = datetime.time(10, 30, 45) + + result = _json_encoder(t) + + assert result == {"__type__": "time", "value": "10:30:45"} + + +def test_json_encoder_timedelta() -> None: + """Test encoding timedelta objects.""" + td = datetime.timedelta(hours=2, minutes=30) + + result = _json_encoder(td) + + assert result == {"__type__": "timedelta", "value": 9000.0} + + +def test_json_encoder_decimal() -> None: + """Test encoding Decimal objects.""" + dec = Decimal("123.45") + + result = _json_encoder(dec) + + assert result == {"__type__": "decimal", "value": "123.45"} + + +def test_json_encoder_bytes() -> None: + """Test encoding bytes objects.""" + b = b"\x01\x02\xff" + + result = _json_encoder(b) + + assert result == {"__type__": "bytes", "value": "0102ff"} + + +def test_json_encoder_uuid() -> None: + """Test encoding UUID objects.""" + u = UUID("12345678-1234-5678-1234-567812345678") + + result = _json_encoder(u) + + assert result == {"__type__": "uuid", "value": "12345678-1234-5678-1234-567812345678"} + + +def test_json_encoder_set() -> None: + """Test encoding set objects.""" + s = {1, 2, 3} + + result = _json_encoder(s) + + assert result["__type__"] == "set" + assert set(result["value"]) == {1, 2, 3} + + +def test_json_encoder_unsupported_type() -> None: + """Test encoding unsupported type raises TypeError.""" + with pytest.raises(TypeError, match=r"Object of type.*is not JSON serializable"): + _json_encoder(lambda: None) + + +def test_json_decoder_datetime() -> None: + """Test decoding datetime objects.""" + data = {"__type__": "datetime", "value": "2025-12-14T10:30:00"} + + result = _json_decoder(data) + + assert isinstance(result, datetime.datetime) + assert result == datetime.datetime(2025, 12, 14, 10, 30, 0) + + +def test_json_decoder_date() -> None: + """Test decoding date objects.""" + data = {"__type__": "date", "value": "2025-12-14"} + + result = _json_decoder(data) + + assert isinstance(result, datetime.date) + assert result == datetime.date(2025, 12, 14) + + +def test_json_decoder_time() -> None: + """Test decoding time objects.""" + data = {"__type__": "time", "value": "10:30:45"} + + result = _json_decoder(data) + + assert isinstance(result, datetime.time) + assert result == datetime.time(10, 30, 45) + + +def test_json_decoder_timedelta() -> None: + """Test decoding timedelta objects.""" + data = {"__type__": "timedelta", "value": 9000.0} + + result = _json_decoder(data) + + assert isinstance(result, datetime.timedelta) + assert result == datetime.timedelta(hours=2, minutes=30) + + +def test_json_decoder_decimal() -> None: + """Test decoding Decimal objects.""" + data = {"__type__": "decimal", "value": "123.45"} + + result = _json_decoder(data) + + assert isinstance(result, Decimal) + assert result == Decimal("123.45") + + +def test_json_decoder_bytes() -> None: + """Test decoding bytes objects.""" + data = {"__type__": "bytes", "value": "0102ff"} + + result = _json_decoder(data) + + assert isinstance(result, bytes) + assert result == b"\x01\x02\xff" + + +def test_json_decoder_uuid() -> None: + """Test decoding UUID objects.""" + data = {"__type__": "uuid", "value": "12345678-1234-5678-1234-567812345678"} + + result = _json_decoder(data) + + assert isinstance(result, UUID) + assert result == UUID("12345678-1234-5678-1234-567812345678") + + +def test_json_decoder_set() -> None: + """Test decoding set objects.""" + data = {"__type__": "set", "value": [1, 2, 3]} + + result = _json_decoder(data) + + assert isinstance(result, set) + assert result == {1, 2, 3} + + +def test_json_decoder_non_special_type() -> None: + """Test decoding non-special type returns original dict.""" + data = {"foo": "bar"} + + result = _json_decoder(data) + + assert result == {"foo": "bar"} + + +def test_roundtrip_json_encoding() -> None: + """Test roundtrip encoding and decoding using AA JSON utilities.""" + test_data = { + "datetime": datetime.datetime(2025, 12, 14, 10, 30, 0), + "date": datetime.date(2025, 12, 14), + "time": datetime.time(10, 30, 45), + "timedelta": datetime.timedelta(hours=2), + "decimal": Decimal("99.99"), + "bytes": b"\xff\xfe", + "uuid": UUID("12345678-1234-5678-1234-567812345678"), + "set": {1, 2, 3}, + } + + encoded: dict[str, object] = {} + for k, v in test_data.items(): + try: + encoded[k] = _json_encoder(v) + except TypeError: + encoded[k] = v + + # Encode via AA serializer + json_str = encode_json(encoded) + + # Decode via AA parser + our special-type walker + raw = decode_json(json_str) + result = _decode_special_types(raw) + + assert result["datetime"] == test_data["datetime"] + assert result["date"] == test_data["date"] + assert result["time"] == test_data["time"] + assert result["timedelta"] == test_data["timedelta"] + assert result["decimal"] == test_data["decimal"] + assert result["bytes"] == test_data["bytes"] + assert result["uuid"] == test_data["uuid"] + assert result["set"] == test_data["set"] diff --git a/uv.lock b/uv.lock index 7f1bf972b..f2d0f1c04 100644 --- a/uv.lock +++ b/uv.lock @@ -45,6 +45,10 @@ argon2 = [ cli = [ { name = "rich-click" }, ] +dogpile = [ + { name = "dogpile-cache", version = "1.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "dogpile-cache", version = "1.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] fsspec = [ { name = "fsspec", version = "2025.10.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "fsspec", version = "2026.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, @@ -94,6 +98,8 @@ dev = [ { name = "coverage", version = "7.10.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "coverage", version = "7.13.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "dishka", marker = "python_full_version >= '3.10'" }, + { name = "dogpile-cache", version = "1.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "dogpile-cache", version = "1.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "duckdb" }, { name = "duckdb-engine" }, { name = "fastapi", extra = ["all"] }, @@ -283,6 +289,8 @@ test = [ { name = "coverage", version = "7.10.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "coverage", version = "7.13.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "dishka", marker = "python_full_version >= '3.10'" }, + { name = "dogpile-cache", version = "1.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "dogpile-cache", version = "1.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "fsspec", version = "2025.10.0", source = { registry = "https://pypi.org/simple" }, extra = ["s3"], marker = "python_full_version < '3.10'" }, { name = "fsspec", version = "2026.1.0", source = { registry = "https://pypi.org/simple" }, extra = ["s3"], marker = "python_full_version >= '3.10'" }, { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, @@ -312,6 +320,7 @@ test = [ requires-dist = [ { name = "alembic", specifier = ">=1.12.0" }, { name = "argon2-cffi", marker = "extra == 'argon2'" }, + { name = "dogpile-cache", marker = "extra == 'dogpile'" }, { name = "eval-type-backport", marker = "python_full_version < '3.10'" }, { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "fastnanoid", marker = "extra == 'nanoid'", specifier = ">=0.4.1" }, @@ -325,7 +334,7 @@ requires-dist = [ { name = "typing-extensions", specifier = ">=4.0.0" }, { name = "uuid-utils", marker = "extra == 'uuid'", specifier = ">=0.6.1" }, ] -provides-extras = ["argon2", "cli", "fsspec", "nanoid", "obstore", "passlib", "pwdlib", "uuid"] +provides-extras = ["argon2", "cli", "dogpile", "fsspec", "nanoid", "obstore", "passlib", "pwdlib", "uuid"] [package.metadata.requires-dev] build = [{ name = "bump-my-version" }] @@ -350,6 +359,7 @@ dev = [ { name = "click" }, { name = "coverage", specifier = ">=7.6.1" }, { name = "dishka", marker = "python_full_version >= '3.10'" }, + { name = "dogpile-cache" }, { name = "duckdb", specifier = ">=1.1.2" }, { name = "duckdb-engine", specifier = ">=0.13.4" }, { name = "fastapi", extras = ["all"], specifier = ">=0.115.3" }, @@ -493,6 +503,7 @@ test = [ { name = "click" }, { name = "coverage", specifier = ">=7.6.1" }, { name = "dishka", marker = "python_full_version >= '3.10'" }, + { name = "dogpile-cache" }, { name = "fsspec", extras = ["s3"] }, { name = "numpy" }, { name = "pgvector" }, @@ -1916,6 +1927,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a7/ec/bb273b7208c606890dc36540fe667d06ce840a6f62f9fae7e658fcdc90fb/cssutils-2.11.1-py3-none-any.whl", hash = "sha256:a67bfdfdff4f3867fab43698ec4897c1a828eca5973f4073321b3bccaf1199b1", size = 385747, upload-time = "2024-06-04T15:51:37.499Z" }, ] +[[package]] +name = "decorator" +version = "5.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/fa/6d96a0978d19e17b68d634497769987b16c8f4cd0a7a05048bec693caa6b/decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", size = 56711, upload-time = "2025-02-24T04:41:34.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" }, +] + [[package]] name = "dict2css" version = "0.3.0.post1" @@ -2002,6 +2022,44 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2", size = 587408, upload-time = "2024-04-23T18:57:14.835Z" }, ] +[[package]] +name = "dogpile-cache" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "decorator", marker = "python_full_version < '3.10'" }, + { name = "stevedore", version = "5.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "typing-extensions", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/86/97/da72845c89c9aa70e3e74609b864eff5e5c2ec46366645e7bb61eaa29e9c/dogpile_cache-1.4.1.tar.gz", hash = "sha256:e25c60e677a5e28ff86124765fbf18c53257bcd7830749cd5ba350ace2a12989", size = 939952, upload-time = "2025-09-12T16:34:32.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/f6/1d9579d7b86e7957dd626e52b65c72af0fcd47b277716efd9889990d92b4/dogpile_cache-1.4.1-py3-none-any.whl", hash = "sha256:99130ce990800c8d89c26a5a8d9923cbe1b78c8a9972c2aaa0abf3d2ef2984ad", size = 63593, upload-time = "2025-09-12T16:34:34.809Z" }, +] + +[[package]] +name = "dogpile-cache" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version == '3.12.*'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "decorator", marker = "python_full_version >= '3.10'" }, + { name = "stevedore", version = "5.6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "typing-extensions", marker = "python_full_version == '3.10.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e7/c8/301ff89746e76745b937606df4753c032787c59ecb37dd4d4250bddc8929/dogpile_cache-1.5.0.tar.gz", hash = "sha256:849c5573c9a38f155cd4173103c702b637ede0361c12e864876877d0cd125eec", size = 947962, upload-time = "2025-10-11T17:35:36.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/80/12235e5b75bb2c586733280854f131b86051e0bbdfb55349ff70d0f72cf9/dogpile_cache-1.5.0-py3-none-any.whl", hash = "sha256:dc7b47d37844db15e8fdc0243c1b58857a2ddc52a5118237a97127bac200e18d", size = 64447, upload-time = "2025-10-11T17:35:38.573Z" }, +] + [[package]] name = "domdf-python-tools" version = "3.10.0" @@ -7655,6 +7713,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" }, ] +[[package]] +name = "stevedore" +version = "5.5.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/5f/8418daad5c353300b7661dd8ce2574b0410a6316a8be650a189d5c68d938/stevedore-5.5.0.tar.gz", hash = "sha256:d31496a4f4df9825e1a1e4f1f74d19abb0154aff311c3b376fcc89dae8fccd73", size = 513878, upload-time = "2025-08-25T12:54:26.806Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/c5/0c06759b95747882bb50abda18f5fb48c3e9b0fbfc6ebc0e23550b52415d/stevedore-5.5.0-py3-none-any.whl", hash = "sha256:18363d4d268181e8e8452e71a38cd77630f345b2ef6b4a8d5614dac5ee0d18cf", size = 49518, upload-time = "2025-08-25T12:54:25.445Z" }, +] + +[[package]] +name = "stevedore" +version = "5.6.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version == '3.12.*'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/96/5b/496f8abebd10c3301129abba7ddafd46c71d799a70c44ab080323987c4c9/stevedore-5.6.0.tar.gz", hash = "sha256:f22d15c6ead40c5bbfa9ca54aa7e7b4a07d59b36ae03ed12ced1a54cf0b51945", size = 516074, upload-time = "2025-11-20T10:06:07.264Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/40/8561ce06dc46fd17242c7724ab25b257a2ac1b35f4ebf551b40ce6105cfa/stevedore-5.6.0-py3-none-any.whl", hash = "sha256:4a36dccefd7aeea0c70135526cecb7766c4c84c473b1af68db23d541b6dc1820", size = 54428, upload-time = "2025-11-20T10:06:05.946Z" }, +] + [[package]] name = "tabulate" version = "0.9.0"