Source code for steplib.modules.db.client

"""Database client abstraction with lazy SQLAlchemy import."""

from __future__ import annotations

from typing import Any

from steplib.core.exceptions import MissingDependencyError


[docs] class DatabaseClient: """Database client backed by SQLAlchemy (requires the ``[db]`` extra). Attributes: engine: The SQLAlchemy engine instance. connection: The active SQLAlchemy connection. """ def __init__(self, connection_string: str) -> None: """Initialize the SQLAlchemy engine, importing lazily. Args: connection_string: A SQLAlchemy-compatible connection string. Raises: MissingDependencyError: If SQLAlchemy is not installed. """ try: from sqlalchemy import create_engine, text except ImportError as exc: raise MissingDependencyError("db", "sqlalchemy") from exc self._text = text self.engine = create_engine(connection_string) self.connection = self.engine.connect() self._tx: Any = None def _commit_if_no_explicit_tx(self) -> None: """Commit the implicit transaction unless an explicit one is active. SQLAlchemy 2.x ``engine.connect()`` connections autobegin and never commit on their own, so statements would be rolled back when the connection closes. Explicit transactions started via :meth:`begin` are left alone — they commit/rollback through their own steps. """ if self._tx is None or not self._tx.is_active: commit = getattr(self.connection, "commit", None) if callable(commit): commit()
[docs] def execute(self, query: str, params: dict[str, Any] | None = None) -> list[dict[str, Any]]: """Execute a SQL query and return rows as a list of dicts. Args: query: The SQL query string. params: Optional bind parameters. Returns: A list of dictionaries, one per row, keyed by column name. """ result = self.connection.execute(self._text(query), params or {}) columns = list(result.keys()) rows = [dict(zip(columns, row, strict=False)) for row in result.fetchall()] self._commit_if_no_explicit_tx() return rows
[docs] def execute_scalar(self, query: str, params: dict[str, Any] | None = None) -> Any: """Execute a SQL query and return a single scalar value. Args: query: The SQL query string. params: Optional bind parameters. Returns: The first column of the first row. """ result = self.connection.execute(self._text(query), params or {}) value = result.scalar() self._commit_if_no_explicit_tx() return value
[docs] def begin(self) -> Any: """Begin a transaction on the underlying connection. If the connection already has an open (implicit) transaction, that transaction is returned instead of raising. Returns: The transaction object from SQLAlchemy. """ in_transaction = getattr(self.connection, "in_transaction", None) get_transaction = getattr(self.connection, "get_transaction", None) if callable(in_transaction) and in_transaction() and callable(get_transaction): self._tx = get_transaction() else: self._tx = self.connection.begin() return self._tx
[docs] def close(self) -> None: """Close the connection and dispose the engine.""" self.connection.close() self.engine.dispose()
[docs] def get_client(connection_string: str) -> DatabaseClient: """Return a database client for the given connection string. Args: connection_string: A SQLAlchemy-compatible connection string. Returns: A ``DatabaseClient`` instance. Raises: MissingDependencyError: If SQLAlchemy is not installed. """ return DatabaseClient(connection_string)