Skip to content

Commit 0225532

Browse files
hasansezertasancofin
authored andcommitted
fix(config): add cache_config field to SQLAlchemy configs
Closes litestar-org#730. The dogpile.cache integration (litestar-org#636) added the `advanced_alchemy.cache` package, listeners, and repository wiring, but never extended `SQLAlchemyAsyncConfig` / `SQLAlchemySyncConfig` with the `cache_config` kwarg that the caching guide promotes. Every example in `docs/usage/caching.rst` raised `TypeError` because the field did not exist. This adds `cache_config: Optional[CacheConfig]` and `cache_manager: Optional[CacheManager]` to `GenericSQLAlchemyConfig`. `__post_init__` builds a `CacheManager` from `cache_config` when one is not supplied explicitly, then publishes it to sessions via `session_config.info["cache_manager"]` so repositories pick it up through the existing `session.info.get("cache_manager")` path. `session_config` and its `info` dict are now shallow-copied during `__post_init__` so two configs constructed with the same `session_config` instance no longer alias each other's cache_manager or `file_object_raise_on_error` flag. `__hash__` also includes `id(cache_manager)` so configs that differ only by cache region remain distinguishable in the class-level registries. Imports of `CacheConfig` / `CacheManager` are lazy: only typed under TYPE_CHECKING at module load, and only imported at runtime inside `__post_init__` when `cache_config` is set, so dogpile.cache probing is still avoided for configs that don't opt in.
1 parent ed8c46b commit 0225532

4 files changed

Lines changed: 296 additions & 2 deletions

File tree

advanced_alchemy/config/common.py

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import copy
12
from dataclasses import dataclass, field
23
from pathlib import Path
34
from typing import TYPE_CHECKING, Any, Callable, ClassVar, Generic, Optional, Union, cast
@@ -16,6 +17,7 @@
1617
from sqlalchemy.orm.session import JoinTransactionMode
1718
from sqlalchemy.sql import TableClause
1819

20+
from advanced_alchemy.cache import CacheConfig, CacheManager
1921
from advanced_alchemy.utils.dataclass import EmptyType
2022

