"""Ejecuta una query SELECT contra una base DuckDB abierta en modo solo lectura. Funcion impura: abre un archivo DuckDB con `duckdb.connect(db_path, read_only=True)`, de modo que nunca crea ni modifica la base. La conexion se cierra siempre en un bloque try/finally. Ejecuta el SQL con parametros posicionales (DuckDB usa el marcador `?`) y devuelve un dict sin lanzar excepciones, siguiendo el estilo del grupo dav del registry: {status:'ok', ...} en exito y {status:'error', error:str} en fallo. Las filas se devuelven como lista de dicts (un dict por fila, mapeando el nombre de columna a su valor). 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 y datetime a isoformat(), Decimal a float, bytes a una cadena base64 y UUID a str. """ import base64 import datetime import decimal import uuid def _to_serializable(value): """Convierte un valor de DuckDB a una forma JSON-serializable. date/datetime/time -> isoformat(), Decimal -> float, bytes -> 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 duckdb_query_readonly( db_path: str, sql: str, params: list = None, max_rows: int = 10000, sandbox: bool = True, ) -> dict: """Ejecuta un SELECT contra una base DuckDB en modo solo lectura. Args: db_path: ruta al archivo DuckDB. Debe existir: el modo read_only NO crea la base. Un path inexistente devuelve {status:'error', ...}. sql: sentencia SQL a ejecutar. Pensada para SELECT; usa el marcador `?` para parametros posicionales. params: lista de parametros posicionales para el SQL. None (default) significa sin parametros. max_rows: numero maximo de filas a materializar (default 10000). Si la query produce mas, se trunca y truncated queda en True. sandbox: si True (default), abre la conexion con ``enable_external_access=False``, lo que prohibe a la query acceder al sistema de ficheros y a la red (``read_csv``/``read_blob``/``glob``/ ``COPY ... TO``/``httpfs``/``ATTACH`` a paths externos). CRITICO cuando el SQL viene de un cliente no confiable: ``read_only=True`` solo protege la base de datos, NO el sistema de ficheros, asi que sin el sandbox un SELECT malicioso puede leer ficheros arbitrarios (p.ej. claves SSH) o escribirlos. Ponlo en False solo para usos internos confiables que necesiten leer CSV/Parquet del disco. Returns: dict. En exito: {status:'ok', columns:[...], 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). En error (sin lanzar): {status:'error', error:str}. """ conn = None try: config = {"enable_external_access": False} if sandbox else {} conn = __import__("duckdb").connect( db_path, read_only=True, config=config ) cursor = conn.execute(sql, params if params is not None else []) description = cursor.description or [] columns = [col[0] for col in description] # Pedimos una fila de mas que max_rows para detectar truncado sin # materializar todo el resultado en memoria. fetched = cursor.fetchmany(max_rows + 1) truncated = len(fetched) > max_rows if truncated: fetched = fetched[:max_rows] rows = [ {columns[i]: _to_serializable(value) for i, value in enumerate(record)} for record in fetched ] 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()