"""SQL Server connection helpers via pyodbc.""" from __future__ import annotations from contextlib import contextmanager from typing import Any, Iterator import pyodbc from pipeline.config import SqlSettings # Tables that live in SQL_APP_DATABASE. Every other name stays in SQL_DATABASE. _APP_TABLES = ("advisories", "products", "product_uses", "label_chunks") def _bracket(identifier: str) -> str: """Quote a SQL Server identifier. A closing bracket inside the name is doubled.""" return "[" + identifier.replace("]", "]]") + "]" def _odbc_server(server: str) -> str: """ ODBC Driver expects host,port. Accept host:port and leave named instances alone. `192.168.220.50:1433` becomes `192.168.220.50,1433`. `localhost\\SQLEXPRESS` has no colon, so it is returned unchanged. """ host, colon, port = server.rpartition(":") if colon and port.isdigit() and ":" not in host: return f"{host},{port}" return server def app_table(settings: SqlSettings, table: str) -> str: """ Name of an application table on the open connection. Bare when `app_database` is the connected database, so a single-database setup keeps unqualified SQL. Otherwise a three-part name, `[Agrosupport].dbo.[advisories]`, so one DBMeteo connection can read the other database on the same instance. """ if table not in _APP_TABLES: allowed = ", ".join(_APP_TABLES) raise ValueError(f"Unknown application table {table!r}. Expected one of {allowed}.") if ( not settings.app_database or settings.app_database.casefold() == settings.database.casefold() ): return table return f"{_bracket(settings.app_database)}.dbo.{_bracket(table)}" def qualify_app_sql(settings: SqlSettings, sql: str) -> str: """ Replace `{advisories}`, `{products}`, `{product_uses}`, and `{label_chunks}`. Call this before `.format(placeholders=...)`. Only those four tokens are substituted, so a `{placeholders}` brace list is left for the later format. """ for table in _APP_TABLES: sql = sql.replace("{" + table + "}", app_table(settings, table)) return sql def _connection_string(settings: SqlSettings) -> str: return ( f"DRIVER={{{settings.driver}}};" f"SERVER={_odbc_server(settings.server)};" f"DATABASE={settings.database};" f"UID={settings.username};" f"PWD={settings.password};" "TrustServerCertificate=yes;" ) def connect_raw(settings: SqlSettings) -> pyodbc.Connection: """ Open a new pyodbc connection without any context-manager lifecycle. Used by the batch orchestrator to hand each worker thread its own long-lived connection (pyodbc connections must not be shared across threads); the caller is responsible for closing it. """ return pyodbc.connect(_connection_string(settings), autocommit=True) @contextmanager def connect(settings: SqlSettings) -> Iterator[pyodbc.Connection]: """Yield an open pyodbc connection that is closed on exit.""" conn = connect_raw(settings) try: yield conn finally: conn.close() def fetch_one(conn: pyodbc.Connection, sql: str, params: tuple[Any, ...] = ()) -> pyodbc.Row | None: cursor = conn.cursor() cursor.execute(sql, params) return cursor.fetchone() def fetch_all(conn: pyodbc.Connection, sql: str, params: tuple[Any, ...] = ()) -> list[pyodbc.Row]: cursor = conn.cursor() cursor.execute(sql, params) return list(cursor.fetchall()) def execute(conn: pyodbc.Connection, sql: str, params: tuple[Any, ...] = ()) -> int: """Run a write statement and return the affected row count (autocommit is on).""" cursor = conn.cursor() cursor.execute(sql, params) return cursor.rowcount @contextmanager def transaction(conn: pyodbc.Connection) -> Iterator[None]: """ Run a block of statements atomically on a connection that normally runs with autocommit on. Used by `insert_advice`'s DELETE-then-INSERT pair: under concurrent workers, autocommit would let a crash between the two statements leave a day's advisory missing. This temporarily disables autocommit, commits on success, rolls back on any exception, and always restores the previous autocommit mode afterwards so the connection is safe to reuse for the next job. """ previous_autocommit = conn.autocommit conn.autocommit = False try: yield conn.commit() except Exception: conn.rollback() raise finally: conn.autocommit = previous_autocommit