"""infer_fk_containment_duckdb — infiere FOREIGN KEYs candidatas por containment. Funcion impura: lee de disco a traves de DuckDB (via las primitivas read-only del grupo `duckdb`: duckdb_list_tables, duckdb_table_schema, duckdb_query_readonly). Pertenece al grupo de capacidad `eda` (relaciones inter-tabla): descubre que columnas de una tabla son una clave foranea probable hacia la clave de otra, SIN que la base la haya declarado. Idea: para un par (columna A de T1, columna B de T2), la inclusion (o containment) de A en B es: inclusion(A subseteq B) = |distinct(A) interseccion distinct(B)| / |distinct(A)| Si inclusion >= min_inclusion y B "parece clave" (alta unicidad en T2, distinct(B) / count(T2) >= 0.95), entonces A -> B es una FK candidata. Todo se calcula con push-down en el motor de DuckDB (COUNT DISTINCT / INTERSECT); nunca se traen filas a RAM. PODA por tipo: solo se evaluan pares cuyas columnas comparten tipo base (ambos enteros, ambos varchar, ambos fecha, ...). Esto evita el O(n^2) de calcular containment para todos los pares de columnas, y descarta pares incompatibles que nunca podrian ser una FK real. Estilo dict-no-throw del grupo duckdb: nunca lanza; captura cualquier error y devuelve {status:'error', error:str}. """ import re from infra import ( duckdb_list_tables, duckdb_query_readonly, duckdb_table_schema, ) # Identificador SQL valido. Los nombres de tabla/columna se interpolan citados en # el SQL (COUNT DISTINCT / INTERSECT no admiten parametros posicionales para # identificadores), asi que se validan antes de tocar la base. _IDENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") # Clases de tipo base. Dos columnas solo se comparan si caen en la misma clase. # Agrupar por clase (no por tipo exacto) permite emparejar INTEGER con BIGINT, # DECIMAL con DOUBLE, etc. — combinaciones legitimas de FK numerica. _INTEGER_TYPES = { "TINYINT", "SMALLINT", "INTEGER", "BIGINT", "HUGEINT", "UTINYINT", "USMALLINT", "UINTEGER", "UBIGINT", "UHUGEINT", } _FLOAT_TYPES = {"FLOAT", "REAL", "DOUBLE", "DECIMAL", "NUMERIC"} _TEXT_TYPES = {"VARCHAR", "TEXT", "STRING", "CHAR", "BPCHAR", "UUID"} _DATETIME_TYPES = { "DATE", "TIME", "TIMESTAMP", "DATETIME", "TIMESTAMP_S", "TIMESTAMP_MS", "TIMESTAMP_NS", "TIMESTAMP_US", "TIMESTAMP WITH TIME ZONE", "TIMESTAMPTZ", "TIMETZ", } _BOOL_TYPES = {"BOOLEAN", "BOOL"} def _base_physical_type(column_type: str) -> str: """Normaliza un tipo fisico DuckDB a su forma base en mayusculas. Quita parametros (DECIMAL(10,2) -> DECIMAL) y modificadores de array (INTEGER[] -> INTEGER) para poder mapearlo a una clase de tipo. """ t = (column_type or "").strip().upper() t = re.sub(r"\[.*\]$", "", t).strip() # INTEGER[] -> INTEGER t = re.sub(r"\(.*\)$", "", t).strip() # VARCHAR(50) -> VARCHAR return t def _type_class(column_type: str) -> str: """Mapea un tipo fisico DuckDB a una clase comparable. Devuelve 'integer' | 'float' | 'text' | 'datetime' | 'boolean' | 'other'. Dos columnas solo se consideran emparejables para FK si comparten clase y la clase no es 'other'. Entero y float NO se mezclan: una FK entera contra una columna float es semanticamente sospechosa y casi nunca una FK real. """ base = _base_physical_type(column_type) if base in _INTEGER_TYPES: return "integer" if base in _FLOAT_TYPES: return "float" if base in _TEXT_TYPES: return "text" if base in _DATETIME_TYPES: return "datetime" if base in _BOOL_TYPES: return "boolean" return "other" def _valid_idents(*names) -> bool: """True si todos los identificadores casan con ^[A-Za-z_][A-Za-z0-9_]*$.""" return all(isinstance(n, str) and _IDENT_RE.match(n) for n in names) # --- Señal de nombre (precisión de FK, issues H3 + H10) ----------------------- # La contención de valores por si sola produce 10-20x falsos positivos: cualquier # clave entera pequeña (1..N) esta contenida en cualquier clave mas grande, y las # columnas de medida (cantidades, importes) caen dentro del rango de los ids. La # señal mas fuerte de una FK real es el NOMBRE de la columna: el patron canonico # `Id -> .Id` (PascalCase de chinook) o `_id -> ._id` (snake_case # de sakila). Filtrar candidatos por nombre ANTES del INTERSECT corrige precision # y rendimiento a la vez (menos pares que evaluar). def _norm(s) -> str: """Normaliza un identificador: minusculas, solo [a-z0-9] (quita `_`, espacios).""" return re.sub(r"[^a-z0-9]", "", str(s).lower()) def _singular(s: str) -> str: """Singular ingles aproximado (KISS): customers->customer, cities->city. Heuristica suficiente para casar columna `_id` con tabla `s`/``. """ if len(s) > 4 and s.endswith("ies"): return s[:-3] + "y" if len(s) > 3 and s.endswith("s") and not s.endswith("ss"): return s[:-1] return s def _ends_id(norm: str) -> bool: """True si el nombre normalizado termina en `id` (incluye el propio `id`).""" return norm.endswith("id") and norm != "" def _id_stem(norm: str) -> str: """Quita el sufijo `id` de un nombre ya normalizado: albumid->album, id->''.""" return norm[:-2] if norm.endswith("id") else norm def _name_eq(a, b) -> bool: """Igualdad de nombres tolerante a singular/plural (normaliza ambos lados).""" na, nb = _norm(a), _norm(b) if not na or not nb: return False return _singular(na) == _singular(nb) def _col_is_own_pk(col: str, table: str) -> bool: """True si `col` parece la PRIMARY KEY de su propia `table` por nombre. Una PK no es origen de FK en un esquema normal: `Genre.GenreId` referencia a su propia tabla, no a otra. Casos: `GenreId` en Genre, `film_id` en film, o el generico `id`. Esto impide que las PKs pequeñas (1..N), contenidas por construccion en cualquier clave mayor, se emitan como FK absurdas (origen). """ nc = _norm(col) if nc == "id": return True if _ends_id(nc) and _name_eq(_id_stem(nc), table): return True return False def _name_signal(from_col: str, to_table: str, to_col: str) -> bool: """True si (from_col -> to_table.to_col) tiene señal de nombre de FK real. Dos condiciones, AMBAS necesarias: 1. El DESTINO es la PK nombrada de su tabla: `to_col` nombra `to_table` (`store_id` en store, `AlbumId` en Album) o es el generico `id`. Esto ancla el destino a una clave real, no a una columna cualquiera de la tabla. 2. El ORIGEN apunta a esa tabla por su nombre: el stem de `from_col` casa con `to_table` (`Id -> X`, `_id -> x`) o lo CONTIENE como subcadena (`manager_staff_id -> staff`). El origen debe terminar en `id`. Mata el grueso de falsos de la contencion pura: `ArtistId -> Invoice.InvoiceId` falla porque "artist" no nombra ni contiene "invoice"; `Quantity -> AlbumId` falla porque "quantity" no termina en id. Conserva las FK reales con nombre que casa (`Track.AlbumId -> Album.AlbumId`). Limite conocido: FK con nombre divergente que no contiene la tabla destino (`Customer.SupportRepId -> Employee.EmployeeId`) no son alcanzables por nombre. """ nfc = _norm(from_col) if not _ends_id(nfc): return False ntc = _norm(to_col) # (1) destino = PK nombrada del to_table, o `id` generico. to_col_ok = (_ends_id(ntc) and _name_eq(_id_stem(ntc), to_table)) or ntc == "id" if not to_col_ok: return False # (2) origen nombra (o contiene) la tabla destino. fstem = _id_stem(nfc) if _name_eq(fstem, to_table): return True sing_t = _singular(_norm(to_table)) if sing_t and (sing_t in fstem or _norm(to_table) in fstem): return True return False def _schema_has_name_hints(cols_by_table: dict) -> bool: """True si el esquema usa convencion de nombres de clave (alguna columna `...id`). Permite degradar a contencion pura (retrocompatible) en bases con columnas cripticas (`c1`, `c2`) que no siguen ninguna convencion: ahi la señal de nombre no aplica y se vuelve al comportamiento historico. """ for cols in cols_by_table.values(): for c in cols: if _ends_id(_norm(c["name"])): return True return False def _scalar(res: dict): """Extrae el unico valor escalar de un resultado duckdb_query_readonly. Devuelve None si el resultado no es ok o no trae filas. """ if res["status"] != "ok" or not res["rows"]: return None row = res["rows"][0] # La query siempre alias-a la unica columna; devolvemos su valor. return next(iter(row.values())) def infer_fk_containment_duckdb( db_path: str, tables: list = None, min_inclusion: float = 0.9, max_card: int = 200000, require_name_signal: bool = True, ) -> dict: """Infiere FOREIGN KEYs candidatas entre tablas DuckDB por containment de valores. Args: db_path: ruta al archivo DuckDB. Debe existir (lectura read-only via las primitivas del grupo duckdb; no se crea). tables: lista de nombres de tabla a considerar. None (default) usa todas las del esquema main (duckdb_list_tables). 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. max_card: tope de filas en la tabla destino (lado B, el caro del INTERSECT). Si count(T2) > max_card, el par se salta para no disparar un INTERSECT gigante; se acumula una nota en skipped[]. Default 200000. require_name_signal: si True (default) exige SEÑAL DE NOMBRE ademas de contencion: la columna origen debe nombrar la tabla destino (patron `Id -> X.Id` / `_id -> x._id`) o referenciar su PK nombrada, y NO puede ser la PK de su propia tabla. Esto elimina el 10-20x de falsos positivos de la contencion pura (issues H3+H10) y, al filtrar pares ANTES del INTERSECT, acelera. Degrada automaticamente a contencion pura si el esquema no usa convencion de nombres de clave (ninguna columna `...id`), por retrocompatibilidad. Con False nunca se exige señal (comportamiento historico). Returns: dict dict-no-throw. En exito: {status:'ok', fk_candidates:[{from_table, from_col, to_table, to_col, inclusion, cardinality, to_is_key, name_match}, ...], # ordenado por inclusion desc tables:[str], skipped:[str], name_signal_enforced:bool} En error (sin lanzar): {status:'error', error:str}. """ try: # 1) Lista de tablas a considerar. if tables is None: list_res = duckdb_list_tables(db_path) if list_res["status"] != "ok": return {"status": "error", "error": list_res["error"]} tables = list_res["tables"] if not isinstance(tables, list): return {"status": "error", "error": "tables debe ser una lista o None"} tables = [t for t in tables if isinstance(t, str)] if not _valid_idents(*tables): return { "status": "error", "error": "algun nombre de tabla no casa con ^[A-Za-z_][A-Za-z0-9_]*$", } skipped = [] # 2) Schema + count + cache de columnas por tabla. # cols_by_table[t] = [{name, type, type_class}, ...] cols_by_table = {} count_by_table = {} for t in tables: sch = duckdb_table_schema(db_path, t) if sch["status"] != "ok": return {"status": "error", "error": sch["error"]} cols = [] for c in sch["columns"]: if not _valid_idents(c["name"]): # Columna con nombre no interpolable: la ignoramos sin abortar. continue cols.append( { "name": c["name"], "type": c["type"], "type_class": _type_class(c["type"]), } ) cols_by_table[t] = cols cnt = _scalar( duckdb_query_readonly(db_path, f'SELECT count(*) AS n FROM "{t}"') ) count_by_table[t] = int(cnt) if cnt is not None else 0 # 3) Cache de distinct(col) por (tabla, columna) para no recomputarlo. distinct_cache = {} def distinct_count(table: str, col: str): key = (table, col) if key in distinct_cache: return distinct_cache[key] val = _scalar( duckdb_query_readonly( db_path, f'SELECT count(DISTINCT "{col}") AS d FROM "{table}"' ) ) val = int(val) if val is not None else 0 distinct_cache[key] = val return val # 4) Cache de "B es key-ish" por (tabla destino, columna). distinct/count # >= 0.95. Solo se evalua para columnas que aparecen como lado B. key_cache = {} def to_is_key(table: str, col: str): cache_key = (table, col) if cache_key in key_cache: return key_cache[cache_key] n = count_by_table[table] if n <= 0: key_cache[cache_key] = (False, 0.0) return key_cache[cache_key] d = distinct_count(table, col) ratio = d / n key_cache[cache_key] = (ratio >= 0.95, ratio) return key_cache[cache_key] # 4b) ¿Exigir señal de nombre? Se exige si el caller lo pide Y el esquema # usa convencion de nombres de clave. Si no hay convencion (columnas # cripticas), se degrada a contencion pura (retrocompatible). enforce_name = require_name_signal and _schema_has_name_hints(cols_by_table) candidates = [] # 5) Pares (A en T1, B en T2) con T1 != T2 y misma clase de tipo (PODA). for t1 in tables: for t2 in tables: if t1 == t2: continue # Lado caro: el INTERSECT lee distinct de T2. Si T2 es enorme, # saltamos todos los pares hacia el (B en T2) y dejamos nota. if count_by_table[t2] > max_card: note = ( f"skip pares -> '{t2}': count {count_by_table[t2]} " f"> max_card {max_card}" ) if note not in skipped: skipped.append(note) continue for a in cols_by_table[t1]: if a["type_class"] == "other": continue # PODA por nombre: una PK nunca es ORIGEN de FK. Excluir aqui # (antes del bucle interno) mata pares absurdos como # `Genre.GenreId -> Track.TrackId` de raiz. if enforce_name and _col_is_own_pk(a["name"], t1): continue for b in cols_by_table[t2]: # PODA: solo pares con la misma clase de tipo base. if a["type_class"] != b["type_class"]: continue # PODA POR NOMBRE (issues H3+H10): exigir señal de nombre # ANTES del INTERSECT. Recorta el grueso de pares falsos y # evita el coste del containment sobre ellos. if enforce_name and not _name_signal( a["name"], t2, b["name"] ): continue # distinct(A); si es 0, no hay containment que medir. d_a = distinct_count(t1, a["name"]) if d_a == 0: continue # B debe parecer key (alta unicidad en T2). b_is_key, _b_ratio = to_is_key(t2, b["name"]) if not b_is_key: continue # interseccion de distintos via INTERSECT (push-down). inter_sql = ( "SELECT count(*) AS c FROM (" f'SELECT DISTINCT "{a["name"]}" FROM "{t1}" ' "INTERSECT " f'SELECT DISTINCT "{b["name"]}" FROM "{t2}"' ")" ) inter = _scalar(duckdb_query_readonly(db_path, inter_sql)) if inter is None: continue inter = int(inter) inclusion = inter / d_a if inclusion < min_inclusion: continue # Cardinalidad: si A es (casi) unica en T1 -> 1:1; si no N:1. n_t1 = count_by_table[t1] a_unique = n_t1 > 0 and (d_a / n_t1) >= 0.95 cardinality = "1:1" if a_unique else "N:1" candidates.append( { "from_table": t1, "from_col": a["name"], "to_table": t2, "to_col": b["name"], "inclusion": inclusion, "cardinality": cardinality, "to_is_key": True, "name_match": _name_signal( a["name"], t2, b["name"] ), } ) # Orden: primero las que tienen señal de nombre (FK mas fiables), luego por # inclusion descendente. En modo degradado (sin señal) todas son False y el # orden cae a inclusion, como antes. candidates.sort( key=lambda c: (c["name_match"], c["inclusion"]), reverse=True ) return { "status": "ok", "fk_candidates": candidates, "tables": tables, "skipped": skipped, "name_signal_enforced": enforce_name, } except Exception as e: # noqa: BLE001 return {"status": "error", "error": str(e)}