Files
fn_registry/python/functions/datascience/infer_fk_containment_duckdb.md
T
egutierrez 763e06c127 feat(browser): auto-commit con 178 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-20 18:22:23 +02:00

7.1 KiB

name, kind, lang, domain, version, purity, signature, description, tags, params, output, uses_functions, uses_types, returns, returns_optional, error_type, imports, tested, tests, test_file_path, file_path
name kind lang domain version purity signature description tags params output uses_functions uses_types returns returns_optional error_type imports tested tests test_file_path file_path
infer_fk_containment_duckdb function py datascience 1.0.0 impure def infer_fk_containment_duckdb(db_path: str, tables: list = None, min_inclusion: float = 0.9, max_card: int = 200000) -> dict Infiere FOREIGN KEYs candidatas entre tablas DuckDB por containment de valores: para un par (col A de T1, col B de T2), inclusion(A subseteq B) = |distinct(A) interseccion distinct(B)| / |distinct(A)|; si inclusion >= min_inclusion y B parece clave (distinct/count >= 0.95) entonces A -> B es FK candidata. Poda por tipo base y push-down SQL (COUNT DISTINCT / INTERSECT) sin traer filas a RAM. Parte del grupo eda (relaciones inter-tabla).
eda
relations
duckdb
foreign-key
schema-inference
datascience
exploratory-data-analysis
name desc
db_path Ruta al archivo DuckDB. Debe existir (lectura read-only via las primitivas del grupo duckdb; no se crea).
name desc
tables Lista de nombres de tabla a considerar. None (default) usa todas las del esquema main (duckdb_list_tables). Cada nombre se valida contra ^[A-Za-z_][A-Za-z0-9_]*$ antes de interpolarlo en el SQL.
name desc
min_inclusion Umbral minimo de inclusion (0-1) para emitir una FK candidata. inclusion(A subseteq B) = |distinct(A) interseccion distinct(B)| / |distinct(A)|. Default 0.9.
name desc
max_card Tope de filas en la tabla destino (lado B, el caro del INTERSECT). Si count(T2) > max_card, los pares hacia T2 se saltan para no disparar un INTERSECT gigante; se acumula una nota en skipped[]. Default 200000.
dict dict-no-throw. En exito {status:'ok', fk_candidates:[{from_table, from_col, to_table, to_col, inclusion, cardinality, to_is_key}, ...], tables:[str], skipped:[str]} con fk_candidates ordenado por inclusion descendente; cardinality es '1:1' (A casi unica en T1) o 'N:1' (A se repite, apunta a la key de T2). En error {status:'error', error:str}.
duckdb_list_tables_py_infra
duckdb_table_schema_py_infra
duckdb_query_readonly_py_infra
false error_go_core
true
test_detecta_fk_orders_customer_id
test_shape_resultado
test_no_inventa_fk_columnas_no_relacionadas
test_no_fk_entre_tipos_incompatibles
test_min_inclusion_alto_filtra
test_subset_explicito_de_tablas
test_db_inexistente_devuelve_error
test_tabla_invalida_devuelve_error
python/functions/datascience/infer_fk_containment_duckdb_test.py python/functions/datascience/infer_fk_containment_duckdb.py

Ejemplo

import sys, os, duckdb
sys.path.insert(0, os.path.join("python", "functions"))
from datascience import infer_fk_containment_duckdb

# Base de ejemplo en /tmp: orders.customer_id -> customers.id
path = "/tmp/fk_demo.duckdb"
if os.path.exists(path):
    os.remove(path)
con = duckdb.connect(path)
con.execute("CREATE TABLE customers (id INTEGER, region VARCHAR)")
con.execute("INSERT INTO customers VALUES (1,'norte'),(2,'sur'),(3,'este'),(4,'oeste')")
con.execute("CREATE TABLE orders (order_id INTEGER, customer_id INTEGER, total DOUBLE)")
con.execute("INSERT INTO orders VALUES (10,1,99.5),(11,2,12.0),(12,1,45.25),(13,3,7.75),(14,4,60.0)")
con.close()

