feat(infra): conexion y consulta directa a SQL Server (Navision) via pymssql
Grupo de capacidad nuevo 'sql-connect' (3 funciones) para conectar a un
Microsoft SQL Server (donde corre Navision) y consultar directamente, en
lugar del ida y vuelta manual de pegar CSVs.
- mssql_connect_py_infra: abre conexion pymssql (login_timeout acotado,
credenciales por argumento, RuntimeError claro si falla).
- mssql_query_py_infra: SELECT parametrizada con binding seguro (sin
inyeccion) sobre conexion abierta; devuelve {columns, rows, row_count};
0 filas -> lista vacia; max_rows con fetchmany; read-only.
- run_mssql_query_py_pipelines: one-shot que compone connect+query y cierra
siempre; CLI imprime JSON o CSV; contrasena desde env var (pass).
Pagina madre docs/capabilities/sql-connect.md + fila en INDEX.md.
Dependencia pymssql>=2.3.13 anadida a python/pyproject.toml + uv.lock.
Tests mock-based (11) verdes; error path verificado end-to-end contra el
driver real (host inalcanzable -> RuntimeError, acotado por login_timeout).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
---
|
||||
name: run_mssql_query
|
||||
kind: pipeline
|
||||
lang: py
|
||||
domain: pipelines
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def run_mssql_query(host: str, database: str, user: str, password: str, sql: str, params=None, port: int = 1433, max_rows: int | None = None, login_timeout: int = 15, query_timeout: int = 30) -> dict"
|
||||
description: "One-shot contra SQL Server (Navision): abre conexion, ejecuta UNA SELECT parametrizada y cierra, devolviendo {columns, rows, row_count}. Compone mssql_connect + mssql_query. Pensado para iterar queries de Navision en un solo comando (fn run run_mssql_query ...) en vez del copia-pega manual. CLI imprime JSON o CSV; la contrasena se lee de una env var (recomendado: MSSQL_PASSWORD=$(pass navision/password)), nunca hardcodeada."
|
||||
tags: [mssql, sqlserver, navision, sql-connect, pipelines]
|
||||
uses_functions:
|
||||
- mssql_connect_py_infra
|
||||
- mssql_query_py_infra
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: host
|
||||
desc: "Host o IP del SQL Server. Desde WSL2 debe ser la IP LAN de Windows, no localhost."
|
||||
- name: database
|
||||
desc: "Nombre de la base de datos a la que conectar (p.ej. la BD de Navision)."
|
||||
- name: user
|
||||
desc: "Usuario de login de SQL Server."
|
||||
- name: password
|
||||
desc: "Contrasena del usuario. Se pasa desde pass/env, nunca como literal en codigo."
|
||||
- name: sql
|
||||
desc: "Sentencia SELECT con placeholders pymssql %s (posicional) o %(nombre)s (nombrado) para los valores."
|
||||
- name: params
|
||||
desc: "Tuple/list (posicional), dict (nombrado) o None. Binding seguro del driver (sin inyeccion)."
|
||||
- name: port
|
||||
desc: "Puerto TCP del SQL Server. Default 1433."
|
||||
- name: max_rows
|
||||
desc: "Si es int positivo, devuelve solo las primeras max_rows filas; None devuelve todas."
|
||||
- name: login_timeout
|
||||
desc: "Segundos para la fase de conexion/login. Default 15. Evita que un host inalcanzable cuelgue."
|
||||
- name: query_timeout
|
||||
desc: "Segundos de timeout por query. Default 30."
|
||||
output: "Dict {columns: [nombres], rows: [{col: val}, ...], row_count: int} con el resultado de la SELECT. La conexion se cierra siempre antes de devolver."
|
||||
tested: true
|
||||
tests:
|
||||
- test_run_mssql_query_composes_connect_and_query
|
||||
- test_run_mssql_query_closes_connection_on_error
|
||||
- test_to_csv_renders_header_and_rows
|
||||
test_file_path: "python/functions/pipelines/run_mssql_query_test.py"
|
||||
file_path: "python/functions/pipelines/run_mssql_query.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
Como API programatica (compone conexion + query + cierre):
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "python", "functions"))
|
||||
from pipelines.run_mssql_query import run_mssql_query
|
||||
|
||||
res = run_mssql_query(
|
||||
host="10.0.0.5", # IP LAN del Windows con SQL Server (no localhost desde WSL2)
|
||||
database="navdb",
|
||||
user="sa",
|
||||
password=os.environ["MSSQL_PASSWORD"], # nunca literal: viene de pass/env
|
||||
sql="SELECT TOP 10 [No_], [Amount] FROM [dbo].[Cartera] WHERE [Customer No_] = %s",
|
||||
params=("CLI-0001",), # binding seguro del driver, sin inyeccion
|
||||
)
|
||||
print(res["row_count"], res["columns"])
|
||||
for fila in res["rows"]:
|
||||
print(fila)
|
||||
```
|
||||
|
||||
Como comando one-shot para iterar sobre Navision (imprime JSON o CSV):
|
||||
|
||||
```bash
|
||||
# La contrasena se lee de la env var, nunca se pasa por la linea de comandos
|
||||
MSSQL_PASSWORD=$(pass navision/password) \
|
||||
./fn run run_mssql_query \
|
||||
--host 10.0.0.5 --database navdb --user sa \
|
||||
--sql "SELECT TOP 5 [No_], [Amount] FROM [dbo].[Cartera] WHERE [Customer No_] = %s" \
|
||||
--param CLI-0001 \
|
||||
--format csv
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando quieras ejecutar una SELECT contra Navision (SQL Server) y ver las filas en un
|
||||
solo paso, sin abrir y cerrar la conexion a mano. Es la via rapida para iterar sobre
|
||||
una query (cartera / posted cartera, etc.): cambias el `--sql`, vuelves a lanzar, y lees
|
||||
el resultado. Para muchas queries seguidas sobre la misma conexion, usa directamente
|
||||
`mssql_connect` una vez + `mssql_query` N veces (este pipeline abre y cierra por llamada).
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Conectividad WSL2 → Windows**: `--host` debe ser la IP LAN del Windows que corre SQL Server, NO `localhost` (desde WSL2 localhost no alcanza al host Windows). Ver memoria `wsl2-localhost-forwarding`.
|
||||
- **Credenciales**: la contrasena se lee de la env var indicada por `--password-env` (default `MSSQL_PASSWORD`). Patron: `MSSQL_PASSWORD=$(pass navision/password) ./fn run run_mssql_query ...`. `--password` literal existe pero esta DESACONSEJADO (queda visible en la lista de procesos). Nunca hardcodees credenciales.
|
||||
- **Placeholders**: usa `%s` / `%(nombre)s` (pymssql), NO `?`. Pasa los valores por `--param` (posicional, repetible y en orden), jamas concatenados en el `--sql` (inyeccion).
|
||||
- **Abre y cierra por llamada**: cada invocacion abre una conexion nueva y la cierra al terminar (incluso si la query falla). No es eficiente para rafagas de muchas queries — para eso compon `mssql_connect` + `mssql_query` tu mismo.
|
||||
- **Read-only**: no hace commit. Pensado para SELECT. No lo uses para INSERT/UPDATE/DELETE.
|
||||
- **Requiere pymssql** instalado en el venv (lo importa `mssql_connect`).
|
||||
- **CSV**: `--format csv` serializa con el modulo `csv` estandar; valores no-string se convierten con `str` en JSON (`default=str`) para fechas/decimales de SQL Server.
|
||||
@@ -0,0 +1,140 @@
|
||||
"""One-shot SQL Server (Navision) query: connect, run a SELECT, print rows.
|
||||
|
||||
Composes the registry functions `mssql_connect` and `mssql_query` so a single
|
||||
`fn run run_mssql_query ...` opens a connection, runs one parameterized SELECT,
|
||||
closes the connection, and prints the rows as JSON or CSV. Built to make
|
||||
iterating over Navision queries a one-command loop instead of a manual
|
||||
copy-paste round trip.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
from infra.mssql_connect import mssql_connect
|
||||
from infra.mssql_query import mssql_query
|
||||
|
||||
|
||||
def run_mssql_query(host: str, database: str, user: str, password: str,
|
||||
sql: str, params=None, port: int = 1433,
|
||||
max_rows: int | None = None, login_timeout: int = 15,
|
||||
query_timeout: int = 30) -> dict:
|
||||
"""Open a SQL Server connection, run one SELECT, close, return the rows.
|
||||
|
||||
Thin impure composition of `mssql_connect` + `mssql_query`. The connection
|
||||
is always closed (try/finally), even on error. Credentials are supplied by
|
||||
the caller (read from `pass`/env) and never hardcoded.
|
||||
|
||||
Args:
|
||||
host: SQL Server host or IP. From WSL2 use the Windows LAN IP, not
|
||||
"localhost".
|
||||
database: Database name to connect to.
|
||||
user: SQL Server login user.
|
||||
password: Password for the login user. Pass it from `pass`/env, never a
|
||||
string literal in code.
|
||||
sql: The SELECT statement, using pymssql placeholders `%s` (positional)
|
||||
or `%(name)s` (named) for any bound values.
|
||||
params: Tuple/list for positional placeholders, dict for named
|
||||
placeholders, or None. Bound safely by the driver (no injection).
|
||||
port: TCP port of the SQL Server instance. Defaults to 1433.
|
||||
max_rows: If a positive int, only the first `max_rows` rows are
|
||||
returned. If None, all rows are returned.
|
||||
login_timeout: Seconds allowed for the connect/login phase. Defaults to
|
||||
15. Keeps an unreachable host from hanging.
|
||||
query_timeout: Seconds allowed for the query. Defaults to 30.
|
||||
|
||||
Returns:
|
||||
The dict returned by `mssql_query`: {"columns": [...], "rows": [...],
|
||||
"row_count": int}.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the connection or the query fails. The original
|
||||
exception (from `mssql_connect` / `mssql_query`) is chained.
|
||||
"""
|
||||
conn = mssql_connect(
|
||||
host, database, user, password,
|
||||
port=port, login_timeout=login_timeout, query_timeout=query_timeout,
|
||||
)
|
||||
try:
|
||||
return mssql_query(conn, sql, params=params, max_rows=max_rows)
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception: # pragma: no cover - close errors are non-fatal here
|
||||
pass
|
||||
|
||||
|
||||
def _to_csv(result: dict) -> str:
|
||||
"""Render a query result dict as CSV text (header + rows)."""
|
||||
import csv
|
||||
import io
|
||||
|
||||
buf = io.StringIO()
|
||||
writer = csv.writer(buf)
|
||||
columns = result.get("columns", [])
|
||||
writer.writerow(columns)
|
||||
for row in result.get("rows", []):
|
||||
writer.writerow([row.get(col) for col in columns])
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
import json
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description=(
|
||||
"Ejecuta una SELECT contra un SQL Server (Navision) e imprime las "
|
||||
"filas. Compone mssql_connect + mssql_query."
|
||||
)
|
||||
)
|
||||
parser.add_argument("--host", required=True, help="Host/IP del SQL Server (IP LAN de Windows desde WSL2).")
|
||||
parser.add_argument("--database", required=True, help="Nombre de la base de datos.")
|
||||
parser.add_argument("--user", required=True, help="Usuario de login.")
|
||||
parser.add_argument(
|
||||
"--password-env", default="MSSQL_PASSWORD",
|
||||
help="Variable de entorno de la que leer la contrasena (default MSSQL_PASSWORD). "
|
||||
"Uso recomendado: MSSQL_PASSWORD=$(pass navision/password) fn run run_mssql_query ...",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--password", default="",
|
||||
help="Contrasena literal (DESACONSEJADO: visible en la lista de procesos). "
|
||||
"Prefiere --password-env.",
|
||||
)
|
||||
parser.add_argument("--sql", required=True, help="Sentencia SELECT (placeholders %%s o %%(nombre)s).")
|
||||
parser.add_argument(
|
||||
"--param", action="append", default=None, dest="params",
|
||||
help="Parametro posicional para los placeholders %%s. Repetible y en orden.",
|
||||
)
|
||||
parser.add_argument("--port", type=int, default=1433, help="Puerto TCP. Default 1433.")
|
||||
parser.add_argument("--max-rows", type=int, default=None, help="Limite de filas devueltas.")
|
||||
parser.add_argument("--login-timeout", type=int, default=15, help="Timeout de login en segundos.")
|
||||
parser.add_argument("--query-timeout", type=int, default=30, help="Timeout de query en segundos.")
|
||||
parser.add_argument(
|
||||
"--format", choices=["json", "csv"], default="json", dest="fmt",
|
||||
help="Formato de salida. Default json.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
password = args.password or os.environ.get(args.password_env, "")
|
||||
if not password:
|
||||
parser.error(
|
||||
f"sin contrasena: define la env var {args.password_env!r} "
|
||||
f"(p.ej. MSSQL_PASSWORD=$(pass navision/password)) o pasa --password."
|
||||
)
|
||||
|
||||
params = tuple(args.params) if args.params else None
|
||||
|
||||
result = run_mssql_query(
|
||||
args.host, args.database, args.user, password,
|
||||
args.sql, params=params, port=args.port, max_rows=args.max_rows,
|
||||
login_timeout=args.login_timeout, query_timeout=args.query_timeout,
|
||||
)
|
||||
|
||||
if args.fmt == "csv":
|
||||
sys.stdout.write(_to_csv(result))
|
||||
else:
|
||||
print(json.dumps(result, default=str, ensure_ascii=False))
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Tests for run_mssql_query: composition of mssql_connect + mssql_query.
|
||||
|
||||
Mock-based, no real SQL Server. The pipeline binds `mssql_connect` and
|
||||
`mssql_query` as module-level names, so we monkeypatch them in the pipeline's
|
||||
namespace and assert the orchestration (connect -> query -> always close).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
import pipelines.run_mssql_query as mod
|
||||
from pipelines.run_mssql_query import run_mssql_query, _to_csv
|
||||
|
||||
|
||||
class FakeConn:
|
||||
def __init__(self):
|
||||
self.closed = False
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
|
||||
def test_run_mssql_query_composes_connect_and_query(monkeypatch):
|
||||
fake_conn = FakeConn()
|
||||
connect_calls = {}
|
||||
query_calls = {}
|
||||
|
||||
def fake_connect(host, database, user, password, **kwargs):
|
||||
connect_calls.update(
|
||||
host=host, database=database, user=user, password=password, **kwargs
|
||||
)
|
||||
return fake_conn
|
||||
|
||||
sentinel = {"columns": ["No_"], "rows": [{"No_": "CLI-1"}], "row_count": 1}
|
||||
|
||||
def fake_query(conn, sql, params=None, max_rows=None):
|
||||
query_calls.update(conn=conn, sql=sql, params=params, max_rows=max_rows)
|
||||
return sentinel
|
||||
|
||||
monkeypatch.setattr(mod, "mssql_connect", fake_connect)
|
||||
monkeypatch.setattr(mod, "mssql_query", fake_query)
|
||||
|
||||
result = run_mssql_query(
|
||||
"10.0.0.5", "navdb", "sa", "pw",
|
||||
"SELECT [No_] FROM [dbo].[Cartera] WHERE [Customer No_] = %s",
|
||||
params=("CLI-0001",), port=1433, max_rows=5,
|
||||
)
|
||||
|
||||
# Returns exactly what mssql_query produced.
|
||||
assert result is sentinel
|
||||
# Connection was opened with the supplied params.
|
||||
assert connect_calls["host"] == "10.0.0.5"
|
||||
assert connect_calls["database"] == "navdb"
|
||||
assert connect_calls["port"] == 1433
|
||||
# Query borrowed the open connection and got the bound params (no interpolation).
|
||||
assert query_calls["conn"] is fake_conn
|
||||
assert query_calls["params"] == ("CLI-0001",)
|
||||
assert query_calls["max_rows"] == 5
|
||||
# Connection is always closed.
|
||||
assert fake_conn.closed is True
|
||||
|
||||
|
||||
def test_run_mssql_query_closes_connection_on_error(monkeypatch):
|
||||
fake_conn = FakeConn()
|
||||
|
||||
monkeypatch.setattr(mod, "mssql_connect", lambda *a, **k: fake_conn)
|
||||
|
||||
def boom(conn, sql, params=None, max_rows=None):
|
||||
raise RuntimeError("mssql_query failed executing query: boom")
|
||||
|
||||
monkeypatch.setattr(mod, "mssql_query", boom)
|
||||
|
||||
with pytest.raises(RuntimeError, match="failed executing query"):
|
||||
run_mssql_query("10.0.0.5", "navdb", "sa", "pw", "SELECT 1")
|
||||
|
||||
# Even on a query error, the connection is closed (try/finally).
|
||||
assert fake_conn.closed is True
|
||||
|
||||
|
||||
def test_to_csv_renders_header_and_rows():
|
||||
result = {
|
||||
"columns": ["No_", "Amount"],
|
||||
"rows": [
|
||||
{"No_": "CLI-1", "Amount": 100},
|
||||
{"No_": "CLI-2", "Amount": 200},
|
||||
],
|
||||
"row_count": 2,
|
||||
}
|
||||
csv_text = _to_csv(result)
|
||||
lines = csv_text.strip().splitlines()
|
||||
assert lines[0] == "No_,Amount"
|
||||
assert lines[1] == "CLI-1,100"
|
||||
assert lines[2] == "CLI-2,200"
|
||||
Reference in New Issue
Block a user