---
name: mssql_query
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def mssql_query(conn, sql: str, params=None, max_rows: int | None = None) -> dict"
description: "Ejecuta una SELECT parametrizada (binding seguro de pymssql, sin inyeccion) sobre una conexion SQL Server/Navision ya abierta y devuelve {columns, rows como lista de dicts, row_count}. Opcion max_rows para limitar las filas."
tags: [mssql, sqlserver, navision, sql-connect, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: true
tests: ["test_golden_maps_rows_to_dicts", "test_binding_passes_params_to_driver", "test_zero_rows_no_error", "test_max_rows_uses_fetchmany", "test_description_none_empty_columns", "test_execution_error_raises_runtimeerror"]
test_file_path: "python/functions/infra/mssql_query_test.py"
params:
  - name: conn
    desc: "Conexion abierta (la que devuelve mssql_connect). No se abre ni cierra aqui; se reutiliza por duck typing via conn.cursor()."
  - name: sql
    desc: "Sentencia SELECT con placeholders pymssql %s (posicional) o %(nombre)s (nombrado) para los valores a vincular."
  - name: params
    desc: "Tuple/list para placeholders posicionales, dict para nombrados, o None. Se pasa a cursor.execute(sql, params) para binding seguro del driver (nunca interpolacion)."
  - name: max_rows
    desc: "Si es int>0, limita a las primeras max_rows filas (fetchmany). Si None, devuelve todas (fetchall)."
output: "Dict con tres claves: 'columns' (lista de nombres de columna en orden, vacia si no hubo result set), 'rows' (lista de dicts columna->valor, una por fila), 'row_count' (int len(rows))."
file_path: "python/functions/infra/mssql_query.py"
---

## Ejemplo

```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from infra.mssql_connect import mssql_connect
from infra.mssql_query import mssql_query

conn = mssql_connect(
    host="10.0.0.5", database="navdb", user="readonly", password="<desde pass>"
)
try:
    res = mssql_query(
        conn,
        "SELECT TOP 10 No_, Amount FROM [dbo].[Cartera] WHERE [Customer No_] = %s",
        ("CLI-0001",),
    )
    print(res["columns"])      # ['No_', 'Amount']
    print(res["row_count"])    # numero de filas devueltas
    for fila in res["rows"]:
        print(fila["No_"], fila["Amount"])
finally:
    conn.close()
```

## Cuando usarla

Cuando ya tienes una conexion abierta con `mssql_connect` y quieres iterar
consultas SELECT sobre Navision / SQL Server sin reabrir la conexion en cada
una. Pasa los valores variables como `params` para que el driver los vincule de
forma segura (sin inyeccion) en lugar de construir el SQL con f-strings.

## Gotchas

- Los placeholders de pymssql son `%s` (posicional) y `%(nombre)s` (nombrado),
  NO el `?` de pyodbc. Si usas el placeholder equivocado, el binding falla.
- Pasa los valores SIEMPRE por el argumento `params`, jamas con f-string o `%`
  dentro del SQL: interpolar abre la puerta a inyeccion SQL.
- No hace commit: es read-only, pensada para SELECT.
- No cierra la conexion — la gestiona el caller (abrir una vez, consultar
  muchas, cerrar al final).
- `max_rows` usa `cursor.fetchmany(max_rows)`; con None usa `fetchall()`.
- Si la sentencia no produce result set (`cursor.description is None`),
  `columns` y `rows` vuelven como listas vacias en lugar de fallar.
- El mensaje de error es generico a proposito: no incluye el SQL ni los params
  para no filtrar datos sensibles.