res = infer_fk_containment_duckdb(path, min_inclusion=0.9)
if res["status"] == "ok":
    for fk in res["fk_candidates"]:
        print(f"{fk['from_table']}.{fk['from_col']} -> "
              f"{fk['to_table']}.{fk['to_col']}  "
              f"inclusion={fk['inclusion']:.2f}  {fk['cardinality']}")
    # -> orders.customer_id -> customers.id  inclusion=1.00  N:1
else:
    print("error:", res["error"])

Cuando usarla

  • Cuando exploras un esquema DuckDB que no conoces y quieres descubrir el grafo de relaciones (que tabla referencia a cual) sin que la base haya declarado FKs.
  • Como paso del grupo eda que va mas alla del perfil por tabla (summarize_table_duckdb): aqui se modelan las relaciones INTER-tabla.
  • Antes de migrar un esquema sin constraints a otro motor (PostgreSQL, etc.) para proponer las FOREIGN KEYs que faltan.
  • Para auditar integridad referencial: una inclusion < 1.0 en una FK que crees que deberia ser total indica valores huerfanos (filas de T1 cuyo valor no existe en la key de T2).

Gotchas

  • Impura: lee de disco via las primitivas read-only del grupo duckdb (no crea ni modifica la base). El db_path debe existir.
  • Coste O(pares podados): el numero de comparaciones es O(tablas^2 x columnas^2) ANTES de la poda. La poda por tipo base (solo se comparan columnas de la misma clase: ambos enteros, ambos varchar, ...) recorta drasticamente ese espacio, pero en esquemas con muchas tablas y columnas del mismo tipo puede seguir siendo costoso. Cada par evaluado dispara un INTERSECT en el motor.
  • INTERSECT puede ser caro en tablas enormes: por eso max_card (default 200000) limita el lado destino. Si count(T2) > max_card, los pares hacia T2 se saltan y se anota en skipped[]. Sube max_card con cuidado: el INTERSECT materializa los distintos de ambos lados.
  • Containment != FK declarada: que A este contenido en B (con B key-ish) es una FK probable, no una garantia. Una columna puede estar contenida por coincidencia (rangos pequenos de enteros, banderas, fechas solapadas) sin ser una relacion real. Revisa siempre las candidatas; trata inclusion y cardinality como senales, no como verdad.
  • Entero y float NO se mezclan: la poda por tipo pone INTEGER/BIGINT/... en la clase integer y FLOAT/DOUBLE/DECIMAL en float, y solo empareja columnas de la misma clase. Una FK entera contra una columna float casi nunca es real, asi que se descarta de entrada.
  • Solo esquema main cuando tables=None: hereda el alcance de duckdb_list_tables (esquema main).
  • Identificadores interpolados: nombres de tabla/columna se validan contra ^[A-Za-z_][A-Za-z0-9_]*$ y se citan (COUNT DISTINCT / INTERSECT no admiten parametros posicionales para identificadores). Una tabla con nombre invalido devuelve {status:'error'}; una columna con nombre invalido se ignora sin abortar.
  • Direccion: cada candidata es A -> B (A es la FK, B es la key referenciada). El par inverso (B -> A) se evalua por separado y normalmente no pasa el filtro de inclusion o el de key.

Notas

Definicion de containment usada:

inclusion(A subseteq B) = |distinct(A) interseccion distinct(B)| / |distinct(A)|

Criterio de emision de FK candidata A (de T1) -> B (de T2):

  1. T1 != T2 y type_class(A) == type_class(B) (poda por clase de tipo base).
  2. count(T2) <= max_card (si no, los pares hacia T2 se saltan -> skipped[]).
  3. distinct(A) > 0.
  4. B es key-ish: distinct(B) / count(T2) >= 0.95.
  5. inclusion(A subseteq B) >= min_inclusion.

Cardinalidad: si A es (casi) unica en T1 (distinct(A) / count(T1) >= 0.95) -> 1:1; si no -> N:1 (A se repite y apunta a la key de T2).

Todo se calcula con push-down (COUNT(DISTINCT), INTERSECT) — nunca se traen filas a RAM. Los count(*) por tabla y los distinct por columna se cachean para no recomputarlos entre pares.

fk_candidate = {
  from_table, from_col, to_table, to_col, inclusion, cardinality, to_is_key
}