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