"""Ejecuta una query SELECT contra PostgreSQL y devuelve filas como list[dict]. Funcion impura: abre una conexion psycopg2 con el DSN dado, ejecuta el SQL con un RealDictCursor (cada fila es un dict columna->valor) y devuelve un dict sin lanzar excepciones, siguiendo el estilo de duckdb_query_readonly del registry: {status:'ok', ...} en exito y {status:'error', error:str} en fallo. La conexion se cierra siempre en un bloque try/finally. Por convencion es de solo lectura: la transaccion se marca read-only (SET TRANSACTION READ ONLY) para que cualquier escritura accidental falle a nivel de servidor, y nunca se hace commit (rollback al final). El resultado se trunca a max_rows para proteger la memoria y marca truncated=True si la query producia mas filas. Los valores que no son JSON-serializables se convierten a una forma serializable: date/datetime/time a isoformat(), Decimal a float, bytes/memoryview a base64 y UUID a str. """ import base64 import datetime import decimal import uuid def _to_serializable(value): """Convierte un valor de PostgreSQL a una forma JSON-serializable. date/datetime/time -> isoformat(), Decimal -> float, bytes/memoryview -> base64 str, UUID -> str. El resto de valores (int, float, str, bool, None) se devuelven sin cambios. """ if value is None: return None if isinstance(value, (datetime.datetime, datetime.date, datetime.time)): return value.isoformat() if isinstance(value, decimal.Decimal): return float(value) if isinstance(value, (bytes, bytearray, memoryview)): return base64.b64encode(bytes(value)).decode("ascii") if isinstance(value, uuid.UUID): return str(value) return value def pg_query( dsn: str, sql: str, params: list = None, max_rows: int = 10000, ) -> dict: """Ejecuta un SELECT contra PostgreSQL en una transaccion read-only. Args: dsn: cadena de conexion PostgreSQL, p.ej. "postgresql://user:pass@localhost:5433/trends". Un DSN invalido o un servidor inalcanzable devuelve {status:'error', ...} (no lanza). sql: sentencia SQL a ejecutar. Pensada para SELECT; usa el marcador `%s` para parametros posicionales (estilo psycopg2). params: lista de parametros posicionales para el SQL, en orden. None (default) significa sin parametros. Pasar los valores aqui en vez de interpolarlos en el SQL evita inyeccion. max_rows: numero maximo de filas a materializar (default 10000). Si la query produce mas, se trunca y truncated queda en True. Returns: dict. En exito: {status:'ok', columns:[str,...], rows:[{col:val, ...}, ...], row_count:int, truncated:bool} donde columns es la lista de nombres de columna y rows es la lista de filas (cada fila un dict, via RealDictCursor). En error (sin lanzar): {status:'error', error:str}. """ try: import psycopg2 from psycopg2 import extras as pg_extras except ImportError as exc: # pragma: no cover - exercised only without dep return { "status": "error", "error": ( "psycopg2 is required for pg_query; install psycopg2-binary " f"({exc})" ), } conn = None try: conn = psycopg2.connect(dsn) # Solo lectura por convencion: cualquier escritura fallara en el servidor. conn.set_session(readonly=True, autocommit=False) with conn.cursor(cursor_factory=pg_extras.RealDictCursor) as cur: cur.execute(sql, params if params is not None else None) description = cur.description or [] columns = [col.name for col in description] # Pedimos una fila de mas que max_rows para detectar truncado. fetched = cur.fetchmany(max_rows + 1) truncated = len(fetched) > max_rows if truncated: fetched = fetched[:max_rows] rows = [ {key: _to_serializable(val) for key, val in record.items()} for record in fetched ] # Nunca escribimos: cerramos la transaccion con rollback. conn.rollback() return { "status": "ok", "columns": columns, "rows": rows, "row_count": len(rows), "truncated": truncated, } except Exception as e: # noqa: BLE001 return {"status": "error", "error": str(e)} finally: if conn is not None: conn.close()