Files
fn_registry/python/functions/infra/duckdb_upsert.md
T
egutierrez 1c4a4b9259 feat(duckdb,dav): primitivas de escritura DuckDB + libretas CardDAV + vCard multi-valor
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>
2026-06-13 00:33:12 +02:00

6.7 KiB

name, kind, lang, domain, version, purity, signature, description, tags, uses_functions, uses_types, returns, returns_optional, error_type, imports, params, output, tested, tests, test_file_path, file_path
name kind lang domain version purity signature description tags uses_functions uses_types returns returns_optional error_type imports params output tested tests test_file_path file_path
duckdb_upsert function py infra 1.0.0 impure def duckdb_upsert(db_path: str, table: str, rows: list[dict], key_cols: list[str], update_cols: list[str] | None = None) -> dict 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).
duckdb
sql
upsert
idempotent
infra
false error_py_core
re
duckdb
name desc
db_path 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 desc
table 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 desc
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 error. Lista vacia -> {status:'ok', inserted:0, updated:0}.
name desc
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.
name desc
update_cols 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).
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}. true
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
python/functions/infra/duckdb_upsert_test.py python/functions/infra/duckdb_upsert.py

Ejemplo

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}.