Files
my-v0-server/app/datafiles/profile.py
T

127 lines
4.2 KiB
Python

"""列概况和预览:上传时在 Parquet 上用 DuckDB 计算,存进文件记录,供 describe_data 和接口返回"""
import math
from pathlib import Path
from typing import Any
import duckdb
from app.datafiles.query import connect, json_value, quote_identifier
from app.schemas.data_file import DataColumn
PREVIEW_ROWS = 20
SAMPLE_ROWS = 5
EXAMPLES = 3
TOP_VALUES = 5
# 去重值不超过这么多的文本、布尔列才给出现次数最多的值
TOP_VALUES_MAX_DISTINCT = 50
# 样例值从开头这么多行里取
EXAMPLE_SCAN_ROWS = 1000
PROFILE_CELL_CHARS = 50
PREVIEW_CELL_CHARS = 200
def profile_table(
path: Path, columns: list[DataColumn], notes: dict[str, str]
) -> tuple[dict[str, Any], list[list[Any]]]:
"""返回列概况和接口用的预览(前 PREVIEW_ROWS 行)"""
connection = connect({"t": path})
try:
rows = connection.execute("SELECT count(*) FROM t").fetchone()[0]
connection.execute("SUMMARIZE t")
fields = [column[0] for column in connection.description]
summary = {row[0]: dict(zip(fields, row)) for row in connection.fetchall()}
head = connection.execute(f"FROM t LIMIT {EXAMPLE_SCAN_ROWS}").fetchall()
profiled = [
_profile_column(
connection,
column,
summary[column.name],
head,
position,
notes.get(column.name),
)
for position, column in enumerate(columns)
]
sample = connection.execute(
f"FROM t USING SAMPLE reservoir({SAMPLE_ROWS} ROWS) REPEATABLE (42)"
).fetchall()
finally:
connection.close()
profile = {
"rows": rows,
"columns": profiled,
"sample": [
[json_value(value, PROFILE_CELL_CHARS) for value in row] for row in sample
],
}
preview = [
[json_value(value, PREVIEW_CELL_CHARS) for value in row]
for row in head[:PREVIEW_ROWS]
]
return profile, preview
def _profile_column(
connection: duckdb.DuckDBPyConnection,
column: DataColumn,
stats: dict[str, Any],
head: list[tuple],
position: int,
note: str | None,
) -> dict[str, Any]:
item: dict[str, Any] = {"name": column.name, "type": column.type}
if column.format:
item["format"] = column.format
null_ratio = float(stats["null_percentage"]) / 100
item["null_ratio"] = round(null_ratio, 4)
# approx_unique 是估算值,小表上可能比行数还大
item["distinct"] = min(
stats["approx_unique"], round(stats["count"] * (1 - null_ratio))
)
if column.type == "number":
for key in ("min", "max", "avg", "q25", "q50", "q75"):
item[key] = _number(stats[key])
elif column.type in ("date", "datetime"):
# SUMMARIZE 的时间用空格分隔,和预览里的 ISO 写法统一
item["min"], item["max"] = (
None if stats[key] is None else stats[key].replace(" ", "T", 1)
for key in ("min", "max")
)
if (
column.type in ("text", "boolean")
and stats["approx_unique"] <= TOP_VALUES_MAX_DISTINCT
):
name = quote_identifier(column.name)
counts = connection.execute(
f"SELECT {name}, count(*) AS n FROM t WHERE {name} IS NOT NULL "
f"GROUP BY 1 ORDER BY n DESC, 1 LIMIT {TOP_VALUES}"
).fetchall()
item["top_values"] = [
{"value": json_value(value, PROFILE_CELL_CHARS), "count": count}
for value, count in counts
]
examples: list[Any] = []
for row in head:
value = json_value(row[position], PROFILE_CELL_CHARS)
if value is not None and value not in examples:
examples.append(value)
if len(examples) == EXAMPLES:
break
item["examples"] = examples
if note:
item["note"] = note
return item
def _number(text: str | None) -> int | float | None:
"""SUMMARIZE 的统计值是字符串;整数不带小数点,其余保留 6 位小数;Parquet 里的 inf 返回空"""
if text is None:
return None
value = float(text)
if not math.isfinite(value):
return None
if value.is_integer() and abs(value) < 2**53:
return int(value)
return round(value, 6)