"""summarize_table_pg — perfil base de una tabla PostgreSQL con SQL push-down. Funcion impura: lee de un servidor PostgreSQL a traves de la primitiva read-only del grupo `postgres`, `pg_query`. Es el adaptador PostgreSQL del corazon del grupo de capacidad `eda` (exploratory data analysis), espejo de `summarize_table_duckdb`: construye EXACTAMENTE el mismo esqueleto de TableProfile (mismas claves) usando queries agregadas que hacen push-down en el motor de PostgreSQL y NO traen filas a RAM (count, count(DISTINCT), min/max/avg/stddev, percentile_cont). Lo que NO calcula aqui (a proposito, para ser barata): skew, kurtosis, histograma, percentiles finos (p1/p5/p95/p99), moda, outliers, correlaciones, key_candidates, quality_score ni el semantic_type. Esas claves quedan en None / [] para que las rellenen luego otras funciones del grupo `eda` sobre una muestra. El contrato de claves (TableProfile / ColumnProfile) es compartido por todo el grupo `eda` y es identico al de `summarize_table_duckdb`, de modo que `profile_table` y el resto del grupo consumen el resultado igual con fuente PostgreSQL. Estilo dict-no-throw del grupo: nunca lanza; captura cualquier error y devuelve {status:'error', error:str}. """ import re from datetime import datetime, timezone from infra import pg_query # Identificador SQL valido. PostgreSQL no admite parametros posicionales para el # nombre de tabla/columna en el cuerpo del SELECT, asi que hay que validar e # interpolar citado con comillas dobles. _IDENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") # Umbral de filas por debajo del cual calculamos COUNT(DISTINCT) EXACTO. Por # encima cap el distinct a n_rows (no estimamos con HLL: PostgreSQL no lo da de # serie sin extension). Documentado en el .md. _EXACT_DISTINCT_MAX_ROWS = 200_000 # Tipos PostgreSQL (data_type de information_schema) que mapean a "numeric". _NUMERIC_TYPES = { "smallint", "integer", "bigint", "decimal", "numeric", "real", "double precision", "smallserial", "serial", "bigserial", } # Tipos PostgreSQL que mapean a "datetime". _DATETIME_TYPES = { "date", "time", "timestamp", "timestamp without time zone", "timestamp with time zone", "time without time zone", "time with time zone", } # Tipos PostgreSQL textuales (candidatos a categorical/text). _TEXT_TYPES = { "text", "character varying", "varchar", "character", "char", "bpchar", } # Claves del sub-dict numeric. summarize solo rellena unas pocas; el resto # quedan en None hasta que una funcion de muestreo las complete. _NUMERIC_SUB_KEYS = ( "min", "max", "mean", "median", "mode", "std", "variance", "cv", "p1", "p5", "p25", "p50", "p75", "p95", "p99", "iqr", "skew", "kurtosis", "n_outliers", "outlier_pct", "zero_pct", "negative_pct", "distribution_type", "histogram", ) def _base_data_type(data_type: str) -> str: """Normaliza un data_type de information_schema a su forma base en minusculas. information_schema.columns.data_type ya viene sin parametros (p.ej. "numeric" en vez de "numeric(10,2)" y "character varying" en vez de "varchar(50)"), pero normalizamos a minusculas y quitamos espacios laterales por seguridad. """ return (data_type or "").strip().lower() def _infer_type(data_type: str, distinct_count, n_rows: int) -> str: """Mapea el data_type PostgreSQL al inferred_type del contrato eda. numeric / datetime / boolean salen directos del tipo. Para los tipos textuales se decide entre categorical y text con la misma heuristica de cardinalidad que el adaptador DuckDB: categorical si distinct_count <= 50 o distinct_count/n_rows < 0.5; si no text. """ base = _base_data_type(data_type) if base in _NUMERIC_TYPES: return "numeric" if base in _DATETIME_TYPES: return "datetime" if base in ("boolean", "bool"): return "boolean" if base in _TEXT_TYPES: au = distinct_count if distinct_count is not None else 0 if n_rows <= 0: return "categorical" if au <= 50 or (au / n_rows) < 0.5: return "categorical" return "text" # Tipos complejos (json, jsonb, uuid, array, bytea, ...): tratamos como text. return "text" def _to_float(value): """Convierte a float un valor agregado de PostgreSQL (Decimal/str/None). pg_query normaliza Decimal a float, pero min/max de columnas no numericas (o valores no convertibles) caen aqui y devolvemos None. """ if value is None: return None try: return float(value) except (TypeError, ValueError): return None def _to_int(value): """Convierte a int de forma defensiva (count(*), count(col) vienen como int).""" if value is None: return 0 try: return int(value) except (TypeError, ValueError): return 0 def summarize_table_pg( dsn: str, table: str, schema: str = "public", high_card_ratio: float = 0.9, ) -> dict: """Perfila una tabla PostgreSQL con SQL push-down (sin traer filas a RAM). Devuelve el MISMO esqueleto TableProfile que summarize_table_duckdb (mismas claves exactas), para que el resto del grupo `eda` funcione igual con fuente PostgreSQL. dict-no-throw. Args: dsn: cadena de conexion PostgreSQL, p.ej. "postgresql://user:pass@localhost:5432/mydb". Un DSN invalido o un servidor inalcanzable devuelve {status:'error', ...} (no lanza). table: nombre de la tabla a perfilar. Se valida contra ^[A-Za-z_][A-Za-z0-9_]*$ y se cita en el SQL (los identificadores no son parametrizables). schema: schema PostgreSQL donde vive la tabla (default "public"). Se valida con el mismo patron y se cita. high_card_ratio: umbral de unicidad (unique_pct) a partir del cual una columna categorical se marca con el flag "high_cardinality". Default 0.9. Returns: dict. En exito: {status:'ok', profile: }. En error (sin lanzar): {status:'error', error:str}. """ try: if not _IDENT_RE.match(table or ""): return { "status": "error", "error": ( f"nombre de tabla invalido: {table!r} " "(debe casar con ^[A-Za-z_][A-Za-z0-9_]*$)" ), } if not _IDENT_RE.match(schema or ""): return { "status": "error", "error": ( f"nombre de schema invalido: {schema!r} " "(debe casar con ^[A-Za-z_][A-Za-z0-9_]*$)" ), } qtable = f'"{schema}"."{table}"' # 1) Columnas + tipos desde information_schema (parametros posicionales). cols_res = pg_query( dsn, "SELECT column_name, data_type FROM information_schema.columns " "WHERE table_schema = %s AND table_name = %s " "ORDER BY ordinal_position", params=[schema, table], ) if cols_res["status"] != "ok": return {"status": "error", "error": cols_res["error"]} col_rows = cols_res["rows"] if not col_rows: return { "status": "error", "error": ( f"tabla no encontrada o sin columnas: {schema}.{table}" ), } col_meta = [ (r.get("column_name"), r.get("data_type")) for r in col_rows ] # 2) Numero total de filas. count_res = pg_query(dsn, f"SELECT count(*) AS n FROM {qtable}") if count_res["status"] != "ok": return {"status": "error", "error": count_res["error"]} n_rows = _to_int(count_res["rows"][0]["n"]) if count_res["rows"] else 0 # 3) Por columna: una query agregada con push-down en el motor. Combina # count no-nulo + count(DISTINCT) (exacto si n_rows <= umbral) +, para # columnas numericas, min/max/avg/stddev_samp/percentiles. No trae filas. exact_distinct_ok = ( 0 < n_rows <= _EXACT_DISTINCT_MAX_ROWS ) columns = [] for name, data_type in col_meta: if not _IDENT_RE.match(name or ""): # Columna con identificador no estandar: la perfilamos sin # agregados numericos (defensivo, no deberia pasar en information_schema). columns.append( _build_column_profile( name, data_type, n_rows, high_card_ratio, non_null=n_rows, distinct=None, agg=None, ) ) continue qcol = f'"{name}"' base_type = _base_data_type(data_type) is_numeric = base_type in _NUMERIC_TYPES select_parts = [f"count({qcol}) AS non_null"] if exact_distinct_ok: select_parts.append(f"count(DISTINCT {qcol}) AS distinct_n") if is_numeric: select_parts.extend([ f"min({qcol}) AS mn", f"max({qcol}) AS mx", f"avg({qcol}) AS av", f"stddev_samp({qcol}) AS sd", f"percentile_cont(0.25) WITHIN GROUP (ORDER BY {qcol}) AS p25", f"percentile_cont(0.5) WITHIN GROUP (ORDER BY {qcol}) AS p50", f"percentile_cont(0.75) WITHIN GROUP (ORDER BY {qcol}) AS p75", ]) agg_sql = f"SELECT {', '.join(select_parts)} FROM {qtable}" agg_res = pg_query(dsn, agg_sql) if agg_res["status"] != "ok": return {"status": "error", "error": agg_res["error"]} agg = agg_res["rows"][0] if agg_res["rows"] else {} non_null = _to_int(agg.get("non_null")) distinct = ( _to_int(agg.get("distinct_n")) if exact_distinct_ok else None ) columns.append( _build_column_profile( name, data_type, n_rows, high_card_ratio, non_null=non_null, distinct=distinct, agg=agg if is_numeric else None, ) ) type_breakdown = { "numeric": 0, "categorical": 0, "datetime": 0, "text": 0, "boolean": 0, } for col in columns: it = col["inferred_type"] if it in type_breakdown: type_breakdown[it] += 1 constant_cols = [c["name"] for c in columns if "constant" in c["flags"]] all_null_cols = [c["name"] for c in columns if c["null_pct"] == 1.0] null_cell_pct = ( sum(c["null_pct"] for c in columns) / len(columns) if columns else 0.0 ) profile = { "table": table, "source": "postgres", "profiled_at": datetime.now(timezone.utc).isoformat(), "n_rows": n_rows, "n_cols": len(columns), "size_bytes": None, "duplicate_rows": None, "duplicate_pct": None, "constant_cols": constant_cols, "all_null_cols": all_null_cols, "null_cell_pct": null_cell_pct, "type_breakdown": type_breakdown, "columns": columns, "correlations": None, "key_candidates": [], "quality_score": None, "llm": None, "models": None, } return {"status": "ok", "profile": profile} except Exception as e: # noqa: BLE001 return {"status": "error", "error": str(e)} def _build_column_profile( name: str, data_type: str, n_rows: int, high_card_ratio: float, non_null: int, distinct, agg: dict = None, ) -> dict: """Construye un ColumnProfile del contrato eda a partir de los agregados PG. name/data_type: metadata de information_schema. non_null: count(col) no-nulo de la query agregada. distinct: count(DISTINCT col) exacto si n_rows <= umbral; None si por encima (entonces se capa a n_rows). agg: fila de agregados numericos (min/max/avg/stddev/p25/p50/p75) o None para columnas no numericas. El shape devuelto es IDENTICO al de summarize_table_duckdb._build_column_profile. """ null_count = n_rows - non_null if n_rows > 0 else 0 if null_count < 0: null_count = 0 null_pct = (null_count / n_rows) if n_rows > 0 else 0.0 # distinct_count: exacto si disponible; si no, capado a n_rows. if distinct is not None: distinct_count = min(distinct, n_rows) if n_rows > 0 else distinct else: # Tabla grande (> umbral): no calculamos distinct exacto; lo capamos a # non_null como cota superior conservadora (a lo sumo tantos distintos # como valores no nulos), y a su vez a n_rows. distinct_count = min(non_null, n_rows) if n_rows > 0 else non_null inferred_type = _infer_type(data_type, distinct_count, n_rows) unique_pct = min(distinct_count / n_rows, 1.0) if n_rows > 0 else 0.0 numeric = None if inferred_type == "numeric": numeric = {k: None for k in _NUMERIC_SUB_KEYS} if agg is not None: numeric["min"] = _to_float(agg.get("mn")) numeric["max"] = _to_float(agg.get("mx")) numeric["mean"] = _to_float(agg.get("av")) numeric["std"] = _to_float(agg.get("sd")) numeric["p25"] = _to_float(agg.get("p25")) numeric["p50"] = _to_float(agg.get("p50")) numeric["p75"] = _to_float(agg.get("p75")) flags = [] if distinct_count <= 1: flags.append("constant") if unique_pct >= 0.99 and null_pct == 0: flags.append("possible_id") if inferred_type == "categorical" and unique_pct >= high_card_ratio: flags.append("high_cardinality") if null_pct > 0.5: flags.append("mostly_null") return { "name": name, "physical_type": data_type, "inferred_type": inferred_type, "semantic_type": "", "count": non_null, "n_rows": n_rows, "null_count": null_count, "null_pct": null_pct, "empty_count": None, "empty_pct": None, "distinct_count": distinct_count, "unique_pct": unique_pct, "flags": flags, "quality_score": None, "numeric": numeric, "categorical": None, "datetime": None, }