Source code for sqlspec.extensions.litestar.store

"""Base session store classes for Litestar integration."""

import re
from abc import ABC, abstractmethod
from datetime import datetime, timedelta, timezone
from typing import TYPE_CHECKING, Any, ClassVar, Final, Generic, TypeVar, cast

from sqlspec.exceptions import ImproperConfigurationError
from sqlspec.migrations.schema import SchemaTarget, ensure_schema_async, ensure_schema_sync
from sqlspec.observability import resolve_db_system
from sqlspec.utils.logging import get_logger
from sqlspec.utils.sync_tools import async_
from sqlspec.utils.type_guards import has_extension_config

if TYPE_CHECKING:
    from types import TracebackType

    from typing_extensions import Self

__all__ = ("BaseSQLSpecStore",)


ConfigT = TypeVar("ConfigT")


logger = get_logger("sqlspec.extensions.litestar.store")


VALID_TABLE_NAME_PATTERN: Final = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$")
MAX_TABLE_NAME_LENGTH: Final = 63


[docs] class BaseSQLSpecStore(ABC, Generic[ConfigT]): """Base class for SQLSpec-backed Litestar session stores. Implements the litestar.stores.base.Store protocol for server-side session storage using SQLSpec database adapters. This abstract base class provides common functionality for all database-specific store implementations including: - Connection management via SQLSpec configs - Session expiration calculation - Table creation utilities Subclasses must implement dialect-specific SQL queries. Args: config: SQLSpec database configuration with extension_config["litestar"] settings. """ __slots__ = ("_config", "_table_name") extension_config_options: ClassVar[frozenset[str]] = frozenset({ "auto_trace_headers", "commit_mode", "connection_key", "correlation_header", "correlation_headers", "create_schema", "disable_di", "enable_correlation_middleware", "enable_sqlcommenter_middleware", "extra_commit_statuses", "extra_rollback_statuses", "manage_schema", "migrations_path", "pool_key", "run_migrations", "session_key", "session_table", })
[docs] def __init__(self, config: ConfigT) -> None: """Initialize the session store. Args: config: SQLSpec database configuration. """ self._config = config self._validate_extension_config() self._table_name = self._table_name_from_config() self._ensure_table_name(self._table_name)
[docs] async def __aenter__(self) -> "Self": """Enter context manager.""" return self
[docs] async def __aexit__( self, exc_type: "type[BaseException] | None", exc_val: "BaseException | None", exc_tb: "TracebackType | None" ) -> None: """Exit context manager.""" return
@property def config(self) -> ConfigT: """Return the database configuration.""" return self._config @property def table_name(self) -> str: """Return the session table name.""" return self._table_name @property def create_schema_enabled(self) -> bool: """Return whether adapter-level creation should run.""" manage_schema, create_schema = self._schema_management_flags() return manage_schema and create_schema
[docs] @abstractmethod async def get(self, key: str, renew_for: "int | timedelta | None" = None) -> "bytes | None": """Get a session value by key. Args: key: Session ID to retrieve. renew_for: If given and the value had an initial expiry time set, renew the expiry time for ``renew_for`` seconds. If the value has not been set with an expiry time this is a no-op. Returns: Session data as bytes if found and not expired, None otherwise. """ raise NotImplementedError
[docs] @abstractmethod async def set(self, key: str, value: "str | bytes", expires_in: "int | timedelta | None" = None) -> None: """Store a session value. Args: key: Session ID. value: Session data (will be converted to bytes if string). expires_in: Time in seconds or timedelta before expiration. """ raise NotImplementedError
[docs] @abstractmethod async def delete(self, key: str) -> None: """Delete a session by key. Args: key: Session ID to delete. """ raise NotImplementedError
[docs] @abstractmethod async def delete_all(self) -> None: """Delete all sessions from the store.""" raise NotImplementedError
[docs] @abstractmethod async def exists(self, key: str) -> bool: """Check if a session key exists and is not expired. Args: key: Session ID to check. Returns: True if the session exists and is not expired. """ raise NotImplementedError
[docs] @abstractmethod async def expires_in(self, key: str) -> "int | None": """Get the time in seconds until the session expires. Args: key: Session ID to check. Returns: Seconds until expiration, or None if no expiry or key doesn't exist. """ raise NotImplementedError
[docs] @abstractmethod async def delete_expired(self) -> int: """Delete all expired sessions. Returns: Number of sessions deleted. """ raise NotImplementedError
[docs] @abstractmethod async def create_table(self) -> None: """Create the session table if it doesn't exist.""" raise NotImplementedError
[docs] def prepare_schema_sync(self, driver: Any) -> None: """Prepare adapter-specific schema decisions with a synchronous driver."""
[docs] async def prepare_schema_async(self, driver: Any) -> None: """Prepare adapter-specific schema decisions with an asynchronous driver."""
[docs] async def reconcile_schema(self, *, assume_existing: bool = False) -> None: """Apply additive session-table changes from the canonical store DDL. Args: assume_existing: Skip table discovery after adapter-level creation. """ manage_schema, create_schema = self._schema_management_flags() if not manage_schema: return statement_config = getattr(self._config, "statement_config", None) dialect = getattr(statement_config, "dialect", None) target = SchemaTarget.from_ddl(self._table_name, self._table_ddl(), dialect=dialect) session_context = cast("Any", self._config).provide_session() if hasattr(session_context, "__aenter__"): async with session_context as driver: await ensure_schema_async( driver, [target], manage_schema=True, create_schema=create_schema, assume_existing=assume_existing ) return await async_(_reconcile_schema_sync)( self._config, target, create_schema=create_schema, assume_existing=assume_existing )
def _table_name_from_config(self) -> str: """Extract the configured session table name.""" default_name = "litestar_session" if has_extension_config(self._config): extension_config = cast("dict[str, dict[str, Any]]", self._config.extension_config) litestar_config: dict[str, Any] = extension_config.get("litestar", {}) session_table = litestar_config.get("session_table", default_name) if session_table is True: return default_name return str(session_table) return default_name def _validate_extension_config(self) -> None: """Reject Litestar options that this adapter store cannot honor.""" if not has_extension_config(self._config): return extension_config = cast("dict[str, dict[str, Any]]", self._config.extension_config) settings = extension_config.get("litestar", {}) unsupported = sorted(set(settings).difference(type(self).extension_config_options)) if unsupported: adapter = type(self).__module__.split(".")[2] keys = ", ".join(repr(key) for key in unsupported) msg = f"Unsupported Litestar configuration key(s) for {adapter}: {keys}" raise ImproperConfigurationError(msg) def _schema_management_flags(self) -> "tuple[bool, bool]": """Return automatic-management and missing-table creation flags.""" if not has_extension_config(self._config): return True, True extension_config = cast("dict[str, dict[str, Any]]", self._config.extension_config) settings = extension_config.get("litestar", {}) return bool(settings.get("manage_schema", True)), bool(settings.get("create_schema", True)) @abstractmethod def _table_ddl(self) -> str: """Get the CREATE TABLE SQL for this database dialect. Returns: SQL statement to create the sessions table. """ raise NotImplementedError @abstractmethod def _drop_table_sql(self) -> "list[str]": """Get the DROP TABLE SQL statements for this database dialect. Returns: List of SQL statements to drop the table and all indexes. Order matters: drop indexes before table. """ raise NotImplementedError def _log_table_created(self) -> None: logger.debug( "Litestar session table ready", extra={"db.system": resolve_db_system(type(self).__name__), "session_table": self._table_name}, ) def _log_delete_all(self) -> None: logger.debug( "Litestar sessions cleared", extra={"db.system": resolve_db_system(type(self).__name__), "session_table": self._table_name}, ) def _log_delete_expired(self, count: int) -> None: logger.debug( "Litestar sessions expired cleanup", extra={ "db.system": resolve_db_system(type(self).__name__), "session_table": self._table_name, "deleted_sessions": count, }, ) def _calculate_expires_at(self, expires_in: "int | timedelta | None") -> "datetime | None": """Calculate expiration timestamp from expires_in. Args: expires_in: Seconds or timedelta until expiration. Returns: UTC datetime of expiration, or None if no expiration. """ if expires_in is None: return None expires_in_seconds = int(expires_in.total_seconds()) if isinstance(expires_in, timedelta) else expires_in if expires_in_seconds <= 0: return None return datetime.now(timezone.utc) + timedelta(seconds=expires_in_seconds) def _value_to_bytes(self, value: "str | bytes") -> bytes: """Convert value to bytes if needed. Args: value: String or bytes value. Returns: Value as bytes. """ if isinstance(value, str): return value.encode("utf-8") return value @staticmethod def _ensure_table_name(table_name: str) -> None: """Validate table name for SQL safety. Args: table_name: Table name to validate. Raises: ValueError: If table name is invalid. """ if not table_name: msg = "Table name cannot be empty" raise ValueError(msg) if len(table_name) > MAX_TABLE_NAME_LENGTH: msg = f"Table name too long: {len(table_name)} chars (max {MAX_TABLE_NAME_LENGTH})" raise ValueError(msg) if not VALID_TABLE_NAME_PATTERN.match(table_name): msg = f"Invalid table name: {table_name!r}. Must start with letter/underscore and contain only alphanumeric characters and underscores" raise ValueError(msg)
def _reconcile_schema_sync(config: Any, target: SchemaTarget, *, create_schema: bool, assume_existing: bool) -> None: """Run additive reconciliation for a synchronous Litestar store.""" with config.provide_session() as driver: ensure_schema_sync( driver, [target], manage_schema=True, create_schema=create_schema, assume_existing=assume_existing )