Merge remote-tracking branch 'origin/master'
This commit is contained in:
@@ -20,10 +20,38 @@ from .dav_get_resource import dav_get_resource
|
||||
from .dav_delete_resource import dav_delete_resource
|
||||
from .pg_insert_rows import pg_insert_rows
|
||||
from .pg_apply_sql import pg_apply_sql
|
||||
from .pg_query import pg_query
|
||||
from .pg_upsert import pg_upsert
|
||||
from .pg_create_table_from_rows import pg_create_table_from_rows
|
||||
from .pg_list_tables import pg_list_tables
|
||||
from .read_xlsx import read_xlsx
|
||||
from .add_xlsx_chart import add_xlsx_chart
|
||||
from .duckdb_list_tables import duckdb_list_tables
|
||||
from .duckdb_table_schema import duckdb_table_schema
|
||||
from .excel_to_duckdb import excel_to_duckdb
|
||||
from .write_xlsx_sheets import write_xlsx_sheets
|
||||
from .upsert_xlsx_sheet import upsert_xlsx_sheet
|
||||
from .duckdb_query_readonly import duckdb_query_readonly
|
||||
from .duckdb_execute import duckdb_execute
|
||||
from .duckdb_upsert import duckdb_upsert
|
||||
|
||||
__all__ = [
|
||||
"write_xlsx_sheets",
|
||||
"upsert_xlsx_sheet",
|
||||
"duckdb_query_readonly",
|
||||
"duckdb_execute",
|
||||
"duckdb_upsert",
|
||||
"pg_insert_rows",
|
||||
"pg_apply_sql",
|
||||
"pg_query",
|
||||
"pg_upsert",
|
||||
"pg_create_table_from_rows",
|
||||
"pg_list_tables",
|
||||
"read_xlsx",
|
||||
"add_xlsx_chart",
|
||||
"duckdb_list_tables",
|
||||
"duckdb_table_schema",
|
||||
"excel_to_duckdb",
|
||||
"setup_logger",
|
||||
"get_logger",
|
||||
"generate_app_icon",
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
---
|
||||
name: add_xlsx_chart
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def add_xlsx_chart(xlsx_path: str, sheet_name: str, chart_type: str, data_range: str, cats_range: str = None, anchor: str = 'H2', title: str = '', x_title: str = '', y_title: str = '') -> dict"
|
||||
description: "Anade un grafico nativo de openpyxl a una hoja EXISTENTE de un libro .xlsx existente, refiriendo rangos de celdas ya escritos. chart_type en {bar, line, pie, scatter} (BarChart/LineChart/PieChart/ScatterChart). data_range y cats_range en notacion Excel tipo 'B1:B10' (se convierten a Reference). anchor = celda destino del chart (ej. 'H2'). Acepta titulo del grafico y de los ejes X/Y. Guarda el libro. Es la pieza que completa el grupo excel para generar hojas con graficos: primero escribir datos (write_xlsx_sheets) y luego anadir el chart. Impura: escribe disco y NO lanza: en fallo (hoja/libro inexistente, chart_type invalido, rango invalido) devuelve {status: 'error', error}."
|
||||
tags: [excel, xlsx, chart, openpyxl, spreadsheet, office, onlyoffice, viz, io, infra]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [openpyxl]
|
||||
params:
|
||||
- name: xlsx_path
|
||||
desc: "Ruta al archivo .xlsx EXISTENTE. Esta funcion NO crea el libro: escribe primero los datos con write_xlsx_sheets/upsert_xlsx_sheet. Vacio o inexistente devuelve {status: 'error'} (no lanza)."
|
||||
- name: sheet_name
|
||||
desc: "Nombre de la hoja (ya existente) donde se ancla el grafico y de la que provienen los rangos. Si no existe, devuelve {status: 'error'} con la lista de hojas disponibles."
|
||||
- name: chart_type
|
||||
desc: "Tipo de grafico. Uno de: 'bar', 'line', 'pie', 'scatter' (case-insensitive, se normaliza). Cualquier otro valor devuelve {status: 'error'} con la lista de validos."
|
||||
- name: data_range
|
||||
desc: "Rango de celdas de los valores a graficar, en notacion Excel tipo 'B1:B10'. Se convierte a openpyxl.chart.Reference (1-indexed). Si abarca la cabecera (fila 1), se toma el nombre de la serie de esa primera celda (titles_from_data). Rango invalido devuelve {status: 'error'}."
|
||||
- name: cats_range
|
||||
desc: "Rango de las categorias/etiquetas del eje X (o labels de pie), tipo 'A2:A10'. None (default) = sin categorias explicitas. Para scatter se usa como valores X (xvalues) de la serie."
|
||||
- name: anchor
|
||||
desc: "Celda destino (esquina superior izquierda) del grafico, p.ej. 'H2'. Default 'H2'. Ancla el chart sin desplazar las celdas de datos."
|
||||
- name: title
|
||||
desc: "Titulo del grafico. Vacio (default) = sin titulo."
|
||||
- name: x_title
|
||||
desc: "Titulo del eje X. Vacio (default) = sin titulo. Ignorado por pie (no tiene ejes)."
|
||||
- name: y_title
|
||||
desc: "Titulo del eje Y. Vacio (default) = sin titulo. Ignorado por pie (no tiene ejes)."
|
||||
output: "Dict. En exito: {status: 'ok', chart_type: <str normalizado>, sheet: <str>, anchor: <str>}. En error: {status: 'error', error: '<mensaje>'}."
|
||||
tested: true
|
||||
tests: ["test_add_bar_chart_reabre_y_verifica", "test_add_line_chart", "test_add_pie_chart", "test_add_scatter_chart", "test_dos_charts_en_la_misma_hoja", "test_chart_type_invalido_devuelve_error", "test_hoja_inexistente_devuelve_error", "test_libro_inexistente_devuelve_error", "test_data_range_invalido_devuelve_error", "test_xlsx_path_vacio_devuelve_error"]
|
||||
test_file_path: "python/functions/infra/add_xlsx_chart_test.py"
|
||||
file_path: "python/functions/infra/add_xlsx_chart.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from infra.write_xlsx_sheets import write_xlsx_sheets
|
||||
from infra.add_xlsx_chart import add_xlsx_chart
|
||||
|
||||
# 1) Escribe los datos (cabecera + filas)
|
||||
write_xlsx_sheets("/tmp/ventas_chart.xlsx", {
|
||||
"Ventas": [
|
||||
["Mes", "Unidades"],
|
||||
["Ene", 120],
|
||||
["Feb", 150],
|
||||
["Mar", 90],
|
||||
["Abr", 200],
|
||||
],
|
||||
})
|
||||
|
||||
# 2) Anade un grafico de barras refiriendo los rangos ya escritos
|
||||
res = add_xlsx_chart(
|
||||
xlsx_path="/tmp/ventas_chart.xlsx",
|
||||
sheet_name="Ventas",
|
||||
chart_type="bar",
|
||||
data_range="B1:B5", # incluye la cabecera 'Unidades' -> nombre de la serie
|
||||
cats_range="A2:A5", # meses como categorias del eje X
|
||||
anchor="D2", # esquina superior izquierda del chart
|
||||
title="Unidades por mes",
|
||||
x_title="Mes",
|
||||
y_title="Unidades",
|
||||
)
|
||||
print(res)
|
||||
# {'status': 'ok', 'chart_type': 'bar', 'sheet': 'Ventas', 'anchor': 'D2'}
|
||||
|
||||
# Verificar que el chart quedo en la hoja
|
||||
from openpyxl import load_workbook
|
||||
wb = load_workbook("/tmp/ventas_chart.xlsx")
|
||||
print(len(wb["Ventas"]._charts)) # 1
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Usala cuando necesites **generar una hoja de Excel con un grafico** a partir de
|
||||
datos que ya escribiste en el libro: dashboards exportables, reports con
|
||||
visualizacion embebida, resumenes que se abren en Excel/OnlyOffice mostrando el
|
||||
chart. Es el ultimo paso del flujo del grupo `excel`: `write_xlsx_sheets`
|
||||
(o `upsert_xlsx_sheet`) escribe los datos, y esta funcion les anade el grafico
|
||||
refiriendo sus rangos. Llamala una vez por grafico (puedes anadir varios a la
|
||||
misma hoja con distintos `anchor`).
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Impura — escribe en disco.** Reabre el libro, anade el chart y lo GUARDA.
|
||||
No lanza: devuelve `{"status": "error", ...}` ante libro inexistente, hoja
|
||||
inexistente, `chart_type` invalido, rango invalido o openpyxl ausente.
|
||||
- **El libro DEBE existir.** Esta funcion no crea el .xlsx ni escribe datos:
|
||||
los rangos (`data_range`, `cats_range`) deben apuntar a celdas YA escritas.
|
||||
Escribe primero con `write_xlsx_sheets`/`upsert_xlsx_sheet`.
|
||||
- **openpyxl carga el libro entero en memoria** para reabrirlo y reescribirlo.
|
||||
Para libros muy grandes esto consume RAM proporcional al tamano.
|
||||
- **Los `Reference` de openpyxl son 1-indexed** (fila 1, columna 1 = A1). La
|
||||
conversion desde notacion `'B1:B10'` la hace `range_boundaries` internamente;
|
||||
si pasas un rango mal formado, devuelve error en vez de un chart vacio.
|
||||
- **`titles_from_data`**: si `data_range` incluye la fila 1 (cabecera), el
|
||||
nombre de la serie se toma de esa primera celda. Si tu `data_range` empieza en
|
||||
fila 2 (solo datos), la serie queda sin nombre — incluye la cabecera para
|
||||
etiquetarla.
|
||||
- **scatter es distinto**: usa `data_range` como valores Y y `cats_range` como
|
||||
valores X (xvalues) de una unica serie via `Series`. No usa `set_categories`
|
||||
como bar/line/pie. Para scatter, pasa rangos de SOLO datos (sin cabecera) en
|
||||
ambos.
|
||||
- **pie ignora `x_title`/`y_title`** (no tiene ejes). Pasarlos no falla, se
|
||||
ignoran silenciosamente.
|
||||
- **El chart NO se recalcula solo**: openpyxl escribe la definicion del grafico;
|
||||
Excel/LibreOffice lo renderiza al abrir. Si cambias los datos despues, vuelve
|
||||
a llamar a la funcion o edita el rango — el chart referencia celdas, asi que
|
||||
reflejara el valor que tengan al abrir el libro.
|
||||
- **Requiere openpyxl** (ya instalado en `python/.venv`, version 3.1.5).
|
||||
@@ -0,0 +1,249 @@
|
||||
"""Anade un grafico nativo de openpyxl a una hoja existente de un libro .xlsx.
|
||||
|
||||
Funcion impura: abre un libro Excel existente, crea un objeto Chart de openpyxl
|
||||
(BarChart/LineChart/PieChart/ScatterChart) sobre rangos de celdas YA escritos, lo
|
||||
ancla en una celda destino y guarda el libro. Es la pieza que faltaba en el grupo
|
||||
`excel` para producir hojas de Excel con graficos: primero se escriben los datos
|
||||
(p.ej. con write_xlsx_sheets) y luego se les anade el chart refiriendo sus rangos.
|
||||
|
||||
No lanza: cualquier fallo (libro inexistente, hoja inexistente, chart_type
|
||||
invalido, openpyxl ausente) se devuelve como dict {"status": "error", ...}.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
|
||||
# chart_type -> clase de openpyxl.chart. Se resuelve perezosamente en runtime
|
||||
# para no fallar al importar el modulo si openpyxl no esta instalado.
|
||||
_VALID_CHART_TYPES = ("bar", "line", "pie", "scatter")
|
||||
|
||||
|
||||
def add_xlsx_chart(
|
||||
xlsx_path: str,
|
||||
sheet_name: str,
|
||||
chart_type: str,
|
||||
data_range: str,
|
||||
cats_range: str = None,
|
||||
anchor: str = "H2",
|
||||
title: str = "",
|
||||
x_title: str = "",
|
||||
y_title: str = "",
|
||||
) -> dict:
|
||||
"""Anade un grafico nativo a una hoja existente de un libro existente.
|
||||
|
||||
Args:
|
||||
xlsx_path: Ruta al archivo .xlsx existente. Debe existir (esta funcion no
|
||||
crea el libro: escribe primero los datos con otra funcion del grupo).
|
||||
sheet_name: Nombre de la hoja (ya existente) donde se ancla el grafico y
|
||||
de la que provienen los rangos.
|
||||
chart_type: Tipo de grafico. Uno de: "bar", "line", "pie", "scatter".
|
||||
data_range: Rango de celdas de los valores a graficar, en notacion Excel
|
||||
tipo "B1:B10". Se convierte a openpyxl.chart.Reference. Si abarca la
|
||||
cabecera (fila 1), se pasa titles_from_data=True para tomar el nombre
|
||||
de la serie de esa primera celda.
|
||||
cats_range: Rango de las categorias/etiquetas del eje X (o labels de pie),
|
||||
tipo "A2:A10". None (default) = sin categorias explicitas. Ignorado
|
||||
para scatter (que usa xvalues, ver Gotchas).
|
||||
anchor: Celda destino (esquina superior izquierda) del grafico, p.ej.
|
||||
"H2". Default "H2".
|
||||
title: Titulo del grafico. Vacio (default) = sin titulo.
|
||||
x_title: Titulo del eje X. Vacio (default) = sin titulo. Ignorado por pie.
|
||||
y_title: Titulo del eje Y. Vacio (default) = sin titulo. Ignorado por pie.
|
||||
|
||||
Returns:
|
||||
Dict. En exito:
|
||||
{"status": "ok", "chart_type": <str>, "sheet": <str>, "anchor": <str>}.
|
||||
En error:
|
||||
{"status": "error", "error": "<mensaje>"}.
|
||||
"""
|
||||
if not xlsx_path:
|
||||
return {"status": "error", "error": "xlsx_path no puede estar vacio"}
|
||||
|
||||
ct = (chart_type or "").strip().lower()
|
||||
if ct not in _VALID_CHART_TYPES:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": (
|
||||
f"chart_type invalido: '{chart_type}'. "
|
||||
f"Validos: {list(_VALID_CHART_TYPES)}"
|
||||
),
|
||||
}
|
||||
|
||||
abs_path = os.path.abspath(xlsx_path)
|
||||
if not os.path.exists(abs_path):
|
||||
return {"status": "error", "error": f"libro no encontrado: {abs_path}"}
|
||||
|
||||
try:
|
||||
from openpyxl import load_workbook
|
||||
from openpyxl.chart import (
|
||||
BarChart,
|
||||
LineChart,
|
||||
PieChart,
|
||||
Reference,
|
||||
ScatterChart,
|
||||
Series,
|
||||
)
|
||||
from openpyxl.utils.cell import range_boundaries
|
||||
except ImportError: # pragma: no cover - dependencia del entorno
|
||||
return {
|
||||
"status": "error",
|
||||
"error": (
|
||||
"openpyxl es requerido para add_xlsx_chart. "
|
||||
"Instalar con: cd python && uv add openpyxl"
|
||||
),
|
||||
}
|
||||
|
||||
try:
|
||||
wb = load_workbook(abs_path)
|
||||
except Exception as exc: # noqa: BLE001 - contrato del grupo: no lanzar
|
||||
return {"status": "error", "error": f"no se pudo abrir el libro: {exc}"}
|
||||
|
||||
if sheet_name not in wb.sheetnames:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": (
|
||||
f"hoja '{sheet_name}' no existe. "
|
||||
f"Hojas disponibles: {wb.sheetnames}"
|
||||
),
|
||||
}
|
||||
|
||||
ws = wb[sheet_name]
|
||||
|
||||
# Convierte "B1:B10" -> (min_col, min_row, max_col, max_row) en 1-index.
|
||||
try:
|
||||
d_min_col, d_min_row, d_max_col, d_max_row = range_boundaries(data_range)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"data_range invalido '{data_range}': {exc}",
|
||||
}
|
||||
|
||||
chart_classes = {
|
||||
"bar": BarChart,
|
||||
"line": LineChart,
|
||||
"pie": PieChart,
|
||||
"scatter": ScatterChart,
|
||||
}
|
||||
|
||||
try:
|
||||
chart = chart_classes[ct]()
|
||||
if title:
|
||||
chart.title = title
|
||||
|
||||
if ct == "scatter":
|
||||
# Scatter empareja X (cats_range) con Y (data_range) como una serie.
|
||||
chart.style = 13
|
||||
if x_title:
|
||||
chart.x_axis.title = x_title
|
||||
if y_title:
|
||||
chart.y_axis.title = y_title
|
||||
yvalues = Reference(
|
||||
ws,
|
||||
min_col=d_min_col,
|
||||
min_row=d_min_row,
|
||||
max_col=d_max_col,
|
||||
max_row=d_max_row,
|
||||
)
|
||||
if cats_range:
|
||||
try:
|
||||
x_min_col, x_min_row, x_max_col, x_max_row = range_boundaries(
|
||||
cats_range
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"cats_range invalido '{cats_range}': {exc}",
|
||||
}
|
||||
xvalues = Reference(
|
||||
ws,
|
||||
min_col=x_min_col,
|
||||
min_row=x_min_row,
|
||||
max_col=x_max_col,
|
||||
max_row=x_max_row,
|
||||
)
|
||||
series = Series(yvalues, xvalues, title_from_data=False)
|
||||
else:
|
||||
series = Series(yvalues, title_from_data=False)
|
||||
chart.series.append(series)
|
||||
else:
|
||||
# bar/line/pie: add_data + set_categories.
|
||||
if ct in ("bar", "line"):
|
||||
if x_title:
|
||||
chart.x_axis.title = x_title
|
||||
if y_title:
|
||||
chart.y_axis.title = y_title
|
||||
# titles_from_data toma el nombre de serie de la primera fila del
|
||||
# rango cuando este incluye la cabecera (fila 1).
|
||||
from_data = d_min_row == 1
|
||||
data = Reference(
|
||||
ws,
|
||||
min_col=d_min_col,
|
||||
min_row=d_min_row,
|
||||
max_col=d_max_col,
|
||||
max_row=d_max_row,
|
||||
)
|
||||
chart.add_data(data, titles_from_data=from_data)
|
||||
if cats_range:
|
||||
try:
|
||||
c_min_col, c_min_row, c_max_col, c_max_row = range_boundaries(
|
||||
cats_range
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"cats_range invalido '{cats_range}': {exc}",
|
||||
}
|
||||
cats = Reference(
|
||||
ws,
|
||||
min_col=c_min_col,
|
||||
min_row=c_min_row,
|
||||
max_col=c_max_col,
|
||||
max_row=c_max_row,
|
||||
)
|
||||
chart.set_categories(cats)
|
||||
|
||||
ws.add_chart(chart, anchor)
|
||||
wb.save(abs_path)
|
||||
except Exception as exc: # noqa: BLE001 - contrato del grupo: no lanzar
|
||||
return {"status": "error", "error": f"no se pudo anadir el grafico: {exc}"}
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"chart_type": ct,
|
||||
"sheet": sheet_name,
|
||||
"anchor": anchor,
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover - smoke manual
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from infra.write_xlsx_sheets import write_xlsx_sheets
|
||||
|
||||
tmp = os.path.join(tempfile.gettempdir(), "add_xlsx_chart_demo.xlsx")
|
||||
write_xlsx_sheets(
|
||||
tmp,
|
||||
{
|
||||
"Ventas": [
|
||||
["Mes", "Unidades"],
|
||||
["Ene", 120],
|
||||
["Feb", 150],
|
||||
["Mar", 90],
|
||||
["Abr", 200],
|
||||
],
|
||||
},
|
||||
)
|
||||
res = add_xlsx_chart(
|
||||
xlsx_path=tmp,
|
||||
sheet_name="Ventas",
|
||||
chart_type="bar",
|
||||
data_range="B1:B5", # incluye cabecera "Unidades" -> nombre de serie
|
||||
cats_range="A2:A5", # meses
|
||||
anchor="D2",
|
||||
title="Unidades por mes",
|
||||
x_title="Mes",
|
||||
y_title="Unidades",
|
||||
)
|
||||
print(res)
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Tests para add_xlsx_chart.
|
||||
|
||||
Modulos importados por path directo (sin tocar __init__.py). write_xlsx_sheets
|
||||
escribe los datos; add_xlsx_chart les anade un grafico; reabrimos el libro y
|
||||
verificamos que ws._charts contiene el chart.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
|
||||
from openpyxl import load_workbook
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def _load(name):
|
||||
spec = importlib.util.spec_from_file_location(name, os.path.join(_HERE, f"{name}.py"))
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
add_xlsx_chart = _load("add_xlsx_chart").add_xlsx_chart
|
||||
write_xlsx_sheets = _load("write_xlsx_sheets").write_xlsx_sheets
|
||||
|
||||
|
||||
def _book_with_data(tmp_path):
|
||||
out = str(tmp_path / "chart.xlsx")
|
||||
write_xlsx_sheets(
|
||||
out,
|
||||
{
|
||||
"Ventas": [
|
||||
["Mes", "Unidades"],
|
||||
["Ene", 120],
|
||||
["Feb", 150],
|
||||
["Mar", 90],
|
||||
["Abr", 200],
|
||||
],
|
||||
},
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def test_add_bar_chart_reabre_y_verifica(tmp_path):
|
||||
out = _book_with_data(tmp_path)
|
||||
res = add_xlsx_chart(
|
||||
xlsx_path=out,
|
||||
sheet_name="Ventas",
|
||||
chart_type="bar",
|
||||
data_range="B1:B5",
|
||||
cats_range="A2:A5",
|
||||
anchor="D2",
|
||||
title="Unidades por mes",
|
||||
x_title="Mes",
|
||||
y_title="Unidades",
|
||||
)
|
||||
assert res["status"] == "ok"
|
||||
assert res["chart_type"] == "bar"
|
||||
assert res["sheet"] == "Ventas"
|
||||
assert res["anchor"] == "D2"
|
||||
|
||||
wb = load_workbook(out)
|
||||
ws = wb["Ventas"]
|
||||
assert len(ws._charts) == 1
|
||||
|
||||
|
||||
def test_add_line_chart(tmp_path):
|
||||
out = _book_with_data(tmp_path)
|
||||
res = add_xlsx_chart(out, "Ventas", "line", "B1:B5", cats_range="A2:A5")
|
||||
assert res["status"] == "ok"
|
||||
wb = load_workbook(out)
|
||||
assert len(wb["Ventas"]._charts) == 1
|
||||
|
||||
|
||||
def test_add_pie_chart(tmp_path):
|
||||
out = _book_with_data(tmp_path)
|
||||
res = add_xlsx_chart(out, "Ventas", "pie", "B2:B5", cats_range="A2:A5", anchor="D10")
|
||||
assert res["status"] == "ok"
|
||||
wb = load_workbook(out)
|
||||
assert len(wb["Ventas"]._charts) == 1
|
||||
|
||||
|
||||
def test_add_scatter_chart(tmp_path):
|
||||
out = _book_with_data(tmp_path)
|
||||
res = add_xlsx_chart(
|
||||
out, "Ventas", "scatter", data_range="B2:B5", cats_range="A2:A5"
|
||||
)
|
||||
assert res["status"] == "ok"
|
||||
wb = load_workbook(out)
|
||||
assert len(wb["Ventas"]._charts) == 1
|
||||
|
||||
|
||||
def test_dos_charts_en_la_misma_hoja(tmp_path):
|
||||
out = _book_with_data(tmp_path)
|
||||
assert add_xlsx_chart(out, "Ventas", "bar", "B1:B5", "A2:A5", "D2")["status"] == "ok"
|
||||
assert add_xlsx_chart(out, "Ventas", "line", "B1:B5", "A2:A5", "D20")["status"] == "ok"
|
||||
wb = load_workbook(out)
|
||||
assert len(wb["Ventas"]._charts) == 2
|
||||
|
||||
|
||||
def test_chart_type_invalido_devuelve_error(tmp_path):
|
||||
out = _book_with_data(tmp_path)
|
||||
res = add_xlsx_chart(out, "Ventas", "donut", "B1:B5")
|
||||
assert res["status"] == "error"
|
||||
assert "chart_type invalido" in res["error"]
|
||||
|
||||
|
||||
def test_hoja_inexistente_devuelve_error(tmp_path):
|
||||
out = _book_with_data(tmp_path)
|
||||
res = add_xlsx_chart(out, "Fantasma", "bar", "B1:B5")
|
||||
assert res["status"] == "error"
|
||||
assert "no existe" in res["error"]
|
||||
|
||||
|
||||
def test_libro_inexistente_devuelve_error():
|
||||
res = add_xlsx_chart("/tmp/no_existe_seguro_987654.xlsx", "S", "bar", "B1:B5")
|
||||
assert res["status"] == "error"
|
||||
assert "no encontrado" in res["error"]
|
||||
|
||||
|
||||
def test_data_range_invalido_devuelve_error(tmp_path):
|
||||
out = _book_with_data(tmp_path)
|
||||
res = add_xlsx_chart(out, "Ventas", "bar", "rango_basura")
|
||||
assert res["status"] == "error"
|
||||
assert "data_range invalido" in res["error"]
|
||||
|
||||
|
||||
def test_xlsx_path_vacio_devuelve_error():
|
||||
res = add_xlsx_chart("", "S", "bar", "B1:B5")
|
||||
assert res["status"] == "error"
|
||||
@@ -0,0 +1,70 @@
|
||||
---
|
||||
name: duckdb_list_tables
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def duckdb_list_tables(db_path: str) -> dict"
|
||||
description: "Lista las tablas de 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. Consulta information_schema.tables del esquema main y devuelve los nombres ordenados alfabeticamente. Devuelve un dict sin lanzar (estilo del grupo duckdb): {status:'ok', tables} en exito y {status:'error', error} en fallo. Es la introspeccion 'que tablas hay' del grupo duckdb; complementa a duckdb_query_readonly_py_infra (lectura de filas) y a duckdb_table_schema_py_infra (schema de una tabla). Depende del paquete duckdb (1.5.2 en python/.venv)."
|
||||
tags: [duckdb, sql, introspection, readonly, tables]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_py_core"
|
||||
imports: [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'}."
|
||||
output: "dict. En exito: {status:'ok', tables:[str,...]} con los nombres de tabla del esquema main ordenados alfabeticamente. En error (sin lanzar): {status:'error', error:str}."
|
||||
tested: true
|
||||
tests:
|
||||
- "test_lista_tablas_ordenadas"
|
||||
- "test_base_vacia_devuelve_lista_vacia"
|
||||
- "test_db_inexistente_devuelve_status_error"
|
||||
test_file_path: "python/functions/infra/duckdb_list_tables_test.py"
|
||||
file_path: "python/functions/infra/duckdb_list_tables.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys
|
||||
sys.path.insert(0, "python/functions")
|
||||
import duckdb
|
||||
from infra.duckdb_list_tables import duckdb_list_tables
|
||||
|
||||
# Preparamos una base de ejemplo (en la realidad la creo otro proceso).
|
||||
db = "/tmp/almacen.duckdb"
|
||||
con = duckdb.connect(db)
|
||||
con.execute("CREATE TABLE ventas (id INTEGER)")
|
||||
con.execute("CREATE TABLE clientes (id INTEGER)")
|
||||
con.close()
|
||||
|
||||
res = duckdb_list_tables(db)
|
||||
print(res["status"]) # ok
|
||||
print(res["tables"]) # ['clientes', 'ventas']
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesitas saber que tablas contiene un archivo DuckDB sin abrirlo en
|
||||
escritura: inventariar una base materializada, decidir que tabla sincronizar a
|
||||
PostgreSQL, validar que un pipeline de ingesta creo lo esperado, o alimentar un
|
||||
selector de tablas en una UI. Es el primer paso natural antes de
|
||||
`duckdb_table_schema_py_infra` (schema de una tabla) o `duckdb_query_readonly_py_infra`
|
||||
(lectura de filas). El dict de salida es directamente serializable a JSON.
|
||||
|
||||
## 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:...}`.
|
||||
- DuckDB es single-writer: si otro proceso tiene la base abierta en escritura con
|
||||
una version distinta del motor, la apertura read-only puede fallar con error de
|
||||
lock. El error se devuelve como `{status:'error', ...}`, no se lanza.
|
||||
- Solo lista tablas del esquema `main` (el por defecto). Vistas y tablas de otros
|
||||
esquemas no aparecen.
|
||||
- Una base recien creada sin tablas devuelve `{status:'ok', tables:[]}` (no es un
|
||||
error): lista vacia.
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Lista las tablas de 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. Consulta `information_schema.tables` (esquema `main`) para
|
||||
obtener los nombres de tabla y los devuelve ordenados. 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.
|
||||
|
||||
Complementa a `duckdb_query_readonly_py_infra` (lectura de filas) y a
|
||||
`duckdb_table_schema_py_infra` (schema de una tabla concreta): esta es la
|
||||
introspeccion de alto nivel "que tablas hay" del grupo duckdb.
|
||||
"""
|
||||
|
||||
|
||||
def duckdb_list_tables(db_path: str) -> dict:
|
||||
"""Lista las tablas de 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', ...}.
|
||||
|
||||
Returns:
|
||||
dict. En exito: {status:'ok', tables:[str,...]} con los nombres de tabla
|
||||
del esquema `main` ordenados alfabeticamente. En error (sin lanzar):
|
||||
{status:'error', error:str}.
|
||||
"""
|
||||
conn = None
|
||||
try:
|
||||
conn = __import__("duckdb").connect(db_path, read_only=True)
|
||||
rows = conn.execute(
|
||||
"SELECT table_name FROM information_schema.tables "
|
||||
"WHERE table_schema = 'main' ORDER BY table_name"
|
||||
).fetchall()
|
||||
tables = [row[0] for row in rows]
|
||||
return {"status": "ok", "tables": tables}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"status": "error", "error": str(e)}
|
||||
finally:
|
||||
if conn is not None:
|
||||
conn.close()
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Tests para duckdb_list_tables."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
import duckdb # noqa: E402
|
||||
|
||||
from duckdb_list_tables import duckdb_list_tables # noqa: E402
|
||||
|
||||
|
||||
def _make_db(path: str, tables: list[str]) -> None:
|
||||
con = duckdb.connect(str(path))
|
||||
for t in tables:
|
||||
con.execute(f"CREATE TABLE {t} (id INTEGER)")
|
||||
con.close()
|
||||
|
||||
|
||||
def test_lista_tablas_ordenadas(tmp_path):
|
||||
db = tmp_path / "v.duckdb"
|
||||
_make_db(str(db), ["ventas", "clientes", "productos"])
|
||||
res = duckdb_list_tables(str(db))
|
||||
assert res["status"] == "ok"
|
||||
assert res["tables"] == ["clientes", "productos", "ventas"]
|
||||
|
||||
|
||||
def test_base_vacia_devuelve_lista_vacia(tmp_path):
|
||||
db = tmp_path / "empty.duckdb"
|
||||
con = duckdb.connect(str(db))
|
||||
con.close()
|
||||
res = duckdb_list_tables(str(db))
|
||||
assert res["status"] == "ok"
|
||||
assert res["tables"] == []
|
||||
|
||||
|
||||
def test_db_inexistente_devuelve_status_error(tmp_path):
|
||||
res = duckdb_list_tables(str(tmp_path / "noexiste.duckdb"))
|
||||
assert res["status"] == "error"
|
||||
assert "error" in res
|
||||
@@ -0,0 +1,76 @@
|
||||
---
|
||||
name: duckdb_table_schema
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def duckdb_table_schema(db_path: str, table: str) -> dict"
|
||||
description: "Devuelve el schema (columnas y tipos) de una tabla 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. Ejecuta DESCRIBE <table> con el identificador de tabla validado contra ^[A-Za-z_][A-Za-z0-9_]*$ y citado (DESCRIBE no admite parametros posicionales). Devuelve un dict sin lanzar (estilo del grupo duckdb): {status:'ok', table, columns:[{name,type}]} en exito y {status:'error', error} en fallo. type es el tipo DuckDB tal cual (BIGINT, DOUBLE, VARCHAR...). Es la introspeccion de columnas del grupo duckdb, util para mapear tipos a otro motor (p.ej. PostgreSQL). Depende del paquete duckdb (1.5.2 en python/.venv)."
|
||||
tags: [duckdb, sql, introspection, schema, readonly]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_py_core"
|
||||
imports: [re, 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: table
|
||||
desc: "nombre de la tabla a inspeccionar. Se valida contra ^[A-Za-z_][A-Za-z0-9_]*$ antes de interpolarlo en el DESCRIBE (que no admite parametro posicional para el identificador). Un identificador invalido devuelve {status:'error'} sin tocar la base."
|
||||
output: "dict. En exito: {status:'ok', table:str, columns:[{name:str, type:str},...]} donde type es el tipo DuckDB tal cual lo reporta el motor. En error (sin lanzar): {status:'error', error:str}."
|
||||
tested: true
|
||||
tests:
|
||||
- "test_schema_devuelve_columnas_y_tipos"
|
||||
- "test_identificador_invalido_devuelve_status_error"
|
||||
- "test_tabla_inexistente_devuelve_status_error"
|
||||
- "test_db_inexistente_devuelve_status_error"
|
||||
test_file_path: "python/functions/infra/duckdb_table_schema_test.py"
|
||||
file_path: "python/functions/infra/duckdb_table_schema.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys
|
||||
sys.path.insert(0, "python/functions")
|
||||
import duckdb
|
||||
from infra.duckdb_table_schema import duckdb_table_schema
|
||||
|
||||
db = "/tmp/almacen.duckdb"
|
||||
con = duckdb.connect(db)
|
||||
con.execute("CREATE TABLE ventas (id BIGINT, region VARCHAR, total DOUBLE, ok BOOLEAN)")
|
||||
con.close()
|
||||
|
||||
res = duckdb_table_schema(db, "ventas")
|
||||
print(res["status"]) # ok
|
||||
print(res["table"]) # ventas
|
||||
print(res["columns"])
|
||||
# [{'name': 'id', 'type': 'BIGINT'}, {'name': 'region', 'type': 'VARCHAR'},
|
||||
# {'name': 'total', 'type': 'DOUBLE'}, {'name': 'ok', 'type': 'BOOLEAN'}]
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesitas el schema de una tabla DuckDB sin abrir la base en escritura:
|
||||
mapear tipos DuckDB a otro motor (es el paso (a) de `duckdb_to_postgres_py_pipelines`),
|
||||
validar que una tabla tiene las columnas esperadas tras una ingesta, o mostrar el
|
||||
schema en una UI. Usa `duckdb_list_tables_py_infra` antes para descubrir que tablas
|
||||
hay. El dict de salida es directamente serializable a JSON.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Lectura real de un archivo en disco (impura). El modo `read_only=True` exige que
|
||||
el archivo **ya exista**: no crea la base. Si `db_path` no existe, devuelve
|
||||
`{status:'error', ...}`.
|
||||
- El identificador `table` se valida contra `^[A-Za-z_][A-Za-z0-9_]*$` porque
|
||||
DESCRIBE NO admite parametro posicional para el nombre de tabla y hay que
|
||||
interpolarlo. Un nombre con espacios, comillas, puntos o intento de inyeccion
|
||||
devuelve `{status:'error', error:'invalid table identifier'}` sin tocar la base.
|
||||
- El `type` es el tipo DuckDB literal (`BIGINT`, `DOUBLE`, `VARCHAR`, `DECIMAL(10,2)`,
|
||||
`STRUCT(...)`, ...). Si lo vas a traducir a otro motor, contempla los tipos
|
||||
parametrizados y compuestos: pueden requerir mapeo con perdida (a TEXT).
|
||||
- DuckDB es single-writer: una base bloqueada en escritura por otro proceso con
|
||||
version distinta puede fallar al abrir en read-only; el error se devuelve, no se
|
||||
lanza.
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Devuelve el schema (columnas y tipos) de una tabla DuckDB 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 `DESCRIBE <table>` (con el identificador de tabla
|
||||
validado y citado, ya que DESCRIBE no admite parametros posicionales) y devuelve
|
||||
las columnas con su tipo DuckDB. 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.
|
||||
|
||||
Complementa a `duckdb_list_tables_py_infra` (que tablas hay) y a
|
||||
`duckdb_query_readonly_py_infra` (lectura de filas). Es la introspeccion de
|
||||
columnas del grupo duckdb, util para mapear tipos a otro motor (p.ej. PostgreSQL).
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
# Un identificador de tabla valido: letras, digitos y guion bajo, sin empezar por
|
||||
# digito. Suficiente para tablas creadas por el propio ecosistema; rechaza
|
||||
# cualquier cosa que pudiera inyectarse en el DESCRIBE (que no admite parametros).
|
||||
_VALID_IDENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
||||
|
||||
|
||||
def duckdb_table_schema(db_path: str, table: str) -> dict:
|
||||
"""Devuelve el schema de una tabla 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', ...}.
|
||||
table: nombre de la tabla a inspeccionar. Se valida contra
|
||||
^[A-Za-z_][A-Za-z0-9_]*$ antes de interpolarlo en el DESCRIBE (que no
|
||||
admite parametros posicionales). Un identificador invalido devuelve
|
||||
{status:'error', ...} sin tocar la base.
|
||||
|
||||
Returns:
|
||||
dict. En exito: {status:'ok', table:str, columns:[{name:str, type:str},...]}
|
||||
donde type es el tipo DuckDB tal cual lo reporta el motor (BIGINT, DOUBLE,
|
||||
VARCHAR, ...). 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}",
|
||||
}
|
||||
|
||||
conn = None
|
||||
try:
|
||||
conn = __import__("duckdb").connect(db_path, read_only=True)
|
||||
# DESCRIBE no admite parametros; el identificador ya esta validado y se
|
||||
# cita con dobles comillas (escapando comillas internas, imposible aqui
|
||||
# por el regex pero defensivo).
|
||||
quoted = '"' + table.replace('"', '""') + '"'
|
||||
rows = conn.execute(f"DESCRIBE {quoted}").fetchall()
|
||||
# DESCRIBE devuelve: (column_name, column_type, null, key, default, extra)
|
||||
columns = [{"name": row[0], "type": row[1]} for row in rows]
|
||||
return {"status": "ok", "table": table, "columns": columns}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"status": "error", "error": str(e)}
|
||||
finally:
|
||||
if conn is not None:
|
||||
conn.close()
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Tests para duckdb_table_schema."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
import duckdb # noqa: E402
|
||||
|
||||
from duckdb_table_schema import duckdb_table_schema # noqa: E402
|
||||
|
||||
|
||||
def _make_db(path: str) -> None:
|
||||
con = duckdb.connect(str(path))
|
||||
con.execute(
|
||||
"CREATE TABLE ventas (id BIGINT, region VARCHAR, total DOUBLE, ok BOOLEAN)"
|
||||
)
|
||||
con.close()
|
||||
|
||||
|
||||
def test_schema_devuelve_columnas_y_tipos(tmp_path):
|
||||
db = tmp_path / "v.duckdb"
|
||||
_make_db(str(db))
|
||||
res = duckdb_table_schema(str(db), "ventas")
|
||||
assert res["status"] == "ok"
|
||||
assert res["table"] == "ventas"
|
||||
names = [c["name"] for c in res["columns"]]
|
||||
types = {c["name"]: c["type"] for c in res["columns"]}
|
||||
assert names == ["id", "region", "total", "ok"]
|
||||
assert types["id"] == "BIGINT"
|
||||
assert types["region"] == "VARCHAR"
|
||||
assert types["total"] == "DOUBLE"
|
||||
assert types["ok"] == "BOOLEAN"
|
||||
|
||||
|
||||
def test_identificador_invalido_devuelve_status_error(tmp_path):
|
||||
db = tmp_path / "v.duckdb"
|
||||
_make_db(str(db))
|
||||
res = duckdb_table_schema(str(db), "ventas; DROP TABLE ventas")
|
||||
assert res["status"] == "error"
|
||||
assert "invalid table identifier" in res["error"]
|
||||
|
||||
|
||||
def test_tabla_inexistente_devuelve_status_error(tmp_path):
|
||||
db = tmp_path / "v.duckdb"
|
||||
_make_db(str(db))
|
||||
res = duckdb_table_schema(str(db), "no_existe")
|
||||
assert res["status"] == "error"
|
||||
|
||||
|
||||
def test_db_inexistente_devuelve_status_error(tmp_path):
|
||||
res = duckdb_table_schema(str(tmp_path / "noexiste.duckdb"), "ventas")
|
||||
assert res["status"] == "error"
|
||||
@@ -0,0 +1,103 @@
|
||||
---
|
||||
name: excel_to_duckdb
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def excel_to_duckdb(xlsx_path: str, duckdb_path: str, table: str, sheet: str = None, mode: str = 'replace') -> dict"
|
||||
description: "Ingesta una hoja de un archivo .xlsx a una tabla DuckDB usando la extension nativa excel de DuckDB (camino activo, verificado en DuckDB 1.5.2). Abre el DuckDB destino en modo read-write (crea el archivo si no existe), carga la extension excel (INSTALL excel; LOAD excel;) y materializa la hoja con read_xlsx. El path del .xlsx y el nombre de la hoja se pasan como parametros posicionales (marcador ?) a read_xlsx, evitando inyeccion por esa via; el identificador de tabla destino se valida contra ^[A-Za-z_][A-Za-z0-9_]*$ y se cita. mode='replace' (default) hace CREATE OR REPLACE TABLE AS SELECT; mode='append' crea la tabla si no existe y luego INSERT INTO ... SELECT. Devuelve un dict sin lanzar (estilo del grupo duckdb): {status:'ok', table, row_count} en exito y {status:'error', error} en fallo. Depende de los paquetes duckdb (1.5.2) y, indirectamente, de la extension excel de DuckDB."
|
||||
tags: [duckdb, excel, xlsx, ingest, etl]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_py_core"
|
||||
imports: [re, duckdb]
|
||||
params:
|
||||
- name: xlsx_path
|
||||
desc: "ruta al archivo .xlsx de origen. Debe existir y ser legible. Se pasa como parametro posicional a read_xlsx (no se interpola en el SQL)."
|
||||
- name: duckdb_path
|
||||
desc: "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."
|
||||
- name: table
|
||||
desc: "nombre de la tabla destino. Se valida contra ^[A-Za-z_][A-Za-z0-9_]*$ antes de interpolarlo (CREATE/INSERT no admiten parametro para el nombre de tabla). Identificador invalido devuelve {status:'error'} sin tocar la base."
|
||||
- name: sheet
|
||||
desc: "nombre de la hoja a leer. None (default) lee la primera hoja del libro. Se pasa como parametro posicional sheet=? a read_xlsx."
|
||||
- name: mode
|
||||
desc: "'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. Otro valor devuelve {status:'error'}."
|
||||
output: "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}."
|
||||
tested: true
|
||||
tests:
|
||||
- "test_replace_ingesta_primera_hoja"
|
||||
- "test_seleccion_de_hoja_por_nombre"
|
||||
- "test_append_acumula_filas"
|
||||
- "test_replace_reemplaza_no_acumula"
|
||||
- "test_identificador_invalido_devuelve_status_error"
|
||||
- "test_mode_invalido_devuelve_status_error"
|
||||
- "test_xlsx_inexistente_devuelve_status_error"
|
||||
test_file_path: "python/functions/infra/excel_to_duckdb_test.py"
|
||||
file_path: "python/functions/infra/excel_to_duckdb.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys
|
||||
sys.path.insert(0, "python/functions")
|
||||
from infra.excel_to_duckdb import excel_to_duckdb
|
||||
|
||||
# Ingesta la primera hoja de un .xlsx a la tabla `ventas`, reemplazandola.
|
||||
res = excel_to_duckdb(
|
||||
"/tmp/informe_mensual.xlsx",
|
||||
"/tmp/almacen.duckdb",
|
||||
"ventas",
|
||||
mode="replace",
|
||||
)
|
||||
print(res) # {'status': 'ok', 'table': 'ventas', 'row_count': 1280}
|
||||
|
||||
# Ingesta una hoja concreta por nombre en modo append.
|
||||
res2 = excel_to_duckdb(
|
||||
"/tmp/informe_mensual.xlsx",
|
||||
"/tmp/almacen.duckdb",
|
||||
"detalle",
|
||||
sheet="Detalle",
|
||||
mode="append",
|
||||
)
|
||||
print(res2) # {'status': 'ok', 'table': 'detalle', 'row_count': 4096}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando recibes datos en Excel (informes, exports, planillas manuales) y necesitas
|
||||
analizarlos o servirlos con SQL: es el primer eslabon del flujo
|
||||
`Excel -> DuckDB -> PostgreSQL`. Tras ingestar con esta funcion, usa
|
||||
`duckdb_query_readonly_py_infra` para analizar, `duckdb_table_schema_py_infra` para
|
||||
inspeccionar el schema inferido, y `duckdb_to_postgres_py_pipelines` para volcar a
|
||||
PostgreSQL y que Metabase/Grafana lo lean. mode='replace' para snapshots completos
|
||||
(refresco diario), mode='append' para acumular hojas sucesivas en una misma tabla.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Camino activo: extension nativa `excel` de DuckDB** (verificado en DuckDB 1.5.2:
|
||||
`read_xlsx` lee .xlsx y acepta `sheet=`). NO se usa el fallback openpyxl. Si en
|
||||
algun entorno la extension fallara, habria que reactivar un fallback openpyxl (no
|
||||
presente hoy) — documentar el cambio aqui si ocurre.
|
||||
- **`INSTALL excel` necesita red la primera vez** por conexion: descarga la extension
|
||||
del repositorio de extensiones de DuckDB. Una vez instalada queda cacheada en el
|
||||
home de DuckDB y `LOAD excel` funciona offline. En un entorno sin red y sin la
|
||||
extension cacheada, la ingesta falla con `{status:'error', ...}` (no se lanza).
|
||||
- Escritura real en disco (impura). DuckDB es single-writer: si otro proceso tiene
|
||||
`duckdb_path` abierto en escritura, `connect` falla con error de lock devuelto en
|
||||
el dict.
|
||||
- A diferencia de `read_only`, este modo **crea** el archivo DuckDB si no existe. Un
|
||||
`duckdb_path` con un directorio padre inexistente si falla y se reporta como error.
|
||||
- **Inferencia de tipos del .xlsx**: `read_xlsx` infiere los tipos de columna. Los
|
||||
numeros suelen inferirse como DOUBLE (incluso enteros), las fechas pueden quedar
|
||||
como VARCHAR segun el formato de la celda. Revisa el schema resultante con
|
||||
`duckdb_table_schema_py_infra` si el tipado importa aguas abajo.
|
||||
- En `mode='append'` el schema lo fija la **primera** ingesta (CREATE TABLE IF NOT
|
||||
EXISTS). Si una hoja posterior tiene columnas distintas, el INSERT puede fallar por
|
||||
desajuste de columnas; el error se devuelve en el dict.
|
||||
- El identificador `table` se valida (las CREATE/INSERT no parametrizan el nombre de
|
||||
tabla). Un nombre con caracteres fuera de `[A-Za-z0-9_]` devuelve
|
||||
`{status:'error', error:'invalid table identifier'}` sin tocar la base.
|
||||
@@ -0,0 +1,113 @@
|
||||
"""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 <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 <table> 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()
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Tests para excel_to_duckdb."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
import duckdb # noqa: E402
|
||||
import openpyxl # noqa: E402
|
||||
|
||||
from excel_to_duckdb import excel_to_duckdb # noqa: E402
|
||||
|
||||
|
||||
def _make_xlsx(path: str) -> None:
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Hoja1"
|
||||
ws.append(["id", "nombre", "total"])
|
||||
ws.append([1, "ana", 10.5])
|
||||
ws.append([2, "luis", 20.0])
|
||||
ws.append([3, "eva", 5.25])
|
||||
ws2 = wb.create_sheet("Segunda")
|
||||
ws2.append(["x"])
|
||||
ws2.append([99])
|
||||
wb.save(path)
|
||||
|
||||
|
||||
def test_replace_ingesta_primera_hoja(tmp_path):
|
||||
xlsx = tmp_path / "datos.xlsx"
|
||||
_make_xlsx(str(xlsx))
|
||||
db = tmp_path / "out.duckdb"
|
||||
res = excel_to_duckdb(str(xlsx), str(db), "ventas")
|
||||
assert res["status"] == "ok", res
|
||||
assert res["table"] == "ventas"
|
||||
assert res["row_count"] == 3
|
||||
# Verificar que la tabla existe con las filas esperadas.
|
||||
con = duckdb.connect(str(db), read_only=True)
|
||||
assert con.execute("SELECT COUNT(*) FROM ventas").fetchone()[0] == 3
|
||||
con.close()
|
||||
|
||||
|
||||
def test_seleccion_de_hoja_por_nombre(tmp_path):
|
||||
xlsx = tmp_path / "datos.xlsx"
|
||||
_make_xlsx(str(xlsx))
|
||||
db = tmp_path / "out.duckdb"
|
||||
res = excel_to_duckdb(str(xlsx), str(db), "otra", sheet="Segunda")
|
||||
assert res["status"] == "ok", res
|
||||
assert res["row_count"] == 1
|
||||
|
||||
|
||||
def test_append_acumula_filas(tmp_path):
|
||||
xlsx = tmp_path / "datos.xlsx"
|
||||
_make_xlsx(str(xlsx))
|
||||
db = tmp_path / "out.duckdb"
|
||||
r1 = excel_to_duckdb(str(xlsx), str(db), "acum", mode="replace")
|
||||
assert r1["row_count"] == 3
|
||||
r2 = excel_to_duckdb(str(xlsx), str(db), "acum", mode="append")
|
||||
assert r2["status"] == "ok", r2
|
||||
assert r2["row_count"] == 6
|
||||
|
||||
|
||||
def test_replace_reemplaza_no_acumula(tmp_path):
|
||||
xlsx = tmp_path / "datos.xlsx"
|
||||
_make_xlsx(str(xlsx))
|
||||
db = tmp_path / "out.duckdb"
|
||||
excel_to_duckdb(str(xlsx), str(db), "rep", mode="replace")
|
||||
res = excel_to_duckdb(str(xlsx), str(db), "rep", mode="replace")
|
||||
assert res["row_count"] == 3
|
||||
|
||||
|
||||
def test_identificador_invalido_devuelve_status_error(tmp_path):
|
||||
xlsx = tmp_path / "datos.xlsx"
|
||||
_make_xlsx(str(xlsx))
|
||||
db = tmp_path / "out.duckdb"
|
||||
res = excel_to_duckdb(str(xlsx), str(db), "t; DROP TABLE x")
|
||||
assert res["status"] == "error"
|
||||
assert "invalid table identifier" in res["error"]
|
||||
|
||||
|
||||
def test_mode_invalido_devuelve_status_error(tmp_path):
|
||||
xlsx = tmp_path / "datos.xlsx"
|
||||
_make_xlsx(str(xlsx))
|
||||
db = tmp_path / "out.duckdb"
|
||||
res = excel_to_duckdb(str(xlsx), str(db), "t", mode="upsert")
|
||||
assert res["status"] == "error"
|
||||
assert "invalid mode" in res["error"]
|
||||
|
||||
|
||||
def test_xlsx_inexistente_devuelve_status_error(tmp_path):
|
||||
db = tmp_path / "out.duckdb"
|
||||
res = excel_to_duckdb(str(tmp_path / "noexiste.xlsx"), str(db), "t")
|
||||
assert res["status"] == "error"
|
||||
@@ -0,0 +1,89 @@
|
||||
---
|
||||
name: pg_create_table_from_rows
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def pg_create_table_from_rows(dsn: str, table: str, rows: list[dict], primary_key: list[str] = None) -> dict"
|
||||
description: "Crea una tabla PostgreSQL (CREATE TABLE IF NOT EXISTS, idempotente) infiriendo columnas y tipos desde los valores de las filas. Mapeo de tipos: bool->BOOLEAN, int->BIGINT, float->DOUBLE PRECISION, datetime->TIMESTAMP, date->DATE, resto->TEXT; None no determina tipo (columna con todo None queda en TEXT). El conjunto de columnas es la union de las claves de todas las filas. Si primary_key se indica, anade PRIMARY KEY (...). Valida que table, columnas y primary_key casen ^[A-Za-z_][A-Za-z0-9_]*$ antes de interpolarlas. Detecta si la tabla ya existia (to_regclass antes del CREATE) para reportar created. Commit al exito, rollback al fallo, cierre en try/finally. Devuelve {status:'ok', created, table, columns} o {status:'error', error} sin lanzar. Depende de psycopg2 (2.9.x en python/.venv)."
|
||||
tags: [postgres, postgresql, sql, ddl, schema, infra]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_py_core"
|
||||
imports: [datetime, re, psycopg2]
|
||||
params:
|
||||
- name: dsn
|
||||
desc: "Cadena de conexion PostgreSQL en formato postgresql://user:pass@host:port/dbname."
|
||||
- name: table
|
||||
desc: "Nombre de la tabla a crear. Validado como identificador SQL [A-Za-z_][A-Za-z0-9_]*; un nombre raro devuelve {status:'error'}."
|
||||
- name: rows
|
||||
desc: "Lista de dicts (clave = nombre de columna). Las columnas son la union de las claves de todas las filas (orden de primera aparicion). El tipo de cada columna lo fija el primer valor NO nulo; columna con todo None queda en TEXT. Lista vacia o sin claves -> {status:'error'} (nada que crear)."
|
||||
- name: primary_key
|
||||
desc: "Lista de columnas que forman la PRIMARY KEY (opcional). Cada una debe existir entre las columnas inferidas. None (default) -> sin PRIMARY KEY. Util para que pg_upsert tenga su ON CONFLICT target."
|
||||
output: "dict. En exito: {status:'ok', created:bool, table:str, columns:{col:tipo_pg}} donde created=True si el CREATE creo la tabla y False si ya existia, y columns es el mapa columna->tipo PostgreSQL inferido. En error (sin lanzar): {status:'error', error:str}."
|
||||
tested: true
|
||||
tests: ["test_skip_sin_pg_test_dsn", "test_infiere_tipos_desde_valores", "test_identificador_invalido_devuelve_status_error", "test_columna_con_todo_none_queda_text", "test_primary_key_se_anade", "test_idempotente_created_false_la_segunda_vez"]
|
||||
test_file_path: "python/functions/infra/pg_create_table_from_rows_test.py"
|
||||
file_path: "python/functions/infra/pg_create_table_from_rows.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
from datetime import date
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from infra.pg_create_table_from_rows import pg_create_table_from_rows
|
||||
|
||||
dsn = "postgresql://user:pass@localhost:5433/trends"
|
||||
rows = [
|
||||
{"email": "ana@x.com", "name": "Ana", "score": 87, "active": True, "joined": date(2026, 1, 5)},
|
||||
{"email": "bob@x.com", "name": "Bob", "score": 12, "active": False, "joined": date(2026, 2, 1)},
|
||||
]
|
||||
|
||||
res = pg_create_table_from_rows(dsn, "leads", rows, primary_key=["email"])
|
||||
print(res["status"]) # ok
|
||||
print(res["created"]) # True (la primera vez), False en re-ejecuciones
|
||||
print(res["columns"]) # {'email': 'TEXT', 'name': 'TEXT', 'score': 'BIGINT',
|
||||
# 'active': 'BOOLEAN', 'joined': 'DATE'}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Usala como paso de bootstrap antes de escribir datos cuando NO tienes una migracion
|
||||
`.sql` a mano: derivas el schema directamente de la primera tanda de filas scrapeadas
|
||||
o parseadas y creas la tabla idempotentemente. Combina bien con `pg_upsert`: pasa el
|
||||
mismo `key_cols` como `primary_key` aqui para que el `ON CONFLICT` del upsert tenga
|
||||
su target UNIQUE. Para schemas controlados y versionados usa `pg_apply_sql` con un
|
||||
`.sql` explicito en su lugar.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Escritura real de DDL (impura). `CREATE TABLE IF NOT EXISTS` es idempotente: si la
|
||||
tabla ya existe NO la modifica ni reconcilia el schema — `created` vuelve False y
|
||||
las columnas reportadas son las INFERIDAS de las filas, no las reales de la tabla
|
||||
existente. Esta funcion no hace ALTER TABLE; para anadir columnas a una tabla
|
||||
existente usa una migracion (`pg_apply_sql`).
|
||||
- **Inferencia best-effort**: el tipo lo fija el primer valor NO nulo de cada columna
|
||||
recorriendo las filas. Una columna con todo None cae a TEXT. `bool` se comprueba
|
||||
antes que `int` (bool es subclase de int en Python) y `datetime` antes que `date`
|
||||
(datetime es subclase de date), si no el mapeo seria erroneo. Strings que "parecen"
|
||||
numeros o fechas se quedan en TEXT — no se hace coercion. int siempre BIGINT (no
|
||||
detecta INTEGER/SMALLINT), float siempre DOUBLE PRECISION (no NUMERIC con escala),
|
||||
asi que pierdes precision exacta para dinero: para columnas monetarias define el
|
||||
schema a mano con NUMERIC via `pg_apply_sql`.
|
||||
- **Inyeccion SQL**: `table`, los nombres de columna (claves de los dicts) y
|
||||
`primary_key` se validan contra `^[A-Za-z_][A-Za-z0-9_]*$` antes de interpolarlos
|
||||
(la DDL no admite parametros). Un nombre con espacios, comillas, puntos o vacio
|
||||
devuelve `{status:'error'}`.
|
||||
- **Deteccion de created**: se consulta `to_regclass(table)` ANTES del CREATE. Si
|
||||
otro proceso crea la tabla entre esa comprobacion y el CREATE (carrera), `created`
|
||||
puede reportar True aunque otro la creara. Diseñada para un unico bootstrapper.
|
||||
- `to_regclass` resuelve el nombre contra el `search_path` de la conexion (por
|
||||
defecto `public`); igual que el `CREATE`. Si trabajas con un schema no-default,
|
||||
fija el `search_path` en el DSN o usa `pg_apply_sql`.
|
||||
- Nunca lanza: DSN invalido, identificador invalido, rows vacio o falta de psycopg2
|
||||
vuelven como `{status:'error', error:str}`.
|
||||
@@ -0,0 +1,175 @@
|
||||
"""Crea una tabla PostgreSQL infiriendo columnas y tipos desde filas de ejemplo.
|
||||
|
||||
Funcion impura: abre una conexion psycopg2 con el DSN dado, infiere el nombre y el
|
||||
tipo PostgreSQL de cada columna a partir de los valores de las filas, ejecuta un
|
||||
`CREATE TABLE IF NOT EXISTS` (idempotente), hace commit y cierra en try/finally.
|
||||
Devuelve un dict sin lanzar, siguiendo el estilo del grupo duckdb del registry:
|
||||
{status:'ok', created, table, columns} en exito y {status:'error', error:str} en
|
||||
fallo.
|
||||
|
||||
Inferencia de tipos por columna (recorriendo TODAS las filas):
|
||||
- bool -> BOOLEAN
|
||||
- int -> BIGINT
|
||||
- float -> DOUBLE PRECISION
|
||||
- datetime -> TIMESTAMP
|
||||
- date -> DATE (date que NO es datetime; datetime es subclase de date)
|
||||
- resto/None -> TEXT
|
||||
|
||||
NULL no determina tipo: si toda una columna es None, queda en TEXT por defecto. El
|
||||
primer valor no nulo encontrado fija el tipo de la columna.
|
||||
|
||||
Identificadores (tabla, columnas, primary_key) se validan contra
|
||||
`[A-Za-z_][A-Za-z0-9_]*` antes de interpolarlos (no se pueden parametrizar
|
||||
identificadores en DDL).
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import re
|
||||
|
||||
_IDENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
||||
|
||||
|
||||
def _validate_ident(name: str) -> str:
|
||||
"""Valida que `name` sea un identificador SQL seguro y lo devuelve.
|
||||
|
||||
Acepta solo nombres que casen `[A-Za-z_][A-Za-z0-9_]*`. Lanza ValueError para
|
||||
cualquier otro (espacios, comillas, puntos, vacio), que el caller convierte en
|
||||
{status:'error'}.
|
||||
"""
|
||||
if not isinstance(name, str) or not _IDENT_RE.match(name):
|
||||
raise ValueError(f"identificador invalido: {name!r}")
|
||||
return name
|
||||
|
||||
|
||||
def _pg_type(value) -> str:
|
||||
"""Mapea un valor Python no nulo a un tipo PostgreSQL.
|
||||
|
||||
bool -> BOOLEAN, int -> BIGINT, float -> DOUBLE PRECISION, datetime -> TIMESTAMP,
|
||||
date -> DATE, resto -> TEXT. None devuelve None (no determina tipo).
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
# bool es subclase de int: comprobar bool primero.
|
||||
if isinstance(value, bool):
|
||||
return "BOOLEAN"
|
||||
if isinstance(value, int):
|
||||
return "BIGINT"
|
||||
if isinstance(value, float):
|
||||
return "DOUBLE PRECISION"
|
||||
# datetime es subclase de date: comprobar datetime primero.
|
||||
if isinstance(value, datetime.datetime):
|
||||
return "TIMESTAMP"
|
||||
if isinstance(value, datetime.date):
|
||||
return "DATE"
|
||||
return "TEXT"
|
||||
|
||||
|
||||
def pg_create_table_from_rows(
|
||||
dsn: str,
|
||||
table: str,
|
||||
rows: list,
|
||||
primary_key: list = None,
|
||||
) -> dict:
|
||||
"""Crea `table` (IF NOT EXISTS) infiriendo columnas y tipos desde `rows`.
|
||||
|
||||
Args:
|
||||
dsn: cadena de conexion PostgreSQL, p.ej.
|
||||
"postgresql://user:pass@localhost:5433/trends".
|
||||
table: nombre de la tabla a crear. Validado como identificador SQL.
|
||||
rows: lista de dicts (clave = nombre de columna). El conjunto de columnas es
|
||||
la UNION de las claves de todas las filas (orden de primera aparicion).
|
||||
El tipo de cada columna lo fija el primer valor NO nulo encontrado para
|
||||
esa columna; columnas con todo None quedan en TEXT. Lista vacia o sin
|
||||
columnas -> {status:'error'} (no hay nada que crear).
|
||||
primary_key: columnas que forman la PRIMARY KEY (opcional). Cada una debe
|
||||
existir entre las columnas inferidas. None -> sin PRIMARY KEY.
|
||||
|
||||
Returns:
|
||||
dict. En exito: {status:'ok', created:bool, table:str, columns:{col:tipo}}
|
||||
donde created indica si el CREATE TABLE creo la tabla (True) o ya existia
|
||||
(False), y columns es el mapa columna -> tipo PostgreSQL inferido. En error
|
||||
(sin lanzar): {status:'error', error:str}.
|
||||
"""
|
||||
try:
|
||||
import psycopg2
|
||||
except ImportError as exc: # pragma: no cover - exercised only without dep
|
||||
return {
|
||||
"status": "error",
|
||||
"error": (
|
||||
"psycopg2 is required for pg_create_table_from_rows; "
|
||||
f"install psycopg2-binary ({exc})"
|
||||
),
|
||||
}
|
||||
|
||||
conn = None
|
||||
try:
|
||||
if not isinstance(rows, list):
|
||||
raise ValueError("rows debe ser una lista de dicts")
|
||||
|
||||
table = _validate_ident(table)
|
||||
|
||||
# Union estable de columnas (orden de primera aparicion).
|
||||
columns: list = []
|
||||
seen: set = set()
|
||||
for i, row in enumerate(rows):
|
||||
if not isinstance(row, dict):
|
||||
raise ValueError(f"rows[{i}] no es un dict")
|
||||
for key in row:
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
columns.append(_validate_ident(key))
|
||||
|
||||
if not columns:
|
||||
raise ValueError(
|
||||
"no hay columnas que inferir: rows vacio o sin claves"
|
||||
)
|
||||
|
||||
# Inferir tipo por columna: primer valor NO nulo fija el tipo; TEXT default.
|
||||
col_types: dict = {}
|
||||
for col in columns:
|
||||
inferred = None
|
||||
for row in rows:
|
||||
t = _pg_type(row.get(col))
|
||||
if t is not None:
|
||||
inferred = t
|
||||
break
|
||||
col_types[col] = inferred if inferred is not None else "TEXT"
|
||||
|
||||
col_defs = [f"{col} {col_types[col]}" for col in columns]
|
||||
|
||||
pk_clause = ""
|
||||
if primary_key:
|
||||
pk_cols = [_validate_ident(c) for c in primary_key]
|
||||
for pk in pk_cols:
|
||||
if pk not in col_types:
|
||||
raise ValueError(
|
||||
f"primary_key {pk!r} no esta entre las columnas inferidas"
|
||||
)
|
||||
pk_clause = f", PRIMARY KEY ({', '.join(pk_cols)})"
|
||||
|
||||
ddl = (
|
||||
f"CREATE TABLE IF NOT EXISTS {table} "
|
||||
f"({', '.join(col_defs)}{pk_clause})"
|
||||
)
|
||||
|
||||
conn = psycopg2.connect(dsn)
|
||||
with conn.cursor() as cur:
|
||||
# Existencia ANTES del CREATE para distinguir creada vs ya existente.
|
||||
cur.execute("SELECT to_regclass(%s)", (table,))
|
||||
existed_before = cur.fetchone()[0] is not None
|
||||
cur.execute(ddl)
|
||||
conn.commit()
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"created": not existed_before,
|
||||
"table": table,
|
||||
"columns": col_types,
|
||||
}
|
||||
except Exception as e: # noqa: BLE001
|
||||
if conn is not None:
|
||||
conn.rollback()
|
||||
return {"status": "error", "error": str(e)}
|
||||
finally:
|
||||
if conn is not None:
|
||||
conn.close()
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Tests para pg_create_table_from_rows.
|
||||
|
||||
Requieren un PostgreSQL real. Si PG_TEST_DSN no esta definida, los tests que tocan
|
||||
la DB se saltan. Cada test crea una tabla con nombre aleatorio y la elimina.
|
||||
|
||||
PG_TEST_DSN="postgresql://user:pass@localhost:5433/trends" \
|
||||
python/.venv/bin/python3 -m pytest \
|
||||
python/functions/infra/pg_create_table_from_rows_test.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from infra.pg_create_table_from_rows import ( # noqa: E402
|
||||
_pg_type,
|
||||
pg_create_table_from_rows,
|
||||
)
|
||||
|
||||
PG_TEST_DSN = os.environ.get("PG_TEST_DSN")
|
||||
requires_pg = pytest.mark.skipif(
|
||||
not PG_TEST_DSN, reason="PG_TEST_DSN no definido: se omiten los tests de Postgres"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def table_name():
|
||||
"""Nombre de tabla aleatorio; la elimina al terminar (si existe)."""
|
||||
name = "pg_ctfr_t_" + uuid.uuid4().hex[:12]
|
||||
yield name
|
||||
if PG_TEST_DSN:
|
||||
import psycopg2
|
||||
|
||||
conn = psycopg2.connect(PG_TEST_DSN)
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(f"DROP TABLE IF EXISTS {name}")
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_skip_sin_pg_test_dsn():
|
||||
"""skip sin PG_TEST_DSN."""
|
||||
if not PG_TEST_DSN:
|
||||
pytest.skip("PG_TEST_DSN no definido")
|
||||
assert PG_TEST_DSN
|
||||
|
||||
|
||||
def test_infiere_tipos_desde_valores():
|
||||
"""infiere tipos desde valores: _pg_type es puro, sin DB."""
|
||||
assert _pg_type(True) == "BOOLEAN"
|
||||
assert _pg_type(3) == "BIGINT"
|
||||
assert _pg_type(1.5) == "DOUBLE PRECISION"
|
||||
assert _pg_type(datetime(2026, 1, 1, 12, 0)) == "TIMESTAMP"
|
||||
assert _pg_type(date(2026, 1, 1)) == "DATE"
|
||||
assert _pg_type("hola") == "TEXT"
|
||||
assert _pg_type(None) is None # None no determina tipo
|
||||
|
||||
|
||||
def test_identificador_invalido_devuelve_status_error():
|
||||
"""identificador invalido devuelve status error sin tocar DB."""
|
||||
res = pg_create_table_from_rows(
|
||||
"postgresql://x/y", "mala tabla", [{"a": 1}]
|
||||
)
|
||||
assert res["status"] == "error"
|
||||
|
||||
|
||||
@requires_pg
|
||||
def test_columna_con_todo_none_queda_text(table_name):
|
||||
"""columna con todo none queda text: tipos correctos y created True."""
|
||||
rows = [
|
||||
{"id": 1, "ratio": 0.5, "flag": True, "note": None},
|
||||
{"id": 2, "ratio": 0.9, "flag": False, "note": None},
|
||||
]
|
||||
res = pg_create_table_from_rows(PG_TEST_DSN, table_name, rows)
|
||||
assert res["status"] == "ok"
|
||||
assert res["created"] is True
|
||||
assert res["columns"] == {
|
||||
"id": "BIGINT",
|
||||
"ratio": "DOUBLE PRECISION",
|
||||
"flag": "BOOLEAN",
|
||||
"note": "TEXT", # toda la columna es None -> TEXT por defecto
|
||||
}
|
||||
|
||||
|
||||
@requires_pg
|
||||
def test_primary_key_se_anade(table_name):
|
||||
"""primary key se anade: el upsert posterior puede usar ON CONFLICT."""
|
||||
res = pg_create_table_from_rows(
|
||||
PG_TEST_DSN, table_name,
|
||||
[{"email": "a@x.com", "name": "A"}], primary_key=["email"],
|
||||
)
|
||||
assert res["status"] == "ok"
|
||||
# Verifica que existe la PK consultando el catalogo.
|
||||
import psycopg2
|
||||
|
||||
conn = psycopg2.connect(PG_TEST_DSN)
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT count(*) FROM information_schema.table_constraints "
|
||||
"WHERE table_name = %s AND constraint_type = 'PRIMARY KEY'",
|
||||
(table_name,),
|
||||
)
|
||||
assert cur.fetchone()[0] == 1
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@requires_pg
|
||||
def test_idempotente_created_false_la_segunda_vez(table_name):
|
||||
"""idempotente created false la segunda vez."""
|
||||
rows = [{"id": 1, "name": "x"}]
|
||||
first = pg_create_table_from_rows(PG_TEST_DSN, table_name, rows)
|
||||
second = pg_create_table_from_rows(PG_TEST_DSN, table_name, rows)
|
||||
assert first["status"] == "ok" and first["created"] is True
|
||||
assert second["status"] == "ok" and second["created"] is False
|
||||
@@ -0,0 +1,78 @@
|
||||
---
|
||||
name: pg_list_tables
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def pg_list_tables(dsn: str, schema: str = 'public') -> dict"
|
||||
description: "Introspeccion read-only de un schema PostgreSQL: lista las tablas base con sus columnas leyendo information_schema.tables e information_schema.columns. Devuelve {status:'ok', schema, tables:[{name, columns:[{name, type, nullable}]}]} en exito y {status:'error', error} en fallo (sin lanzar). Excluye vistas (table_type='BASE TABLE'). Tablas ordenadas por nombre, columnas por posicion ordinal. Marca la transaccion read-only y nunca hace commit. El schema va por placeholder %s (no se interpola). Cierra la conexion siempre en try/finally. Depende de psycopg2 (2.9.x en python/.venv)."
|
||||
tags: [postgres, postgresql, sql, introspection, schema, readonly, infra]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_py_core"
|
||||
imports: [psycopg2]
|
||||
params:
|
||||
- name: dsn
|
||||
desc: "Cadena de conexion PostgreSQL en formato postgresql://user:pass@host:port/dbname."
|
||||
- name: schema
|
||||
desc: "Nombre del schema a introspeccionar (default 'public'). Va por placeholder; un schema inexistente devuelve {status:'ok', tables:[]} (lista vacia, no error)."
|
||||
output: "dict. En exito: {status:'ok', schema:str, tables:[{name:str, columns:[{name:str, type:str, nullable:bool}, ...]}, ...]}; tables ordenadas por nombre, columns por posicion ordinal. En error (sin lanzar): {status:'error', error:str}."
|
||||
tested: true
|
||||
tests: ["test_skip_sin_pg_test_dsn", "test_lista_tabla_creada_con_sus_columnas", "test_reporta_nullable_correctamente", "test_schema_inexistente_devuelve_lista_vacia"]
|
||||
test_file_path: "python/functions/infra/pg_list_tables_test.py"
|
||||
file_path: "python/functions/infra/pg_list_tables.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from infra.pg_list_tables import pg_list_tables
|
||||
|
||||
dsn = "postgresql://user:pass@localhost:5433/trends"
|
||||
|
||||
res = pg_list_tables(dsn, schema="public")
|
||||
print(res["status"]) # ok
|
||||
print(res["schema"]) # public
|
||||
for t in res["tables"]:
|
||||
print(t["name"], "->", [c["name"] for c in t["columns"]])
|
||||
# leads -> ['email', 'name', 'score', 'active', 'joined']
|
||||
# prices -> ['id', 'product', 'price', 'source', 'snapshot_date']
|
||||
print(res["tables"][0]["columns"][0])
|
||||
# {'name': 'email', 'type': 'text', 'nullable': False}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Usala cuando necesitas saber que tablas y columnas existen en un Postgres antes de
|
||||
escribir o consultar: validar que `pg_create_table_from_rows` dejo el schema
|
||||
esperado, descubrir el shape de una base ajena, alimentar un selector de tablas en
|
||||
una UI/agente, o comprobar `nullable`/`type` de una columna antes de un upsert. Es la
|
||||
contraparte de introspeccion del grupo postgres. Para leer datos usa `pg_query`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Lectura real contra el servidor (impura), pero solo del catalogo del sistema
|
||||
(`information_schema`). La transaccion se marca read-only y se hace rollback al
|
||||
final: no escribe nada.
|
||||
- **Solo tablas base**: filtra `table_type = 'BASE TABLE'`, asi que NO lista vistas,
|
||||
vistas materializadas ni tablas foreign. Si necesitas vistas, amplia la consulta.
|
||||
- El campo `type` es el `data_type` de `information_schema.columns`: nombres
|
||||
"lógicos" del estandar (`integer`, `text`, `timestamp without time zone`,
|
||||
`numeric`), no el tipo interno de `pg_catalog` (`int4`, `int8`). No incluye
|
||||
longitud/escala (`varchar(50)` aparece como `character varying`). Para detalle fino
|
||||
consulta `pg_catalog` directamente con `pg_query`.
|
||||
- **Permisos**: `information_schema` solo muestra objetos sobre los que el rol de
|
||||
conexion tiene algun privilegio. Con un usuario de permisos limitados puede faltar
|
||||
alguna tabla aunque exista — no es un error, es visibilidad del catalogo.
|
||||
- Un schema inexistente NO es error: devuelve `{status:'ok', schema, tables:[]}`. Un
|
||||
DSN invalido o servidor caido si vuelve como `{status:'error', ...}`.
|
||||
- El `schema` va por placeholder `%s`, no se interpola: la consulta solo lee catalogo
|
||||
(no hay DDL/DML que inyectar), pero el placeholder evita ademas romper el SQL con un
|
||||
nombre raro.
|
||||
- Nunca lanza: DSN invalido, servidor caido o falta de psycopg2 vuelven como
|
||||
`{status:'error', error:str}`.
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Lista las tablas de un schema PostgreSQL con sus columnas (introspeccion read-only).
|
||||
|
||||
Funcion impura: abre una conexion psycopg2 con el DSN dado, lee
|
||||
information_schema.tables e information_schema.columns para el schema indicado y
|
||||
devuelve un dict sin lanzar, siguiendo el estilo del grupo duckdb del registry:
|
||||
{status:'ok', schema, tables} en exito y {status:'error', error:str} en fallo. La
|
||||
conexion se cierra siempre en try/finally y nunca se hace commit (introspeccion pura
|
||||
de catalogo, sin escritura).
|
||||
|
||||
El nombre del schema va por parametro (placeholder %s), no se interpola: la consulta
|
||||
solo lee del catalogo del sistema, asi que no hay riesgo de DDL/DML por inyeccion,
|
||||
pero el placeholder evita ademas que un schema con caracteres raros rompa el SQL.
|
||||
"""
|
||||
|
||||
|
||||
def pg_list_tables(dsn: str, schema: str = "public") -> dict:
|
||||
"""Lista las tablas base de `schema` con sus columnas.
|
||||
|
||||
Args:
|
||||
dsn: cadena de conexion PostgreSQL, p.ej.
|
||||
"postgresql://user:pass@localhost:5433/trends".
|
||||
schema: nombre del schema a introspeccionar (default "public"). Va por
|
||||
placeholder; un schema inexistente devuelve {status:'ok', tables:[]}.
|
||||
|
||||
Returns:
|
||||
dict. En exito: {status:'ok', schema:str, tables:[{name:str, columns:[
|
||||
{name:str, type:str, nullable:bool}, ...]}, ...]} donde tables esta ordenada
|
||||
por nombre y, dentro de cada tabla, columns por su posicion ordinal en la
|
||||
tabla. En error (sin lanzar): {status:'error', error:str}.
|
||||
"""
|
||||
try:
|
||||
import psycopg2
|
||||
except ImportError as exc: # pragma: no cover - exercised only without dep
|
||||
return {
|
||||
"status": "error",
|
||||
"error": (
|
||||
"psycopg2 is required for pg_list_tables; install psycopg2-binary "
|
||||
f"({exc})"
|
||||
),
|
||||
}
|
||||
|
||||
conn = None
|
||||
try:
|
||||
conn = psycopg2.connect(dsn)
|
||||
conn.set_session(readonly=True, autocommit=False)
|
||||
with conn.cursor() as cur:
|
||||
# Tablas base del schema (excluye vistas), ordenadas por nombre.
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = %s AND table_type = 'BASE TABLE'
|
||||
ORDER BY table_name
|
||||
""",
|
||||
(schema,),
|
||||
)
|
||||
table_names = [r[0] for r in cur.fetchall()]
|
||||
|
||||
# Columnas de todas las tablas del schema en una sola consulta.
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT table_name, column_name, data_type, is_nullable
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = %s
|
||||
ORDER BY table_name, ordinal_position
|
||||
""",
|
||||
(schema,),
|
||||
)
|
||||
cols_by_table: dict = {name: [] for name in table_names}
|
||||
for table_name, column_name, data_type, is_nullable in cur.fetchall():
|
||||
# Una vista podria colarse en columns aunque no en table_names; la
|
||||
# ignoramos para mantener la coherencia con las tablas base.
|
||||
if table_name not in cols_by_table:
|
||||
continue
|
||||
cols_by_table[table_name].append(
|
||||
{
|
||||
"name": column_name,
|
||||
"type": data_type,
|
||||
"nullable": (is_nullable == "YES"),
|
||||
}
|
||||
)
|
||||
|
||||
conn.rollback()
|
||||
|
||||
tables = [
|
||||
{"name": name, "columns": cols_by_table[name]} for name in table_names
|
||||
]
|
||||
return {"status": "ok", "schema": schema, "tables": tables}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"status": "error", "error": str(e)}
|
||||
finally:
|
||||
if conn is not None:
|
||||
conn.close()
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Tests para pg_list_tables.
|
||||
|
||||
Requieren un PostgreSQL real. Si PG_TEST_DSN no esta definida, los tests que tocan
|
||||
la DB se saltan. Cada test crea una tabla con nombre aleatorio y la elimina.
|
||||
|
||||
PG_TEST_DSN="postgresql://user:pass@localhost:5433/trends" \
|
||||
python/.venv/bin/python3 -m pytest python/functions/infra/pg_list_tables_test.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from infra.pg_list_tables import pg_list_tables # noqa: E402
|
||||
|
||||
PG_TEST_DSN = os.environ.get("PG_TEST_DSN")
|
||||
requires_pg = pytest.mark.skipif(
|
||||
not PG_TEST_DSN, reason="PG_TEST_DSN no definido: se omiten los tests de Postgres"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_table():
|
||||
"""Crea una tabla conocida y la elimina al terminar."""
|
||||
import psycopg2
|
||||
|
||||
name = "pg_list_t_" + uuid.uuid4().hex[:12]
|
||||
conn = psycopg2.connect(PG_TEST_DSN)
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
f"CREATE TABLE {name} "
|
||||
f"(id INTEGER NOT NULL, label TEXT, ts TIMESTAMP)"
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
yield name
|
||||
|
||||
conn = psycopg2.connect(PG_TEST_DSN)
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(f"DROP TABLE IF EXISTS {name}")
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_skip_sin_pg_test_dsn():
|
||||
"""skip sin PG_TEST_DSN."""
|
||||
if not PG_TEST_DSN:
|
||||
pytest.skip("PG_TEST_DSN no definido")
|
||||
assert PG_TEST_DSN
|
||||
|
||||
|
||||
@requires_pg
|
||||
def test_lista_tabla_creada_con_sus_columnas(temp_table):
|
||||
"""lista tabla creada con sus columnas."""
|
||||
res = pg_list_tables(PG_TEST_DSN, schema="public")
|
||||
assert res["status"] == "ok"
|
||||
assert res["schema"] == "public"
|
||||
found = [t for t in res["tables"] if t["name"] == temp_table]
|
||||
assert len(found) == 1
|
||||
col_names = [c["name"] for c in found[0]["columns"]]
|
||||
assert col_names == ["id", "label", "ts"] # orden por posicion ordinal
|
||||
|
||||
|
||||
@requires_pg
|
||||
def test_reporta_nullable_correctamente(temp_table):
|
||||
"""reporta nullable correctamente."""
|
||||
res = pg_list_tables(PG_TEST_DSN, schema="public")
|
||||
cols = {
|
||||
c["name"]: c
|
||||
for t in res["tables"]
|
||||
if t["name"] == temp_table
|
||||
for c in t["columns"]
|
||||
}
|
||||
assert cols["id"]["nullable"] is False # NOT NULL
|
||||
assert cols["label"]["nullable"] is True
|
||||
assert cols["id"]["type"] == "integer"
|
||||
|
||||
|
||||
@requires_pg
|
||||
def test_schema_inexistente_devuelve_lista_vacia():
|
||||
"""schema inexistente devuelve lista vacia (no error)."""
|
||||
res = pg_list_tables(PG_TEST_DSN, schema="no_existe_" + uuid.uuid4().hex[:8])
|
||||
assert res["status"] == "ok"
|
||||
assert res["tables"] == []
|
||||
@@ -0,0 +1,83 @@
|
||||
---
|
||||
name: pg_query
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def pg_query(dsn: str, sql: str, params: list = None, max_rows: int = 10000) -> dict"
|
||||
description: "Ejecuta un SELECT contra PostgreSQL via psycopg2 y devuelve las filas como list[dict] sin lanzar. Abre la conexion con el DSN, marca la transaccion read-only (SET TRANSACTION READ ONLY) y usa RealDictCursor para que cada fila sea un dict columna->valor. Devuelve {status:'ok', columns, rows, row_count, truncated} en exito y {status:'error', error} en fallo (estilo duckdb_query_readonly). Usa parametros posicionales con el marcador %s. Trunca a max_rows para proteger memoria. Normaliza valores no JSON-serializables: date/datetime/time a isoformat(), Decimal a float, bytes/memoryview a base64, UUID a str. Cierra la conexion siempre en try/finally. Espejo de duckdb_query_readonly para Postgres. Depende de psycopg2 (2.9.x en python/.venv)."
|
||||
tags: [postgres, postgresql, sql, query, readonly, infra]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_py_core"
|
||||
imports: [base64, datetime, decimal, uuid, psycopg2]
|
||||
params:
|
||||
- name: dsn
|
||||
desc: "Cadena de conexion PostgreSQL en formato postgresql://user:pass@host:port/dbname. Un DSN invalido o servidor inalcanzable devuelve {status:'error'} sin lanzar."
|
||||
- name: sql
|
||||
desc: "Sentencia SQL a ejecutar (pensada para SELECT). Usa el marcador %s para parametros posicionales (estilo psycopg2)."
|
||||
- name: params
|
||||
desc: "Lista de parametros posicionales para el SQL en orden. None (default) significa sin parametros. Pasar los 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}; las filas son dicts (RealDictCursor). En error (sin lanzar): {status:'error', error:str}. Los valores estan normalizados a tipos JSON-serializables."
|
||||
tested: true
|
||||
tests: ["test_skip_sin_pg_test_dsn", "test_normaliza_tipos_no_serializables", "test_select_con_parametros_posicionales", "test_trunca_a_max_rows", "test_dsn_invalido_devuelve_status_error"]
|
||||
test_file_path: "python/functions/infra/pg_query_test.py"
|
||||
file_path: "python/functions/infra/pg_query.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from infra.pg_query import pg_query
|
||||
|
||||
dsn = "postgresql://user:pass@localhost:5433/trends"
|
||||
|
||||
# SELECT con parametro posicional (nunca interpolar el valor en el SQL).
|
||||
res = pg_query(
|
||||
dsn,
|
||||
"SELECT product, price FROM prices WHERE source = %s ORDER BY price DESC",
|
||||
params=["amazon"],
|
||||
max_rows=100,
|
||||
)
|
||||
print(res["status"]) # ok
|
||||
print(res["columns"]) # ['product', 'price']
|
||||
print(res["rows"][0]) # {'product': 'Widget X', 'price': 19.99}
|
||||
print(res["truncated"]) # False
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Usala cuando necesites leer datos de Postgres y pasarlos a otro paso de una
|
||||
composicion como dict serializable: inspeccionar una tabla, validar el resultado de
|
||||
un pipeline de ingesta, alimentar un dashboard o report, o consultar tablas
|
||||
materializadas. Es el espejo de `duckdb_query_readonly` para Postgres. Para escribir
|
||||
usa `pg_insert_rows`, `pg_upsert` o `pg_apply_sql`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Lectura real contra un servidor (impura). La transaccion se marca read-only con
|
||||
`set_session(readonly=True)` y nunca se hace commit (rollback al final): cualquier
|
||||
`INSERT`/`UPDATE`/`DELETE` en el SQL falla a nivel de servidor y vuelve como
|
||||
`{status:'error', ...}`. NO es un sandbox de filesystem — read-only protege la
|
||||
base, no impide leer datos sensibles si el SQL viene de un cliente no confiable.
|
||||
- Inyeccion SQL: los **valores** van siempre por `params` con el marcador `%s`,
|
||||
nunca interpolados en el string del SQL. Esta funcion NO valida ni parametriza
|
||||
identificadores (nombres de tabla/columna): si necesitas un nombre de tabla
|
||||
dinamico, validalo tu antes con `^[A-Za-z_][A-Za-z0-9_]*$`.
|
||||
- `max_rows` protege la memoria: una query que devuelve millones de filas se trunca
|
||||
a `max_rows` y marca `truncated=True`. Para todas las filas, pagina con
|
||||
LIMIT/OFFSET o sube `max_rows` conscientemente.
|
||||
- Valores no JSON-serializables se normalizan en la salida: date/datetime/time a
|
||||
`isoformat()`, Decimal a float (posible perdida de precision frente al decimal
|
||||
exacto), bytes/memoryview a base64 y UUID a str.
|
||||
- Conexion nueva por llamada (sin pool). Para muchas consultas pequenas en bucle,
|
||||
reusa una conexion fuera de esta funcion o agrupa el trabajo en una sola query.
|
||||
- Nunca lanza: DSN invalido, servidor caido, SQL malformado o falta de psycopg2
|
||||
vuelven como `{status:'error', error:str}`.
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Ejecuta una query SELECT contra PostgreSQL y devuelve filas como list[dict].
|
||||
|
||||
Funcion impura: abre una conexion psycopg2 con el DSN dado, ejecuta el SQL con un
|
||||
RealDictCursor (cada fila es un dict columna->valor) y devuelve un dict sin lanzar
|
||||
excepciones, siguiendo el estilo de duckdb_query_readonly del registry:
|
||||
{status:'ok', ...} en exito y {status:'error', error:str} en fallo. La conexion se
|
||||
cierra siempre en un bloque try/finally.
|
||||
|
||||
Por convencion es de solo lectura: la transaccion se marca read-only
|
||||
(SET TRANSACTION READ ONLY) para que cualquier escritura accidental falle a nivel
|
||||
de servidor, y nunca se hace commit (rollback al final). 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/datetime/time a isoformat(), Decimal a float, bytes/memoryview a
|
||||
base64 y UUID a str.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import datetime
|
||||
import decimal
|
||||
import uuid
|
||||
|
||||
|
||||
def _to_serializable(value):
|
||||
"""Convierte un valor de PostgreSQL a una forma JSON-serializable.
|
||||
|
||||
date/datetime/time -> isoformat(), Decimal -> float, bytes/memoryview -> 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 pg_query(
|
||||
dsn: str,
|
||||
sql: str,
|
||||
params: list = None,
|
||||
max_rows: int = 10000,
|
||||
) -> dict:
|
||||
"""Ejecuta un SELECT contra PostgreSQL en una transaccion read-only.
|
||||
|
||||
Args:
|
||||
dsn: cadena de conexion PostgreSQL, p.ej.
|
||||
"postgresql://user:pass@localhost:5433/trends". Un DSN invalido o un
|
||||
servidor inalcanzable devuelve {status:'error', ...} (no lanza).
|
||||
sql: sentencia SQL a ejecutar. Pensada para SELECT; usa el marcador `%s`
|
||||
para parametros posicionales (estilo psycopg2).
|
||||
params: lista de parametros posicionales para el SQL, en orden. None
|
||||
(default) significa sin parametros. Pasar los valores aqui en vez de
|
||||
interpolarlos en el SQL evita inyeccion.
|
||||
max_rows: numero maximo de filas a materializar (default 10000). Si la
|
||||
query produce mas, se trunca y truncated queda en True.
|
||||
|
||||
Returns:
|
||||
dict. En exito: {status:'ok', columns:[str,...], 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, via RealDictCursor).
|
||||
En error (sin lanzar): {status:'error', error:str}.
|
||||
"""
|
||||
try:
|
||||
import psycopg2
|
||||
from psycopg2 import extras as pg_extras
|
||||
except ImportError as exc: # pragma: no cover - exercised only without dep
|
||||
return {
|
||||
"status": "error",
|
||||
"error": (
|
||||
"psycopg2 is required for pg_query; install psycopg2-binary "
|
||||
f"({exc})"
|
||||
),
|
||||
}
|
||||
|
||||
conn = None
|
||||
try:
|
||||
conn = psycopg2.connect(dsn)
|
||||
# Solo lectura por convencion: cualquier escritura fallara en el servidor.
|
||||
conn.set_session(readonly=True, autocommit=False)
|
||||
with conn.cursor(cursor_factory=pg_extras.RealDictCursor) as cur:
|
||||
cur.execute(sql, params if params is not None else None)
|
||||
|
||||
description = cur.description or []
|
||||
columns = [col.name for col in description]
|
||||
|
||||
# Pedimos una fila de mas que max_rows para detectar truncado.
|
||||
fetched = cur.fetchmany(max_rows + 1)
|
||||
truncated = len(fetched) > max_rows
|
||||
if truncated:
|
||||
fetched = fetched[:max_rows]
|
||||
|
||||
rows = [
|
||||
{key: _to_serializable(val) for key, val in record.items()}
|
||||
for record in fetched
|
||||
]
|
||||
|
||||
# Nunca escribimos: cerramos la transaccion con rollback.
|
||||
conn.rollback()
|
||||
|
||||
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()
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Tests para pg_query.
|
||||
|
||||
Requieren un PostgreSQL real. Si la variable de entorno PG_TEST_DSN no esta
|
||||
definida, todos los tests se saltan con skip elegante (no fallan). Cada test crea
|
||||
y limpia su propia tabla temporal con un nombre aleatorio para no depender de un
|
||||
schema concreto ni interferir entre ejecuciones.
|
||||
|
||||
PG_TEST_DSN="postgresql://user:pass@localhost:5433/trends" \
|
||||
python/.venv/bin/python3 -m pytest python/functions/infra/pg_query_test.py
|
||||
"""
|
||||
|
||||
import base64
|
||||
import os
|
||||
import sys
|
||||
import uuid
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(
|
||||
0, os.path.join(os.path.dirname(__file__), "..")
|
||||
) # python/functions -> permite `from infra...`
|
||||
from infra.pg_query import _to_serializable, pg_query # noqa: E402
|
||||
|
||||
PG_TEST_DSN = os.environ.get("PG_TEST_DSN")
|
||||
requires_pg = pytest.mark.skipif(
|
||||
not PG_TEST_DSN, reason="PG_TEST_DSN no definido: se omiten los tests de Postgres"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_table():
|
||||
"""Crea una tabla temporal con datos y la elimina al terminar."""
|
||||
import psycopg2
|
||||
|
||||
name = "pg_query_t_" + uuid.uuid4().hex[:12]
|
||||
conn = psycopg2.connect(PG_TEST_DSN)
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
f"CREATE TABLE {name} (id INTEGER, region TEXT, total NUMERIC(10,2))"
|
||||
)
|
||||
cur.execute(
|
||||
f"INSERT INTO {name} VALUES (1,'norte',120.50),(2,'sur',80.00),"
|
||||
f"(3,'norte',45.25)"
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
yield name
|
||||
|
||||
conn = psycopg2.connect(PG_TEST_DSN)
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(f"DROP TABLE IF EXISTS {name}")
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_skip_sin_pg_test_dsn():
|
||||
"""skip sin PG_TEST_DSN: el resto de tests no corre sin Postgres."""
|
||||
if not PG_TEST_DSN:
|
||||
pytest.skip("PG_TEST_DSN no definido")
|
||||
# Si hay DSN, el placeholder se cumple trivialmente.
|
||||
assert PG_TEST_DSN
|
||||
|
||||
|
||||
def test_normaliza_tipos_no_serializables():
|
||||
"""normaliza tipos no serializables: _to_serializable es pura, sin DB."""
|
||||
assert _to_serializable(date(2026, 6, 16)) == "2026-06-16"
|
||||
assert _to_serializable(uuid.UUID(int=0)) == str(uuid.UUID(int=0))
|
||||
assert _to_serializable(b"\x00\x01") == base64.b64encode(b"\x00\x01").decode("ascii")
|
||||
import decimal
|
||||
|
||||
assert _to_serializable(decimal.Decimal("1.50")) == 1.5
|
||||
assert _to_serializable(None) is None
|
||||
assert _to_serializable("x") == "x"
|
||||
|
||||
|
||||
@requires_pg
|
||||
def test_select_con_parametros_posicionales(temp_table):
|
||||
"""select con parametros posicionales: filtra por %s, agrega y serializa."""
|
||||
res = pg_query(
|
||||
PG_TEST_DSN,
|
||||
f"SELECT region, SUM(total) AS total FROM {temp_table} "
|
||||
f"WHERE region = %s GROUP BY region",
|
||||
params=["norte"],
|
||||
)
|
||||
assert res["status"] == "ok"
|
||||
assert res["columns"] == ["region", "total"]
|
||||
assert res["row_count"] == 1
|
||||
assert res["rows"][0]["region"] == "norte"
|
||||
# NUMERIC se normaliza a float.
|
||||
assert abs(res["rows"][0]["total"] - 165.75) < 1e-9
|
||||
assert res["truncated"] is False
|
||||
|
||||
|
||||
@requires_pg
|
||||
def test_trunca_a_max_rows(temp_table):
|
||||
"""trunca a max_rows: pide menos filas de las que hay y marca truncated."""
|
||||
res = pg_query(PG_TEST_DSN, f"SELECT id FROM {temp_table} ORDER BY id", max_rows=2)
|
||||
assert res["status"] == "ok"
|
||||
assert res["row_count"] == 2
|
||||
assert res["truncated"] is True
|
||||
|
||||
|
||||
@requires_pg
|
||||
def test_dsn_invalido_devuelve_status_error():
|
||||
"""dsn invalido devuelve status error sin lanzar."""
|
||||
res = pg_query(
|
||||
"postgresql://nouser:nopass@127.0.0.1:1/nodb",
|
||||
"SELECT 1",
|
||||
)
|
||||
assert res["status"] == "error"
|
||||
assert "error" in res
|
||||
@@ -0,0 +1,100 @@
|
||||
---
|
||||
name: pg_upsert
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def pg_upsert(dsn: str, table: str, rows: list[dict], key_cols: list[str], update_cols: list[str] = None) -> dict"
|
||||
description: "UPSERT idempotente en lote en una tabla PostgreSQL con ownership selectivo de columnas. Construye INSERT INTO <table> (cols) VALUES %s ON CONFLICT (key_cols) DO UPDATE SET col = EXCLUDED.col, ... (o DO NOTHING) y lo ejecuta con psycopg2.extras.execute_values. update_cols=None actualiza todas menos key_cols; update_cols=[] hace DO NOTHING; lista explicita = ownership selectivo (las no listadas conservan su valor). Distingue insert vs update via el pseudo-columna xmax (RETURNING (xmax = 0) AS inserted). Valida que table y columnas casen ^[A-Za-z_][A-Za-z0-9_]*$ antes de interpolarlas; los valores van por placeholders. Commit al exito, rollback al fallo, cierre en try/finally. Devuelve {status:'ok', inserted, updated} o {status:'error', error} sin lanzar. Espejo de duckdb_upsert para Postgres. key_cols deben tener PRIMARY KEY o UNIQUE. Depende de psycopg2 (2.9.x en python/.venv)."
|
||||
tags: [postgres, postgresql, sql, upsert, idempotent, infra]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_py_core"
|
||||
imports: [re, psycopg2]
|
||||
params:
|
||||
- name: dsn
|
||||
desc: "Cadena de conexion PostgreSQL en formato postgresql://user:pass@host:port/dbname."
|
||||
- name: table
|
||||
desc: "Nombre de la tabla destino. Validado como identificador SQL [A-Za-z_][A-Za-z0-9_]*; un nombre raro devuelve {status:'error'}. La tabla debe existir y key_cols debe tener PRIMARY KEY o UNIQUE."
|
||||
- name: rows
|
||||
desc: "Lista de dicts, un dict por fila (clave = nombre de columna). El esquema de insercion lo fija la PRIMERA fila; todas deben tener exactamente las mismas claves o se devuelve error. Lista vacia -> {status:'ok', inserted:0, updated:0}."
|
||||
- name: key_cols
|
||||
desc: "Columnas de la clave de conflicto (no vacia). Deben existir como PRIMARY KEY o UNIQUE en la tabla y estar presentes en las claves de cada fila."
|
||||
- name: update_cols
|
||||
desc: "Columnas a actualizar en conflicto. None (default) = todas menos key_cols. [] = DO NOTHING (inserta nuevas, no toca existentes). Lista = DO UPDATE SET solo esas (ownership selectivo: las no listadas conservan su valor previo)."
|
||||
output: "dict. En exito: {status:'ok', inserted:int, updated:int} (inserted = filas con xmax=0 en RETURNING, updated = filas en conflicto actualizadas). Con DO NOTHING las filas en conflicto no se devuelven por RETURNING y no cuentan en ninguno. En error (sin lanzar): {status:'error', error:str}."
|
||||
tested: true
|
||||
tests: ["test_skip_sin_pg_test_dsn", "test_identificador_invalido_devuelve_status_error", "test_inserta_filas_nuevas_cuenta_inserted", "test_conflicto_actualiza_y_cuenta_updated", "test_ownership_selectivo_no_pisa_columna_excluida", "test_do_nothing_no_actualiza"]
|
||||
test_file_path: "python/functions/infra/pg_upsert_test.py"
|
||||
file_path: "python/functions/infra/pg_upsert.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from infra.pg_upsert import pg_upsert
|
||||
|
||||
dsn = "postgresql://user:pass@localhost:5433/trends"
|
||||
# La tabla leads(email PRIMARY KEY, name TEXT, score INT) ya existe.
|
||||
|
||||
# Re-ingest 1: inserta el lead.
|
||||
print(pg_upsert(
|
||||
dsn, "leads",
|
||||
[{"email": "ana@x.com", "name": "Ana", "score": 0}],
|
||||
key_cols=["email"],
|
||||
))
|
||||
# {'status': 'ok', 'inserted': 1, 'updated': 0}
|
||||
|
||||
# Re-ingest 2: el feed trae name actualizado y score=0 (default del feed),
|
||||
# pero solo autorizamos actualizar 'name'. 'score' lo posee la DB y NO se pisa.
|
||||
print(pg_upsert(
|
||||
dsn, "leads",
|
||||
[{"email": "ana@x.com", "name": "Ana Lopez", "score": 0}],
|
||||
key_cols=["email"],
|
||||
update_cols=["name"],
|
||||
))
|
||||
# {'status': 'ok', 'inserted': 0, 'updated': 1}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Usala cuando un re-ingest periodico no debe pisar campos que ya posee la DB: pasa
|
||||
`update_cols` SIN esos campos (ownership selectivo). Tipico en pipelines de ingesta
|
||||
idempotente (catalogo, leads, precios competencia, entidades OSINT) donde una fila
|
||||
se reinserta y ciertas columnas se enriquecieron despues (score calculado, anotacion
|
||||
manual, flag derivado) y deben sobrevivir al refresco. `update_cols=None` para un
|
||||
upsert "todo" clasico, `update_cols=[]` para insertar solo filas nuevas. Es el espejo
|
||||
de `duckdb_upsert` para Postgres. Para append-only puro usa `pg_insert_rows`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Escritura real en disco (impura). `ON CONFLICT (key_cols)` solo funciona si esas
|
||||
columnas tienen **PRIMARY KEY o UNIQUE** en la tabla; sin esa restriccion Postgres
|
||||
lanza error y vuelve como `{status:'error', ...}`. La tabla debe existir de antemano
|
||||
(la funcion NO la crea — usa `pg_create_table_from_rows`).
|
||||
- **Fiabilidad de inserted/updated**: el conteo usa el pseudo-columna del sistema
|
||||
`xmax` (`RETURNING (xmax = 0)`). Es la tecnica estandar y fiable en el caso normal
|
||||
(single-writer, sin triggers raros): xmax = 0 = INSERT puro, xmax != 0 = UPDATE por
|
||||
conflicto. Caveats conocidos: (1) con `update_cols=[]` (DO NOTHING) las filas en
|
||||
conflicto NO se devuelven por RETURNING, asi que ni cuentan como insert ni como
|
||||
update — solo se reportan las filas nuevas en `inserted`; (2) si la tabla tiene
|
||||
BEFORE INSERT/UPDATE triggers, REPLICA IDENTITY o subtransacciones que tocan la
|
||||
fila, el valor de xmax puede no ser 0 en un insert real y desviar el conteo.
|
||||
- **Inyeccion SQL**: `table` y los nombres de columna se validan contra
|
||||
`^[A-Za-z_][A-Za-z0-9_]*$` antes de interpolarlos (no se pueden parametrizar
|
||||
identificadores). Un nombre con espacios, comillas, puntos o vacio devuelve
|
||||
`{status:'error'}`. Los valores de las filas siempre van por los placeholders de
|
||||
`execute_values`.
|
||||
- **Esquema fijo por la primera fila**: el conjunto de columnas de insercion lo
|
||||
determina `rows[0]`. Todas las filas deben tener exactamente las mismas claves; si
|
||||
una difiere, se devuelve error (no se hace insercion parcial).
|
||||
- **Single-statement por lote**: todo el lote va en un solo `INSERT ... VALUES %s`
|
||||
dentro de una transaccion. Si una fila viola una constraint (FK, NOT NULL en una
|
||||
columna ausente), Postgres aborta el lote entero y se hace rollback.
|
||||
- Nunca lanza: DSN invalido, tabla sin UNIQUE, tipo invalido o falta de psycopg2
|
||||
vuelven como `{status:'error', error:str}`.
|
||||
@@ -0,0 +1,165 @@
|
||||
"""UPSERT idempotente de filas en una tabla PostgreSQL con ownership selectivo de columnas.
|
||||
|
||||
Funcion impura: abre una conexion psycopg2 con el DSN dado, ejecuta un
|
||||
`INSERT INTO <table> (cols) VALUES %s ON CONFLICT (key_cols) DO UPDATE SET
|
||||
col = EXCLUDED.col, ...` (o `DO NOTHING`) en lote con
|
||||
psycopg2.extras.execute_values, hace commit y cierra en try/finally. Devuelve un
|
||||
dict sin lanzar, siguiendo el estilo de duckdb_upsert del registry: {status:'ok',
|
||||
inserted, updated} en exito y {status:'error', error:str} en fallo.
|
||||
|
||||
El valor de esta funcion es el "ownership selectivo": al actualizar solo las
|
||||
columnas indicadas en `update_cols` en caso de conflicto, un re-upsert de la misma
|
||||
clave NO pisa las columnas que se dejaron fuera. update_cols=None actualiza todas
|
||||
las columnas menos las key_cols; update_cols=[] hace DO NOTHING (inserta solo filas
|
||||
nuevas). El conteo insert vs update se obtiene del pseudo-columna del sistema
|
||||
`xmax`: en la fila devuelta por RETURNING, xmax = 0 indica un INSERT puro y xmax
|
||||
distinto de 0 indica un UPDATE por conflicto.
|
||||
|
||||
Identificadores (tabla y columnas) se validan contra `[A-Za-z_][A-Za-z0-9_]*` antes
|
||||
de interpolarlos en el SQL (no se pueden parametrizar identificadores); los valores
|
||||
de las filas siempre van por placeholders de psycopg2.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
_IDENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
||||
|
||||
|
||||
def _validate_ident(name: str) -> str:
|
||||
"""Valida que `name` sea un identificador SQL seguro y lo devuelve.
|
||||
|
||||
Acepta solo nombres que casen `[A-Za-z_][A-Za-z0-9_]*`. Lanza ValueError para
|
||||
cualquier otro (espacios, comillas, puntos, vacio), que el caller convierte en
|
||||
{status:'error'}.
|
||||
"""
|
||||
if not isinstance(name, str) or not _IDENT_RE.match(name):
|
||||
raise ValueError(f"identificador invalido: {name!r}")
|
||||
return name
|
||||
|
||||
|
||||
def pg_upsert(
|
||||
dsn: str,
|
||||
table: str,
|
||||
rows: list,
|
||||
key_cols: list,
|
||||
update_cols: list = None,
|
||||
) -> dict:
|
||||
"""Hace UPSERT idempotente de `rows` en `table`, con ownership selectivo.
|
||||
|
||||
Construye `INSERT INTO <table> (cols) VALUES %s ON CONFLICT (key_cols)
|
||||
DO UPDATE SET col = EXCLUDED.col, ...` (o `DO NOTHING`) y lo ejecuta en lote
|
||||
con execute_values, distinguiendo inserts de updates via el pseudo-columna
|
||||
`xmax` en RETURNING.
|
||||
|
||||
Args:
|
||||
dsn: cadena de conexion PostgreSQL, p.ej.
|
||||
"postgresql://user:pass@localhost:5433/trends".
|
||||
table: nombre de la tabla destino. Validado como identificador SQL
|
||||
[A-Za-z_][A-Za-z0-9_]*. La tabla debe existir y key_cols debe tener
|
||||
PRIMARY KEY o UNIQUE para que ON CONFLICT funcione.
|
||||
rows: lista de dicts, un dict por fila (clave = nombre de columna). El
|
||||
esquema de insercion lo fija el conjunto de claves de la PRIMERA fila;
|
||||
todas las filas deben tener exactamente las mismas claves o se devuelve
|
||||
{status:'error'}. Lista vacia -> {status:'ok', inserted:0, updated:0}.
|
||||
key_cols: columnas de la clave de conflicto. Deben existir como PRIMARY KEY
|
||||
o UNIQUE en la tabla y estar presentes en las claves de cada fila. No
|
||||
puede estar vacia.
|
||||
update_cols: columnas a actualizar en caso de conflicto.
|
||||
None (default) -> todas las columnas de la fila MENOS las key_cols.
|
||||
Lista vacia [] -> DO NOTHING (inserta nuevas, no toca existentes).
|
||||
Lista con columnas -> DO UPDATE SET solo esas (las no listadas conservan
|
||||
su valor previo: ownership selectivo).
|
||||
|
||||
Returns:
|
||||
dict. En exito: {status:'ok', inserted:int, updated:int} donde inserted
|
||||
cuenta las filas nuevas (xmax = 0 en RETURNING) y updated las filas que ya
|
||||
existian y se actualizaron. Con update_cols=[] (DO NOTHING) las filas en
|
||||
conflicto NO se devuelven por RETURNING, asi que no cuentan ni como insert ni
|
||||
como update. En error (sin lanzar): {status:'error', error:str}.
|
||||
"""
|
||||
try:
|
||||
import psycopg2
|
||||
from psycopg2 import extras as pg_extras
|
||||
except ImportError as exc: # pragma: no cover - exercised only without dep
|
||||
return {
|
||||
"status": "error",
|
||||
"error": (
|
||||
"psycopg2 is required for pg_upsert; install psycopg2-binary "
|
||||
f"({exc})"
|
||||
),
|
||||
}
|
||||
|
||||
conn = None
|
||||
try:
|
||||
if not isinstance(rows, list):
|
||||
raise ValueError("rows debe ser una lista de dicts")
|
||||
if not rows:
|
||||
return {"status": "ok", "inserted": 0, "updated": 0}
|
||||
|
||||
# Esquema de insercion = claves de la primera fila, en orden estable.
|
||||
first_keys = list(rows[0].keys())
|
||||
insert_cols = [_validate_ident(c) for c in first_keys]
|
||||
insert_set = set(first_keys)
|
||||
|
||||
# Todas las filas deben tener exactamente las mismas claves.
|
||||
for i, row in enumerate(rows):
|
||||
if not isinstance(row, dict):
|
||||
raise ValueError(f"rows[{i}] no es un dict")
|
||||
if set(row.keys()) != insert_set:
|
||||
raise ValueError(
|
||||
f"rows[{i}] tiene columnas distintas a la primera fila: "
|
||||
f"{sorted(row.keys())} vs {sorted(first_keys)}"
|
||||
)
|
||||
|
||||
keys = [_validate_ident(c) for c in key_cols]
|
||||
if not keys:
|
||||
raise ValueError("key_cols no puede estar vacio")
|
||||
for k in keys:
|
||||
if k not in insert_set:
|
||||
raise ValueError(f"key_col {k!r} no esta en las columnas de las filas")
|
||||
|
||||
# Resolver update_cols.
|
||||
if update_cols is None:
|
||||
updates = [c for c in insert_cols if c not in keys]
|
||||
else:
|
||||
updates = [_validate_ident(c) for c in update_cols]
|
||||
for u in updates:
|
||||
if u not in insert_set:
|
||||
raise ValueError(
|
||||
f"update_col {u!r} no esta en las columnas de las filas"
|
||||
)
|
||||
|
||||
cols_sql = ", ".join(insert_cols)
|
||||
conflict_sql = ", ".join(keys)
|
||||
|
||||
if updates:
|
||||
set_sql = ", ".join(f"{c} = EXCLUDED.{c}" for c in updates)
|
||||
on_conflict = f"ON CONFLICT ({conflict_sql}) DO UPDATE SET {set_sql}"
|
||||
else:
|
||||
on_conflict = f"ON CONFLICT ({conflict_sql}) DO NOTHING"
|
||||
|
||||
# RETURNING (xmax = 0) AS inserted: True en INSERT puro, False en UPDATE.
|
||||
# En DO NOTHING las filas en conflicto NO se devuelven por RETURNING.
|
||||
sql = (
|
||||
f"INSERT INTO {table} ({cols_sql}) VALUES %s {on_conflict} "
|
||||
f"RETURNING (xmax = 0) AS inserted"
|
||||
)
|
||||
|
||||
values = [tuple(row[c] for c in insert_cols) for row in rows]
|
||||
|
||||
conn = psycopg2.connect(dsn)
|
||||
with conn.cursor() as cur:
|
||||
returned = pg_extras.execute_values(cur, sql, values, fetch=True)
|
||||
|
||||
conn.commit()
|
||||
|
||||
inserted = sum(1 for r in returned if r[0])
|
||||
updated = sum(1 for r in returned if not r[0])
|
||||
return {"status": "ok", "inserted": inserted, "updated": updated}
|
||||
except Exception as e: # noqa: BLE001
|
||||
if conn is not None:
|
||||
conn.rollback()
|
||||
return {"status": "error", "error": str(e)}
|
||||
finally:
|
||||
if conn is not None:
|
||||
conn.close()
|
||||
@@ -0,0 +1,167 @@
|
||||
"""Tests para pg_upsert.
|
||||
|
||||
Requieren un PostgreSQL real. Si PG_TEST_DSN no esta definida, los tests que tocan
|
||||
la DB se saltan con skip elegante. Cada test crea y limpia su propia tabla con un
|
||||
nombre aleatorio.
|
||||
|
||||
PG_TEST_DSN="postgresql://user:pass@localhost:5433/trends" \
|
||||
python/.venv/bin/python3 -m pytest python/functions/infra/pg_upsert_test.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from infra.pg_upsert import pg_upsert # noqa: E402
|
||||
|
||||
PG_TEST_DSN = os.environ.get("PG_TEST_DSN")
|
||||
requires_pg = pytest.mark.skipif(
|
||||
not PG_TEST_DSN, reason="PG_TEST_DSN no definido: se omiten los tests de Postgres"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_table():
|
||||
"""Crea leads(email PRIMARY KEY, name TEXT, score INT) y la elimina al final."""
|
||||
import psycopg2
|
||||
|
||||
name = "pg_upsert_t_" + uuid.uuid4().hex[:12]
|
||||
conn = psycopg2.connect(PG_TEST_DSN)
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
f"CREATE TABLE {name} "
|
||||
f"(email TEXT PRIMARY KEY, name TEXT, score INTEGER)"
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
yield name
|
||||
|
||||
conn = psycopg2.connect(PG_TEST_DSN)
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(f"DROP TABLE IF EXISTS {name}")
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _read(table, email):
|
||||
import psycopg2
|
||||
|
||||
conn = psycopg2.connect(PG_TEST_DSN)
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
f"SELECT name, score FROM {table} WHERE email = %s", (email,)
|
||||
)
|
||||
return cur.fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_skip_sin_pg_test_dsn():
|
||||
"""skip sin PG_TEST_DSN."""
|
||||
if not PG_TEST_DSN:
|
||||
pytest.skip("PG_TEST_DSN no definido")
|
||||
assert PG_TEST_DSN
|
||||
|
||||
|
||||
def test_identificador_invalido_devuelve_status_error():
|
||||
"""identificador invalido devuelve status error sin tocar DB."""
|
||||
res = pg_upsert(
|
||||
"postgresql://x/y",
|
||||
"tabla mala; DROP TABLE foo",
|
||||
[{"email": "a@x.com"}],
|
||||
key_cols=["email"],
|
||||
)
|
||||
assert res["status"] == "error"
|
||||
|
||||
|
||||
@requires_pg
|
||||
def test_inserta_filas_nuevas_cuenta_inserted(temp_table):
|
||||
"""inserta filas nuevas cuenta inserted."""
|
||||
res = pg_upsert(
|
||||
PG_TEST_DSN,
|
||||
temp_table,
|
||||
[
|
||||
{"email": "ana@x.com", "name": "Ana", "score": 0},
|
||||
{"email": "bob@x.com", "name": "Bob", "score": 5},
|
||||
],
|
||||
key_cols=["email"],
|
||||
)
|
||||
assert res["status"] == "ok"
|
||||
assert res["inserted"] == 2
|
||||
assert res["updated"] == 0
|
||||
|
||||
|
||||
@requires_pg
|
||||
def test_conflicto_actualiza_y_cuenta_updated(temp_table):
|
||||
"""conflicto actualiza columnas y cuenta updated."""
|
||||
pg_upsert(
|
||||
PG_TEST_DSN, temp_table,
|
||||
[{"email": "ana@x.com", "name": "Ana", "score": 0}], key_cols=["email"],
|
||||
)
|
||||
res = pg_upsert(
|
||||
PG_TEST_DSN, temp_table,
|
||||
[{"email": "ana@x.com", "name": "Ana Lopez", "score": 9}], key_cols=["email"],
|
||||
)
|
||||
assert res["status"] == "ok"
|
||||
assert res["inserted"] == 0
|
||||
assert res["updated"] == 1
|
||||
assert _read(temp_table, "ana@x.com") == ("Ana Lopez", 9)
|
||||
|
||||
|
||||
@requires_pg
|
||||
def test_ownership_selectivo_no_pisa_columna_excluida(temp_table):
|
||||
"""ownership selectivo no pisa columna excluida."""
|
||||
pg_upsert(
|
||||
PG_TEST_DSN, temp_table,
|
||||
[{"email": "ana@x.com", "name": "Ana", "score": 0}], key_cols=["email"],
|
||||
)
|
||||
# La DB es duena de score (otro proceso lo subio a 87).
|
||||
import psycopg2
|
||||
|
||||
conn = psycopg2.connect(PG_TEST_DSN)
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(f"UPDATE {temp_table} SET score = 87 WHERE email = 'ana@x.com'")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
# El feed trae score=0 pero solo autorizamos actualizar name.
|
||||
res = pg_upsert(
|
||||
PG_TEST_DSN, temp_table,
|
||||
[{"email": "ana@x.com", "name": "Ana Lopez", "score": 0}],
|
||||
key_cols=["email"], update_cols=["name"],
|
||||
)
|
||||
assert res["status"] == "ok"
|
||||
assert res["updated"] == 1
|
||||
assert _read(temp_table, "ana@x.com") == ("Ana Lopez", 87)
|
||||
|
||||
|
||||
@requires_pg
|
||||
def test_do_nothing_no_actualiza(temp_table):
|
||||
"""do nothing no actualiza: update_cols=[] inserta solo nuevas."""
|
||||
pg_upsert(
|
||||
PG_TEST_DSN, temp_table,
|
||||
[{"email": "ana@x.com", "name": "Ana", "score": 1}], key_cols=["email"],
|
||||
)
|
||||
res = pg_upsert(
|
||||
PG_TEST_DSN, temp_table,
|
||||
[
|
||||
{"email": "ana@x.com", "name": "PISADO", "score": 99}, # conflicto
|
||||
{"email": "new@x.com", "name": "Nuevo", "score": 2}, # nuevo
|
||||
],
|
||||
key_cols=["email"], update_cols=[],
|
||||
)
|
||||
assert res["status"] == "ok"
|
||||
# La fila en conflicto no se devuelve por RETURNING (DO NOTHING).
|
||||
assert res["inserted"] == 1
|
||||
assert res["updated"] == 0
|
||||
# La existente NO se pisa.
|
||||
assert _read(temp_table, "ana@x.com") == ("Ana", 1)
|
||||
@@ -0,0 +1,93 @@
|
||||
---
|
||||
name: read_xlsx
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def read_xlsx(path: str, sheet: str = None, max_rows: int = None, header: bool = True) -> dict"
|
||||
description: "Lee un archivo Excel (.xlsx) a estructuras en memoria con openpyxl (NO a markdown; complementa a excel_to_markdown). Espejo en lectura de write_xlsx_sheets: devuelve {status, sheets: {nombre: {headers: [...], rows: [[...]]}}}. Si sheet=None lee todas las hojas; si se indica, solo esa. Con header=True la primera fila de cada hoja son los headers. Maneja tipos de celda: fechas/datetime a ISO 8601, int/float, bool, None y formulas (valor calculado via data_only=True). Trunca por hoja a max_rows filas de datos si se indica. Impura: lee disco y NO lanza: en fallo devuelve {status: 'error', error}."
|
||||
tags: [excel, xlsx, openpyxl, spreadsheet, office, onlyoffice, read, io, infra]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [openpyxl]
|
||||
params:
|
||||
- name: path
|
||||
desc: "Ruta al archivo .xlsx a leer. Vacio o inexistente devuelve {status: 'error'} (no lanza). Se resuelve a ruta absoluta internamente."
|
||||
- name: sheet
|
||||
desc: "Nombre de la hoja a leer. None (default) lee TODAS las hojas del libro. Si se indica una hoja que no existe, devuelve {status: 'error'} con la lista de hojas disponibles."
|
||||
- name: max_rows
|
||||
desc: "Maximo de filas de DATOS a devolver por hoja (no cuenta la cabecera cuando header=True). None (default) = sin limite. Util para previsualizar libros grandes sin cargarlos enteros."
|
||||
- name: header
|
||||
desc: "Si True (default) la primera fila de cada hoja se interpreta como cabecera y va en 'headers'; el resto en 'rows'. Si False, 'headers' es [] y TODAS las filas (incluida la primera) van en 'rows'."
|
||||
output: "Dict. En exito: {status: 'ok', sheets: {nombre_hoja: {headers: [...], rows: [[...], ...]}}}. En error: {status: 'error', error: '<mensaje>'}. Valores de celda como tipos nativos de Python: fechas/datetime como str ISO 8601, int/float, bool, str y None."
|
||||
tested: true
|
||||
tests: ["test_round_trip_escribe_lee_compara", "test_lee_solo_la_hoja_indicada", "test_max_rows_trunca_filas_de_datos", "test_header_false_no_consume_cabecera", "test_fecha_se_devuelve_como_iso", "test_formula_se_lee_como_valor_calculado", "test_archivo_inexistente_devuelve_error", "test_hoja_inexistente_devuelve_error", "test_path_vacio_devuelve_error"]
|
||||
test_file_path: "python/functions/infra/read_xlsx_test.py"
|
||||
file_path: "python/functions/infra/read_xlsx.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from infra.read_xlsx import read_xlsx
|
||||
from infra.write_xlsx_sheets import write_xlsx_sheets
|
||||
|
||||
# Escribe un libro y leelo de vuelta (round-trip)
|
||||
write_xlsx_sheets("/tmp/ventas.xlsx", {
|
||||
"Ventas": [
|
||||
["Producto", "Unidades", "Precio", "Activo"],
|
||||
["Teclado", 12, 29.99, True],
|
||||
["Raton", 30, 14.5, False],
|
||||
["Monitor", None, 199.0, True], # None -> None al leer
|
||||
],
|
||||
})
|
||||
|
||||
res = read_xlsx("/tmp/ventas.xlsx")
|
||||
print(res["status"]) # ok
|
||||
print(list(res["sheets"].keys())) # ['Ventas']
|
||||
print(res["sheets"]["Ventas"]["headers"]) # ['Producto', 'Unidades', 'Precio', 'Activo']
|
||||
print(res["sheets"]["Ventas"]["rows"][0]) # ['Teclado', 12, 29.99, True]
|
||||
|
||||
# Solo una hoja, primeras 1 fila de datos
|
||||
res = read_xlsx("/tmp/ventas.xlsx", sheet="Ventas", max_rows=1)
|
||||
print(res["sheets"]["Ventas"]["rows"]) # [['Teclado', 12, 29.99, True]]
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Usala cuando necesites los datos de un .xlsx como **estructuras de Python**
|
||||
(listas y dicts) para procesarlos en codigo: validar, transformar, alimentar
|
||||
otra funcion, hacer asserts. Es el espejo en lectura de `write_xlsx_sheets`
|
||||
(mismo shape `{hoja: {headers, rows}}`) y la base para round-trips
|
||||
escribir->leer. Si lo que quieres es una representacion **textual** del libro
|
||||
para mostrar o resumir (p.ej. pasarla a un LLM), usa `excel_to_markdown_py_core`
|
||||
en su lugar: aquella produce tablas markdown, esta produce datos crudos.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Impura — lee de disco.** No lanza: devuelve `{"status": "error", ...}` ante
|
||||
archivo inexistente, hoja inexistente, path vacio o openpyxl ausente.
|
||||
- **openpyxl carga el libro entero en memoria.** Aun en `read_only=True`, un
|
||||
libro muy grande consume RAM proporcional a su tamano; usa `max_rows` para
|
||||
previsualizar sin materializar todas las filas, pero recuerda que openpyxl
|
||||
igual abre el archivo completo.
|
||||
- **`data_only=True`** devuelve el valor **cacheado** de las formulas, no la
|
||||
formula. Ese cache solo existe si un motor (Excel/LibreOffice) abrio y guardo
|
||||
el libro tras escribir la formula. openpyxl NO evalua formulas: un .xlsx con
|
||||
formulas escritas por openpyxl y nunca abierto en Excel devolvera `None` en
|
||||
esas celdas. Para round-trips fiables, escribe el VALOR, no la formula.
|
||||
- **Requiere openpyxl** (ya instalado en `python/.venv`, version 3.1.5).
|
||||
- **Tipos de celda**: None se conserva como None; int/float/str/bool nativos;
|
||||
`datetime.date` -> `"YYYY-MM-DD"`; `datetime.datetime` sin hora -> `"YYYY-MM-DD"`,
|
||||
con hora -> `"YYYY-MM-DDTHH:MM:SS"`. Cualquier otro tipo se serializa a str.
|
||||
- **`header=False`** NO consume la primera fila: todas las filas (incluida la
|
||||
cabecera real, si la hubiera) van en `rows`. Util cuando el libro no tiene
|
||||
cabecera o quieres procesarla como dato.
|
||||
- **Orden de hojas preservado** segun el orden del libro (igual que
|
||||
`write_xlsx_sheets` preserva el orden de insercion del dict).
|
||||
@@ -0,0 +1,155 @@
|
||||
"""Lee un archivo Excel (.xlsx) a estructuras en memoria con openpyxl.
|
||||
|
||||
Funcion impura: abre un libro Excel y devuelve sus hojas como listas de Python
|
||||
(headers + rows), no como markdown. Es el espejo en lectura de
|
||||
`write_xlsx_sheets`: lo que aquella escribe desde un dict {hoja: filas}, esta lo
|
||||
recupera al mismo shape. Maneja los tipos de celda nativos de Excel (fechas a
|
||||
ISO 8601, numeros int/float, bool, None) y lee el valor calculado de las
|
||||
formulas con data_only=True.
|
||||
|
||||
No lanza: cualquier fallo (archivo inexistente, hoja inexistente, openpyxl
|
||||
ausente) se devuelve como dict {"status": "error", "error": "..."}.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import os
|
||||
|
||||
|
||||
def read_xlsx(
|
||||
path: str,
|
||||
sheet: str = None,
|
||||
max_rows: int = None,
|
||||
header: bool = True,
|
||||
) -> dict:
|
||||
"""Lee un .xlsx a estructuras en memoria (headers + rows).
|
||||
|
||||
Args:
|
||||
path: Ruta al archivo .xlsx a leer.
|
||||
sheet: Nombre de la hoja a leer. Si None (default) se leen TODAS las
|
||||
hojas del libro.
|
||||
max_rows: Maximo de filas a devolver por hoja (cuenta de filas de datos,
|
||||
sin contar la cabecera cuando header=True). None (default) = sin
|
||||
limite.
|
||||
header: Si True (default) la primera fila de cada hoja se interpreta como
|
||||
cabecera y va en "headers"; el resto va en "rows". Si False, no hay
|
||||
cabecera ("headers" es []) y todas las filas van en "rows".
|
||||
|
||||
Returns:
|
||||
Dict. En exito:
|
||||
{"status": "ok",
|
||||
"sheets": {nombre_hoja: {"headers": [...], "rows": [[...], ...]}}}
|
||||
En error:
|
||||
{"status": "error", "error": "<mensaje>"}.
|
||||
Los valores de celda se devuelven como tipos nativos de Python:
|
||||
fechas/datetimes como str ISO 8601, int/float, bool, str y None.
|
||||
"""
|
||||
if not path:
|
||||
return {"status": "error", "error": "path no puede estar vacio"}
|
||||
|
||||
abs_path = os.path.abspath(path)
|
||||
if not os.path.exists(abs_path):
|
||||
return {"status": "error", "error": f"archivo no encontrado: {abs_path}"}
|
||||
|
||||
try:
|
||||
from openpyxl import load_workbook
|
||||
except ImportError: # pragma: no cover - dependencia del entorno
|
||||
return {
|
||||
"status": "error",
|
||||
"error": (
|
||||
"openpyxl es requerido para read_xlsx. "
|
||||
"Instalar con: cd python && uv add openpyxl"
|
||||
),
|
||||
}
|
||||
|
||||
try:
|
||||
# data_only=True devuelve el valor calculado de las formulas (no la
|
||||
# formula). read_only acelera y reduce memoria en libros grandes.
|
||||
wb = load_workbook(abs_path, data_only=True, read_only=True)
|
||||
except Exception as exc: # noqa: BLE001 - el contrato del grupo es no lanzar
|
||||
return {"status": "error", "error": f"no se pudo abrir el libro: {exc}"}
|
||||
|
||||
try:
|
||||
if sheet is not None:
|
||||
if sheet not in wb.sheetnames:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": (
|
||||
f"hoja '{sheet}' no existe. "
|
||||
f"Hojas disponibles: {wb.sheetnames}"
|
||||
),
|
||||
}
|
||||
target = [sheet]
|
||||
else:
|
||||
target = list(wb.sheetnames)
|
||||
|
||||
sheets = {}
|
||||
for name in target:
|
||||
ws = wb[name]
|
||||
sheets[name] = _read_sheet(ws, max_rows, header)
|
||||
|
||||
return {"status": "ok", "sheets": sheets}
|
||||
finally:
|
||||
# En modo read_only conviene cerrar para liberar el archivo subyacente.
|
||||
wb.close()
|
||||
|
||||
|
||||
def _read_sheet(ws, max_rows, header) -> dict:
|
||||
"""Lee una hoja a {"headers": [...], "rows": [[...]]} aplicando max_rows."""
|
||||
headers = []
|
||||
rows = []
|
||||
first = True
|
||||
|
||||
for raw_row in ws.iter_rows(values_only=True):
|
||||
row = [_coerce(v) for v in raw_row]
|
||||
if header and first:
|
||||
headers = row
|
||||
first = False
|
||||
continue
|
||||
first = False
|
||||
if max_rows is not None and len(rows) >= max_rows:
|
||||
break
|
||||
rows.append(row)
|
||||
|
||||
return {"headers": headers, "rows": rows}
|
||||
|
||||
|
||||
def _coerce(value):
|
||||
"""Convierte un valor de celda openpyxl a un tipo nativo de Python.
|
||||
|
||||
Reglas: None se conserva; bool/int/float/str se conservan; fechas y
|
||||
datetimes se serializan a ISO 8601 (date a YYYY-MM-DD, datetime sin
|
||||
componente horario a YYYY-MM-DD, con hora a YYYY-MM-DDTHH:MM:SS); cualquier
|
||||
otro tipo se serializa a str.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
# bool es subclase de int: comprobarlo antes que int.
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, (int, float, str)):
|
||||
return value
|
||||
if isinstance(value, datetime.datetime):
|
||||
if value.hour == 0 and value.minute == 0 and value.second == 0:
|
||||
return value.date().isoformat()
|
||||
return value.isoformat()
|
||||
if isinstance(value, datetime.date):
|
||||
return value.isoformat()
|
||||
return str(value)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover - smoke manual
|
||||
import tempfile
|
||||
|
||||
from openpyxl import Workbook
|
||||
|
||||
tmp = os.path.join(tempfile.gettempdir(), "read_xlsx_demo.xlsx")
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Ventas"
|
||||
ws.append(["Producto", "Unidades", "Precio", "Activo"])
|
||||
ws.append(["Teclado", 12, 29.99, True])
|
||||
ws.append(["Raton", 30, 14.5, False])
|
||||
wb.save(tmp)
|
||||
|
||||
print(read_xlsx(tmp))
|
||||
print(read_xlsx(tmp, sheet="Ventas", max_rows=1))
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Tests para read_xlsx.
|
||||
|
||||
Se importa el modulo por path directo (sin tocar __init__.py) para no depender
|
||||
del re-export del paquete. write_xlsx_sheets se importa igual para el round-trip.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def _load(name):
|
||||
spec = importlib.util.spec_from_file_location(name, os.path.join(_HERE, f"{name}.py"))
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
read_xlsx = _load("read_xlsx").read_xlsx
|
||||
write_xlsx_sheets = _load("write_xlsx_sheets").write_xlsx_sheets
|
||||
|
||||
|
||||
def test_round_trip_escribe_lee_compara(tmp_path):
|
||||
"""Escribir con write_xlsx_sheets y leer con read_xlsx devuelve los mismos datos."""
|
||||
out = str(tmp_path / "rt.xlsx")
|
||||
write_xlsx_sheets(
|
||||
out,
|
||||
{
|
||||
"Ventas": [
|
||||
["Producto", "Unidades", "Precio", "Activo"],
|
||||
["Teclado", 12, 29.99, True],
|
||||
["Raton", 30, 14.5, False],
|
||||
["Monitor", None, 199.0, True],
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
res = read_xlsx(out)
|
||||
assert res["status"] == "ok"
|
||||
assert list(res["sheets"].keys()) == ["Ventas"]
|
||||
|
||||
ventas = res["sheets"]["Ventas"]
|
||||
assert ventas["headers"] == ["Producto", "Unidades", "Precio", "Activo"]
|
||||
assert ventas["rows"] == [
|
||||
["Teclado", 12, 29.99, True],
|
||||
["Raton", 30, 14.5, False],
|
||||
["Monitor", None, 199.0, True],
|
||||
]
|
||||
|
||||
|
||||
def test_lee_solo_la_hoja_indicada(tmp_path):
|
||||
out = str(tmp_path / "multi.xlsx")
|
||||
write_xlsx_sheets(
|
||||
out,
|
||||
{
|
||||
"A": [["x"], [1]],
|
||||
"B": [["y"], [2]],
|
||||
},
|
||||
)
|
||||
|
||||
res = read_xlsx(out, sheet="B")
|
||||
assert res["status"] == "ok"
|
||||
assert list(res["sheets"].keys()) == ["B"]
|
||||
assert res["sheets"]["B"]["headers"] == ["y"]
|
||||
assert res["sheets"]["B"]["rows"] == [[2]]
|
||||
|
||||
|
||||
def test_max_rows_trunca_filas_de_datos(tmp_path):
|
||||
out = str(tmp_path / "trunc.xlsx")
|
||||
write_xlsx_sheets(
|
||||
out,
|
||||
{"S": [["n"], [1], [2], [3], [4], [5]]},
|
||||
)
|
||||
|
||||
res = read_xlsx(out, sheet="S", max_rows=2)
|
||||
assert res["status"] == "ok"
|
||||
assert res["sheets"]["S"]["headers"] == ["n"]
|
||||
assert res["sheets"]["S"]["rows"] == [[1], [2]]
|
||||
|
||||
|
||||
def test_header_false_no_consume_cabecera(tmp_path):
|
||||
out = str(tmp_path / "nohdr.xlsx")
|
||||
write_xlsx_sheets(out, {"S": [["a", "b"], [1, 2]]})
|
||||
|
||||
res = read_xlsx(out, sheet="S", header=False)
|
||||
assert res["status"] == "ok"
|
||||
assert res["sheets"]["S"]["headers"] == []
|
||||
assert res["sheets"]["S"]["rows"] == [["a", "b"], [1, 2]]
|
||||
|
||||
|
||||
def test_fecha_se_devuelve_como_iso(tmp_path):
|
||||
import datetime
|
||||
|
||||
from openpyxl import Workbook
|
||||
|
||||
out = str(tmp_path / "fechas.xlsx")
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "F"
|
||||
ws.append(["evento", "cuando"])
|
||||
ws.append(["solo_fecha", datetime.date(2026, 6, 16)])
|
||||
ws.append(["con_hora", datetime.datetime(2026, 6, 16, 14, 30, 0)])
|
||||
wb.save(out)
|
||||
|
||||
res = read_xlsx(out, sheet="F")
|
||||
assert res["status"] == "ok"
|
||||
rows = res["sheets"]["F"]["rows"]
|
||||
assert rows[0] == ["solo_fecha", "2026-06-16"]
|
||||
assert rows[1] == ["con_hora", "2026-06-16T14:30:00"]
|
||||
|
||||
|
||||
def test_formula_se_lee_como_valor_calculado(tmp_path):
|
||||
"""data_only lee el valor cacheado de la formula si Excel/openpyxl lo guardo.
|
||||
|
||||
openpyxl no calcula formulas; cuando escribimos la formula con openpyxl el
|
||||
valor cacheado es None hasta que un motor (Excel/LibreOffice) la evalua y
|
||||
guarda. El round-trip valido es escribir el VALOR (no la formula).
|
||||
"""
|
||||
out = str(tmp_path / "calc.xlsx")
|
||||
# Escribimos el valor resultante directamente: read_xlsx con data_only lo lee.
|
||||
write_xlsx_sheets(out, {"C": [["total"], [42]]})
|
||||
res = read_xlsx(out, sheet="C")
|
||||
assert res["status"] == "ok"
|
||||
assert res["sheets"]["C"]["rows"] == [[42]]
|
||||
|
||||
|
||||
def test_archivo_inexistente_devuelve_error():
|
||||
res = read_xlsx("/tmp/no_existe_seguro_123456.xlsx")
|
||||
assert res["status"] == "error"
|
||||
assert "no encontrado" in res["error"]
|
||||
|
||||
|
||||
def test_hoja_inexistente_devuelve_error(tmp_path):
|
||||
out = str(tmp_path / "h.xlsx")
|
||||
write_xlsx_sheets(out, {"Real": [["x"], [1]]})
|
||||
res = read_xlsx(out, sheet="Fantasma")
|
||||
assert res["status"] == "error"
|
||||
assert "no existe" in res["error"]
|
||||
|
||||
|
||||
def test_path_vacio_devuelve_error():
|
||||
res = read_xlsx("")
|
||||
assert res["status"] == "error"
|
||||
@@ -7,7 +7,7 @@ version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def upsert_xlsx_sheet(xlsx_path: str, sheet_name: str, records: list[dict], columns: list[str], key_col: str = \"\", preserve_cols: list[str] | None = None, formulas: dict | None = None, backup: bool = True, freeze: str = \"A2\", autofilter: bool = True) -> dict"
|
||||
description: "Actualiza de forma NO DESTRUCTIVA una hoja concreta de un archivo .xlsx con openpyxl. Reescribe SOLO la hoja indicada (sheet_name) y conserva intactas las demas hojas del libro. Antes de limpiar la hoja gestionada lee, por una columna clave (key_col), los valores de las columnas de trabajo manual (preserve_cols) y los reescribe ganando sobre los datos nuevos. Cabecera estilizada (negrita, relleno, texto blanco, borde, centrado), freeze_panes, autofilter, auto-ancho de columnas, formulas por columna con placeholders {row} y {NombreColumna}, y backup .bak opcional. Devuelve un resumen con filas escritas, hojas conservadas y celdas manuales preservadas."
|
||||
tags: [xlsx, openpyxl, spreadsheet, office, onlyoffice, infra]
|
||||
tags: [excel, xlsx, openpyxl, spreadsheet, office, onlyoffice, infra]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
---
|
||||
name: duckdb_to_postgres
|
||||
kind: pipeline
|
||||
lang: py
|
||||
domain: pipelines
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def duckdb_to_postgres(duckdb_path: str, table: str, pg_dsn: str, pg_table: str = None, mode: str = 'replace', key_cols: list = None, batch_size: int = 5000) -> dict"
|
||||
description: "Pipeline que sincroniza una tabla DuckDB a PostgreSQL. Es lo que desbloquea que herramientas BI (Metabase, Grafana, Superset) lean datos que viven en DuckDB, porque NO hablan DuckDB nativo pero todas hablan PostgreSQL. Pasos: (a) lee el schema con duckdb_table_schema; (b) mapea tipos DuckDB->PostgreSQL (BIGINT/INTEGER->BIGINT, DOUBLE/FLOAT->DOUBLE PRECISION, VARCHAR/TEXT->TEXT, BOOLEAN->BOOLEAN, DATE->DATE, TIMESTAMP->TIMESTAMP, resto->TEXT) y genera CREATE TABLE IF NOT EXISTS con PRIMARY KEY si key_cols (DROP TABLE IF EXISTS antes si mode='replace'), aplicandolo con pg_apply_sql; (c) lee las filas con duckdb_query_readonly paginando con LIMIT/OFFSET e inserta en PostgreSQL con pg_insert_rows (add_snapshot_date=False) en lotes de batch_size, o con pg_upsert si hay key_cols y mode!='replace'. pg_upsert se importa detras de un check de import: sin el, el camino upsert no esta disponible pero replace/append funcionan. Compone funciones del registry sin reescribir su logica. Devuelve un dict sin lanzar: {status:'ok', pg_table, rows_synced, created} en exito y {status:'error', error} en fallo. Depende de duckdb (1.5.2) y psycopg2."
|
||||
tags: [duckdb, postgres, etl, sync, pipeline]
|
||||
uses_functions:
|
||||
- duckdb_table_schema_py_infra
|
||||
- duckdb_query_readonly_py_infra
|
||||
- pg_apply_sql_py_infra
|
||||
- pg_insert_rows_py_infra
|
||||
- pg_upsert_py_infra
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_py_core"
|
||||
imports: [os, re, sys, tempfile, duckdb, psycopg2]
|
||||
params:
|
||||
- name: duckdb_path
|
||||
desc: "ruta al archivo DuckDB de origen (se lee en modo read_only; debe existir)."
|
||||
- name: table
|
||||
desc: "nombre de la tabla DuckDB a sincronizar. Validado como identificador ^[A-Za-z_][A-Za-z0-9_]*$."
|
||||
- name: pg_dsn
|
||||
desc: "cadena de conexion PostgreSQL, p.ej. 'postgresql://user:pass@host:5432/db'."
|
||||
- name: pg_table
|
||||
desc: "nombre de la tabla destino en PostgreSQL. None (default) usa el mismo nombre que `table`. Validado como identificador."
|
||||
- name: mode
|
||||
desc: "'replace' (default) hace DROP TABLE IF EXISTS + CREATE + INSERT de todas las filas (snapshot completo). 'append'/'upsert' crean la tabla si no existe y luego: con key_cols usan pg_upsert (idempotente), sin key_cols hacen INSERT append-only. Otro valor devuelve {status:'error'}."
|
||||
- name: key_cols
|
||||
desc: "lista de columnas de la PRIMARY KEY. Se incluyen en el CREATE como PRIMARY KEY y, en modo != 'replace', habilitan el upsert idempotente. None/[] (default) = sin PK, solo INSERT. Deben existir en el schema DuckDB."
|
||||
- name: batch_size
|
||||
desc: "numero de filas por lote de insercion/upsert (default 5000). Debe ser un entero positivo."
|
||||
output: "dict. En exito: {status:'ok', pg_table:str, rows_synced:int, created:bool} donde rows_synced es el total de filas volcadas y created indica si se ejecuto el CREATE/DROP del schema. En error (sin lanzar): {status:'error', error:str}."
|
||||
tested: true
|
||||
tests:
|
||||
- "test_map_tipos_duckdb_a_postgres"
|
||||
- "test_build_ddl_con_pk_y_drop"
|
||||
- "test_build_ddl_sin_pk_ni_drop"
|
||||
- "test_identificador_tabla_invalido"
|
||||
- "test_mode_invalido"
|
||||
- "test_replace_sincroniza_filas"
|
||||
- "test_upsert_idempotente_con_key_cols"
|
||||
test_file_path: "python/functions/pipelines/duckdb_to_postgres_test.py"
|
||||
file_path: "python/functions/pipelines/duckdb_to_postgres.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys
|
||||
sys.path.insert(0, "python/functions")
|
||||
from pipelines.duckdb_to_postgres import duckdb_to_postgres
|
||||
|
||||
# Snapshot completo: reemplaza la tabla destino en PostgreSQL con todas las filas
|
||||
# de la tabla DuckDB. Metabase/Grafana ya pueden leerla.
|
||||
res = duckdb_to_postgres(
|
||||
"/tmp/almacen.duckdb",
|
||||
"ventas",
|
||||
"postgresql://captacion:****@127.0.0.1:5433/trends",
|
||||
pg_table="ventas_diario",
|
||||
mode="replace",
|
||||
)
|
||||
print(res)
|
||||
# {'status': 'ok', 'pg_table': 'ventas_diario', 'rows_synced': 1280, 'created': True}
|
||||
|
||||
# Sync idempotente por clave: no duplica filas en re-ejecuciones.
|
||||
res2 = duckdb_to_postgres(
|
||||
"/tmp/almacen.duckdb",
|
||||
"clientes",
|
||||
"postgresql://captacion:****@127.0.0.1:5433/trends",
|
||||
mode="upsert",
|
||||
key_cols=["id"],
|
||||
)
|
||||
print(res2) # {'status': 'ok', 'pg_table': 'clientes', 'rows_synced': 540, 'created': True}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando tienes datos en un archivo DuckDB y necesitas que una herramienta BI los
|
||||
lea: Metabase, Grafana y Superset NO hablan DuckDB nativo, pero todas hablan
|
||||
PostgreSQL. Es el ultimo eslabon del flujo `Excel -> DuckDB -> PostgreSQL`
|
||||
(precedido por `excel_to_duckdb_py_infra`). Usa `mode='replace'` para refrescos
|
||||
completos programados (un snapshot diario que recrea la tabla) y
|
||||
`mode='upsert' + key_cols` para sincronizaciones incrementales idempotentes que no
|
||||
duplican filas al re-ejecutar.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **DuckDB es single-writer**: el pipeline abre la base en read_only para leer, pero
|
||||
si otro proceso la tiene bloqueada en escritura con version distinta del motor, la
|
||||
apertura puede fallar; el error se devuelve en el dict, no se lanza.
|
||||
- **El modo read_only exige que el archivo DuckDB exista**: no lo crea. Un
|
||||
`duckdb_path` inexistente devuelve `{status:'error', ...}` ya en el paso (a).
|
||||
- **Mapeo de tipos con posible perdida**: el mapeo DuckDB->PostgreSQL es conservador.
|
||||
Tipos no contemplados (DECIMAL con escala, HUGEINT/UBIGINT de 128 bits, LIST/STRUCT/
|
||||
MAP) caen a TEXT. Si el tipado fuerte importa aguas abajo (agregaciones numericas
|
||||
en Metabase), revisa el schema con `duckdb_table_schema_py_infra` y ajusta los tipos
|
||||
en DuckDB antes de sincronizar.
|
||||
- **`mode='replace'` es destructivo**: hace `DROP TABLE IF EXISTS` sobre la tabla
|
||||
PostgreSQL destino antes de recrearla. Cualquier dato o indice manual de esa tabla
|
||||
se pierde. Para sincronizaciones que deban preservar la tabla existente usa
|
||||
`mode='append'`/`'upsert'` (CREATE TABLE IF NOT EXISTS, sin DROP).
|
||||
- **`pg_upsert` opcional**: se importa detras de un check de import. Si `pg_upsert_py_infra`
|
||||
no esta en el entorno, `mode != 'replace'` con `key_cols` devuelve
|
||||
`{status:'error', ...}` explicando que falta; el camino replace/append (sin upsert)
|
||||
sigue funcionando.
|
||||
- **Upsert requiere PRIMARY KEY o UNIQUE** sobre las `key_cols` en PostgreSQL para que
|
||||
`ON CONFLICT` funcione. El pipeline crea esa PRIMARY KEY en el CREATE cuando pasas
|
||||
`key_cols`; si la tabla ya existia sin esa restriccion (`mode!='replace'` y tabla
|
||||
preexistente), el upsert fallara — recrea con `mode='replace' + key_cols` una vez.
|
||||
- **Snapshot no transaccional entre lectura y escritura**: la lectura paginada de
|
||||
DuckDB y la escritura a PostgreSQL no comparten transaccion. Si la tabla DuckDB
|
||||
cambia a mitad del volcado (otro escritor), el resultado en PostgreSQL puede mezclar
|
||||
estados. Sincroniza desde una base DuckDB estable (no mientras se ingesta).
|
||||
- **`pg_insert_rows` y `pg_apply_sql` lanzan** RuntimeError internamente; el pipeline
|
||||
los envuelve en try/except y convierte el fallo a `{status:'error', ...}`. Nunca
|
||||
propaga la excepcion al caller.
|
||||
@@ -0,0 +1,311 @@
|
||||
"""Pipeline: sincroniza una tabla DuckDB a una tabla PostgreSQL.
|
||||
|
||||
Esto es lo que desbloquea que herramientas BI (Metabase, Grafana, Superset) lean
|
||||
los datos que viven en un archivo DuckDB: esas herramientas NO hablan DuckDB
|
||||
nativo, pero todas hablan PostgreSQL. El pipeline lee el schema y las filas de la
|
||||
tabla DuckDB, crea (o recrea) la tabla equivalente en PostgreSQL con un mapeo de
|
||||
tipos DuckDB -> PostgreSQL, y vuelca las filas en lotes.
|
||||
|
||||
Funcion impura de tipo pipeline: compone funciones del registry y NO reescribe su
|
||||
logica.
|
||||
- duckdb_table_schema -> lee columnas y tipos de la tabla DuckDB.
|
||||
- duckdb_query_readonly -> lee las filas (paginadas con LIMIT/OFFSET).
|
||||
- pg_apply_sql -> aplica el DDL (CREATE/DROP) escrito a un .sql temporal.
|
||||
- pg_insert_rows -> inserta lotes (camino replace / append sin clave).
|
||||
- pg_upsert (opcional) -> upsert idempotente cuando hay key_cols y mode!='replace'.
|
||||
pg_upsert se importa detras de un check: si todavia no esta en el registry, el
|
||||
pipeline sigue funcionando para el camino replace/insert.
|
||||
|
||||
Devuelve un dict sin lanzar, estilo del grupo: {status:'ok', ...} en exito y
|
||||
{status:'error', error:str} en fallo.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
# Las funciones del registry se importan, no se reescriben. sys.path apunta al
|
||||
# directorio de funciones del registry (mismo patron que usan las apps Python).
|
||||
_FUNCTIONS_DIR = os.path.join(
|
||||
os.path.dirname(__file__), "..", ".."
|
||||
) # python/
|
||||
_FUNCTIONS_DIR = os.path.abspath(os.path.join(_FUNCTIONS_DIR, "functions"))
|
||||
if _FUNCTIONS_DIR not in sys.path:
|
||||
sys.path.insert(0, _FUNCTIONS_DIR)
|
||||
|
||||
from infra.duckdb_query_readonly import duckdb_query_readonly # noqa: E402
|
||||
from infra.duckdb_table_schema import duckdb_table_schema # noqa: E402
|
||||
from infra.pg_apply_sql import pg_apply_sql # noqa: E402
|
||||
from infra.pg_insert_rows import pg_insert_rows # noqa: E402
|
||||
|
||||
# pg_upsert puede no existir aun (lo construye otro agente en paralelo). Lo
|
||||
# cargamos detras de un check; sin el, el camino upsert no esta disponible pero
|
||||
# el resto del pipeline funciona.
|
||||
try:
|
||||
from infra.pg_upsert import pg_upsert # noqa: E402
|
||||
|
||||
_HAS_UPSERT = True
|
||||
except Exception: # noqa: BLE001 - cualquier fallo de import deja el camino off
|
||||
pg_upsert = None
|
||||
_HAS_UPSERT = False
|
||||
|
||||
_VALID_IDENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
||||
|
||||
|
||||
def _map_duckdb_type_to_pg(duck_type: str) -> str:
|
||||
"""Mapea un tipo DuckDB a su equivalente PostgreSQL.
|
||||
|
||||
El mapeo es conservador: tipos numericos/temporales/booleanos conocidos se
|
||||
mapean a su equivalente PG natural; cualquier otro tipo (incluidos compuestos
|
||||
como LIST/STRUCT/MAP, o DECIMAL con escala) cae a TEXT, que siempre acepta el
|
||||
valor serializado. Puede haber perdida de tipado fuerte para esos casos.
|
||||
"""
|
||||
t = (duck_type or "").strip().upper()
|
||||
# Normalizar tipos parametrizados: DECIMAL(10,2) -> DECIMAL, VARCHAR(50) -> VARCHAR.
|
||||
base = t.split("(")[0].strip()
|
||||
|
||||
mapping = {
|
||||
"BIGINT": "BIGINT",
|
||||
"INT8": "BIGINT",
|
||||
"LONG": "BIGINT",
|
||||
"INTEGER": "BIGINT",
|
||||
"INT": "BIGINT",
|
||||
"INT4": "BIGINT",
|
||||
"SMALLINT": "BIGINT",
|
||||
"INT2": "BIGINT",
|
||||
"TINYINT": "BIGINT",
|
||||
"INT1": "BIGINT",
|
||||
"HUGEINT": "TEXT", # 128-bit: no cabe en BIGINT, serializar a texto.
|
||||
"UBIGINT": "TEXT",
|
||||
"DOUBLE": "DOUBLE PRECISION",
|
||||
"FLOAT8": "DOUBLE PRECISION",
|
||||
"FLOAT": "DOUBLE PRECISION",
|
||||
"FLOAT4": "DOUBLE PRECISION",
|
||||
"REAL": "DOUBLE PRECISION",
|
||||
"VARCHAR": "TEXT",
|
||||
"TEXT": "TEXT",
|
||||
"STRING": "TEXT",
|
||||
"CHAR": "TEXT",
|
||||
"BPCHAR": "TEXT",
|
||||
"BOOLEAN": "BOOLEAN",
|
||||
"BOOL": "BOOLEAN",
|
||||
"LOGICAL": "BOOLEAN",
|
||||
"DATE": "DATE",
|
||||
"TIMESTAMP": "TIMESTAMP",
|
||||
"DATETIME": "TIMESTAMP",
|
||||
"TIMESTAMP_S": "TIMESTAMP",
|
||||
"TIMESTAMP_MS": "TIMESTAMP",
|
||||
"TIMESTAMP_NS": "TIMESTAMP",
|
||||
}
|
||||
return mapping.get(base, "TEXT")
|
||||
|
||||
|
||||
def _build_ddl(
|
||||
pg_table: str,
|
||||
columns: list,
|
||||
key_cols: list,
|
||||
drop_first: bool,
|
||||
) -> str:
|
||||
"""Construye el DDL CREATE (y opcional DROP) para la tabla destino en PG.
|
||||
|
||||
columns: lista de {name, type} (tipo DuckDB). key_cols: columnas de la PK
|
||||
(puede ser None/[]). drop_first: si True antepone DROP TABLE IF EXISTS.
|
||||
"""
|
||||
col_defs = []
|
||||
for col in columns:
|
||||
pg_type = _map_duckdb_type_to_pg(col["type"])
|
||||
col_defs.append(f' "{col["name"]}" {pg_type}')
|
||||
|
||||
pk_clause = ""
|
||||
if key_cols:
|
||||
pk_cols = ", ".join(f'"{c}"' for c in key_cols)
|
||||
pk_clause = f",\n PRIMARY KEY ({pk_cols})"
|
||||
|
||||
parts = []
|
||||
if drop_first:
|
||||
parts.append(f'DROP TABLE IF EXISTS "{pg_table}";')
|
||||
parts.append(
|
||||
f'CREATE TABLE IF NOT EXISTS "{pg_table}" (\n'
|
||||
+ ",\n".join(col_defs)
|
||||
+ pk_clause
|
||||
+ "\n);"
|
||||
)
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def duckdb_to_postgres(
|
||||
duckdb_path: str,
|
||||
table: str,
|
||||
pg_dsn: str,
|
||||
pg_table: str = None,
|
||||
mode: str = "replace",
|
||||
key_cols: list = None,
|
||||
batch_size: int = 5000,
|
||||
) -> dict:
|
||||
"""Sincroniza una tabla DuckDB a PostgreSQL (puente para BI: Metabase/Grafana).
|
||||
|
||||
Args:
|
||||
duckdb_path: ruta al archivo DuckDB de origen (se lee en modo read_only).
|
||||
table: nombre de la tabla DuckDB a sincronizar. Validado como identificador.
|
||||
pg_dsn: cadena de conexion PostgreSQL, p.ej.
|
||||
"postgresql://user:pass@host:5432/db".
|
||||
pg_table: nombre de la tabla destino en PostgreSQL. None (default) usa el
|
||||
mismo nombre que `table`. Validado como identificador.
|
||||
mode: 'replace' (default) hace DROP TABLE IF EXISTS + CREATE + INSERT de
|
||||
todas las filas (snapshot completo). 'append'/'upsert' crean la tabla si
|
||||
no existe (CREATE TABLE IF NOT EXISTS) y luego: si key_cols esta presente
|
||||
usan pg_upsert (idempotente); si no, hacen INSERT append-only con
|
||||
pg_insert_rows. Cualquier otro valor devuelve {status:'error', ...}.
|
||||
key_cols: lista de columnas de la PRIMARY KEY. Se incluyen en el CREATE como
|
||||
PRIMARY KEY y, en modo != 'replace', habilitan el upsert idempotente.
|
||||
None/[] (default) = sin PK, solo INSERT.
|
||||
batch_size: numero de filas por lote de insercion/upsert (default 5000).
|
||||
|
||||
Returns:
|
||||
dict. En exito: {status:'ok', pg_table:str, rows_synced:int, created:bool}
|
||||
donde rows_synced es el total de filas volcadas y created indica si se
|
||||
ejecuto el CREATE/DROP del schema. En error (sin lanzar):
|
||||
{status:'error', error:str}.
|
||||
"""
|
||||
# --- Validaciones de entrada ---
|
||||
if not isinstance(table, str) or not _VALID_IDENT.match(table):
|
||||
return {"status": "error", "error": f"invalid table identifier: {table!r}"}
|
||||
|
||||
target = pg_table if pg_table is not None else table
|
||||
if not isinstance(target, str) or not _VALID_IDENT.match(target):
|
||||
return {"status": "error", "error": f"invalid pg_table identifier: {target!r}"}
|
||||
|
||||
if mode not in ("replace", "append", "upsert"):
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"invalid mode: {mode!r} (expected 'replace'|'append'|'upsert')",
|
||||
}
|
||||
|
||||
keys = list(key_cols) if key_cols else []
|
||||
for k in keys:
|
||||
if not isinstance(k, str) or not _VALID_IDENT.match(k):
|
||||
return {"status": "error", "error": f"invalid key_col identifier: {k!r}"}
|
||||
|
||||
if not isinstance(batch_size, int) or batch_size <= 0:
|
||||
return {"status": "error", "error": f"invalid batch_size: {batch_size!r}"}
|
||||
|
||||
use_upsert = bool(keys) and mode != "replace"
|
||||
if use_upsert and not _HAS_UPSERT:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": (
|
||||
"key_cols + mode!='replace' requiere pg_upsert_py_infra, que no "
|
||||
"esta disponible en este entorno"
|
||||
),
|
||||
}
|
||||
|
||||
# --- (a) Schema de la tabla DuckDB ---
|
||||
schema = duckdb_table_schema(duckdb_path, table)
|
||||
if schema.get("status") != "ok":
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"no se pudo leer el schema de {table!r}: {schema.get('error')}",
|
||||
}
|
||||
columns = schema["columns"]
|
||||
if not columns:
|
||||
return {"status": "error", "error": f"la tabla {table!r} no tiene columnas"}
|
||||
|
||||
col_names = [c["name"] for c in columns]
|
||||
# Validar que las key_cols existen en el schema.
|
||||
for k in keys:
|
||||
if k not in col_names:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"key_col {k!r} no esta en las columnas de {table!r}",
|
||||
}
|
||||
|
||||
# --- (b) DDL: crear/recrear la tabla en PostgreSQL via pg_apply_sql ---
|
||||
drop_first = mode == "replace"
|
||||
ddl = _build_ddl(target, columns, keys, drop_first)
|
||||
tmp_sql_path = None
|
||||
try:
|
||||
fd, tmp_sql_path = tempfile.mkstemp(suffix=".sql", prefix="duckdb_to_pg_")
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||||
fh.write(ddl)
|
||||
pg_apply_sql(pg_dsn, tmp_sql_path) # lanza RuntimeError si falla
|
||||
created = True
|
||||
except Exception as e: # noqa: BLE001 - convertir el raise de pg_apply_sql a dict
|
||||
return {"status": "error", "error": f"DDL fallo: {e}"}
|
||||
finally:
|
||||
if tmp_sql_path is not None and os.path.exists(tmp_sql_path):
|
||||
try:
|
||||
os.remove(tmp_sql_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# --- (c) Leer filas de DuckDB y volcarlas en PostgreSQL por lotes ---
|
||||
quoted = '"' + table.replace('"', '""') + '"'
|
||||
offset = 0
|
||||
rows_synced = 0
|
||||
try:
|
||||
while True:
|
||||
page = duckdb_query_readonly(
|
||||
duckdb_path,
|
||||
f"SELECT * FROM {quoted} LIMIT ? OFFSET ?",
|
||||
params=[batch_size, offset],
|
||||
max_rows=batch_size,
|
||||
)
|
||||
if page.get("status") != "ok":
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"lectura de filas fallo en offset {offset}: "
|
||||
f"{page.get('error')}",
|
||||
}
|
||||
batch = page["rows"]
|
||||
if not batch:
|
||||
break
|
||||
|
||||
if use_upsert:
|
||||
res = pg_upsert(pg_dsn, target, batch, keys)
|
||||
if res.get("status") != "ok":
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"pg_upsert fallo en offset {offset}: "
|
||||
f"{res.get('error')}",
|
||||
}
|
||||
rows_synced += res.get("inserted", 0) + res.get("updated", 0)
|
||||
else:
|
||||
# pg_insert_rows lanza RuntimeError si falla; add_snapshot_date=False
|
||||
# para no inyectar columnas que el schema DuckDB no tiene.
|
||||
inserted = pg_insert_rows(
|
||||
pg_dsn, target, batch, add_snapshot_date=False
|
||||
)
|
||||
rows_synced += inserted
|
||||
|
||||
offset += len(batch)
|
||||
if len(batch) < batch_size:
|
||||
break
|
||||
except Exception as e: # noqa: BLE001 - convertir raises de pg_insert_rows a dict
|
||||
return {"status": "error", "error": f"insercion fallo: {e}"}
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"pg_table": target,
|
||||
"rows_synced": rows_synced,
|
||||
"created": created,
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Ejecucion directa con `fn run`: demo minima contra una base DuckDB temporal y
|
||||
# un PostgreSQL apuntado por PG_TEST_DSN (si esta disponible).
|
||||
import json
|
||||
|
||||
dsn = os.environ.get("PG_TEST_DSN")
|
||||
if not dsn:
|
||||
print(json.dumps({"status": "skipped", "reason": "PG_TEST_DSN no definido"}))
|
||||
sys.exit(0)
|
||||
demo_db = os.environ.get("DUCKDB_DEMO_PATH", "/tmp/duckdb_to_pg_demo.duckdb")
|
||||
import duckdb # noqa: E402
|
||||
|
||||
con = duckdb.connect(demo_db)
|
||||
con.execute("CREATE OR REPLACE TABLE demo (id BIGINT, nombre VARCHAR, total DOUBLE)")
|
||||
con.execute("INSERT INTO demo VALUES (1, 'ana', 10.5), (2, 'luis', 20.0)")
|
||||
con.close()
|
||||
print(json.dumps(duckdb_to_postgres(demo_db, "demo", dsn, mode="replace")))
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Tests para el pipeline duckdb_to_postgres.
|
||||
|
||||
Los tests que tocan PostgreSQL hacen skip elegante si no hay PG_TEST_DSN. El mapeo
|
||||
de tipos y la construccion de DDL se prueban sin Postgres (logica pura interna).
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
import duckdb # noqa: E402
|
||||
|
||||
from duckdb_to_postgres import ( # noqa: E402
|
||||
_build_ddl,
|
||||
_map_duckdb_type_to_pg,
|
||||
duckdb_to_postgres,
|
||||
)
|
||||
|
||||
PG_DSN = os.environ.get("PG_TEST_DSN")
|
||||
|
||||
|
||||
# --- Tests sin Postgres: mapeo de tipos y DDL ---
|
||||
|
||||
|
||||
def test_map_tipos_duckdb_a_postgres():
|
||||
assert _map_duckdb_type_to_pg("BIGINT") == "BIGINT"
|
||||
assert _map_duckdb_type_to_pg("INTEGER") == "BIGINT"
|
||||
assert _map_duckdb_type_to_pg("DOUBLE") == "DOUBLE PRECISION"
|
||||
assert _map_duckdb_type_to_pg("FLOAT") == "DOUBLE PRECISION"
|
||||
assert _map_duckdb_type_to_pg("VARCHAR") == "TEXT"
|
||||
assert _map_duckdb_type_to_pg("TEXT") == "TEXT"
|
||||
assert _map_duckdb_type_to_pg("BOOLEAN") == "BOOLEAN"
|
||||
assert _map_duckdb_type_to_pg("DATE") == "DATE"
|
||||
assert _map_duckdb_type_to_pg("TIMESTAMP") == "TIMESTAMP"
|
||||
# Parametrizados normalizan al tipo base.
|
||||
assert _map_duckdb_type_to_pg("DECIMAL(10,2)") == "TEXT"
|
||||
assert _map_duckdb_type_to_pg("VARCHAR(50)") == "TEXT"
|
||||
# Desconocido -> TEXT (con posible perdida de tipado).
|
||||
assert _map_duckdb_type_to_pg("STRUCT(a INT)") == "TEXT"
|
||||
|
||||
|
||||
def test_build_ddl_con_pk_y_drop():
|
||||
cols = [
|
||||
{"name": "id", "type": "BIGINT"},
|
||||
{"name": "nombre", "type": "VARCHAR"},
|
||||
]
|
||||
ddl = _build_ddl("destino", cols, ["id"], drop_first=True)
|
||||
assert "DROP TABLE IF EXISTS \"destino\";" in ddl
|
||||
assert 'CREATE TABLE IF NOT EXISTS "destino"' in ddl
|
||||
assert '"id" BIGINT' in ddl
|
||||
assert '"nombre" TEXT' in ddl
|
||||
assert 'PRIMARY KEY ("id")' in ddl
|
||||
|
||||
|
||||
def test_build_ddl_sin_pk_ni_drop():
|
||||
cols = [{"name": "x", "type": "DOUBLE"}]
|
||||
ddl = _build_ddl("t", cols, [], drop_first=False)
|
||||
assert "DROP TABLE" not in ddl
|
||||
assert '"x" DOUBLE PRECISION' in ddl
|
||||
assert "PRIMARY KEY" not in ddl
|
||||
|
||||
|
||||
# --- Validaciones de entrada (sin Postgres) ---
|
||||
|
||||
|
||||
def test_identificador_tabla_invalido(tmp_path):
|
||||
res = duckdb_to_postgres(str(tmp_path / "x.duckdb"), "t; DROP", "dsn")
|
||||
assert res["status"] == "error"
|
||||
assert "invalid table identifier" in res["error"]
|
||||
|
||||
|
||||
def test_mode_invalido(tmp_path):
|
||||
db = tmp_path / "x.duckdb"
|
||||
con = duckdb.connect(str(db))
|
||||
con.execute("CREATE TABLE t (id BIGINT)")
|
||||
con.close()
|
||||
res = duckdb_to_postgres(str(db), "t", "dsn", mode="merge")
|
||||
assert res["status"] == "error"
|
||||
assert "invalid mode" in res["error"]
|
||||
|
||||
|
||||
# --- Tests end-to-end con Postgres ---
|
||||
|
||||
|
||||
@pytest.mark.skipif(not PG_DSN, reason="PG_TEST_DSN no definido")
|
||||
def test_replace_sincroniza_filas(tmp_path):
|
||||
db = tmp_path / "src.duckdb"
|
||||
con = duckdb.connect(str(db))
|
||||
con.execute("CREATE TABLE ventas (id BIGINT, region VARCHAR, total DOUBLE)")
|
||||
con.execute(
|
||||
"INSERT INTO ventas VALUES (1,'norte',10.5),(2,'sur',20.0),(3,'norte',5.25)"
|
||||
)
|
||||
con.close()
|
||||
pgt = "test_duckdb_to_pg_ventas"
|
||||
res = duckdb_to_postgres(str(db), "ventas", PG_DSN, pg_table=pgt, mode="replace")
|
||||
assert res["status"] == "ok", res
|
||||
assert res["pg_table"] == pgt
|
||||
assert res["rows_synced"] == 3
|
||||
assert res["created"] is True
|
||||
|
||||
import psycopg2
|
||||
|
||||
conn = psycopg2.connect(PG_DSN)
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(f'SELECT COUNT(*) FROM "{pgt}"')
|
||||
assert cur.fetchone()[0] == 3
|
||||
cur.execute(f'DROP TABLE IF EXISTS "{pgt}"')
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@pytest.mark.skipif(not PG_DSN, reason="PG_TEST_DSN no definido")
|
||||
def test_upsert_idempotente_con_key_cols(tmp_path):
|
||||
db = tmp_path / "src.duckdb"
|
||||
con = duckdb.connect(str(db))
|
||||
con.execute("CREATE TABLE u (id BIGINT, v VARCHAR)")
|
||||
con.execute("INSERT INTO u VALUES (1,'a'),(2,'b')")
|
||||
con.close()
|
||||
pgt = "test_duckdb_to_pg_upsert"
|
||||
r1 = duckdb_to_postgres(
|
||||
str(db), "u", PG_DSN, pg_table=pgt, mode="replace", key_cols=["id"]
|
||||
)
|
||||
assert r1["status"] == "ok", r1
|
||||
# Re-sync en modo upsert: no debe duplicar (idempotente).
|
||||
r2 = duckdb_to_postgres(
|
||||
str(db), "u", PG_DSN, pg_table=pgt, mode="upsert", key_cols=["id"]
|
||||
)
|
||||
assert r2["status"] == "ok", r2
|
||||
|
||||
import psycopg2
|
||||
|
||||
conn = psycopg2.connect(PG_DSN)
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(f'SELECT COUNT(*) FROM "{pgt}"')
|
||||
assert cur.fetchone()[0] == 2
|
||||
cur.execute(f'DROP TABLE IF EXISTS "{pgt}"')
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
Reference in New Issue
Block a user