"""detect_declared_keys_duckdb — lee las claves DECLARADAS de un schema DuckDB. Funcion impura: lee de disco a traves de la primitiva read-only del grupo `duckdb` (duckdb_query_readonly). Pertenece al grupo de capacidad `eda` (relaciones de clave): a diferencia de infer_fk_containment_duckdb, que INFIERE FOREIGN KEYs candidatas por containment de valores, esta funcion devuelve las constraints REALES que el schema ha declarado (PRIMARY KEY / FOREIGN KEY / UNIQUE) leyendo la table function `duckdb_constraints()`. Es la pieza del capitulo RELACIONES de AutomaticEDA que muestra las relaciones de clave reales cuando existen — frente a la inferencia, que se usa cuando el schema no las declaro. Estilo dict-no-throw del grupo duckdb: nunca lanza; captura cualquier error y devuelve {status:'error', error:str}. """ from infra import duckdb_query_readonly def _as_list(value) -> list: """Normaliza el valor de una columna LIST de DuckDB a una lista de strings. En DuckDB 1.5.2, `constraint_column_names` y `referenced_column_names` llegan ya como listas Python a traves de duckdb_query_readonly. Este helper es solo una red de seguridad: si por cualquier motivo llegara como string (p.ej. la representacion `[id, customer_id]`), la parsea de forma defensiva. """ if value is None: return [] if isinstance(value, (list, tuple)): return [str(v) for v in value] if isinstance(value, str): s = value.strip() if s.startswith("[") and s.endswith("]"): s = s[1:-1] if not s.strip(): return [] return [ part.strip().strip("'\"") for part in s.split(",") if part.strip().strip("'\"") ] return [str(value)] def detect_declared_keys_duckdb(db_path: str, table: str = None) -> dict: """Detecta las claves PRIMARY KEY / FOREIGN KEY / UNIQUE declaradas en DuckDB. Lee la table function `duckdb_constraints()` y extrae solo las constraints de clave (PRIMARY KEY, FOREIGN KEY, UNIQUE), ignorando NOT NULL y CHECK. Args: db_path: ruta al archivo DuckDB. Debe existir (lectura read-only; no se crea). Un path inexistente devuelve {status:'error', ...} sin lanzar. table: si se pasa, filtra los resultados a esa tabla: incluye PRIMARY KEY y UNIQUE cuya tabla sea `table`, y FOREIGN KEY cuya tabla ORIGEN sea `table`. None (default) devuelve los constraints de todas las tablas. La comparacion de nombres es case-sensitive (tal cual los devuelve DuckDB). Returns: dict dict-no-throw. En exito: {status:'ok', primary_keys:[{table:str, columns:[str, ...]}, ...], foreign_keys:[{table:str, columns:[str, ...], referenced_table:str, referenced_columns:[str, ...]}, ...], unique:[{table:str, columns:[str, ...]}, ...], tables:[str, ...]} # tablas (origen) con algun PK/FK/UNIQUE emitido En error (sin lanzar): {status:'error', error:str}. """ try: sql = ( "SELECT table_name, constraint_type, constraint_column_names, " "referenced_table, referenced_column_names FROM duckdb_constraints()" ) res = duckdb_query_readonly(db_path, sql) if res["status"] != "ok": return {"status": "error", "error": res["error"]} primary_keys = [] foreign_keys = [] unique = [] tables = set() for row in res["rows"]: ctype = row["constraint_type"] tname = row["table_name"] # Filtro por tabla origen: para PK/FK/UNIQUE el dueño del constraint es # `table_name`. Una FK se atribuye a su tabla origen (no a la # referenciada), igual que el filtro pide. if table is not None and tname != table: continue cols = _as_list(row["constraint_column_names"]) if ctype == "PRIMARY KEY": primary_keys.append({"table": tname, "columns": cols}) tables.add(tname) elif ctype == "UNIQUE": unique.append({"table": tname, "columns": cols}) tables.add(tname) elif ctype == "FOREIGN KEY": foreign_keys.append( { "table": tname, "columns": cols, "referenced_table": row["referenced_table"], "referenced_columns": _as_list( row["referenced_column_names"] ), } ) tables.add(tname) # NOT NULL y CHECK se ignoran: no son relaciones de clave. return { "status": "ok", "primary_keys": primary_keys, "foreign_keys": foreign_keys, "unique": unique, "tables": sorted(tables), } except Exception as e: # noqa: BLE001 return {"status": "error", "error": str(e)}