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