1c4a4b9259
Cinco funciones nuevas para soportar DuckDB como fuente de verdad del project osint:
Grupo duckdb (escritura, complementan a duckdb_query_readonly):
- duckdb_execute_py_infra (impure): ejecuta INSERT/UPDATE/DELETE/DDL en read-write, commit, {status,rowcount}. 6 tests.
- duckdb_upsert_py_infra (impure): UPSERT ON CONFLICT actualizando solo update_cols → ownership selectivo (un re-upsert no pisa columnas excluidas). 7 tests.
Grupo dav (libretas de contactos + vCard multi-valor):
- dav_make_addressbook_py_infra (impure): crea una libreta CardDAV nueva via extended MKCOL (RFC 5689). Idempotente. 12 tests.
- dav_list_addressbooks_py_infra (impure): lista las libretas del contacts-home (PROPFIND Depth:1). 7 tests.
- build_vcard_py_core (pure): serializa un contacto a vCard 3.0 multi-valor (N TEL/EMAIL/ADR + X-OSINT-*). 5 tests.
Paginas de capacidad duckdb.md y dav.md actualizadas.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
117 lines
6.7 KiB
Markdown
117 lines
6.7 KiB
Markdown
---
|
|
name: duckdb_upsert
|
|
kind: function
|
|
lang: py
|
|
domain: infra
|
|
version: "1.0.0"
|
|
purity: impure
|
|
signature: "def duckdb_upsert(db_path: str, table: str, rows: list[dict], key_cols: list[str], update_cols: list[str] | None = None) -> dict"
|
|
description: "UPSERT idempotente de filas en una tabla DuckDB con ownership selectivo de columnas. Construye INSERT INTO <table> (cols) VALUES (?,...) ON CONFLICT (key_cols) DO UPDATE SET col=excluded.col, ... (o DO NOTHING) y lo ejecuta fila por fila para contar inserts vs updates. La clave del patron es update_cols: en un conflicto solo se actualizan esas columnas, de modo que las columnas excluidas conservan su valor previo (la DB es duena de ellas y un re-ingest no las pisa). update_cols=None actualiza todas menos key_cols; update_cols=[] hace DO NOTHING. Abre duckdb.connect(db_path) en lectura-escritura, commit y close en try/finally. Valida que tabla y columnas casen [A-Za-z_][A-Za-z0-9_]* antes de interpolarlas; los valores van por placeholders '?'. Devuelve dict sin lanzar: {status:'ok', inserted, updated} o {status:'error', error}. key_cols deben tener PRIMARY KEY o UNIQUE en la tabla. Depende del paquete duckdb (1.5.2 en python/.venv)."
|
|
tags: [duckdb, sql, upsert, idempotent, infra]
|
|
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. Se abre en lectura-escritura (duckdb.connect), por lo que se crea si no existe; pero la tabla destino debe existir y tener PRIMARY KEY o UNIQUE en key_cols para que ON CONFLICT funcione."
|
|
- 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'} (no se interpola sin validar)."
|
|
- name: rows
|
|
desc: "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 error. Lista vacia -> {status:'ok', inserted:0, updated:0}."
|
|
- name: key_cols
|
|
desc: "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."
|
|
- name: update_cols
|
|
desc: "columnas a actualizar en caso de conflicto. None (default) = todas las columnas de la fila menos 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)."
|
|
output: "dict. En exito: {status:'ok', inserted:int, updated:int} donde inserted cuenta las claves que no existian y updated las que ya existian (con update_cols=[] / DO NOTHING, updated cuenta los conflictos vistos pero la fila no cambia). En error (sin lanzar): {status:'error', error:str}."
|
|
tested: true
|
|
tests:
|
|
- "test_upsert_fila_nueva_inserta"
|
|
- "test_update_cols_selectivo_no_pisa_columnas_excluidas"
|
|
- "test_update_cols_vacio_do_nothing_no_cambia_existente"
|
|
- "test_varias_filas_a_la_vez_mezcla_insert_y_update"
|
|
- "test_rows_vacio_devuelve_cero"
|
|
- "test_columnas_inconsistentes_devuelve_error"
|
|
- "test_identificador_invalido_devuelve_error"
|
|
test_file_path: "python/functions/infra/duckdb_upsert_test.py"
|
|
file_path: "python/functions/infra/duckdb_upsert.py"
|
|
---
|
|
|
|
## Ejemplo
|
|
|
|
```python
|
|
import sys
|
|
sys.path.insert(0, "python/functions")
|
|
import duckdb
|
|
from infra.duckdb_upsert import duckdb_upsert
|
|
|
|
db = "/tmp/leads.duckdb"
|
|
con = duckdb.connect(db)
|
|
con.execute("CREATE TABLE leads (email VARCHAR PRIMARY KEY, name VARCHAR, score INTEGER)")
|
|
con.close()
|
|
|
|
# Re-ingest 1: inserta el lead.
|
|
print(duckdb_upsert(
|
|
db, "leads",
|
|
[{"email": "ana@x.com", "name": "Ana", "score": 0}],
|
|
key_cols=["email"],
|
|
))
|
|
# {'status': 'ok', 'inserted': 1, 'updated': 0}
|
|
|
|
# Mientras tanto, un proceso de scoring escribio score=87 en la DB (fuente de verdad).
|
|
con = duckdb.connect(db)
|
|
con.execute("UPDATE leads SET score = 87 WHERE email = 'ana@x.com'")
|
|
con.close()
|
|
|
|
# Re-ingest 2: el feed trae name actualizado y score=0 (valor por defecto del feed),
|
|
# pero solo autorizamos actualizar 'name'. 'score' lo posee la DB y NO se pisa.
|
|
print(duckdb_upsert(
|
|
db, "leads",
|
|
[{"email": "ana@x.com", "name": "Ana Lopez", "score": 0}],
|
|
key_cols=["email"],
|
|
update_cols=["name"],
|
|
))
|
|
# {'status': 'ok', 'inserted': 0, 'updated': 1}
|
|
|
|
con = duckdb.connect(db, read_only=True)
|
|
print(con.execute("SELECT name, score FROM leads WHERE email = 'ana@x.com'").fetchone())
|
|
# ('Ana Lopez', 87) <- name actualizado, score conservado
|
|
con.close()
|
|
```
|
|
|
|
## Cuando usarla
|
|
|
|
Cuando la DB es la fuente de verdad y un re-ingest no debe pisar campos que ya
|
|
posee la DB: pasa `update_cols` SIN esos campos. Tipico en pipelines de ingesta
|
|
idempotente donde una fila se reinserta periodicamente (catalogo, leads, entidades
|
|
OSINT, snapshots) pero ciertas columnas se enriquecieron despues (score calculado,
|
|
anotacion manual, flag derivado) y deben sobrevivir al refresco. Usa
|
|
`update_cols=None` para un upsert "todo" clasico, `update_cols=[]` para insertar
|
|
solo filas nuevas sin tocar las existentes, y una lista explicita para ownership
|
|
selectivo. Util como paso de escritura en una composicion: el dict de salida es
|
|
serializable y reporta cuantas filas se insertaron vs actualizaron.
|
|
|
|
## 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 DuckDB
|
|
no detecta el conflicto y devolveria `{status:'error', ...}` o duplicaria. La
|
|
tabla debe existir de antemano (la funcion no la crea).
|
|
- **Single-writer**: la cuenta inserted/updated consulta la existencia de cada
|
|
clave en la misma conexion/transaccion justo antes de insertarla. Si otro
|
|
proceso escribe concurrentemente la misma base, las cuentas pueden desviarse y
|
|
DuckDB puede rechazar abrir el archivo por lock. Diseñada para un unico escritor.
|
|
- **Identificadores validados**: `table` y los nombres de columna deben casar
|
|
`[A-Za-z_][A-Za-z0-9_]*` (DuckDB no permite parametrizar identificadores, asi que
|
|
se interpolan tras validar). Un nombre con espacios, comillas, puntos o vacio
|
|
devuelve `{status:'error'}`. Los valores de las filas siempre van por `?`.
|
|
- **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 fila difiere, se devuelve error (no se hace insercion parcial).
|
|
- `update_cols=[]` genera `DO NOTHING`: la fila existente queda intacta, pero el
|
|
contador `updated` sigue reflejando los conflictos vistos (no son inserts nuevos).
|
|
- Nunca lanza: todo fallo (path bloqueado, tabla inexistente, tipo invalido) vuelve
|
|
como `{status:'error', error:str}`.
|