AI_Agro_Support/pipeline/db.py
Arsham Mirehvandi f77c0abebd Update database handling and configuration for advisory generation
- Enhanced `.env.example` to include `SQL_APP_DATABASE` for managing additional advisory-related tables.
- Refactored `config.yaml` to remove the `crops` block, shifting to a database-driven approach for job discovery.
- Updated `README.md` to clarify the new database structure and job discovery process, emphasizing the role of the `AI_agrosupport_an_pathogen_models` and `AI_agrosupport_agro_models` tables.
- Modified SQL queries across multiple files to accommodate the new database schema, ensuring correct table references and improving overall data handling.
- Adjusted `pipeline` logic to utilize the new configuration, enhancing the efficiency of the advisory generation process.
2026-10-14 10:57:45 +02:00

141 lines
4.5 KiB
Python

"""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