"""Ingesta una hoja de un archivo .xlsx a una tabla DuckDB. Funcion impura: abre el archivo DuckDB destino en modo read-write (`duckdb.connect(duckdb_path)`, que crea el archivo si no existe), carga la extension `excel` de DuckDB y materializa la hoja del .xlsx en una tabla con `read_xlsx`. La conexion se cierra siempre en un bloque try/finally. Devuelve un dict sin lanzar excepciones, siguiendo el estilo del grupo duckdb del registry: {status:'ok', ...} en exito y {status:'error', error:str} en fallo. Camino activo (verificado en DuckDB 1.5.2): extension nativa `excel`. El path del .xlsx y el nombre de la hoja se pasan como parametros posicionales (marcador `?`) a `read_xlsx`, por lo que NO se interpolan en el SQL y no hay inyeccion por esa via. El identificador de tabla destino SI se interpola (CREATE/INSERT no admiten parametro para el nombre de tabla), asi que se valida contra un regex estricto. mode='replace' (default) -> `CREATE OR REPLACE TABLE AS SELECT * FROM read_xlsx(?)`: reemplaza la tabla entera. mode='append' -> crea la tabla si no existe (`CREATE TABLE IF NOT EXISTS ... AS SELECT ... LIMIT 0` para fijar el schema) y luego `INSERT INTO
SELECT * FROM read_xlsx(?)`. """ import re # Identificador de tabla valido: letras, digitos y guion bajo, sin empezar por # digito. Rechaza cualquier cosa que pudiera inyectarse en el CREATE/INSERT. _VALID_IDENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") def excel_to_duckdb( xlsx_path: str, duckdb_path: str, table: str, sheet: str = None, mode: str = "replace", ) -> dict: """Ingesta una hoja de un .xlsx a una tabla DuckDB via la extension excel. Args: xlsx_path: ruta al archivo .xlsx de origen. Debe existir y ser legible. Se pasa como parametro posicional a read_xlsx (no se interpola). duckdb_path: ruta al archivo DuckDB destino. Se abre en modo escritura, que crea el archivo si no existe. DuckDB es single-writer: si otro proceso lo tiene abierto en escritura, falla con error de lock. table: nombre de la tabla destino. Se valida contra ^[A-Za-z_][A-Za-z0-9_]*$ antes de interpolarlo en el SQL (CREATE/INSERT no admiten parametro para el nombre de tabla). Identificador invalido devuelve {status:'error', ...} sin tocar la base. sheet: nombre de la hoja a leer. None (default) lee la primera hoja del libro. Se pasa como parametro posicional (sheet=?) a read_xlsx. mode: 'replace' (default) reemplaza la tabla entera con CREATE OR REPLACE TABLE AS SELECT. 'append' crea la tabla si no existe y luego inserta las filas con INSERT INTO ... SELECT. Cualquier otro valor devuelve {status:'error', ...}. Returns: dict. En exito: {status:'ok', table:str, row_count:int} donde row_count es el numero de filas que tiene la tabla tras la ingesta. En error (sin lanzar): {status:'error', error:str}. """ if not isinstance(table, str) or not _VALID_IDENT.match(table): return { "status": "error", "error": f"invalid table identifier: {table!r}", } if mode not in ("replace", "append"): return { "status": "error", "error": f"invalid mode: {mode!r} (expected 'replace' or 'append')", } quoted = '"' + table.replace('"', '""') + '"' # Argumentos de read_xlsx: path siempre, sheet solo si se especifica. Todo # como parametros posicionales para evitar inyeccion via el .xlsx/hoja. if sheet is not None: read_call = "read_xlsx(?, sheet=?)" read_params = [xlsx_path, sheet] else: read_call = "read_xlsx(?)" read_params = [xlsx_path] conn = None try: conn = __import__("duckdb").connect(duckdb_path) # La extension excel se instala (red la 1a vez) y carga en la conexion. conn.execute("INSTALL excel; LOAD excel;") if mode == "replace": conn.execute( f"CREATE OR REPLACE TABLE {quoted} AS SELECT * FROM {read_call}", read_params, ) else: # append # Fijamos el schema de la tabla con un SELECT vacio si no existe, sin # cargar datos; luego insertamos todas las filas. conn.execute( f"CREATE TABLE IF NOT EXISTS {quoted} AS " f"SELECT * FROM {read_call} LIMIT 0", read_params, ) conn.execute( f"INSERT INTO {quoted} SELECT * FROM {read_call}", read_params, ) conn.commit() row_count = conn.execute(f"SELECT COUNT(*) FROM {quoted}").fetchone()[0] return {"status": "ok", "table": table, "row_count": int(row_count)} except Exception as e: # noqa: BLE001 return {"status": "error", "error": str(e)} finally: if conn is not None: conn.close()