--- name: pg_query kind: function lang: py domain: infra version: "1.0.0" purity: impure signature: "def pg_query(dsn: str, sql: str, params: list = None, max_rows: int = 10000) -> dict" description: "Ejecuta un SELECT contra PostgreSQL via psycopg2 y devuelve las filas como list[dict] sin lanzar. Abre la conexion con el DSN, marca la transaccion read-only (SET TRANSACTION READ ONLY) y usa RealDictCursor para que cada fila sea un dict columna->valor. Devuelve {status:'ok', columns, rows, row_count, truncated} en exito y {status:'error', error} en fallo (estilo duckdb_query_readonly). Usa parametros posicionales con el marcador %s. Trunca a max_rows para proteger memoria. Normaliza valores no JSON-serializables: date/datetime/time a isoformat(), Decimal a float, bytes/memoryview a base64, UUID a str. Cierra la conexion siempre en try/finally. Espejo de duckdb_query_readonly para Postgres. Depende de psycopg2 (2.9.x en python/.venv)." tags: [postgres, postgresql, sql, query, readonly, infra] uses_functions: [] uses_types: [] returns: [] returns_optional: false error_type: "error_py_core" imports: [base64, datetime, decimal, uuid, psycopg2] params: - name: dsn desc: "Cadena de conexion PostgreSQL en formato postgresql://user:pass@host:port/dbname. Un DSN invalido o servidor inalcanzable devuelve {status:'error'} sin lanzar." - name: sql desc: "Sentencia SQL a ejecutar (pensada para SELECT). Usa el marcador %s para parametros posicionales (estilo psycopg2)." - name: params desc: "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." - name: max_rows desc: "Numero maximo de filas a materializar en memoria (default 10000). Si la query produce mas, el resultado se trunca y truncated queda en True." output: "dict. En exito: {status:'ok', columns:[str,...], rows:[{col:val,...},...], row_count:int, truncated:bool}; las filas son dicts (RealDictCursor). En error (sin lanzar): {status:'error', error:str}. Los valores estan normalizados a tipos JSON-serializables." tested: true tests: ["test_skip_sin_pg_test_dsn", "test_normaliza_tipos_no_serializables", "test_select_con_parametros_posicionales", "test_trunca_a_max_rows", "test_dsn_invalido_devuelve_status_error"] test_file_path: "python/functions/infra/pg_query_test.py" file_path: "python/functions/infra/pg_query.py" --- ## Ejemplo ```python import sys, os sys.path.insert(0, os.path.join("python", "functions")) from infra.pg_query import pg_query dsn = "postgresql://user:pass@localhost:5433/trends" # SELECT con parametro posicional (nunca interpolar el valor en el SQL). res = pg_query( dsn, "SELECT product, price FROM prices WHERE source = %s ORDER BY price DESC", params=["amazon"], max_rows=100, ) print(res["status"]) # ok print(res["columns"]) # ['product', 'price'] print(res["rows"][0]) # {'product': 'Widget X', 'price': 19.99} print(res["truncated"]) # False ``` ## Cuando usarla Usala cuando necesites leer datos de Postgres y pasarlos a otro paso de una composicion como dict serializable: inspeccionar una tabla, validar el resultado de un pipeline de ingesta, alimentar un dashboard o report, o consultar tablas materializadas. Es el espejo de `duckdb_query_readonly` para Postgres. Para escribir usa `pg_insert_rows`, `pg_upsert` o `pg_apply_sql`. ## Gotchas - Lectura real contra un servidor (impura). La transaccion se marca read-only con `set_session(readonly=True)` y nunca se hace commit (rollback al final): cualquier `INSERT`/`UPDATE`/`DELETE` en el SQL falla a nivel de servidor y vuelve como `{status:'error', ...}`. NO es un sandbox de filesystem — read-only protege la base, no impide leer datos sensibles si el SQL viene de un cliente no confiable. - Inyeccion SQL: los **valores** van siempre por `params` con el marcador `%s`, nunca interpolados en el string del SQL. Esta funcion NO valida ni parametriza identificadores (nombres de tabla/columna): si necesitas un nombre de tabla dinamico, validalo tu antes con `^[A-Za-z_][A-Za-z0-9_]*$`. - `max_rows` protege la memoria: una query que devuelve millones de filas se trunca a `max_rows` y marca `truncated=True`. Para todas las filas, pagina con LIMIT/OFFSET o sube `max_rows` conscientemente. - Valores no JSON-serializables se normalizan en la salida: date/datetime/time a `isoformat()`, Decimal a float (posible perdida de precision frente al decimal exacto), bytes/memoryview a base64 y UUID a str. - Conexion nueva por llamada (sin pool). Para muchas consultas pequenas en bucle, reusa una conexion fuera de esta funcion o agrupa el trabajo en una sola query. - Nunca lanza: DSN invalido, servidor caido, SQL malformado o falta de psycopg2 vuelven como `{status:'error', error:str}`.