2123
__all__ = (
@@ -187,6 +189,25 @@ class GenericSQLAlchemyConfig(Generic[EngineT, SessionT, SessionMakerT]):
187189
- ``False``: Log warnings on file operation failures, don't raise exceptions
188190
- ``True`` (default): Raise exceptions on file operation failures
189191
"""
192+
cache_config: "Optional[CacheConfig]" = None
193+
"""Optional :class:`CacheConfig <advanced_alchemy.cache.CacheConfig>` for dogpile.cache integration.
194+
195+
When set, a :class:`CacheManager <advanced_alchemy.cache.CacheManager>` is instantiated during
196+
:meth:`__post_init__` and stored in ``session_config.info["cache_manager"]``. Repositories
197+
created against sessions produced by this config will pick the manager up automatically.
198+
199+
Requires the optional ``dogpile.cache`` dependency (``pip install advanced-alchemy[dogpile]``);
200+
without it the manager falls back to a no-op :class:`NullRegion`.
201+
202+
.. seealso::
203+
:doc:`/usage/caching`
204+
"""
205+
cache_manager: "Optional[CacheManager]" = None
206+
"""Optional pre-built :class:`CacheManager <advanced_alchemy.cache.CacheManager>` instance.
207+
208+
Takes precedence over :attr:`cache_config`. Useful when sharing a single cache manager across
209+
multiple configs.
210+
"""
190211
_SESSION_SCOPE_KEY_REGISTRY: "ClassVar[set[str]]" = field(init=False, default=cast("set[str]", set()))
191212
"""Internal counter for ensuring unique identification of session scope keys in the class."""
192213
_ENGINE_APP_STATE_KEY_REGISTRY: "ClassVar[set[str]]" = field(init=False, default=cast("set[str]", set()))
@@ -203,20 +224,34 @@ def __post_init__(self) -> None:
203224
else:
204225
metadata_registry.set(self.bind_key, self.metadata)
205226

206-
# Store file_object_raise_on_error in session_config.info
207-
# Ensure session_config.info is a dict (convert from Empty if needed)
227+
# Detach session_config and its info dict from any caller-shared instance so writes
228+
# below (file_object flag, cache_manager) don't bleed between configs that happened
229+
# to be constructed with the same session_config object.
230+
self.session_config = copy.copy(self.session_config)
208231
if self.session_config.info is Empty:
209232
self.session_config.info = {}
233+
elif isinstance(self.session_config.info, dict):
234+
self.session_config.info = dict(self.session_config.info)
210235
if isinstance(self.session_config.info, dict):
211236
self.session_config.info["file_object_raise_on_error"] = self.file_object_raise_on_error
212237

238+
# Build a CacheManager from cache_config if one wasn't supplied explicitly,
239+
# then propagate it to sessions via session_config.info["cache_manager"].
240+
if self.cache_manager is None and self.cache_config is not None:
241+
from advanced_alchemy.cache import CacheManager
242+
243+
self.cache_manager = CacheManager(self.cache_config)
244+
if self.cache_manager is not None and isinstance(self.session_config.info, dict):
245+
self.session_config.info["cache_manager"] = self.cache_manager
246+
213247
def __hash__(self) -> int: # pragma: no cover
214248
return hash(
215249
(
216250
self.__class__.__qualname__,
217251
self.connection_string,
218252
self.engine_config.__class__.__qualname__,
219253
self.bind_key,
254+
id(self.cache_manager) if self.cache_manager is not None else None,
220255
)
221256
)
222257

tests/integration/test_cache_repository.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -553,3 +553,115 @@ class CachedAuthorRepository(SQLAlchemySyncRepository[Any]):
553553

554554
finally:
555555
CachedAuthor.metadata.drop_all(sqlite_engine)
556+
557+
558+
# Config-driven cache_config integration (issue #730)
559+
560+
561+
@pytest.mark.asyncio
562+
@pytest.mark.aiosqlite
563+
@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed")
564+
async def test_async_config_cache_config_repo_auto_pickup(
565+
aiosqlite_engine: AsyncEngine,
566+
request: pytest.FixtureRequest,
567+
) -> None:
568+
"""SQLAlchemyAsyncConfig(cache_config=...) wires session.info so the second get() is a cache hit."""
569+
from sqlalchemy import text
570+
571+
from advanced_alchemy.config import SQLAlchemyAsyncConfig
572+
573+
worker_id = get_worker_id(request)
574+
CachedAuthor = get_cached_author_model("aiosqlite_cfg", worker_id)
575+
table_name = CachedAuthor.__tablename__
576+
577+
async with aiosqlite_engine.begin() as conn:
578+
await conn.run_sync(CachedAuthor.metadata.create_all)
579+
580+
try:
581+
config = SQLAlchemyAsyncConfig(
582+
engine_instance=aiosqlite_engine,
583+
cache_config=CacheConfig(backend="dogpile.cache.memory", expiration_time=300, key_prefix="cfg:"),
584+
)
585+
586+
class CachedAuthorRepository(SQLAlchemyAsyncRepository[Any]):
587+
model_type = CachedAuthor
588+
589+
async with config.get_session() as session:
590+
repo = CachedAuthorRepository(session=session, auto_expunge=True)
591+
assert repo._cache_manager is config.cache_manager
592+
593+
author = CachedAuthor(name="Cfg Async")
594+
await repo.add(author)
595+
await session.commit()
596+
author_id = author.id
597+
598+
first = await repo.get(author_id)
599+
assert first.name == "Cfg Async"
600+
assert config.cache_manager is not None
601+
cached_region = config.cache_manager.get_entity_sync(table_name, author_id, CachedAuthor)
602+
assert cached_region is not None and cached_region.name == "Cfg Async"
603+
604+
# Delete the row out from under SQLAlchemy. Cache survives, so the next get() can only
605+
# succeed if it's served from the cache region, not the DB.
606+
async with aiosqlite_engine.begin() as conn:
607+
await conn.execute(text(f"DELETE FROM {table_name}"))
608+
609+
async with config.get_session() as session:
610+
repo = CachedAuthorRepository(session=session, auto_expunge=True)
611+
from_cache = await repo.get(author_id)
612+
assert from_cache.name == "Cfg Async"
613+
finally:
614+
async with aiosqlite_engine.begin() as conn:
615+
await conn.run_sync(CachedAuthor.metadata.drop_all)
616+
617+
618+
@pytest.mark.sqlite
619+
@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed")
620+
def test_sync_config_cache_config_repo_auto_pickup(
621+
sqlite_engine: Engine,
622+
request: pytest.FixtureRequest,
623+
) -> None:
624+
"""SQLAlchemySyncConfig(cache_config=...) wires session.info so the second get() is a cache hit."""
625+
from sqlalchemy import text
626+
627+
from advanced_alchemy.config import SQLAlchemySyncConfig
628+
629+
worker_id = get_worker_id(request)
630+
CachedAuthor = get_cached_author_model("sqlite_cfg", worker_id)
631+
table_name = CachedAuthor.__tablename__
632+
633+
CachedAuthor.metadata.create_all(sqlite_engine)
634+
635+
try:
636+
config = SQLAlchemySyncConfig(
637+
engine_instance=sqlite_engine,
638+
cache_config=CacheConfig(backend="dogpile.cache.memory", expiration_time=300, key_prefix="cfg:"),
639+
)
640+
641+
class CachedAuthorRepository(SQLAlchemySyncRepository[Any]):
642+
model_type = CachedAuthor
643+
644+
with config.get_session() as session:
645+
repo = CachedAuthorRepository(session=session, auto_expunge=True)
646+
assert repo._cache_manager is config.cache_manager
647+
648+
author = CachedAuthor(name="Cfg Sync")
649+
repo.add(author)
650+
session.commit()
651+
author_id = author.id
652+
653+
first = repo.get(author_id)
654+
assert first.name == "Cfg Sync"
655+
assert config.cache_manager is not None
656+
cached_region = config.cache_manager.get_entity_sync(table_name, author_id, CachedAuthor)
657+
assert cached_region is not None and cached_region.name == "Cfg Sync"
658+
659+
with sqlite_engine.begin() as conn:
660+
conn.execute(text(f"DELETE FROM {table_name}"))
661+
662+
with config.get_session() as session:
663+
repo = CachedAuthorRepository(session=session, auto_expunge=True)
664+
from_cache = repo.get(author_id)
665+
assert from_cache.name == "Cfg Sync"
666+
finally:
667+
CachedAuthor.metadata.drop_all(sqlite_engine)

tests/unit/test_config/test_async_config.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,3 +140,77 @@ def test_post_init_routing_config_fallback_to_engine_configs() -> None:
140140

141141
config = SQLAlchemyAsyncConfig(routing_config=mock_routing)
142142
assert config.connection_string == "sqlite+aiosqlite:///fallback"
143+
144+
145+
def test_post_init_cache_config_builds_manager() -> None:
146+
"""cache_config triggers CacheManager creation and propagation to session.info."""
147+
from advanced_alchemy.cache import CacheConfig, CacheManager
148+
149+
config = SQLAlchemyAsyncConfig(
150+
connection_string="sqlite+aiosqlite:///:memory:",
151+
cache_config=CacheConfig(backend="dogpile.cache.memory", expiration_time=300),
152+
)
153+
assert isinstance(config.cache_manager, CacheManager)
154+
assert config.session_config.info["cache_manager"] is config.cache_manager
155+
156+
157+
def test_post_init_explicit_cache_manager_overrides() -> None:
158+
"""An explicit cache_manager is preserved and propagated; cache_config is not re-instantiated."""
159+
from advanced_alchemy.cache import CacheConfig, CacheManager
160+
161+
manager = CacheManager(CacheConfig(backend="dogpile.cache.memory"))
162+
config = SQLAlchemyAsyncConfig(
163+
connection_string="sqlite+aiosqlite:///:memory:",
164+
cache_manager=manager,
165+
)
166+
assert config.cache_manager is manager
167+
assert config.session_config.info["cache_manager"] is manager
168+
169+
170+
def test_post_init_without_cache_config_leaves_info_clean() -> None:
171+
"""No cache_config means no cache_manager key on session.info."""
172+
config = SQLAlchemyAsyncConfig(connection_string="sqlite+aiosqlite:///:memory:")
173+
assert config.cache_manager is None
174+
assert "cache_manager" not in config.session_config.info
175+
176+
177+
def test_post_init_does_not_alias_shared_session_config_info() -> None:
178+
"""Two configs sharing a session_config must not clobber each other's cache_manager."""
179+
from advanced_alchemy.cache import CacheConfig, CacheManager
180+
from advanced_alchemy.config.asyncio import AsyncSessionConfig
181+
182+
shared = AsyncSessionConfig(info={"user_key": "preserved"})
183+
mgr_a = CacheManager(CacheConfig(backend="dogpile.cache.memory", key_prefix="a:"))
184+
mgr_b = CacheManager(CacheConfig(backend="dogpile.cache.memory", key_prefix="b:"))
185+
186+
cfg_a = SQLAlchemyAsyncConfig(
187+
connection_string="sqlite+aiosqlite:///:memory:",
188+
session_config=shared,
189+
cache_manager=mgr_a,
190+
)
191+
cfg_b = SQLAlchemyAsyncConfig(
192+
connection_string="sqlite+aiosqlite:///:memory:",
193+
session_config=shared,
194+
cache_manager=mgr_b,
195+
)
196+
197+
assert cfg_a.session_config.info["cache_manager"] is mgr_a
198+
assert cfg_b.session_config.info["cache_manager"] is mgr_b
199+
# User-supplied keys survive the defensive copy.
200+
assert cfg_a.session_config.info["user_key"] == "preserved"
201+
assert cfg_b.session_config.info["user_key"] == "preserved"
202+
203+
204+
def test_hash_distinguishes_configs_by_cache_manager() -> None:
205+
"""Configs identical except for cache_manager hash differently."""
206+
from advanced_alchemy.cache import CacheConfig, CacheManager
207+
208+
mgr_a = CacheManager(CacheConfig(backend="dogpile.cache.memory", key_prefix="a:"))
209+
mgr_b = CacheManager(CacheConfig(backend="dogpile.cache.memory", key_prefix="b:"))
210+
211+
cfg_a = SQLAlchemyAsyncConfig(connection_string="sqlite+aiosqlite:///:memory:", cache_manager=mgr_a)
212+
cfg_b = SQLAlchemyAsyncConfig(connection_string="sqlite+aiosqlite:///:memory:", cache_manager=mgr_b)
213+
cfg_none = SQLAlchemyAsyncConfig(connection_string="sqlite+aiosqlite:///:memory:")
214+
215+
assert hash(cfg_a) != hash(cfg_b)
216+
assert hash(cfg_a) != hash(cfg_none)

tests/unit/test_config/test_sync_config.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,3 +128,76 @@ def test_post_init_routing_config_fallback_to_engine_configs() -> None:
128128

129129
config = SQLAlchemySyncConfig(routing_config=mock_routing)
130130
assert config.connection_string == "sqlite:///fallback"
131+
132+
133+
def test_post_init_cache_config_builds_manager() -> None:
134+
"""cache_config triggers CacheManager creation and propagation to session.info."""
135+
from advanced_alchemy.cache import CacheConfig, CacheManager
136+
137+
config = SQLAlchemySyncConfig(
138+
connection_string="sqlite:///:memory:",
139+
cache_config=CacheConfig(backend="dogpile.cache.memory", expiration_time=300),
140+
)
141+
assert isinstance(config.cache_manager, CacheManager)
142+
assert config.session_config.info["cache_manager"] is config.cache_manager
143+
144+
145+
def test_post_init_explicit_cache_manager_overrides() -> None:
146+
"""An explicit cache_manager is preserved and propagated; cache_config is not re-instantiated."""
147+
from advanced_alchemy.cache import CacheConfig, CacheManager
148+
149+
manager = CacheManager(CacheConfig(backend="dogpile.cache.memory"))
150+
config = SQLAlchemySyncConfig(
151+
connection_string="sqlite:///:memory:",
152+
cache_manager=manager,
153+
)
154+
assert config.cache_manager is manager
155+
assert config.session_config.info["cache_manager"] is manager
156+
157+
158+
def test_post_init_without_cache_config_leaves_info_clean() -> None:
159+
"""No cache_config means no cache_manager key on session.info."""
160+
config = SQLAlchemySyncConfig(connection_string="sqlite:///:memory:")
161+
assert config.cache_manager is None
162+
assert "cache_manager" not in config.session_config.info
163+
164+
165+
def test_post_init_does_not_alias_shared_session_config_info() -> None:
166+
"""Two configs sharing a session_config must not clobber each other's cache_manager."""
167+
from advanced_alchemy.cache import CacheConfig, CacheManager
168+
from advanced_alchemy.config.sync import SyncSessionConfig
169+
170+
shared = SyncSessionConfig(info={"user_key": "preserved"})
171+
mgr_a = CacheManager(CacheConfig(backend="dogpile.cache.memory", key_prefix="a:"))
172+
mgr_b = CacheManager(CacheConfig(backend="dogpile.cache.memory", key_prefix="b:"))
173+
174+
cfg_a = SQLAlchemySyncConfig(
175+
connection_string="sqlite:///:memory:",
176+
session_config=shared,
177+
cache_manager=mgr_a,
178+
)
179+
cfg_b = SQLAlchemySyncConfig(
180+
connection_string="sqlite:///:memory:",
181+
session_config=shared,
182+
cache_manager=mgr_b,
183+
)
184+
185+
assert cfg_a.session_config.info["cache_manager"] is mgr_a
186+
assert cfg_b.session_config.info["cache_manager"] is mgr_b
187+
assert cfg_a.session_config.info["user_key"] == "preserved"
188+
assert cfg_b.session_config.info["user_key"] == "preserved"
189+
190+
191+
def test_hash_distinguishes_configs_by_cache_manager() -> None:
192+
"""Configs identical except for cache_manager hash differently."""
193+
from advanced_alchemy.cache import CacheConfig, CacheManager
194+
195+
mgr_a = CacheManager(CacheConfig(backend="dogpile.cache.memory", key_prefix="a:"))
196+
mgr_b = CacheManager(CacheConfig(backend="dogpile.cache.memory", key_prefix="b:"))
197+
198+
cfg_a = SQLAlchemySyncConfig(connection_string="sqlite:///:memory:", cache_manager=mgr_a)
199+
cfg_b = SQLAlchemySyncConfig(connection_string="sqlite:///:memory:", cache_manager=mgr_b)
200+
cfg_none = SQLAlchemySyncConfig(connection_string="sqlite:///:memory:")
201+
202+
assert hash(cfg_a) != hash(cfg_b)
203+
assert hash(cfg_a) != hash(cfg_none)

0 commit comments

Comments
 (0)