---
name: duckdb_query_readonly
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def duckdb_query_readonly(db_path: str, sql: str, params: list = None, max_rows: int = 10000) -> dict"
description: "Ejecuta una query SELECT contra una base DuckDB abierta en modo solo lectura (duckdb.connect(db_path, read_only=True)), de modo que nunca crea ni modifica la base. La conexion se cierra siempre en try/finally. Usa parametros posicionales con el marcador '?'. Devuelve un dict sin lanzar (estilo del grupo dav): {status:'ok', columns, rows, row_count, truncated} en exito y {status:'error', error} en fallo. Las filas son list[dict]. Trunca a max_rows para proteger memoria. Convierte valores no serializables: date/datetime/time a isoformat(), Decimal a float, bytes a base64, UUID a str. Depende del paquete duckdb (1.5.2 en python/.venv)."
tags: [duckdb, sql, query, readonly]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_py_core"
imports: [base64, datetime, decimal, uuid, duckdb]
params:
  - name: db_path
    desc: "ruta al archivo DuckDB. Debe existir: el modo read_only NO crea la base. Un path inexistente devuelve {status:'error'}."
  - name: sql
    desc: "sentencia SQL a ejecutar (pensada para SELECT). Usa el marcador '?' para parametros posicionales."
  - name: params
    desc: "lista de parametros posicionales para el SQL en orden. None (default) significa sin parametros. Pasar 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}. En error (sin lanzar): {status:'error', error:str}. Los valores de las filas estan normalizados a tipos JSON-serializables."
tested: true
tests:
  - "test_query_ok_devuelve_filas_como_dicts"
  - "test_query_con_params_posicionales"
  - "test_sql_invalido_devuelve_status_error"
  - "test_db_inexistente_devuelve_status_error"
  - "test_truncado_a_max_rows"
  - "test_valores_no_serializables_se_convierten"
test_file_path: "python/functions/infra/duckdb_query_readonly_test.py"
file_path: "python/functions/infra/duckdb_query_readonly.py"
---

## Ejemplo

```python
import sys
sys.path.insert(0, "python/functions")
import duckdb
from infra.duckdb_query_readonly import duckdb_query_readonly

# Preparamos una base de ejemplo (esto seria un proceso separado en la realidad).
db = "/tmp/ventas.duckdb"
con = duckdb.connect(db)
con.execute("CREATE TABLE ventas (id INTEGER, region VARCHAR, total DECIMAL(10,2))")
con.execute("INSERT INTO ventas VALUES (1, 'norte', 120.50), (2, 'sur', 80.00), (3, 'norte', 45.25)")
con.close()

# Lectura solo-lectura con parametro posicional.
res = duckdb_query_readonly(
    db,
    "SELECT region, SUM(total) AS total FROM ventas WHERE region = ? GROUP BY region",
    params=["norte"],
)
print(res["status"])      # ok
print(res["columns"])     # ['region', 'total']
print(res["rows"])        # [{'region': 'norte', 'total': 165.75}]
print(res["truncated"])   # False
```

## Cuando usarla

Cuando necesitas leer datos de un archivo DuckDB sin riesgo de modificarlo:
inspeccionar una base materializada, validar el resultado de un pipeline,
alimentar un dashboard o un report, o consultar tablas/Parquet exportados por
otra funcion del registry. El modo read_only garantiza que la consulta nunca
crea ni altera la base, y el dict de salida es directamente serializable a JSON
para pasarlo al siguiente paso de una composicion.

## Gotchas

- Lectura real de un archivo en disco (impura). El modo `read_only=True` exige
  que el archivo **ya exista**: a diferencia del modo escritura, no crea la base.
  Si `db_path` no existe, devuelve `{status:'error', error:...}`.
- Conflicto de lock: si otro proceso tiene la misma base abierta en escritura
  con una version de DuckDB distinta, la apertura puede fallar (DuckDB no permite
  abrir un archivo bloqueado por otra version del motor). El error se devuelve
  como `{status:'error', ...}`, no se lanza.
- `max_rows` protege la memoria: una query que devuelve millones de filas se
  trunca a `max_rows` y marca `truncated=True`. Si necesitas todas las filas,
  pagina con LIMIT/OFFSET en el SQL o sube `max_rows` conscientemente.
- Los parametros van en `params` con el marcador `?`, nunca interpolados en el
  string del SQL (previene inyeccion).
- Valores no JSON-serializables se normalizan en la salida: date/datetime/time a
  `isoformat()`, Decimal a float (puede haber perdida de precision frente al
  decimal exacto), bytes a base64 y UUID a str.
