Source code for httk.data.db.engine
"""Database engine lifecycle: :class:`Database` wraps a SQLAlchemy engine behind an httk-facing API.
A :class:`Database` names *where* data lives — an SQLite file, an in-memory
SQLite database, a DuckDB file — and owns the connection pool that reaches it.
It deliberately exposes no SQL surface of its own: the store layer
(:class:`~httk.data.db.store.SqlStore`) asks it for connections internally, and
user code only constructs one (usually via :meth:`Database.sqlite` or
:meth:`Database.duckdb`) and passes it on. There is no global engine registry
and no interpreter-exit hook; dispose of a database explicitly with
:meth:`Database.dispose` or use it as a context manager.
"""
import importlib
import os
from types import TracebackType
from typing import Self
import sqlalchemy
__all__ = [
"Database",
]
[docs]
class Database:
"""A relational database reachable through a wrapped SQLAlchemy engine.
Construct one with :meth:`sqlite` or :meth:`duckdb` (or, for other
SQLAlchemy-supported backends, by passing a preconfigured engine directly).
The instance is a context manager; leaving the ``with`` block disposes the
engine's connection pool.
"""
def __init__(self, engine: sqlalchemy.Engine) -> None:
"""Wrap an already-configured SQLAlchemy :class:`~sqlalchemy.Engine`."""
self._engine = engine
@classmethod
[docs]
def sqlite(cls, path: str | os.PathLike[str] | None = None) -> Self:
"""An SQLite database stored in the file at ``path``, or in memory when ``path`` is None.
The in-memory variant is configured (via a static connection pool with a
shared, thread-unrestricted connection) so that every connection drawn
from the engine sees the one and same database; file-backed databases
use SQLAlchemy's default pooling.
"""
if path is None:
engine = sqlalchemy.create_engine(
"sqlite://",
poolclass=sqlalchemy.StaticPool,
connect_args={"check_same_thread": False},
)
else:
engine = sqlalchemy.create_engine(f"sqlite:///{os.fspath(path)}")
return cls(engine)
@classmethod
[docs]
def duckdb(cls, path: str | os.PathLike[str] | None = None) -> Self:
"""A DuckDB database stored in the file at ``path``, or in memory when ``path`` is None.
Raises:
ImportError: If the ``duckdb_engine`` SQLAlchemy dialect is not
installed; install the ``httk-data[duckdb]`` extra to get it.
"""
try:
importlib.import_module("duckdb_engine")
except ImportError as error:
raise ImportError(
"the DuckDB backend needs the 'duckdb_engine' SQLAlchemy dialect; "
"install the 'httk-data[duckdb]' extra to use Database.duckdb()"
) from error
location = ":memory:" if path is None else os.fspath(path)
engine = sqlalchemy.create_engine(f"duckdb:///{location}")
# duckdb_engine derives from the psycopg2 dialect, which doubles
# backslashes when rendering inline string literals (PostgreSQL's
# non-standard-conforming-strings legacy). DuckDB always uses
# standard-conforming string literals, so that doubling corrupts e.g.
# the LIKE ... ESCAPE '\' clause the search DSL emits; turn it off.
engine.dialect._backslash_escapes = False # type: ignore[attr-defined]
return cls(engine)
@property
[docs]
def engine(self) -> sqlalchemy.Engine:
"""The underlying SQLAlchemy engine (for use by the storage layer itself)."""
return self._engine
[docs]
def dispose(self) -> None:
"""Close the engine's connection pool; the database can no longer be used after this."""
self._engine.dispose()
def __enter__(self) -> Self:
return self
def __exit__(
self,
exc_type: type[BaseException] | None,
exc_value: BaseException | None,
traceback: TracebackType | None,
) -> None:
self.dispose()
def __repr__(self) -> str:
return f"{type(self).__name__}({self._engine.url!r})"