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>
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
"""Tests para duckdb_upsert."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
import duckdb
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from infra.duckdb_upsert import duckdb_upsert # noqa: E402
|
||||
|
||||
|
||||
def _fresh_db():
|
||||
"""Crea un .duckdb temporal con tabla t(k PK, a, b) y devuelve su path."""
|
||||
fd, path = tempfile.mkstemp(suffix=".duckdb")
|
||||
os.close(fd)
|
||||
os.remove(path) # DuckDB crea el archivo limpio.
|
||||
con = duckdb.connect(path)
|
||||
con.execute("CREATE TABLE t (k INTEGER PRIMARY KEY, a VARCHAR, b VARCHAR)")
|
||||
con.close()
|
||||
return path
|
||||
|
||||
|
||||
def _select_row(path, k):
|
||||
con = duckdb.connect(path, read_only=True)
|
||||
try:
|
||||
return con.execute("SELECT k, a, b FROM t WHERE k = ?", [k]).fetchone()
|
||||
finally:
|
||||
con.close()
|
||||
|
||||
|
||||
def test_upsert_fila_nueva_inserta():
|
||||
path = _fresh_db()
|
||||
try:
|
||||
res = duckdb_upsert(
|
||||
path, "t", [{"k": 1, "a": "a1", "b": "b1"}], key_cols=["k"]
|
||||
)
|
||||
assert res == {"status": "ok", "inserted": 1, "updated": 0}
|
||||
assert _select_row(path, 1) == (1, "a1", "b1")
|
||||
finally:
|
||||
os.remove(path)
|
||||
|
||||
|
||||
def test_update_cols_selectivo_no_pisa_columnas_excluidas():
|
||||
path = _fresh_db()
|
||||
try:
|
||||
duckdb_upsert(path, "t", [{"k": 1, "a": "a1", "b": "b1"}], key_cols=["k"])
|
||||
# Re-upsert de la misma k cambiando a y b en el dict, pero solo
|
||||
# autorizando actualizar 'a'. 'b' debe conservar el valor viejo.
|
||||
res = duckdb_upsert(
|
||||
path,
|
||||
"t",
|
||||
[{"k": 1, "a": "a2", "b": "b2"}],
|
||||
key_cols=["k"],
|
||||
update_cols=["a"],
|
||||
)
|
||||
assert res == {"status": "ok", "inserted": 0, "updated": 1}
|
||||
assert _select_row(path, 1) == (1, "a2", "b1") # a cambio, b NO
|
||||
finally:
|
||||
os.remove(path)
|
||||
|
||||
|
||||
def test_update_cols_vacio_do_nothing_no_cambia_existente():
|
||||
path = _fresh_db()
|
||||
try:
|
||||
duckdb_upsert(path, "t", [{"k": 1, "a": "a1", "b": "b1"}], key_cols=["k"])
|
||||
res = duckdb_upsert(
|
||||
path,
|
||||
"t",
|
||||
[{"k": 1, "a": "X", "b": "Y"}],
|
||||
key_cols=["k"],
|
||||
update_cols=[],
|
||||
)
|
||||
assert res == {"status": "ok", "inserted": 0, "updated": 1}
|
||||
assert _select_row(path, 1) == (1, "a1", "b1") # intacta
|
||||
finally:
|
||||
os.remove(path)
|
||||
|
||||
|
||||
def test_varias_filas_a_la_vez_mezcla_insert_y_update():
|
||||
path = _fresh_db()
|
||||
try:
|
||||
duckdb_upsert(path, "t", [{"k": 1, "a": "a1", "b": "b1"}], key_cols=["k"])
|
||||
res = duckdb_upsert(
|
||||
path,
|
||||
"t",
|
||||
[
|
||||
{"k": 1, "a": "a1b", "b": "b1b"}, # update
|
||||
{"k": 2, "a": "a2", "b": "b2"}, # insert
|
||||
{"k": 3, "a": "a3", "b": "b3"}, # insert
|
||||
],
|
||||
key_cols=["k"],
|
||||
)
|
||||
assert res == {"status": "ok", "inserted": 2, "updated": 1}
|
||||
assert _select_row(path, 1) == (1, "a1b", "b1b")
|
||||
assert _select_row(path, 2) == (2, "a2", "b2")
|
||||
assert _select_row(path, 3) == (3, "a3", "b3")
|
||||
finally:
|
||||
os.remove(path)
|
||||
|
||||
|
||||
def test_rows_vacio_devuelve_cero():
|
||||
path = _fresh_db()
|
||||
try:
|
||||
res = duckdb_upsert(path, "t", [], key_cols=["k"])
|
||||
assert res == {"status": "ok", "inserted": 0, "updated": 0}
|
||||
finally:
|
||||
os.remove(path)
|
||||
|
||||
|
||||
def test_columnas_inconsistentes_devuelve_error():
|
||||
path = _fresh_db()
|
||||
try:
|
||||
res = duckdb_upsert(
|
||||
path,
|
||||
"t",
|
||||
[{"k": 1, "a": "a1", "b": "b1"}, {"k": 2, "a": "a2"}],
|
||||
key_cols=["k"],
|
||||
)
|
||||
assert res["status"] == "error"
|
||||
finally:
|
||||
os.remove(path)
|
||||
|
||||
|
||||
def test_identificador_invalido_devuelve_error():
|
||||
path = _fresh_db()
|
||||
try:
|
||||
res = duckdb_upsert(
|
||||
path, "t; DROP TABLE t", [{"k": 1, "a": "a1", "b": "b1"}], key_cols=["k"]
|
||||
)
|
||||
assert res["status"] == "error"
|
||||
finally:
|
||||
os.remove(path)
|
||||
Reference in New Issue
Block a user