Files
AgentLab/backend/app/data/catalog_profile.py
ThearchyHelios f774e42463 数据剖析可以同时剖析几张表:剖析设置加「同时剖析的表数」(table_concurrency,1–3,缺省 1,即以前的一张做完再做下一张;上限 3 是给这个源上的分析留出连接池的临时连接),每张表的查询仍一条一条发,所以同一时刻对业务库最多这么多条查询。剖析任务的执行改成按设置开几个执行者,按任务里的顺序一张结束就开始下一张;进度、发过的查询各落在自己那张表上(不再记「正在做的那一张」)。每张表开始前照旧重读数据源:停用、关掉剖析或止损后不再开始新的表,正在做的几张照常做完(止损时剩下的项记止损的原因),都结束后再把没开始的记停下;止损的连续失败、超时比例按几张表的查询合起来算。停止、关停时正在做的几张各自收尾,做完的项照常写进目录,都收完才往上交(等的时候又被停止也等完);一张表让任务整体出错(快照被改动、数据源没了、记结果写库出错)时,另几张按停止收尾、原因写这一句,本进程续不上租约的不写。同时要估同一张表的行数时只估一次(按表加锁,先来的没估成后来的自己估)。同源已有剖析时的 409 说法从「第 x / y 张表」改成「已完成 x / y 张表」。界面:设置表单多一格(单位「张」,提示写明业务库同时要应付几条查询、和分析共用连接),风险说明、卡片悬停、开始前的醒目一句在大于 1 时写明「同时剖析 N 张表、每张表的查询一条一条地发」,预算多一行,这次最多用时按几轮算。测试:同一时刻在途查询不超过设置、每张表仍一条一条发(1/2/3 三种)、几张一起显示进行中并按顺序开始、同时停止各自写进目录、止损后不再开始新表、停用时正在做的做完、一张出错另几张按停止收尾、共享行数只估一次;check-catalog-profile 加一节(预算、估算、说法、两行进行中、表清单)。验收:后端全量 14979 通过;止损那条并发测试单独跑 30 遍、整个剖析任务测试文件并行跑 15 遍都没有偶发;tsc;check-catalog-profile 217 项、check-copy 28 项全过
2026-10-21 09:08:10 +08:00

2208 lines
125 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""数据剖析:对业务库发少量只读查询,验证数据目录里推断出来的关联关系,补上基数、覆盖率和码值候选。
**为什么要有它。** 命名推断只看列名,可能推错,也给不出基数和覆盖率;而只有 verified、confirmed 的关系才会
触发错误级的 SQL 检查。剖析拿数据说话:从子表抽一批不同的键值去父表里对,对得上、父键唯一的升为 verified
(来源记为 profile,和外键约束的 fk 分开),对不上的保持 proposed 并在备注里写明覆盖率。顺带给状态类的列
取一次取值分布(码值候选,含义留给人填),给只有一个日期列的表提议业务日期。
**一次剖析一张表。** 入口是 profile_table:先列全这张表要做的检查(plan_checks,纯函数——范围里的 profile_checks、
建任务时列出的检查项、执行时用的是同一份),第一条查询估行数(确知是空表就整张表跳过),再逐项执行,每项开始、结束经
hooks 回调(后台任务据此把进度落库),整张表结束把结论并进目录(_apply,乐观锁)。没查成的项,目录里剖析以前对它的
结论原样留着:这次没查成不等于结论错了。以前是一次同步请求剖析一批表、预算按整次算,选 50 张表只做得完前两三张,
其余整张记「查询次数用完」;现在每张表各有自己的预算,任务、排队、停止、留痕的落库在 data/catalog_profile_jobs.py,
这里不碰任务表。
**这是数据目录里唯一会对业务库发查询的地方**,约束都是硬的:
- 默认关闭,按数据源在 options.catalog_profile 里开启(profile_settings);
- 每条查询先过守卫的只读判定(不论数据源本身可写与否),再交给查询层 run_query:同一套连接、守卫和
时限,不另开连接;一张表的查询一条一条发,不并发;同时剖析几张表按设置(table_concurrency,缺省 1、最多
MAX_TABLE_CONCURRENCY),所以同一时刻对业务库最多这么多条查询;
- 单条查询的时限取设置和数据源查询时限中小的,且不超过这张表剩下的时长;
- 每张表有自己的预算(TableBudget:查询次数、时长),用完只停这张表,剩下的项记「这张表的查询次数(时长)用完」,下一张
表拿满额。整个任务一个查询器(SourceRunner)管任务级止损:连续 3 条查询失败(多半是连接断了、没有权限)、最近 10 条里
7 条超时(库响应慢,再发只是给业务库加压)就停下整个任务;最近 6 条里 4 条超时先提醒;
- 每条发出的查询(含超时、失败、守卫拒掉的)都留痕,SQL 里的字面量换成「?」(sql_semantics.normalize_sql):抽样到的
键值不进 AgentLab 自己的库;
- 表有多大先看数据库的统计信息,没有统计信息就数到上限为止,不做全表 COUNT;超过上限的表不做
COUNT(DISTINCT)、MIN/MAX 这类整表统计,抽样和取值分布也只看前若干行;
- 数据源设置里遮罩的列不取样,也不出现在任何一条剖析查询里;
- 只取低基数列的取值分布(最多 21 个取值),不取明细行。
**取值观察**(next-round 03 B2,data/catalog_profile_observe)是这里的另外四类检查(行数、码值观察、最新业务日期、按日
行数),同样计入每张表的预算、止损和留痕,遮罩列同样不出现在任何一条查询里;只在剖析任务的范围是 observe / maintenance
时带上(profile_table 的 observe)。
表助手的只读查询、起草时核对有效记录条件走单条查询的入口(run_single_query):同一个只读守卫、同一个单条时限,一次一条,
不设预算、不留痕。
"""
from __future__ import annotations
import asyncio
import collections
import copy
import logging
import re
import time
from collections.abc import Awaitable, Callable
from dataclasses import asdict, dataclass, field
from datetime import date as _date, datetime, timedelta, timezone
from decimal import Decimal
from typing import Any, NamedTuple
from sqlalchemy import select, update
from sqlalchemy.dialects import mysql, oracle, postgresql, sqlite
from sqlalchemy.ext.asyncio import AsyncSession
from app.data import catalog, guard
from app.data import engine as data_engine
from app.data.engine import CATALOG_PROFILE_OPTION, QueryResult, SnapshotTampered
from app.data.guard import QueryLimits, SqlRejected
from app.data.sql_semantics import normalize_sql
from app.db.base import SessionLocal
logger = logging.getLogger(__name__)
#: 数据源 options 里剖析设置的键
PROFILE_OPTION = CATALOG_PROFILE_OPTION
# ==========================================================================
# 设置
# ==========================================================================
@dataclass(frozen=True)
class ProfileSettings:
"""一个数据源的剖析设置。缺省即「关闭」:剖析会对业务库发查询,必须有人按源明确打开。"""
enabled: bool = False
#: 每张表最多发几条查询(估行数、抽样、核对都算)。用完只停这张表,剩下的项记为「这张表的查询次数用完」
table_max_queries: int = 40
#: 每张表的时长上限(秒)。用完同样只停这张表
table_max_s: float = 60
#: 单条查询的时限(秒)。实际取它和数据源自己的查询时限中较小的一个,且不超过这张表剩下的时长
query_timeout_s: float = 10
#: 每条关系从子表抽多少个不同的键值去父表里对
sample_size: int = 2000
#: 行数不超过它的表才做 COUNT(DISTINCT)、MIN/MAX 这类整表统计;更大的、或者不知道多大的表只看前若干行。
#: 填 0 表示一律不做整表统计
max_scan_rows: int = 100_000
#: 同时剖析几张表(每张表的查询仍一条一条发)。缺省 1:一张做完再做下一张;大于 1 时业务库同时要应付这么多条查询,
#: 和这个源上的分析共用连接池,所以上限是 MAX_TABLE_CONCURRENCY
table_concurrency: int = 1
def to_dict(self) -> dict[str, Any]:
return asdict(self)
@classmethod
def from_dict(cls, raw: Any) -> ProfileSettings:
"""任务里存着的设置快照 → 设置(读法同 profile_settings:哪一项不对就用缺省;开关按快照里的)。"""
return profile_settings({PROFILE_OPTION: raw}) if isinstance(raw, dict) else cls()
#: 数值项:(中文叫法, 下限, 上限, 是否必须是整数)。叫法和数据源设置里剖析预算的标签一致(frontend/src/lib/terms.ts),
#: 报错里只写它
_NUMBER_FIELDS: dict[str, tuple[str, float, float, bool]] = {
"table_max_queries": ("每张表最多查询数", 1, 200, True),
"table_max_s": ("每张表时长上限(秒)", 10, 600, False),
"query_timeout_s": ("单条查询时限(秒)", 1, 60, False),
"sample_size": ("核对键值数", 10, 10_000, True),
"max_scan_rows": ("整表统计行数上限", 0, 10_000_000, True),
"table_concurrency": ("同时剖析的表数", 1, 3, True),
}
#: 同时剖析的表数的上限:数据源的连接池常驻 3 条、临时再借 2 条(data/engine.EngineCache),和这个源上的分析共用,
#: 剖析最多占常驻的 3 条,临时的留给分析
MAX_TABLE_CONCURRENCY = int(_NUMBER_FIELDS["table_concurrency"][2])
_ENABLED_LABEL = "开启数据剖析"
_KNOWN_KEYS = frozenset({"enabled", *_NUMBER_FIELDS})
#: 以前的整次预算(一次剖析最多几条查询、总时长)。现在预算按表算,这两项不再有意义:启动时从库里删掉
#: (migrate_settings),保存时请求里还带着的(老页面)只摘掉、不报错(without_legacy)。不折算成新键:旧值按整次剖析
#: 算,和「每张表」含义不同,硬折算才是不照实
LEGACY_KEYS = ("max_queries", "max_total_s")
def _fmt(value: float) -> str:
return str(int(value)) if float(value).is_integer() else f"{value:g}"
def _number(value: Any, low: float, high: float, integer: bool) -> int | float | None:
"""设置里的一个数:数字和数字写成的文字都认(表单存的常是文字)。不合规返回 None。"""
if value is None or isinstance(value, bool):
return None
try:
number = float(str(value).strip())
except ValueError:
return None
if number != number or not low <= number <= high: # NaN 也在这里挡掉
return None
if number.is_integer():
return int(number)
return None if integer else number
def profile_settings(options: dict[str, Any] | None) -> ProfileSettings:
"""options → 剖析设置。读的时候宽松:库里存着的某一项不对(老数据、手改过),那一项用缺省,不报错;
开关只认 true,写成别的一律当作没开。认不出的项(含迁移前的旧键)不看。"""
raw = (options or {}).get(PROFILE_OPTION)
if not isinstance(raw, dict):
return ProfileSettings()
values: dict[str, Any] = {"enabled": raw.get("enabled") is True}
for key, (_, low, high, integer) in _NUMBER_FIELDS.items():
number = _number(raw.get(key), low, high, integer)
if number is not None:
values[key] = number
return ProfileSettings(**values)
def profile_settings_problem(options: dict[str, Any] | None) -> str | None:
"""保存数据源时查一下剖析设置;没填、或者填对了返回 None。写的时候严格:不认识的项、超出范围的值都拒。"""
found = profile_settings_rejection(options)
return found[0] if found else None
def profile_settings_rejection(options: dict[str, Any] | None) -> tuple[str, str | None] | None:
"""同 profile_settings_problem,另给出错的是哪一项:(给人看的一句, 字段名)。字段名是 enabled 或 _NUMBER_FIELDS
的键(table_max_queries……),格式不对、有认不出的项时为 None。接口带着它回 422,表单不必从原话里认是哪一格。
旧键(LEGACY_KEYS)在这之前已经由 without_legacy 摘掉。"""
raw = (options or {}).get(PROFILE_OPTION)
if raw is None:
return None
if not isinstance(raw, dict):
return "数据剖析设置的格式不正确,请在数据源设置中重新填写", None
if unknown := sorted(set(raw) - _KNOWN_KEYS):
return f"数据剖析设置中有无法识别的项「{'、'.join(map(str, unknown))}」,请删除后重试", None
if "enabled" in raw and not isinstance(raw["enabled"], bool):
return f"数据剖析的「{_ENABLED_LABEL}」只能是开启或关闭", "enabled"
for key, (label, low, high, integer) in _NUMBER_FIELDS.items():
if key in raw and _number(raw[key], low, high, integer) is None:
kind = "整数" if integer else "数"
return f"数据剖析的「{label}」需要填写 {_fmt(low)} 到 {_fmt(high)} 之间的{kind};当前为「{raw[key]}」", key
return None
def without_legacy(options: dict[str, Any] | None) -> dict[str, Any] | None:
"""保存数据源时:剖析设置里还带着旧的整次预算(老页面提交的),只摘掉,不报错。没有旧键原样返回。"""
raw = (options or {}).get(PROFILE_OPTION)
if not isinstance(raw, dict) or not any(key in raw for key in LEGACY_KEYS):
return options
return {**(options or {}), PROFILE_OPTION: {k: v for k, v in raw.items() if k not in LEGACY_KEYS}}
async def migrate_settings(session: AsyncSession) -> int:
"""服务启动时一次性迁移:库里所有数据源 options.catalog_profile 中的旧键(max_queries、max_total_s)删掉,记日志。
不折算成新键(见 LEGACY_KEYS)。返回改了几个数据源。只读写设置,不连业务库。"""
from app.db.models import DataSource
rows = (await session.execute(select(DataSource.id, DataSource.name, DataSource.options))).all()
changed = 0
for source_id, name, options in rows:
cleaned = without_legacy(options if isinstance(options, dict) else None)
if cleaned is options or cleaned is None:
continue
dropped = {k: (options or {}).get(PROFILE_OPTION, {}).get(k) for k in LEGACY_KEYS
if k in ((options or {}).get(PROFILE_OPTION) or {})}
await session.execute(update(DataSource).where(DataSource.id == source_id).values(options=cleaned)
.execution_options(synchronize_session=False))
logger.info("数据剖析设置迁移:数据源 %s(%s)去掉了按整次剖析算的旧预算 %s,改按每张表的预算", source_id, name,
dropped)
changed += 1
if changed:
await session.commit()
return changed
# ==========================================================================
# SQL:字面量与方言
# ==========================================================================
#: 查询层不支持参数绑定(run_query 只收一段 SQL),核对键值只能把值拼进 IN 列表。所以只拼「自己刚从库里
#: 取回的原值」,并且按类型生成字面量:整数原样写,文字加单引号、单引号写两遍。下面几种文字不拼,跳过:
#: - 带反斜杠的:MySQL 在 NO_BACKSLASH_ESCAPES 开与不开时读法不同,守卫会整条拒掉;
#: - 带「:名字」的:查询层用 SQLAlchemy 的 text() 执行,它不认引号,会把字符串里的 :b 当成绑定参数;
#: - 带控制字符的、太长的:键值不该长这样,多半不是键。
_BIND_LIKE = re.compile(r"(?<![:\w\\]):\w")
_CONTROL = re.compile(r"[\x00-\x1f\x7f]")
_INT_TEXT = re.compile(r"-?\d{1,38}")
#: 文字字面量的长度上限
MAX_LITERAL_LEN = 200
def sql_literal(value: Any, kind: str | None) -> str | None:
"""一个取回的值 → SQL 字面量;不安全或类型不支持时返回 None(调用方跳过这个值)。
kind 是查询层记下的列类型(QueryResult.column_types:number / text / date …),None 表示拿不准。
DECIMAL 经查询层已经转成了字符串(保精度),所以 number 列的整数文字原样写成数字。只认整数和文字:
非整数的数做相等比较靠不住;日期、时间的字面量各家写法不同,不猜;布尔不当键。
"""
if value is None or isinstance(value, bool):
return None
if isinstance(value, int):
return str(value)
if isinstance(value, float):
return str(int(value)) if value.is_integer() and abs(value) < 2 ** 53 else None
if isinstance(value, Decimal):
return str(int(value)) if value.is_finite() and value == value.to_integral_value() else None
if not isinstance(value, str):
return None
if kind == "number":
return value if _INT_TEXT.fullmatch(value) else None
if kind not in ("text", None):
return None
if len(value) > MAX_LITERAL_LEN or "\\" in value or _CONTROL.search(value) or _BIND_LIKE.search(value):
return None
return "'" + value.replace("'", "''") + "'"
#: 定长文字类型(CHAR(2)、NCHAR、bpchar、CHARACTER(2)):PostgreSQL、Oracle 取回的值带尾随空格补齐
_FIXED_CHAR = re.compile(r"^\s*(?:N?CHAR|BPCHAR|CHARACTER)\b(?!\s+VARYING)", re.IGNORECASE)
def is_fixed_char(type_name: str | None) -> bool:
"""定长文字列(CHAR(n)):值的尾随空格是补齐出来的,不是取值的一部分(SQL 比较时也不算)。"""
return bool(type_name) and bool(_FIXED_CHAR.match(str(type_name)))
class KeyLiterals(NamedTuple):
"""key_literals 的结果。literals:去父表里对的 IN 列表字面量(去重、保持顺序);unmatchable:按类型就对不上父键、
算作没对上的键值个数(去重后);unsafe:值本身不能安全拼进 SQL、不计入核对的个数。"""
literals: list[str]
unmatchable: int
unsafe: int
def key_literals(values: list[Any], kind: str | None, *, parent: str | None,
child_type: str | None) -> KeyLiterals:
"""从子表取回的键值 → 去父表里对的 IN 列表字面量(KeyLiterals)。
字面量按**被指向列**的类型族写(catalog.column_family),不是按子表取回的值:
- 父列是文字、子列是数:写成 '123'。PostgreSQL 上 varchar 列 IN (123) 直接报「operator does not exist」;
- 父列是数、子列是文字:只拼整数写法的值('0012' 也照 12 写)。别的('A1'、'WALKIN')不拼——Oracle 上数字列
IN ('A1') 会把字面量转成数报 ORA-01722,MySQL 会把 'A1' 转成 0 去比、对上不该对上的行——但它们**照样是子表
的键值**:整数主键里永远没有 'WALKIN',所以算作没对上,计入 unmatchable,由调用方留在覆盖率的分母里。要是
从分母里拿掉,散客代号占九成的一列只剩 1 个整数写法的键对上,就成了「覆盖率 100%」;
- 父列是时间:不在这里处理,_check_relation 先整条跳过(各家时间字面量写法不同、精度不同,键不核对);
- 父列类型认不出:照子表取回的值的类型写(sql_literal,以前的做法)。
子表是定长文字(CHAR(n))时先去掉尾随空格:PG、Oracle 取回的 'A ' 拿去和 VARCHAR 父列比就对不上,而按 CHAR 的
语义尾随空格本来就不算。不安全的值(反斜杠、「:名字」、控制字符、太长)照 sql_literal 的规矩跳过,记进 unsafe
(调用方另记「类型不支持核对,未计入」)。两个不同的取值写成同一个字面量('0012' 和 '12')只算一个,不算 unsafe。
"""
if parent == "time":
raise ValueError("被指向列是日期时间的关系不核对键值(见 _check_relation)")
fixed = is_fixed_char(child_type)
out: list[str] = []
unmatchable: set[str] = set()
unsafe = 0
for value in values:
if fixed and isinstance(value, str):
value = value.rstrip(" ")
if parent == "text":
if isinstance(value, bool):
unsafe += 1
continue
if isinstance(value, (int, float, Decimal)):
lit = sql_literal(value, "number")
lit = None if lit is None else "'" + lit + "'"
else:
lit = sql_literal(value, "text")
elif parent == "number":
if isinstance(value, str):
text = value.strip()
if not _INT_TEXT.fullmatch(text):
unmatchable.add(text)
continue
lit = str(int(text))
else:
lit = sql_literal(value, "number")
else:
lit = sql_literal(value, kind)
if lit is None:
unsafe += 1
else:
out.append(lit)
return KeyLiterals(list(dict.fromkeys(out)), len(unmatchable), unsafe)
_DIALECT_MODULES = {"sqlite": sqlite, "postgres": postgresql, "postgresql": postgresql, "mysql": mysql,
"mariadb": mysql, "oracle": oracle}
#: 日期列的最小值、最大值只看这个区间里的值(含下限、不含上限):0000-00-00(MySQL 关了严格模式写进去的零日期)、
#: 0001-01-01、9999-12-31(「没有」「永久」的占位日期)、PostgreSQL 的 -infinity / infinity 都落在区间外。区间外的
#: 值照实数出来(「有 N 个无效日期未计入」),不让一个占位日期把业务日期的范围撑成两千年。两端整天排除:带时刻的
#: 9999-12-31 23:59:59、文字存的「0001-01-01 00:00:00」也算占位
VALID_DATE_FROM = "0001-01-02"
VALID_DATE_BEFORE = "9999-12-31"
_ISO_DAY = re.compile(r"\d{4}-\d{2}-\d{2}")
class SqlDialect:
"""剖析 SQL 的方言差异:标识符怎么加引号、怎么只取前 n 行、统计信息在哪个系统视图里、日期字面量和按天截断
怎么写、有没有块采样。只拼字符串,不连库。
标识符交给 SQLAlchemy 各方言的 identifier_preparer:需要时才加引号(memberTags 加、visits 不加),
MySQL 用反引号。Oracle 尤其不能一律加双引号:探查拿到的表名是 SQLAlchemy 规整过的小写,库里其实是
大写,"visits" 加了引号就成了另一张不存在的表;不加引号 Oracle 自己转大写才对得上。反过来,库里真是小写的
对象(建表时写了 "visits")探查时记了 quote_name,这种要强制加引号、不转大写(table、column、stats_sql)。
"""
def __init__(self, kind: str | None) -> None:
self.kind = (kind or "").lower()
module = _DIALECT_MODULES.get(self.kind)
if module is None:
raise ValueError(f"不支持的数据库类型:{kind}")
self._dialect = module.dialect()
self._prep = self._dialect.identifier_preparer
self.oracle = self.kind == "oracle"
def quote(self, name: str, *, force: bool = False) -> str:
"""标识符。force:探查记了 quote_name 的名字,原样加引号(Oracle 小写带引号的对象)。"""
return self._prep.quote_identifier(name) if force else self._prep.quote(name)
def table(self, meta: dict[str, Any] | None, name: str) -> str:
"""写进 FROM 的表名:探查时指定了 schema 的带上它(Oracle 只读账号名下没有对象,全靠 schema 前缀)。"""
schema = (meta or {}).get("schema")
quoted = self.quote(name, force=(meta or {}).get("quote_name") is True)
return f"{self._prep.quote_schema(schema)}.{quoted}" if schema else quoted
def column(self, meta: dict[str, Any] | None, name: str) -> str:
"""写进 SQL 的列名:表结构里这一列记了 quote_name 的强制加引号。"""
cols = (meta or {}).get("columns") or []
entry = next((c for c in cols if isinstance(c, dict) and c.get("name") == name), None)
return self.quote(name, force=bool(entry and entry.get("quote_name") is True))
def date_literal(self, value: Any, *, text: bool = False) -> str:
"""日期字面量(取值观察的时间窗口、日期列的有效区间用)。PostgreSQL、MySQL、Oracle 都认 DATE 'YYYY-MM-DD';
SQLite 没有日期类型,日期存成文字,写字符串。text:列是文字存的日期(VARCHAR),按字符串比——PostgreSQL 上
varchar 和 date 比较会直接报错。只收真实存在的日期(date 或 YYYY-MM-DD),别的抛 ValueError:拼进 SQL 的
只能是我们自己算出来的日子。"""
if isinstance(value, datetime):
value = value.date()
if isinstance(value, _date):
day = value.isoformat()
elif isinstance(value, str) and _ISO_DAY.fullmatch(value):
try:
day = _date.fromisoformat(value).isoformat()
except ValueError:
raise ValueError(f"不是有效的日期:{value}") from None
else:
raise ValueError(f"不是有效的日期:{value!r}")
if text or self.kind == "sqlite":
return f"'{day}'"
return f"DATE '{day}'"
def day_trunc(self, column: str) -> str:
"""时间列截到天(按日行数用)。PostgreSQL CAST(x AS DATE);MySQL DATE(x);Oracle 必须用 TRUNC(x):Oracle 的
DATE 带时分秒,CAST(ts AS DATE) 截不掉;SQLite 的日期是文字,取前 10 个字符。"""
if self.kind in ("postgres", "postgresql"):
return f"CAST({column} AS DATE)"
if self.kind in ("mysql", "mariadb"):
return f"DATE({column})"
if self.oracle:
return f"TRUNC({column})"
return f"substr({column}, 1, 10)"
def sample_from(self, table: str, percent: float) -> str | None:
"""块采样的 FROM:PostgreSQL TABLESAMPLE SYSTEM (p)、Oracle SAMPLE BLOCK (p),按数据块随机取约 p% 的行;
MySQL、SQLite 没有块采样,返回 None(调用方退回「按存储顺序的前 N 行」,并照实写口径)。"""
p = float(percent)
if not (0 < p < 100):
raise ValueError(f"采样比例要在 0 到 100 之间:{percent}")
if self.kind in ("postgres", "postgresql"):
return f"{table} TABLESAMPLE SYSTEM ({p:g})"
if self.oracle:
return f"{table} SAMPLE BLOCK ({p:g})"
return None
def limit(self, sql: str, n: int) -> str:
"""只取前 n 行。Oracle 11g 没有 LIMIT 也没有 FETCH FIRST,一律套一层 ROWNUM。"""
if self.oracle:
return f"SELECT * FROM ({sql}) WHERE ROWNUM <= {int(n)}"
return f"{sql} LIMIT {int(n)}"
def _first_rows(self, table: str, column: str, scan_cap: int | None) -> str:
"""FROM 子句:整张表,或者只看前 scan_cap 行非空值的子查询(大表不做整表扫描)。"""
if scan_cap is None:
return table
return f"({self.limit(f'SELECT {column} FROM {table} WHERE {column} IS NOT NULL', scan_cap)}) s"
def stats_sql(self, meta: dict[str, Any] | None, name: str) -> str | None:
"""读这张表行数估算值的查询:MySQL information_schema.TABLES.TABLE_ROWS、PostgreSQL pg_class.reltuples、
Oracle ALL_TABLES.NUM_ROWS。SQLite 没有可靠的统计信息,返回 None(改为数到上限为止)。"""
schema = (meta or {}).get("schema")
if self.kind in ("mysql", "mariadb"):
where_schema = sql_literal(schema, "text") if schema else "DATABASE()"
table = sql_literal(name, "text")
if not (where_schema and table):
return None
return (f"SELECT TABLE_ROWS FROM information_schema.TABLES "
f"WHERE TABLE_SCHEMA = {where_schema} AND TABLE_NAME = {table}")
if self.kind in ("postgres", "postgresql"):
where_schema = sql_literal(schema, "text") if schema else "current_schema()"
table = sql_literal(name, "text")
if not (where_schema and table):
return None
return ("SELECT c.reltuples FROM pg_catalog.pg_class c "
"JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace "
f"WHERE n.nspname = {where_schema} AND c.relname = {table}")
if self.oracle:
denorm = self._dialect.denormalize_name
owner = sql_literal(denorm(schema), "text") if schema else "SYS_CONTEXT('USERENV', 'CURRENT_SCHEMA')"
# 小写带引号的对象(quote_name)在数据字典里就是小写,不能转成大写去找
table = sql_literal(name if (meta or {}).get("quote_name") is True else denorm(name), "text")
if not (owner and table):
return None
return f"SELECT NUM_ROWS FROM ALL_TABLES WHERE OWNER = {owner} AND TABLE_NAME = {table}"
return None
def bounded_count_sql(self, table: str, cap: int) -> str:
"""数到 cap 行为止:小表得到准确行数,大表只知道「至少 cap 行」,不做全表 COUNT。"""
return f"SELECT COUNT(*) AS n FROM ({self.limit(f'SELECT 1 AS one FROM {table}', cap)}) s"
def distinct_sample_sql(self, table: str, column: str, n: int, *, scan_cap: int | None) -> str:
"""至多 n 个不同的非空键值。scan_cap 给了时只在前 scan_cap 行里找:键的取值少时 DISTINCT … LIMIT
凑不满 n 个,会一直扫到表尾。"""
if scan_cap is None:
return self.limit(f"SELECT DISTINCT {column} FROM {table} WHERE {column} IS NOT NULL", n)
return self.limit(f"SELECT DISTINCT {column} FROM {self._first_rows(table, column, scan_cap)}", n)
def match_count_sql(self, table: str, column: str, literals: list[str]) -> str:
"""被指向表里能对上这些键值的个数(去重:被指向列有重复值时不多算)。"""
return f"SELECT COUNT(DISTINCT {column}) AS n FROM {table} WHERE {column} IN ({', '.join(literals)})"
def unique_check_sql(self, table: str, column: str) -> str:
"""非空值个数和不同值个数:相等即这一列(非空部分)唯一。整表统计,只对小表用。"""
return f"SELECT COUNT({column}) AS n, COUNT(DISTINCT {column}) AS d FROM {table}"
def value_counts_sql(self, table: str, column: str, n: int, *, scan_cap: int | None) -> str:
"""取值分布:出现最多的 n 个取值和各自的行数。只拿取值和计数,不拿明细行。"""
sql = (f"SELECT {column}, COUNT(*) AS cnt FROM {self._first_rows(table, column, scan_cap)} "
f"WHERE {column} IS NOT NULL GROUP BY {column} ORDER BY cnt DESC, {column}")
return self.limit(sql, n)
def min_max_sql(self, table: str, columns: list[tuple[str, bool]]) -> str:
"""日期列的最小值、最大值和无效日期的个数:每列三个值(MIN、MAX、区间外的个数)。columns 是 (写进 SQL 的列,
是不是文字存的日期)。只看有效区间(VALID_DATE_FROM ≤ x < VALID_DATE_BEFORE)里的值,见 VALID_DATE_FROM。
整表统计,只对小表用。"""
parts = []
for c, text in columns:
lo = self.date_literal(VALID_DATE_FROM, text=text)
hi = self.date_literal(VALID_DATE_BEFORE, text=text)
valid = f"{c} >= {lo} AND {c} < {hi}"
parts.append(f"MIN(CASE WHEN {valid} THEN {c} END), MAX(CASE WHEN {valid} THEN {c} END), "
f"SUM(CASE WHEN {c} < {lo} OR {c} >= {hi} THEN 1 ELSE 0 END)")
return f"SELECT {', '.join(parts)} FROM {table}"
# ---- 取值观察(data/catalog_profile_observe):最新业务日期、窗口、按日行数、块采样。金样和容器集成测试都覆盖
def max_date_sql(self, table: str, column: str, *, text: bool = False) -> str:
"""业务日期列的最新日期:有效区间(VALID_DATE_FROM ≤ x < VALID_DATE_BEFORE)里的最大值,占位日期、无穷大不算。大表
只在这一列打头有索引时发(取最大值走索引)。"""
lo = self.date_literal(VALID_DATE_FROM, text=text)
hi = self.date_literal(VALID_DATE_BEFORE, text=text)
return f"SELECT MAX({column}) FROM {table} WHERE {column} >= {lo} AND {column} < {hi}"
def _window(self, column: str, since: Any, text: bool, before: Any = None) -> str:
"""时间窗口的条件:since 起(含),并且早于 before(不含;缺省是占位日期 9999-12-31——PostgreSQL 的 infinity、
Oracle 的 9999-12-31 都大于任何一天,不截掉上端就会在「最近 35 天」里多出一天)。"""
return (f"{column} >= {self.date_literal(since, text=text)} "
f"AND {column} < {self.date_literal(before or VALID_DATE_BEFORE, text=text)}")
def day_volume_sql(self, table: str, column: str, since: Any, until: Any, *, text: bool = False) -> str:
"""最近若干天按天计数:since 到 until(都含;until 是观察当天)每天几行。只在日期列打头有索引时对大表发(走索引的
范围扫描)。上界是 until 的第二天(不含):业务日期有未来值(预约、计划类的日期,或者写错的)时,今天以后的日子
不进窗口——它们不是「最近的整天」,也不该被当成缺天(data/value_drift)。时间列上「< 第二天」把 until 当天的
时分秒都算进来;文字存的日期按字符串比,同样成立。"""
last = until if isinstance(until, _date) else _date.fromisoformat(str(until))
if isinstance(last, datetime):
last = last.date()
day = self.day_trunc(column)
return (f"SELECT {day} AS d, COUNT(*) AS n FROM {table} "
f"WHERE {self._window(column, since, text, last + timedelta(days=1))} GROUP BY {day} ORDER BY d")
def window_value_counts_sql(self, table: str, column: str, date_column: str, since: Any, n: int, *,
text: bool = False) -> str:
"""时间窗口里的取值分布:业务日期 since 起(含)的行里,出现最多的 n 个取值和各自的行数。"""
sql = (f"SELECT {column}, COUNT(*) AS cnt FROM {table} "
f"WHERE {self._window(date_column, since, text)} AND {column} IS NOT NULL "
f"GROUP BY {column} ORDER BY cnt DESC, {column}")
return self.limit(sql, n)
def sample_value_counts_sql(self, table: str, column: str, percent: float, n: int) -> str | None:
"""块采样里的取值分布;没有块采样的方言返回 None。"""
source = self.sample_from(table, percent)
if source is None:
return None
sql = (f"SELECT {column}, COUNT(*) AS cnt FROM {source} WHERE {column} IS NOT NULL "
f"GROUP BY {column} ORDER BY cnt DESC, {column}")
return self.limit(sql, n)
# ==========================================================================
# 发查询:守卫、单条时限、每张表的预算、任务级止损、留痕
# ==========================================================================
#: 这张表的时长只剩这么一点时不再发新查询:发出去的时限也只剩零点几秒,白占一次查询次数
_MIN_WINDOW_S = 0.5
#: 连续失败这么多条就停下整个任务:多半是连接断了、账号没有权限,再发只是一条条重复同一个错误
MAX_CONSECUTIVE_ERRORS = 3
#: 最近 SLOW_WINDOW 条查询里 SLOW_STOP 条超时就停下整个任务:库响应慢,再发只是给业务库加压
SLOW_WINDOW = 10
SLOW_STOP = 7
#: 最近 NOTICE_WINDOW 条里 NOTICE_ON 条超时先提醒(任务的 notice),好转后清掉
NOTICE_WINDOW = 6
NOTICE_ON = 4
#: 「运行原因没查成」的跳过原因:计入 unfinished,使这张表成为 partial。其余(遮罩、视图、表太大、取值太多……)是
#: 按规则不查,不算没做完
UNFINISHED_REASONS = frozenset({"budget", "deadline", "failed", "timeout", "error", "rejected", "slow", "cancelled"})
FAILED_STOP = "连续 {n} 条查询失败,已停止剖析:{line}"
SLOW_STOP_TEXT = f"数据库响应慢:最近 {SLOW_WINDOW} 条查询 {SLOW_STOP} 条超时,已停止,避免给业务库加压"
SLOW_NOTICE = "数据库响应慢:最近 {n} 条查询有 {k} 条超时。超时再多会自动停止,也可以先停止、换个时间再剖析"
#: 停止落在一条查询上:asyncio 这边不再等它的结果,库那边由单条时限截止(run_query 的 _server_deadline)
CANCELLED_QUERY = "剖析已停止,没有等这条查询的结果;数据库按单条时限截止它"
class _Stop(Exception):
"""停下:budget 这张表的查询次数用完、deadline 这张表的时长用完(只停这张表);failed 连续失败、slow 超时比例
(整个任务停下)。剩下的检查都不再发查询。"""
def __init__(self, reason: str) -> None:
super().__init__(reason)
self.reason = reason
class _Skip(Exception):
"""这一项没查成或不查:timeout 超时、error 查询失败、rejected 未通过守卫、masked 被遮罩……记下原因,接着查别的。
carry:目录里剖析以前对这一项写的结论要不要原样留着。没查成的(超时、表太大……)留着——这次没查不等于
结论错了;被遮罩的列上取过值的结论(码值、日期范围)不留:遮罩之后,带着原值的结论也不该再给人看。
"""
def __init__(self, reason: str, detail: str, *, carry: bool = True) -> None:
super().__init__(detail)
self.reason = reason
self.detail = detail
self.carry = carry
class QueryFailed(Exception):
"""单条只读查询(run_single_query)没成:reason 是 rejected 未通过守卫 / timeout 超时 / error 查询失败,detail
是给人看的整句(同剖析里这一项的说明)。"""
def __init__(self, reason: str, detail: str) -> None:
super().__init__(detail)
self.reason = reason
self.detail = detail
def _brief(e: BaseException) -> str:
"""驱动报错的第一行,去掉 SQLAlchemy 加的「(模块.类名)」前缀和后面附的 SQL、文档链接。"""
lines = str(getattr(e, "orig", None) or e).strip().splitlines()
first = re.sub(r"^\([\w.]+\)\s*", "", lines[0] if lines else "")
return first[:160] or type(e).__name__
class TableBudget:
"""一张表的预算:最多 table_max_queries 条查询、table_max_s 秒(从这张表开始算)。用完抛 _Stop(budget /
deadline),只停这张表,下一张表另起一份满额的。"""
def __init__(self, settings: ProfileSettings) -> None:
self.max_queries = int(settings.table_max_queries)
self.seconds = float(settings.table_max_s)
self.ends = time.monotonic() + self.seconds
self.used = 0
#: 一条查询的时限被这张表剩下的时长截短后超时了:时长已经用完
self.out_of_time = False
def remaining(self) -> float:
"""要发一条查询之前:次数、时长都还有,返回这张表剩下的秒数;用完抛 _Stop。不记次数(守卫拒掉的不算)。"""
if self.used >= self.max_queries:
raise _Stop("budget")
left = self.ends - time.monotonic()
if self.out_of_time or left <= _MIN_WINDOW_S:
raise _Stop("deadline")
return left
class SourceRunner:
"""剖析发的每一条查询都从这里走(整个任务一个):先按只读过守卫,再交给查询层 run_query;管单条时限、任务级止损,
每条查询交给 record 留痕。
- 只读:不论数据源本身可写与否,先用 guard.check(readonly=True) 判一遍,再进 run_query——那里还会按
数据源自己的设置判一遍。两遍是同一个守卫,不为剖析放宽,也不另开连接。
- 单条时限:取设置和数据源查询时限中较小的一个,并且不超过这张表剩下的时长(budget)。
- 预算:每发一条记一次这张表的次数(超时、失败的也算),守卫拒掉的没发出去,不算。
- 止损:连续 MAX_CONSECUTIVE_ERRORS 条失败 → stopped = failed;最近 SLOW_WINDOW 条里 SLOW_STOP 条超时 →
stopped = slow。停下之后再发直接抛 _Stop,error 是整句原因。最近 NOTICE_WINDOW 条里 NOTICE_ON 条超时时 notice
有一句提醒,好转后清掉。
"""
def __init__(self, source: Any, settings: ProfileSettings, dialect: SqlDialect) -> None:
self.source = source
self.settings = settings
self.kind = dialect.kind
self.timeout = min(float(settings.query_timeout_s), float(data_engine.query_timeout(source)))
#: 整个任务发出的查询数、其中超时的、失败的
self.queries = 0
self.timeouts = 0
self.errors = 0
self.stopped: str | None = None
self.error: str | None = None
self.notice: str | None = None
self._consecutive = 0
self._recent: collections.deque[bool] = collections.deque(maxlen=SLOW_WINDOW)
def _timed(self, timed_out: bool) -> None:
"""记下这条查询超时没有,按比例判提醒和止损。"""
self._recent.append(timed_out)
recent = list(self._recent)
window = recent[-NOTICE_WINDOW:]
hits = sum(window)
self.notice = SLOW_NOTICE.format(n=len(window), k=hits) if hits >= NOTICE_ON else None
if sum(recent) >= SLOW_STOP and self.stopped is None:
self.stopped = "slow"
self.error = SLOW_STOP_TEXT
async def run(self, sql: str, *, max_rows: int = 1, budget: TableBudget | None = None,
record: Callable[[dict[str, Any]], None] | None = None) -> QueryResult:
if self.stopped:
raise _Stop(self.stopped)
left = budget.remaining() if budget is not None else None
at = datetime.now(timezone.utc).isoformat(timespec="milliseconds")
def log(outcome: str, ms: int, rows: int | None, detail: str | None) -> None:
if record is not None:
record({"at": at, "sql": normalize_sql(sql, self.kind).text, "ms": ms, "rows": rows,
"outcome": outcome, "detail": detail})
try:
guard.check(sql, readonly=True, source_name=getattr(self.source, "name", ""), dialect=self.kind)
except SqlRejected as e:
detail = f"剖析查询未通过安全守卫:{e}"
log("rejected", 0, None, detail)
raise _Skip("rejected", detail) from e
if budget is not None:
budget.used += 1
self.queries += 1
cut = left is not None and left < self.timeout
seconds = round(min(self.timeout, left) if left is not None else self.timeout, 3)
limits = QueryLimits(max_rows=max(1, int(max_rows)), timeout_seconds=seconds)
started = time.perf_counter()
def ms() -> int:
return int((time.perf_counter() - started) * 1000)
try:
result = await data_engine.run_query(self.source, sql, limits=limits)
except SqlRejected as e:
# 上面已经按只读过了同一个守卫,查询层再拒只可能是时限到了
self.timeouts += 1
if cut and budget is not None:
budget.out_of_time = True
detail = f"查询超时:超过 {_fmt(seconds)} 秒被中断,已跳过"
log("timeout", ms(), None, detail)
self._timed(True)
raise _Skip("timeout", detail) from e
except SnapshotTampered:
raise
except asyncio.CancelledError:
log("error", ms(), None, CANCELLED_QUERY)
raise
except Exception as e: # noqa: BLE001 - 一条查询失败不该拖垮整张表,原因照实记下
self.errors += 1
self._consecutive += 1
detail = f"查询失败:{_brief(e)}"
log("error", ms(), None, detail)
self._timed(False)
if self._consecutive >= MAX_CONSECUTIVE_ERRORS and self.stopped is None:
self.stopped = "failed"
self.error = FAILED_STOP.format(n=MAX_CONSECUTIVE_ERRORS, line=_brief(e))
raise _Skip("error", detail) from e
self._consecutive = 0
log("ok", ms(), result.row_count, None)
self._timed(False)
return result
async def run_single_query(source: Any, sql: str, *, settings: ProfileSettings, max_rows: int = 1,
kind: str | None = None) -> QueryResult:
"""单条只读查询(表助手的 run_query、起草时核对有效记录条件):同剖析一样过只读守卫、交给 run_query,单条时限取剖析
设置和数据源查询时限中较小的一个。只有一条:不设预算、不止损、不留痕。
没成抛 QueryFailed(reason、detail);快照被改动抛 SnapshotTampered;不支持的数据库类型抛 ValueError。"""
dialect = SqlDialect(kind if kind is not None else getattr(source, "kind", None))
try:
return await SourceRunner(source, settings, dialect).run(sql, max_rows=max_rows)
except _Skip as e:
raise QueryFailed(e.reason, e.detail) from e
# ==========================================================================
# 表有多大
# ==========================================================================
@dataclass(frozen=True)
class TableSize:
"""一张表的行数。rows 为 None 表示不知道(没有统计信息、也没数)或者超过了整表统计的上限。"""
rows: int | None
#: stats 数据库的统计信息(估算值);count 数到上限为止;unknown 没能得到
method: str
#: 数到上限也没数完:至少这么多行
at_least: int | None = None
def within(self, limit: int) -> bool:
"""行数已知且不超过 limit:可以做整表统计。不知道多大的表按大表对待。"""
return self.rows is not None and self.rows <= limit
def to_dict(self) -> dict[str, Any]:
return {"rows": self.rows, "method": self.method, "at_least": self.at_least}
def _first(result: QueryResult) -> Any:
return result.rows[0][0] if result.rows and result.rows[0] else None
def _as_int(value: Any) -> int | None:
if value is None or isinstance(value, bool):
return None
try:
number = float(str(value))
except ValueError:
return None
return int(number) if number == number and abs(number) != float("inf") else None
async def estimate_size(run: Callable[..., Awaitable[QueryResult]], dialect: SqlDialect, meta: dict[str, Any],
name: str, *, max_scan_rows: int) -> tuple[TableSize, list[_Skip]]:
"""先读统计信息;读不到(SQLite、没分析过的表)再数到 max_scan_rows + 1 行为止。
统计信息为 0 或负数(PostgreSQL 没分析过的表是 -1,MySQL 的估算值可能是 0)当作不知道:一张其实很大的表
要是被当成空表,后面就会对它做整表统计。统计信息说的行数不超过 max_scan_rows 时也再数一次(多一条有上限的
计数):统计信息可能是很久以前的(表后来涨到了几千万行,ANALYZE 没再跑过),照它把大表当小表,就会对它做
整表的 COUNT(DISTINCT)、MIN/MAX。数过的结果是准数(count),数到上限还没数完就按大表算(at_least)。
返回 (行数, 途中没查成的几条)。这张表的查询次数、时长用完(或者任务止损)时抛 _Stop。
"""
skips: list[_Skip] = []
sql = dialect.stats_sql(meta, name)
if sql:
try:
rows = _as_int(_first(await run(sql)))
except _Skip as e:
skips.append(e)
else:
if rows is not None and rows > max(max_scan_rows, 0):
return TableSize(rows, "stats"), skips
if rows is not None and rows > 0 and max_scan_rows <= 0:
return TableSize(rows, "stats"), skips
if max_scan_rows <= 0:
return TableSize(None, "unknown"), skips
cap = max_scan_rows + 1
try:
counted = _as_int(_first(await run(dialect.bounded_count_sql(dialect.table(meta, name), cap))))
except _Skip as e:
skips.append(e)
return TableSize(None, "unknown"), skips
if counted is None:
return TableSize(None, "unknown"), skips
if counted <= max_scan_rows:
return TableSize(counted, "count"), skips
return TableSize(None, "count", at_least=cap), skips
# ==========================================================================
# 一张表的剖析:上下文、结果
# ==========================================================================
#: IN 列表每批最多几个值:Oracle 的 IN 列表不能超过 1000 项(ORA-01795),各家统一按它分批
IN_CHUNK = 1000
#: 剖析写的备注都以它开头(「数据剖析:」):人工确认过的关系,只覆盖剖析自己写的备注,不碰人写的(认的时候连
#: 升级前带日期的「数据剖析(日期):」一起认,catalog.is_profile_note)。不写日期,时间看这一项的 updated_at
NOTE_PREFIX = catalog.PROFILE_NOTE_PREFIX
#: 停下的原因 → 剩下没查的项的说明。failed、slow 是任务级止损,停的是整个任务;cancelled 的说法由调用方给(停止、
#: 服务重启各有各的说法,见 TableHooks.stop_text)
_STOP_DETAIL = {
"budget": "这张表的查询次数已用完(上限 {max_queries} 条),未检查",
"deadline": "这张表的时长已用完(上限 {max_s} 秒),未检查",
"failed": "连续多条查询失败,已停止剖析,未检查",
"slow": "数据库响应慢,已停止剖析,未检查",
}
#: 整张表的原因(TableResult.error)
VIEW_ERROR = "视图没有统计信息,剖析可能触发整个视图的计算,已跳过"
EMPTY_ERROR = "空表,没有可剖析的数据"
NOTHING_ERROR = "没有可剖析的项:没有要核对的关联关系、像码值的列和日期列,或者都被遮罩了"
CONFLICT_ERROR = "这张表的数据目录正被其他人修改,剖析结果未能写入,请稍后重新剖析"
WRITE_ERROR = "写入数据目录时出错:{line}"
TABLE_ERROR = "剖析这张表时出错:{line}"
#: 停止、服务重启时正在剖析的那张表:做完的项照常写进了目录,剩下的没查
STOPPED_TABLE = "{why}:已做完的 {done} 项照常写进了数据目录,剩下 {rest} 项没查"
#: 停止的说法(TableHooks.stop_text 没给时)
STOPPED = "剖析已停止"
class ProfileDisabled(Exception):
"""这个数据源没有开启数据剖析,或者数据源停用了。message 给人看;code 是接口回 409 时带的机读码
(api/coded.py:profile_disabled / datasource_inactive)。"""
def __init__(self, message: str, code: str = "profile_disabled") -> None:
super().__init__(message)
self.code = code
@dataclass
class ProfileSkip:
"""没做的一项和原因。
kind:relation 关系、codes 码值、date 日期列、row_estimate 行数估算、table 整张表;取值观察的 rows 行数、codes_observe
码值观察、freshness 最新业务日期、daily_volume 按日行数(data/catalog_profile_observe)。
reason:budget 这张表的查询次数用完、deadline 这张表的时长用完、failed 连续失败止损、slow 超时比例止损、
cancelled 停止或服务重启、timeout 超时、error 查询失败、rejected 未通过守卫(以上算「运行原因没查成」,见
UNFINISHED_REASONS);masked 字段被遮罩、too_large 表太大、view 视图、unsupported 暂不支持、no_data 没有数据、
missing 表结构里没有、high_cardinality 取值太多(不像码值)、nothing 没有可剖析的项(按规则不查)。detail 是给人看的整句。
"""
kind: str
target: str
reason: str
detail: str
path: str | None = None
def to_dict(self) -> dict[str, Any]:
return {"kind": self.kind, "target": self.target, "path": self.path, "reason": self.reason,
"detail": self.detail}
@dataclass
class TableResult:
"""一张表这次剖析的结果。
status:ok 每项都有结论、partial 有项因为运行原因没查成、failed 写入一直冲突或整张表出错、skipped 表结构里没有 /
视图 / 空表 / 没有可剖析的项。checks 是检查项([{kind, target, path, state, reason, detail, queries}],按执行顺序,
含按规则不查的);findings 是写进目录的发现(给界面看,形状见 _relation_finding 等);query_log 是发过的查询
(字面量已换成「?」)。"""
table: str
status: str = "running"
checks: list[dict[str, Any]] = field(default_factory=list)
#: 进行中时正在做哪一项的整句
current: str | None = None
queries: int = 0
elapsed_ms: int = 0
row_estimate: TableSize | None = None
findings: list[dict[str, Any]] = field(default_factory=list)
skipped: list[ProfileSkip] = field(default_factory=list)
#: 日期类列的取值范围(MIN、MAX)。只有一个日期列时它还写进了业务日期的备注;多个时只在这里
date_ranges: list[dict[str, Any]] = field(default_factory=list)
query_log: list[dict[str, Any]] = field(default_factory=list)
added: int = 0
updated: int = 0
removed: int = 0
version: int = 0
#: 整张表的原因:表结构里没有、视图、空表、没有可剖析的项、写入一直冲突、停止 / 中断 / 止损
error: str | None = None
#: 取值观察(data/catalog_profile_observe)观察到的:[{kind, column, basis, basis_key, data}],由剖析任务和结果一起落库
observations: list[dict[str, Any]] = field(default_factory=list)
@property
def skipped_count(self) -> int:
return len(self.skipped)
@property
def unfinished(self) -> int:
"""跳过的项里因为运行原因没查成的(次数、时长、超时、出错、守卫拒、止损、停止)。"""
return sum(1 for s in self.skipped if s.reason in UNFINISHED_REASONS)
def summary(self) -> dict[str, int]:
return summarize(self.findings)
def summarize(findings: list[dict[str, Any]] | None) -> dict[str, int]:
"""发现的合计:有结论的关系条数、其中有确证(verified)的、取到码值候选的列数、这些码值里含义还空着的个数、提议了
业务日期的表数(一张表时是 0 或 1)。"""
found = [f for f in findings or [] if isinstance(f, dict)]
relations = [f for f in found if f.get("kind") == "relation"]
codes = [f for f in found if f.get("kind") == "codes"]
return {"relations_checked": len(relations),
"relations_verified": sum(1 for f in relations if f.get("status") == "verified"),
"codes": len(codes), "codes_pending": sum(int(f.get("pending") or 0) for f in codes),
"business_dates": sum(1 for f in found if f.get("kind") == "business_date")}
@dataclass
class _RelationOutcome:
"""一条关系核对完的结论。写不写、怎么写由 _apply 按写入时目录里那条关系的状态决定。"""
rid: str
target: str
#: 外键关系不抽样(外键已保证引用完整),没有覆盖率
coverage: float | None
cardinality: str | None
holds: bool
sample: int
matched: int
note: str
#: 基数是用数据核实的:子表一侧是否唯一有了定论(主键、唯一约束,或数过 COUNT 和 COUNT(DISTINCT))。
#: 子表太大、没数成的按多对一记,但不算核实——SQL 检查据此不把一对多关联后的重复计算报成错误
cardinality_checked: bool = False
@dataclass
class _CodesOutcome:
"""一列的取值分布:[(码值, 行数)],按行数从多到少。complete:看的是全表(没有只看前若干行),取到的就是
这一列的全部取值,码值项标「已列全」,SQL 检查才拿它判断某个值「不在码值表里」。"""
column: str
values: list[tuple[str, int]]
rows: int
note: str
complete: bool = False
#: 列定义(ENUM)列出的取值:这次没出现的也写进码值(含义待填),码值是「可能的取值」
declared: list[str] = field(default_factory=list)
@dataclass
class _DateOutcome:
"""表里唯一一个日期类列,提议作为业务日期。"""
column: str
low: str
high: str
note: str
@dataclass
class _TableOutcome:
"""一张表所有检查的结论,以及没查成、要原样保留旧结论的槽位。"""
relations: list[_RelationOutcome] = field(default_factory=list)
codes: list[_CodesOutcome] = field(default_factory=list)
business_date: _DateOutcome | None = None
#: 没查成的项:目录里剖析以前写的结论原样留着(不然会被 covered={"profile"} 当成「这次没有」删掉)
keep_relations: set[str] = field(default_factory=set)
keep_codes: set[str] = field(default_factory=set)
keep_business_date: bool = False
@dataclass
class ProfileContext:
"""一个剖析任务共用的:源(上传源是绑定当前快照的视图)、设置、方言、查询器(任务级止损在它身上)、表结构、遮罩的
列(小写,任务每张表开始前按数据源最新的设置换掉)、已经估过的行数(父表的行数核对关系时会用到,不重复估)。
同时剖析的几张表共用这一份(都在同一个事件循环里)。"""
source: Any
settings: ProfileSettings
dialect: SqlDialect
runner: SourceRunner
tables: dict[str, Any]
#: 遮罩的列(小写)。遮罩按列名生效、不分大小写、不分表,和证据面板的比法一致
masked: set[str]
sizes: dict[str, TableSize] = field(default_factory=dict)
#: 正在估哪张表的行数(见 _TableCx.size):同时剖析的几张表要估同一张时只估一次
size_locks: dict[str, asyncio.Lock] = field(default_factory=dict)
#: 对象索引里探查范围外的对象(name_key):关系指向它们时说「在探查范围外」,不说「表结构里没有」
outside: frozenset[str] = frozenset()
def make_context(source: Any, settings: ProfileSettings, *, options: dict[str, Any] | None,
outside: frozenset[str] = frozenset()) -> ProfileContext:
"""剖析任务的上下文。options 是数据源记录的 options(遮罩按它);不支持的数据库类型抛 ValueError。"""
dialect = SqlDialect(getattr(source, "kind", None))
return ProfileContext(source=source, settings=settings, dialect=dialect,
runner=SourceRunner(source, settings, dialect),
tables=(getattr(source, "schema_cache", None) or {}).get("tables") or {},
masked=masked_of(options), outside=outside)
def masked_of(options: dict[str, Any] | None) -> set[str]:
return {c.lower() for c in data_engine.masked_columns(options)}
@dataclass
class TableHooks:
"""profile_table 的回调(后台任务据此落库)。都可以不给。
- progress:一项检查(含开头的估行数)开始、结束时;result 里的 checks、current、queries、query_log 是这时的样子;
- query:每发出(或被守卫拒掉)一条查询之后;
- recorded:整张表结束,和写目录在同一个事务里(session 是那一次的,shield 起来,停止时也不会被打断;不用自己提交,
写完由 _record 和目录一起提交);
- stop_text:停止时的说法(「剖析已停止」「服务重启,剖析中断」),停止之后才调。
"""
progress: Callable[[TableResult], Awaitable[None]] | None = None
query: Callable[[TableResult], Awaitable[None]] | None = None
recorded: Callable[[AsyncSession, TableResult], Awaitable[None]] | None = None
stop_text: Callable[[], str] | None = None
class _TableCx:
"""一张表这次剖析的执行状态:预算、正在做的检查项、发过的查询。检查函数经它发查询、估行数。"""
def __init__(self, job: ProfileContext, table: str, result: TableResult, hooks: TableHooks) -> None:
self.job = job
self.settings = job.settings
self.dialect = job.dialect
self.tables = job.tables
self.table = table
self.result = result
self.hooks = hooks
self.budget = TableBudget(job.settings)
#: 正在做的检查项(result.checks 里的那一个);None 是这张表开头的估行数
self.check: dict[str, Any] | None = None
#: 这张表此刻的目录(开始剖析这张表时读的):取值观察按它找已确认的业务日期
self.table_notes: dict[str, Any] = {}
def is_masked(self, column: str) -> bool:
return column.lower() in self.job.masked
def scan_cap(self, size: TableSize) -> int | None:
"""大表(或不知道多大)抽样、取值分布只看前多少行;小表整表看,返回 None。"""
if size.within(self.settings.max_scan_rows):
return None
return max(self.settings.sample_size, self.settings.max_scan_rows)
async def run(self, sql: str, *, max_rows: int = 1, kind: str | None = None,
target: str | None = None) -> QueryResult:
"""发一条查询:记进这张表的留痕(kind、target 是它属于哪一项)、计数,再回调 hooks.query。"""
check = self.check
label = (kind or (check["kind"] if check else "row_estimate"),
target or (check["target"] if check else self.table))
result = self.result
def record(entry: dict[str, Any]) -> None:
result.query_log.append({"at": entry["at"], "kind": label[0], "target": label[1], "sql": entry["sql"],
"ms": entry["ms"], "rows": entry["rows"], "outcome": entry["outcome"],
"detail": entry["detail"]})
if entry["outcome"] != "rejected":
result.queries += 1
if check is not None:
check["queries"] = int(check.get("queries") or 0) + 1
try:
found = await self.job.runner.run(sql, max_rows=max_rows, budget=self.budget, record=record)
except _Skip:
await self._queried()
raise
await self._queried()
return found
async def _queried(self) -> None:
if self.hooks.query is not None:
await self.hooks.query(self.result)
async def size(self, table: str, result: TableResult) -> TableSize:
"""表有多大,一个任务里每张表只估一次。途中没查成的记进 result(行数估算这一项)。同时剖析的几张表要估同一张
(同一张父表,或者它自己正在剖析)时后来的等先来的估完、用它的结果,查询记在先来的那张表上;先来的没估成(停止、
预算用完)就自己估。"""
if table not in self.job.sizes:
async with self.job.size_locks.setdefault(table, asyncio.Lock()):
if table not in self.job.sizes:
async def run(sql: str, **kw: Any) -> QueryResult:
return await self.run(sql, kind="row_estimate", target=table, **kw)
size, skips = await estimate_size(run, self.dialect, self.tables[table], table,
max_scan_rows=self.settings.max_scan_rows)
self.job.sizes[table] = size
result.skipped += [ProfileSkip("row_estimate", table, s.reason, s.detail) for s in skips]
return self.job.sizes[table]
async def size_with_data(self, table: str, result: TableResult) -> TableSize:
"""同 size,但确知是空表时直接跳过这一项(抛 _Skip),不再为它发查询。"""
size = await self.size(table, result)
if size.rows == 0:
raise _Skip("no_data", f"{table} 是空表,无法剖析")
return size
def _column(meta: dict[str, Any], name: str) -> str | None:
"""表结构里这一列的写法(目录里的列名大小写可能和表结构不同);没有返回 None。"""
lowered = name.lower()
return next((str(c["name"]) for c in meta.get("columns") or []
if isinstance(c, dict) and str(c.get("name", "")).lower() == lowered), None)
def _column_meta(meta: dict[str, Any], name: str) -> dict[str, Any] | None:
"""表结构里这一列(类型、取值列表、quote_name);name 是表结构里的写法(_column 的返回值)。"""
return next((c for c in meta.get("columns") or [] if isinstance(c, dict) and c.get("name") == name), None)
def _constraint(meta: dict[str, Any], column: str) -> str | None:
"""这一列单独是主键或某个唯一约束时返回「主键」/「唯一约束」,否则 None。"""
wanted = [column.lower()]
if [c.lower() for c in meta.get("primary_key") or []] == wanted:
return "主键"
for group in meta.get("unique") or []:
if isinstance(group, list) and [str(c).lower() for c in group] == wanted:
return "唯一约束"
return None
def _percent(ratio: float) -> str:
"""覆盖率写成百分数:整数不带小数,其余留一位;不到 100% 的不四舍五入成 100%。"""
text = f"{ratio * 100:.1f}".rstrip("0").rstrip(".")
return "99.9%" if text == "100" and ratio < 1 else f"{text}%"
def _relation_target(rel: dict[str, Any]) -> str:
left = ", ".join(rel.get("columns") or [])
right = ", ".join(f"{rel.get('to_table')}.{c}" for c in rel.get("to_columns") or [])
return f"{left} → {right}"
# ==========================================================================
# 检查:关联关系
# ==========================================================================
def _relation_candidates(notes: dict[str, Any] | None) -> list[dict[str, Any]]:
"""要核对的关系:没被驳回的。人工确认过的也核对,只补覆盖率和基数。
外键约束来的也核对,但只核对基数:外键保证了引用完整(不必抽样算覆盖率),却只保证被指向的键唯一,
子表一侧每个键是一行还是多行要看数据。以前跳过外键关系,外键来的一对多永远拿不到「用数据核实过」,
扇出检查只能报 warning——最常见的「订单关联明细后对订单金额求和」在受管发布时也挡不住。"""
return [r for r in (notes or {}).get("relations") or []
if isinstance(r, dict) and r.get("id") and r.get("status") != "rejected"]
async def _unique(cx: _TableCx, table: str, meta: dict[str, Any], column: str,
result: TableResult) -> tuple[bool | None, str]:
"""这一列(非空部分)唯一吗:(True / False / None 不知道, 依据)。有主键或唯一约束直接认;否则只对行数不超过
整表统计上限的表数一次 COUNT 和 COUNT(DISTINCT),大表不扫。"""
how = _constraint(meta, column)
if how:
return True, how
size = await cx.size(table, result)
if not size.within(cx.settings.max_scan_rows):
return None, "too_large"
d = cx.dialect
res = await cx.run(d.unique_check_sql(d.table(meta, table), d.column(meta, column)))
row = res.rows[0] if res.rows else [None, None]
total, distinct = _as_int(row[0]), _as_int(row[1])
if total is None or distinct is None:
return None, "count"
return total == distinct, "count"
def _fk_note(child: tuple[bool | None, str]) -> str:
"""外键关系的剖析备注:只说子表一侧唯不唯一、依据是什么。不写日期(同 _relation_note)。"""
unique, how = child
if unique is None:
why = "子表太大,没有整表统计" if how == "too_large" else "没能数清子表的取值"
return f"{catalog.PROFILE_NOTE_PREFIX}外键约束已保证引用完整,未抽样核对覆盖率;{why},基数未用数据核实"
basis = "按主键或唯一约束" if how not in ("count", "too_large") else "整表数过取值"
shape = "每个键只有一行(一对一)" if unique else "同一个键有多行(一对多)"
return f"{catalog.PROFILE_NOTE_PREFIX}外键约束已保证引用完整,未抽样核对覆盖率;子表一侧{basis}:{shape}"
#: MySQL 上比较文字按列的排序规则:常见的排序规则不分大小写(_ci),PAD SPACE 的还忽略尾随空格。覆盖率、唯一性、
#: 码值的分组都按它算,写进说明,人才知道「A01」和「a01」在这里算同一个值
MYSQL_COLLATION_NOTE = "MySQL 按列的排序规则比较文字(常见的排序规则不分大小写),覆盖率和唯一性按它计算"
MYSQL_CODES_COLLATION_NOTE = "MySQL 按列的排序规则分组(常见的排序规则不分大小写),大小写不同的写法可能并成一个取值"
def _collation_note(dialect: SqlDialect, family: str | None, note: str) -> str | None:
"""MySQL、MariaDB 上文字列(或类型认不出的列)的排序规则说明;别的方言、数字列没有。"""
return note if dialect.kind in ("mysql", "mariadb") and family in ("text", None) else None
def _relation_note(*, sample: int, matched: int, coverage: float, scan_cap: int | None,
skipped_values: int, parent: tuple[bool | None, str], child: tuple[bool | None, str] | None,
holds: bool, unmatchable: int = 0, collation: str | None = None) -> str:
"""关系的剖析备注:取了多少个键值、看了多少行、覆盖率、父键和子键唯一的依据。人据此判断这条结论有多可靠。
不写日期:什么时候剖析的看这一项的 updated_at(界面按本地时间显示)。
不说「抽样」:DISTINCT … LIMIT 取的是数据库先给出的那几个值,大表上是按存储顺序的前若干行里的值,不是随机抽的
(多半是最老的数据)。unmatchable:被指向列是数字、子表取到的却不是整数写法的键值个数(key_literals)——已经算在
sample 里、算作没对上,这里说明为什么没去父表里对。"""
parts: list[str] = []
low = coverage < catalog.PROFILE_VERIFY_COVERAGE
if low:
parts.append(f"覆盖率 {_percent(coverage)},可能不是这条关系")
scope = f"(按存储顺序只看前 {scan_cap} 行)" if scan_cap else ""
sampled = f"子表取了 {sample} 个不同键值{scope},父表对上 {matched} 个"
parts.append(sampled if low else f"{sampled},覆盖率 {_percent(coverage)}")
if skipped_values:
parts.append(f"另有 {skipped_values} 个键值的类型不支持核对,未计入")
if unmatchable:
parts.append(f"其中 {unmatchable} 个不是整数写法,被指向列是数字,算作没对上")
if collation:
parts.append(collation)
unique, how = parent
if unique is True:
parts.append(f"被指向列是{how}" if how in ("主键", "唯一约束") else "被指向列经计数核对唯一")
elif unique is False:
parts.append("被指向列有重复值,不能作为关联的被指向键")
elif how == "too_large":
parts.append("被指向的表超过整表统计的行数上限,未核对被指向列是否唯一")
else:
parts.append("未能核对被指向列是否唯一")
if child is not None:
child_unique, child_how = child
if child_unique is True:
basis = child_how if child_how in ("主键", "唯一约束") else "计数核对"
parts.append(f"子表一侧也唯一({basis}),一对一")
elif child_unique is False:
parts.append("子表一侧有重复值,多对一")
elif child_how == "too_large":
parts.append("子表超过整表统计的行数上限,未核对子表一侧是否唯一,按多对一记")
else:
parts.append("未能核对子表一侧是否唯一,按多对一记")
if not holds and not low:
parts.append("暂不升为有确证")
return NOTE_PREFIX + ";".join(parts) + "。"
async def _check_relation(cx: _TableCx, table: str, rel: dict[str, Any], out: _TableOutcome,
result: TableResult) -> None:
"""从子表取至多 sample_size 个不同的非空键值,去父表里数对上几个,得到覆盖率;再看父键、子键唯不唯一。"""
# 两端在表结构里的写法;按规则不查的(多列、不在表结构里、指向视图、碰到遮罩列)plan_checks 已经列成跳过,这里再判
# 一遍只是兜底
parent, col, pcol = _relation_ends(table, rel, cx.tables, cx.job.masked, cx.job.outside)
meta, pmeta = cx.tables[table], cx.tables[parent]
if rel.get("source") == "fk":
await cx.size_with_data(table, result)
child = await _unique(cx, table, meta, col, result)
cardinality = None if child[0] is None else ("one_to_one" if child[0] else "many_to_one")
out.relations.append(_RelationOutcome(rid=str(rel["id"]), target=_relation_target(rel), coverage=None,
cardinality=cardinality, holds=cardinality is not None, sample=0,
matched=0, note=_fk_note(child), cardinality_checked=child[0] is not None))
return
d, s = cx.dialect, cx.settings
child_col, parent_col = _column_meta(meta, col), _column_meta(pmeta, pcol)
parent_family = catalog.column_family(parent_col)
if parent_family == "time":
# 时间键不核对:各家时间字面量写法、精度不同,拼出来的 IN 列表对不对得上说不准。不发子表的取值查询
raise _Skip("unsupported", f"{parent}.{pcol} 是日期时间,各库的时间写法和精度不同,不核对这种关系的覆盖率")
size = await cx.size_with_data(table, result)
scan_cap = cx.scan_cap(size)
child_table, parent_table = d.table(meta, table), d.table(pmeta, parent)
res = await cx.run(d.distinct_sample_sql(child_table, d.column(meta, col), s.sample_size, scan_cap=scan_cap),
max_rows=s.sample_size)
kind = (res.column_types or {}).get(res.columns[0]) if res.columns else None
values = [row[0] for row in res.rows if row]
literals, unmatchable, unsafe = key_literals(values, kind, parent=parent_family,
child_type=(child_col or {}).get("type"))
if not values:
raise _Skip("no_data", f"{table}.{col} 没有非空值,无法核对")
if not literals and not unmatchable:
raise _Skip("unsupported", f"{table}.{col} 的取值类型不支持核对(只核对整数和文字)")
# 分母是子表取到的全部键值:不是整数写法、按类型就对不上整数父键的(unmatchable)也在里面,算作没对上
sample = len(literals) + unmatchable
matched = 0
for start in range(0, len(literals), IN_CHUNK):
sql = d.match_count_sql(parent_table, d.column(pmeta, pcol), literals[start:start + IN_CHUNK])
matched += _as_int(_first(await cx.run(sql))) or 0
matched = min(matched, len(literals))
coverage = round(matched / sample, 4)
parent_unique = await _unique(cx, parent, pmeta, pcol, result)
child_unique = await _unique(cx, table, meta, col, result) if parent_unique[0] else None
cardinality = None
if child_unique is not None:
cardinality = "one_to_one" if child_unique[0] is True else "many_to_one"
holds = coverage >= catalog.PROFILE_VERIFY_COVERAGE and cardinality is not None
note = _relation_note(sample=sample, matched=matched, coverage=coverage, scan_cap=scan_cap,
skipped_values=unsafe, parent=parent_unique, child=child_unique, holds=holds,
unmatchable=unmatchable,
collation=_collation_note(d, parent_family, MYSQL_COLLATION_NOTE))
checked = child_unique is not None and child_unique[0] is not None
out.relations.append(_RelationOutcome(rid=str(rel["id"]), target=_relation_target(rel), coverage=coverage,
cardinality=cardinality, holds=holds, sample=sample,
matched=matched, note=note, cardinality_checked=checked))
# ==========================================================================
# 检查:码值候选、日期列
# ==========================================================================
#: 码值候选最多几个取值。查询取 CODES_MAX + 1 个:取满了说明不是低基数列
CODES_MAX = 20
#: 码值的长度上限。更长的不像状态码,像说明文字
MAX_CODE_LEN = 64
#: 像状态、类型、渠道的列名里会有的词
_CODE_WORDS = frozenset({"status", "state", "type", "kind", "category", "channel", "level", "method", "mode",
"reason", "stage", "phase", "grade", "flag", "source", "result"})
#: 以这些词结尾的列不是码值:标识、名称、说明、时间、金额、数量(channel_id、level_name、status_time……)
_NOT_CODE_TAILS = frozenset({"id", "no", "num", "number", "name", "desc", "description", "text", "remark", "note",
"at", "date", "time", "on", "amount", "price", "count", "qty", "rate", "url"})
#: 像日期的取值:2026-07-01、2026/07/01,后面可以跟时间
_DATE_TEXT = re.compile(r"^\d{4}[-/]\d{2}[-/]\d{2}")
#: 日期列最小值、最大值不计入的值(见 VALID_DATE_FROM)在说明里的说法
INVALID_DATES = "零日期 0000-00-00、0001-01-01、9999-12-31 这类占位日期或无穷大"
def _is_enum(type_name: str | None, enum_values: Any = None) -> bool:
"""取值列表有限的列:MySQL 的 ENUM(…) / SET(…),或者探查记了取值列表(PostgreSQL 的枚举:类型写法只是类型名,
setup_status 这种以 set 开头的名字不算 SET,见 catalog._MYSQL_ENUM)。"""
return bool(enum_values) or catalog._mysql_enum_kind(type_name) is not None
def _code_type_ok(type_name: str | None, enum_values: Any = None) -> bool:
"""ENUM / SET(取值列表有限,直接算码值候选)、整数,或者短文字(定长不超过 MAX_CODE_LEN;SQLite 的 TEXT 不带
长度,也算)。类型是按方言编译的完整写法:VARCHAR2(20 CHAR) 的长度按 20 算。"""
if _is_enum(type_name, enum_values):
return True
t = catalog._type_words(type_name)
if not t or any(k in t for k in ("CLOB", "BLOB", "BINARY", "JSON", "DATE", "TIME", "INTERVAL", "REAL", "FLOAT",
"DOUBLE", "BOOL")):
return False
if "INT" in t:
return True
size = re.search(r"\(\s*(\d+)(?:\s+(?:CHAR|BYTE))?\s*(?:,\s*(\d+)\s*)?\)", t)
if any(k in t for k in ("NUMBER", "NUMERIC", "DECIMAL")):
return size is None or not size.group(2) or int(size.group(2)) == 0
if any(k in t for k in ("CHAR", "TEXT", "STRING")):
return size is None or int(size.group(1)) <= MAX_CODE_LEN
return False
def _looks_like_code(name: str) -> bool:
words = catalog.name_words(name)
return bool(words) and words[-1] not in _NOT_CODE_TAILS and any(w in _CODE_WORDS for w in words)
def _code_columns(meta: dict[str, Any], notes: dict[str, Any]) -> list[str]:
"""要取值分布的列:目录里度量类型记为状态的、剖析以前提过码值候选的(再核一次)、ENUM / SET 列(取值列表有限,
不看名字)、或者类型和命名像状态、类型、渠道的整数或短文字列。主键、唯一约束列、关系里的键列不算;码值人工确认
或驳回过的不再取。被遮罩的列也列出来,由检查记为「被遮罩」跳过——要让人看得见为什么它没有码值。"""
pk = [c.lower() for c in meta.get("primary_key") or []]
unique = {str(g[0]).lower() for g in meta.get("unique") or [] if isinstance(g, list) and len(g) == 1}
keys = {str(c).lower() for r in notes.get("relations") or [] if isinstance(r, dict) for c in r.get("columns") or []}
described = notes.get("columns") if isinstance(notes.get("columns"), dict) else {}
out: list[str] = []
for col in meta.get("columns") or []:
name = str(col.get("name") or "")
items = described.get(name) if isinstance(described.get(name), dict) else {}
codes, measure = items.get("codes"), items.get("measure")
if isinstance(codes, dict) and codes.get("status") in ("confirmed", "rejected"):
continue
status_measure = (isinstance(measure, dict) and measure.get("value") == "status"
and measure.get("status") != "rejected")
profiled = isinstance(codes, dict) and codes.get("source") == "profile"
enum = _is_enum(col.get("type"), col.get("enum_values"))
if not (status_measure or profiled or enum or _looks_like_code(name)):
continue
lowered = name.lower()
if (pk == [lowered]) or lowered in unique or lowered in keys or not _code_type_ok(col.get("type"),
col.get("enum_values")):
continue
out.append(name)
return out
def _code_key(value: Any, *, fixed: bool = False) -> str | None:
"""取值分布里的一个取值 → 码值(文字)。只认整数和不太长的文字。fixed:定长文字列(CHAR(n)),去掉补齐的尾随
空格(PG、Oracle 取回的是 'A ',WHERE status = 'A' 照样对得上,码值写 'A')。"""
if value is None or isinstance(value, bool):
return None
if isinstance(value, int):
return str(value)
if isinstance(value, float):
return str(int(value)) if value.is_integer() else None
if isinstance(value, str) and fixed:
value = value.rstrip(" ")
if isinstance(value, str) and value.strip() and len(value) <= MAX_CODE_LEN and not _CONTROL.search(value):
return value
return None
def _share(count: int, total: int) -> str:
if total <= 0:
return "0%"
ratio = count / total
return "不足 1%" if 0 < ratio < 0.01 else f"{round(ratio * 100)}%"
async def _check_codes(cx: _TableCx, table: str, column: str, out: _TableOutcome, result: TableResult) -> None:
"""GROUP BY 取出现最多的 CODES_MAX + 1 个取值:不超过 CODES_MAX 个就是码值候选(值有了,含义等人填)。"""
if cx.is_masked(column):
raise _Skip("masked", f"{table}.{column} 在数据源设置中被遮罩,不取值", carry=False)
d, meta = cx.dialect, cx.tables[table]
col_meta = _column_meta(meta, column) or {}
size = await cx.size_with_data(table, result)
scan_cap = cx.scan_cap(size)
res = await cx.run(d.value_counts_sql(d.table(meta, table), d.column(meta, column), CODES_MAX + 1,
scan_cap=scan_cap),
max_rows=CODES_MAX + 1)
if not res.rows:
raise _Skip("no_data", f"{table}.{column} 没有非空值,无法取值分布")
if len(res.rows) > CODES_MAX:
raise _Skip("high_cardinality", f"{table}.{column} 的取值超过 {CODES_MAX} 个,不作为码值候选", carry=False)
fixed = is_fixed_char(col_meta.get("type"))
enum = _is_enum(col_meta.get("type"), col_meta.get("enum_values"))
counts: dict[str, int] = {}
for row in res.rows:
key = _code_key(row[0], fixed=fixed)
if key is None:
raise _Skip("unsupported", f"{table}.{column} 的取值不是整数或短文字,不作为码值候选", carry=False)
counts[key] = counts.get(key, 0) + (_as_int(row[1]) or 0)
values = list(counts.items())
if len(values) >= 3 and all(n == 1 for _, n in values) and not enum:
# 每个取值只出现一次(票种编码 T01…T09):这是一列标识,不是状态码。ENUM 的取值列表是列定义给的,不按这条判
raise _Skip("high_cardinality", f"{table}.{column} 的取值各不相同,像标识而不是码值,不作为码值候选",
carry=False)
rows = sum(n for _, n in values)
# 不说「抽样」:大表看的是按存储顺序的前若干行(多半是最老的数据),不是随机抽的
scope = f"按存储顺序取前 {scan_cap} 行统计" if scan_cap else "统计全表"
spread = "、".join(f"{k}({_share(n, rows)})" for k, n in values)
extra: list[str] = []
# ENUM 的取值列表(SET 的是成员,取值是成员的组合,不算):这次没出现的也是可能的取值
is_set = catalog._mysql_enum_kind(col_meta.get("type")) == "SET"
declared = [] if is_set else [str(v) for v in col_meta.get("enum_values") or []]
unseen = [v for v in declared if v not in counts]
if unseen:
extra.append(f"列定义里另有 {len(unseen)} 个取值这次没有出现:{'、'.join(unseen[:CODES_MAX])}")
family = "text" if enum else catalog.column_family(col_meta)
collation = _collation_note(d, family, MYSQL_CODES_COLLATION_NOTE)
if collation:
extra.append(collation)
tail = "".join(f";{x}" for x in extra)
note = f"{NOTE_PREFIX}{scope},{rows} 行非空值共 {len(values)} 个取值:{spread}{tail}。"
out.codes.append(_CodesOutcome(column=column, values=values, rows=rows, note=note, complete=scan_cap is None,
declared=unseen))
def _is_date_column(col: dict[str, Any]) -> bool:
"""日期类列(catalog.is_date_column:和 SQL 检查认时间列是同一个判断)。"""
return catalog.is_date_column(str(col.get("name") or ""), col.get("type"), enum_values=col.get("enum_values"))
async def _check_dates(cx: _TableCx, table: str, columns: list[str], total: int, out: _TableOutcome,
result: TableResult) -> None:
"""日期类列的 MIN、MAX,作为业务日期的参考。整表统计,只对行数不超过上限的表做。表里只有一个日期类列
(total 是表结构里日期类列的个数,含被遮罩的)时提议它作为业务日期。"""
d, meta = cx.dialect, cx.tables[table]
size = await cx.size_with_data(table, result)
if not size.within(cx.settings.max_scan_rows):
raise _Skip("too_large", f"{table} 的行数超过整表统计的行数上限({cx.settings.max_scan_rows} 行),"
"不取日期列的最小值和最大值")
# 文字存的日期(SQLite 的 TEXT、业务库里的 VARCHAR 日期)按字符串和有效区间比;日期类型的按日期字面量比
texts = [catalog.column_family(_column_meta(meta, c)) != "time" for c in columns]
res = await cx.run(d.min_max_sql(d.table(meta, table), [(d.column(meta, c), t) for c, t in zip(columns, texts)]))
row = res.rows[0] if res.rows else []
invalid: dict[str, int] = {}
for i, column in enumerate(columns):
low, high, bad = (row[3 * i], row[3 * i + 1], row[3 * i + 2]) if len(row) >= 3 * i + 3 else (None, None, 0)
invalid[column] = _as_int(bad) or 0
if low is None or high is None or not (_DATE_TEXT.match(str(low)) and _DATE_TEXT.match(str(high))):
continue
found: dict[str, Any] = {"column": column, "min": str(low), "max": str(high)}
if invalid[column]:
found["invalid"] = invalid[column]
result.date_ranges.append(found)
if total != 1:
return
if not result.date_ranges:
if invalid.get(columns[0]):
raise _Skip("no_data", f"{table}.{columns[0]} 只有 {invalid[columns[0]]} 个无效日期({INVALID_DATES}),"
"没有可识别的日期值")
raise _Skip("no_data", f"{table}.{columns[0]} 没有可识别的日期值")
found = result.date_ranges[0]
dropped = (f"另有 {found['invalid']} 个无效日期({INVALID_DATES})未计入," if found.get("invalid") else "")
note = (f"{NOTE_PREFIX}表里只有这一个日期类列,取值从 {str(found['min'])[:19]} 到 "
f"{str(found['max'])[:19]},{dropped}可作为业务日期的参考。")
out.business_date = _DateOutcome(column=found["column"], low=found["min"], high=found["max"], note=note)
# ==========================================================================
# 结论进目录
# ==========================================================================
def _relation_finding(o: _RelationOutcome, rel: dict[str, Any], *, status: str, confirmed: bool) -> dict[str, Any]:
"""一条关系的核实结论。previous_status 是剖析之前目录里这条关系的状态:报告按它说「升为已验证」还是「仍为已验证」
(重新剖析、目录没变时不能说升了);cardinality_checked:基数是不是用数据核实的(子表一侧数过是否唯一),
报告据此写「已用数据核实」,不从说明的原话里认。"""
previous = rel.get("status")
if rel.get("source") == "fk":
summary = ("外键约束," + ("子表每个键只有一行(一对一)" if o.cardinality == "one_to_one"
else "子表同一个键有多行(一对多)") + ",基数已用数据核实") \
if o.cardinality_checked else "外键约束,子表太大或没能数清,基数未用数据核实"
elif confirmed:
summary = "已人工确认,只补充覆盖率和基数"
elif status == "verified":
summary = f"覆盖率 {_percent(o.coverage)}," + ("仍为有确证" if previous == "verified" else "升为有确证")
elif o.coverage < catalog.PROFILE_VERIFY_COVERAGE:
summary = f"覆盖率 {_percent(o.coverage)},保持推断"
else:
summary = "被指向列未核实唯一,保持推断"
return {"kind": "relation", "path": f"relations.{o.rid}", "target": o.target,
"columns": list(rel.get("columns") or []), "to_table": rel.get("to_table"),
"to_columns": list(rel.get("to_columns") or []), "status": status, "confirmed": confirmed,
"previous_status": previous, "previous_coverage": rel.get("coverage"), "coverage": o.coverage,
"cardinality": o.cardinality,
"cardinality_checked": o.cardinality_checked, "sample": o.sample, "matched": o.matched, "summary": summary}
def _codes_value(c: _CodesOutcome, item: dict[str, Any] | None) -> dict[str, str]:
"""码值候选的值:观察到的取值,含义先空着。原来有别的来源(模型起草、数据库注释)写过含义的,对得上的取值
沿用它的含义;它写了、数据里没出现的取值也留着——码值是「可能的取值」,没出现不等于不存在。"""
old = item.get("value") if isinstance(item, dict) and isinstance(item.get("value"), dict) else {}
value = {key: str(old.get(key) or "") for key, _ in c.values}
for key in c.declared:
value.setdefault(key, str(old.get(key) or ""))
for key, meaning in old.items():
if key not in value and isinstance(meaning, str) and meaning.strip():
value[key] = meaning
return value
def _codes_finding(c: _CodesOutcome, value: dict[str, str]) -> dict[str, Any]:
"""码值候选的结论。pending:写进目录的码值里还有几个含义空着(别的来源写过的含义会沿用),报告按它说
「N 个含义待填写」,不从 summary 的原话里认。"""
pending = sum(1 for v in value.values() if not v.strip())
summary = f"{len(c.values)} 个取值" + (f",{pending} 个含义待填写" if pending else "")
return {"kind": "codes", "path": f"columns.{c.column}.codes", "column": c.column,
"values": [{"value": k, "rows": n} for k, n in c.values], "rows": c.rows, "status": "proposed",
"pending": pending, "summary": summary}
def _patch_confirmed(rel: dict[str, Any], o: _RelationOutcome, at: str) -> bool:
"""人工确认过的关系只补覆盖率和基数:覆盖率是测量值,照新的写;基数只在原来没有时补,不改人定的;
备注只在原来为空、或者是剖析自己写的时候换成新的(只是日期变了不换)。状态、来源、确认时的依据(reason,保存提案时
另存,不在备注里)不动。返回改没改。"""
changed = False
if rel.get("coverage") != o.coverage:
rel["coverage"] = o.coverage
changed = True
if rel.get("cardinality") is None and o.cardinality is not None:
rel["cardinality"] = o.cardinality
if o.cardinality_checked:
rel["cardinality_checked"] = True
changed = True
note = rel.get("note")
if (not note or catalog.is_profile_note(note)) and not catalog.same_profile_note(note, o.note):
rel["note"] = o.note
changed = True
if changed:
rel["updated_at"] = at
return changed
def _patch_fk(rel: dict[str, Any], o: _RelationOutcome, at: str) -> bool:
"""外键关系只补基数和「用数据核实过」:外键结构推的多对一可能其实是一对一,数据说了算。来源、状态、
覆盖率都不动;备注同 _patch_confirmed(只是旧备注的日期不同不换)。人工确认过的(值不变、保存即确认的外键关系,
来源仍是 fk)同 _patch_confirmed:不改人定的基数,数据核实出的不一样时也不标「用数据核实过」,测量的说法照常写进
备注。返回改没改。"""
changed = False
overruled = rel.get("status") == "confirmed" and rel.get("cardinality") not in (None, o.cardinality)
if o.cardinality_checked and not overruled:
if rel.get("cardinality") != o.cardinality:
rel["cardinality"] = o.cardinality
changed = True
if rel.get("cardinality_checked") is not True:
rel["cardinality_checked"] = True
changed = True
note = rel.get("note")
if (not note or catalog.is_profile_note(note)) and not catalog.same_profile_note(note, o.note):
rel["note"] = o.note
changed = True
if changed:
rel["updated_at"] = at
return changed
def _apply(existing: dict[str, Any] | None, out: _TableOutcome, *,
at: str) -> tuple[dict[str, Any], list[dict[str, Any]], catalog.MergeStats]:
"""把一张表的结论并进写入时的目录:(新目录, 写进去的发现, 计数)。纯函数,写入冲突重来时再调一次。
- 没被确认、驳回的关系:记为 source=profile,覆盖率够、父键唯一的 verified,否则 proposed;按 merge_notes
的规则并入(剖析顶得掉命名推断、模型起草,顶不掉外键和人工)。
- 人工确认过的关系:只补覆盖率和基数(_patch_confirmed)。
- 外键约束来的关系:只补基数和「用数据核实过」(_patch_fk),来源、状态、覆盖率都不动。
- 码值候选:profile / proposed,含义沿用别的来源写过的(_codes_value);整表统计得到的标「已列全」,只看了
前若干行的不标;人工确认、驳回过的码值不碰。
- 业务日期:只有一个日期类列时提议,profile / proposed;已有别的来源定的业务日期不顶掉。
- covered={"profile"}:这次完整检查过、却没再得出的剖析结论删掉(比如取值变多、不再像码值的列,列被遮罩
之后它的码值);没查成的检查(超时、预算用完……)把剖析以前写的结论原样带上,不因为这次没查成就删。
"""
existing = existing or {}
current = {str(r["id"]): r for r in existing.get("relations") or [] if isinstance(r, dict) and r.get("id")}
relations: list[dict[str, Any]] = []
confirmed: dict[str, _RelationOutcome] = {}
foreign: dict[str, _RelationOutcome] = {}
findings: list[dict[str, Any]] = []
for o in out.relations:
rel = current.get(o.rid)
if rel is None or rel.get("status") == "rejected":
continue # 核对期间被删、被驳回了:不写
if rel.get("source") == "fk":
foreign[o.rid] = o
findings.append(_relation_finding(o, rel, status=str(rel.get("status")), confirmed=False))
continue
if rel.get("status") == "confirmed":
confirmed[o.rid] = o
findings.append(_relation_finding(o, rel, status="confirmed", confirmed=True))
continue
status = "verified" if o.holds else "proposed"
relations.append({"id": o.rid, "columns": list(rel["columns"]), "to_table": rel["to_table"],
"to_columns": list(rel["to_columns"]), "cardinality": o.cardinality,
"coverage": o.coverage, "source": "profile", "status": status, "note": o.note,
# 只在基数用数据核实过时有这个键:没核实的关系和以前一字不差
**({"cardinality_checked": True} if o.cardinality_checked else {})})
findings.append(_relation_finding(o, rel, status=status, confirmed=False))
for rid in out.keep_relations:
rel = current.get(rid)
if rel is not None and rel.get("source") == "profile":
relations.append(copy.deepcopy(rel))
draft: dict[str, Any] = {"relations": relations} if relations else {}
described = existing.get("columns") if isinstance(existing.get("columns"), dict) else {}
columns: dict[str, dict[str, Any]] = {}
for c in out.codes:
item = (described.get(c.column) or {}).get("codes") if isinstance(described.get(c.column), dict) else None
if isinstance(item, dict) and item.get("status") in ("confirmed", "rejected"):
continue
columns[c.column] = {"codes": catalog.make_item(_codes_value(c, item), "profile", "proposed", note=c.note,
complete=c.complete)}
findings.append(_codes_finding(c, columns[c.column]["codes"]["value"]))
for column in out.keep_codes:
item = (described.get(column) or {}).get("codes") if isinstance(described.get(column), dict) else None
if isinstance(item, dict) and item.get("source") == "profile":
columns[column] = {"codes": copy.deepcopy(item)}
if columns:
draft["columns"] = columns
current_date = existing.get("business_date") if isinstance(existing.get("business_date"), dict) else None
ours = current_date is None or (current_date.get("source") == "profile"
and current_date.get("status") not in ("confirmed", "rejected"))
if out.business_date is not None and ours:
# 已有别的来源(模型起草、人工)定的业务日期不顶掉:按来源的可信程度剖析排得更前,但它只知道
# 「表里只有这一个日期列」,不比写了规则和时区的那条更懂业务
b = out.business_date
draft["business_date"] = catalog.make_item({"column": b.column}, "profile", "proposed", note=b.note)
findings.append({"kind": "business_date", "path": "business_date", "column": b.column, "min": b.low,
"max": b.high, "status": "proposed", "summary": f"提议按 {b.column} 作为业务日期"})
elif out.keep_business_date and current_date is not None and current_date.get("source") == "profile":
draft["business_date"] = copy.deepcopy(current_date)
merged, stats = catalog.merge_notes(existing, draft, covered={"profile"}, at=at)
for rel in merged.get("relations") or []:
rid = str(rel.get("id"))
o = foreign.get(rid)
if o is not None and rel.get("source") == "fk":
if _patch_fk(rel, o, at):
stats += catalog.MergeStats(updated=1)
continue
o = confirmed.get(rid)
if o is not None and rel.get("status") == "confirmed" and _patch_confirmed(rel, o, at):
stats += catalog.MergeStats(updated=1)
return merged, findings, stats
# ==========================================================================
# 一张表要做哪些检查(纯函数)
# ==========================================================================
@dataclass(frozen=True)
class PlannedCheck:
"""一项检查。skip 给了的是按规则不查的(遮罩、多列关系、表结构里没有、被指向的是视图):(原因, 说明, 要不要原样
留着旧结论),不发查询;relation / column / columns 是执行时要的参数。"""
kind: str
target: str
path: str | None
skip: tuple[str, str, bool] | None = None
relation: dict[str, Any] | None = None
column: str | None = None
columns: tuple[str, ...] = ()
#: 日期检查:表结构里日期类列的个数(含被遮罩的)。只有一个时才提议业务日期
date_total: int = 0
def as_check(self) -> dict[str, Any]:
"""检查项(TableResult.checks 的一项):还没开始,或者按规则跳过。"""
state, reason, detail = ("skipped", self.skip[0], self.skip[1]) if self.skip else ("pending", None, None)
return {"kind": self.kind, "target": self.target, "path": self.path, "state": state, "reason": reason,
"detail": detail, "queries": 0}
#: 被指向的表在探查范围外(对象索引里有):不是「表结构里没有」,重新探查也没用,要把它加进探查范围
OUT_OF_SCOPE_PARENT = "被指向的表 {table} 在探查范围外,可以在数据源的「探查范围」里加入它(或在数据目录页搜索它、加入范围)"
def _relation_ends(table: str, rel: dict[str, Any], tables: dict[str, Any], masked: set[str],
outside: frozenset[str] | set[str] = frozenset()) -> tuple[str, str, str]:
"""一条关系两端在表结构里的写法:(被指向的表, 子表的列, 被指向的列)。按规则不查的抛 _Skip:多列组成的、表结构里
没有的表或列、被指向的是视图、两端有被遮罩的列(遮罩的列不出现在任何一条剖析查询里)。outside 是对象索引里探查范围外
的对象(name_key):被指向的表在里面时说「在探查范围外,可以加入范围」,不说「表结构里没有、请重新探查」。"""
from app.data.names import name_key
cols, to_cols = list(rel.get("columns") or []), list(rel.get("to_columns") or [])
to_table = str(rel.get("to_table") or "")
if len(cols) != 1 or len(to_cols) != 1:
raise _Skip("unsupported", "多列组成的关联关系暂不剖析")
meta = tables[table]
parent = to_table if to_table in tables else catalog.resolve_table_name({"tables": tables}, to_table)
if parent is None and outside and name_key(to_table.rsplit(".", 1)[-1]) in outside:
raise _Skip("out_of_scope", OUT_OF_SCOPE_PARENT.format(table=to_table))
if parent is None:
raise _Skip("missing", f"表结构里没有被指向的表 {to_table},请先重新探查结构")
pmeta = tables[parent]
col, pcol = _column(meta, cols[0]), _column(pmeta, to_cols[0])
if col is None or pcol is None:
missing = f"{table}.{cols[0]}" if col is None else f"{parent}.{to_cols[0]}"
raise _Skip("missing", f"表结构里没有列 {missing},请先重新探查结构")
if pmeta.get("is_view"):
raise _Skip("view", f"被指向的 {parent} 是视图,不剖析")
for name, owner in ((col, table), (pcol, parent)):
if name.lower() in masked:
raise _Skip("masked", f"{owner}.{name} 在数据源设置中被遮罩,不取样", carry=True)
return parent, col, pcol
def plan_checks(table: str, meta: dict[str, Any] | None, notes: dict[str, Any] | None, *, tables: dict[str, Any],
masked: set[str], outside: frozenset[str] | set[str] = frozenset()) -> list[PlannedCheck]:
"""这张表要做的检查,按价值排:关系、码值、日期。范围(profile_checks)、建任务时列出的检查项、执行时用的都是这一份:
先列全再执行,中途停下时没做的每一项都要记下原因。视图、表结构里没有的表返回空列表。
按规则不查的也列出来(带 skip):被遮罩的码值列、日期列,多列的、两端不在表结构里的、指向视图的、碰到遮罩列的关系——
要让人看得见为什么它没有结论。masked 是遮罩的列(小写);outside 是对象索引里探查范围外的对象(name_key,见
_relation_ends)。"""
if not isinstance(meta, dict) or meta.get("is_view"):
return []
notes = notes or {}
checks: list[PlannedCheck] = []
for rel in _relation_candidates(notes):
skip: tuple[str, str, bool] | None = None
try:
_relation_ends(table, rel, tables, masked, outside)
except _Skip as e:
skip = (e.reason, e.detail, e.carry)
checks.append(PlannedCheck("relation", _relation_target(rel), f"relations.{rel['id']}", skip, relation=rel))
for column in _code_columns(meta, notes):
skip = (("masked", f"{table}.{column} 在数据源设置中被遮罩,不取值", False)
if column.lower() in masked else None)
checks.append(PlannedCheck("codes", column, f"columns.{column}.codes", skip, column=column))
dates = [str(c["name"]) for c in meta.get("columns") or [] if isinstance(c, dict) and _is_date_column(c)]
for column in dates:
if column.lower() in masked:
checks.append(PlannedCheck("date", column, None,
("masked", f"{table}.{column} 在数据源设置中被遮罩,不取最小值和最大值", False),
column=column))
usable = [c for c in dates if c.lower() not in masked]
if usable:
checks.append(PlannedCheck("date", "、".join(usable), "business_date" if len(dates) == 1 else None,
columns=tuple(usable), date_total=len(dates)))
return checks
def has_pending_relations(notes: dict[str, Any] | None) -> bool:
"""这张表还有没有待核实的关系:要核对的关系里有还是推断(proposed)的。挑表时这样的表排在前面。"""
return any(r.get("status") == "proposed" for r in _relation_candidates(notes))
def pending_relation_paths(notes: dict[str, Any] | None) -> set[str]:
"""待核实(proposed)关系的路径(relations.<编号>)。上次剖析的检查项里没有它们,就是剖析之后冒出了新的待核实关系。"""
return {f"relations.{r['id']}" for r in _relation_candidates(notes) if r.get("status") == "proposed"}
# ==========================================================================
# 入口:剖析一张表
# ==========================================================================
ESTIMATING = "正在估算 {table} 的行数"
#: 取值观察各项进行中的说法
OBSERVE_CURRENT = {"rows": "正在记 {table} 的行数", "codes_observe": "正在观察 {table}.{column} 的取值分布",
"freshness": "正在取 {table}.{column} 的最新业务日期",
"daily_volume": "正在数 {table} 最近 35 天每天的行数"}
def _current_text(table: str, check: PlannedCheck) -> str:
"""进行中时正在做哪一项的整句(逐表一行的最后一栏)。"""
if check.kind in OBSERVE_CURRENT:
return OBSERVE_CURRENT[check.kind].format(table=table, column=check.column or "")
if check.kind == "relation":
return f"正在核对 {table}.{check.target}"
if check.kind == "codes":
return f"正在取 {table}.{check.column} 的取值分布"
if len(check.columns) == 1:
return f"正在取 {table}.{check.columns[0]} 的最小值和最大值"
return f"正在取 {table} 中 {check.target} 的最小值和最大值"
def _keep(out: _TableOutcome, check: PlannedCheck) -> None:
"""这一项没查成:目录里剖析以前对它写的结论标记为原样保留。"""
if check.kind == "relation" and check.relation is not None:
out.keep_relations.add(str(check.relation["id"]))
elif check.kind == "codes" and check.column:
out.keep_codes.add(check.column)
elif check.kind == "date" and check.columns:
out.keep_business_date = True
def _close_open(result: TableResult, plan: list[PlannedCheck], out: _TableOutcome, reason: str, detail: str) -> int:
"""还没结束的项(排着的、正在做的)都记为跳过、原因 reason,旧结论原样保留。返回记了几项。"""
n = 0
for check, planned in zip(result.checks, plan):
if check["state"] in ("pending", "running"):
check.update(state="skipped", reason=reason, detail=detail)
result.skipped.append(ProfileSkip(check["kind"], check["target"], reason, detail, check["path"]))
_keep(out, planned)
n += 1
return n
def _stop_detail(cx: _TableCx, reason: str) -> str:
return _STOP_DETAIL[reason].format(max_queries=cx.settings.table_max_queries, max_s=_fmt(cx.settings.table_max_s))
async def _execute(cx: _TableCx, table: str, check: PlannedCheck, out: _TableOutcome) -> None:
from app.data import catalog_profile_observe as observe
if check.kind in observe.OBSERVE_KINDS:
await observe.execute(cx, table, check, cx.result)
elif check.kind == "relation":
await _check_relation(cx, table, check.relation or {}, out, cx.result)
elif check.kind == "codes":
await _check_codes(cx, table, str(check.column), out, cx.result)
else:
await _check_dates(cx, table, list(check.columns), check.date_total, out, cx.result)
async def _progress(cx: _TableCx) -> None:
if cx.hooks.progress is not None:
await cx.hooks.progress(cx.result)
async def _run_checks(cx: _TableCx, table: str, plan: list[PlannedCheck], runnable: list[int],
out: _TableOutcome) -> None:
"""第一条查询估行数(确知是空表就整张表跳过),再逐项执行。预算用完、任务止损时剩下的项记原因、保留旧结论。"""
result = cx.result
result.current = ESTIMATING.format(table=table)
await _progress(cx)
try:
size = await cx.size(table, result)
except _Stop as e:
_close_open(result, plan, out, e.reason, _stop_detail(cx, e.reason))
if e.reason in ("failed", "slow"):
result.error = cx.job.runner.error
return
finally:
result.current = None
if size.rows == 0:
# 空表:行数观察照记 0 行(一张表变空是最大的「行数下跌」),别的项都不查
for check, planned in zip(result.checks, plan):
if planned.kind == "rows" and check["state"] == "pending":
result.observations.append({"kind": "rows", "column": "", "basis": {"method": size.method,
"query_refs": []},
"basis_key": str(size.method), "data": {"rows": 0, "at_least": None,
"method": size.method}})
check["state"] = "done"
_close_open(result, plan, out, "no_data", f"{table} 是空表,无法剖析")
result.status, result.error = "skipped", EMPTY_ERROR
return
for i in runnable:
planned, check = plan[i], result.checks[i]
check["state"] = "running"
result.current = _current_text(table, planned)
cx.check = check
await _progress(cx)
try:
await _execute(cx, table, planned, out)
check["state"] = "done"
except _Skip as e:
check.update(state="skipped", reason=e.reason, detail=e.detail)
result.skipped.append(ProfileSkip(planned.kind, planned.target, e.reason, e.detail, planned.path))
if e.carry:
_keep(out, planned)
except _Stop as e:
cx.check = None
_close_open(result, plan, out, e.reason, _stop_detail(cx, e.reason))
if e.reason in ("failed", "slow"):
result.error = cx.job.runner.error
break
finally:
cx.check = None
result.current = None
await _progress(cx)
async def _own_session(fn: Callable[..., Awaitable[Any]], *args: Any) -> Any:
async with SessionLocal() as session:
return await fn(session, *args)
async def _read_notes(session: AsyncSession, source_id: str, table: str) -> dict[str, Any]:
entry = await catalog.read_entry(session, source_id, table)
return entry.notes if entry else {}
async def profile_table(cx: ProfileContext, table: str, *,
db: Callable[..., Awaitable[Any]] | None = None, hooks: TableHooks | None = None,
actor: str | None = None, regular: bool = True, observe: bool = False) -> TableResult:
"""剖析一张表并把结论写进目录,返回这张表的结果(TableResult)。
regular:做关系、码值、日期这几项剖析(缺省);observe:接着做取值观察的四类检查(data/catalog_profile_observe,观察到的
记在 result.observations,由剖析任务和结果一起落库)。只观察(regular=False)时不往目录里并剖析结论——不然这次没做的剖析
项会被当成「这次完整检查过、却没再得出」删掉;目录里唯一可能变的是观察撤销「已列全」。
- 表结构里没有、视图:skipped,不发查询。
- 先列全检查项(plan_checks,读的是这时目录里的最新内容),按规则不查的直接记跳过;一项可查的都没有:skipped
「没有可剖析的项」,不发查询。
- 第一条查询估行数,确知是空表:skipped「空表」。
- 逐项执行:这张表的查询次数、时长用完(TableBudget)只停这张表,剩下的项记 budget / deadline;任务止损(cx.runner)
停下时剩下的项记 failed / slow,error 是止损的那一句。
- 停止(任务被 cancel):正在跑的那条查询由数据库按单条时限截止;已做完的项照常写进目录,没做的记 cancelled、保留旧
结论;写完再把停止传下去(CancelledError)。
- 整张表结束把结论并进目录(乐观锁,撞上别人刚改过重读重并,三次仍冲突记 failed),和 hooks.recorded 在同一个事务里
提交。
db 是执行库操作的:db(fn, *args) 开一个会话调 fn(session, *args)。后台任务给它的 SerialWriter(一条一条、shield),
不给就各开一个会话。快照被改动(SnapshotTampered)原样抛出,不写目录:整个任务都该停。
"""
hooks = hooks or TableHooks()
db = db or _own_session
started = time.monotonic()
result = TableResult(table=table)
meta = cx.tables.get(table)
if not isinstance(meta, dict) or meta.get("is_view"):
reason = "view" if isinstance(meta, dict) else "missing"
why = VIEW_ERROR if reason == "view" else catalog.missing_table_error(table)
result.status, result.error = "skipped", why
result.skipped.append(ProfileSkip("table", table, reason, why))
await db(_record, cx.source.id, table, None, result, actor, hooks.recorded)
return result
notes = await db(_read_notes, cx.source.id, table)
plan = plan_checks(table, meta, notes, tables=cx.tables, masked=cx.masked, outside=cx.outside) if regular else []
if observe:
from app.data import catalog_profile_observe
plan += catalog_profile_observe.plan_observations(table, meta, notes, masked=cx.masked)
out = _TableOutcome()
tcx = _TableCx(cx, table, result, hooks)
tcx.table_notes = notes
result.checks = [p.as_check() for p in plan]
for planned in plan:
if planned.skip is not None:
result.skipped.append(ProfileSkip(planned.kind, planned.target, planned.skip[0], planned.skip[1],
planned.path))
if planned.skip[2]:
_keep(out, planned)
runnable = [i for i, p in enumerate(plan) if p.skip is None]
stopped: asyncio.CancelledError | None = None
try:
if not runnable:
result.status, result.error = "skipped", NOTHING_ERROR
result.skipped.append(ProfileSkip("table", table, "nothing", NOTHING_ERROR))
else:
await _run_checks(tcx, table, plan, runnable, out)
except asyncio.CancelledError as e:
stopped = e
why = hooks.stop_text() if hooks.stop_text is not None else STOPPED
done = sum(1 for c in result.checks if c["state"] == "done")
rest = _close_open(result, plan, out, "cancelled", f"{why},未检查")
result.error = STOPPED_TABLE.format(why=why, done=done, rest=rest)
except SnapshotTampered:
raise
except Exception as e: # noqa: BLE001 - 一张表出了意外不拖垮别的表:照实记下,做完的项照常写
logger.exception("剖析表 %s 时出错", table)
line = _brief(e)
_close_open(result, plan, out, "error", TABLE_ERROR.format(line=line))
result.status, result.error = "failed", TABLE_ERROR.format(line=line)
result.current = None
result.row_estimate = cx.sizes.get(table)
result.elapsed_ms = int((time.monotonic() - started) * 1000)
await db(_record, cx.source.id, table, out if regular else None, result, actor, hooks.recorded)
if stopped is not None:
raise stopped
return result
def _settle_status(result: TableResult) -> None:
"""表的状态:skipped、failed 的不变;有运行原因没查成的项是 partial,否则 ok。"""
if result.status in ("skipped", "failed"):
return
result.status = "partial" if result.unfinished else "ok"
async def _record(session: AsyncSession, source_id: str, table: str, out: _TableOutcome | None, result: TableResult,
actor: str | None, recorded: Callable[[AsyncSession, TableResult], Awaitable[None]] | None) -> None:
"""整张表结束:把结论并进目录(out 为 None 的不写:表结构里没有、视图),定下表的状态,再交给 recorded(同一个会话)。
修改时间取这一次写入的时间。
目录和 recorded 写的这张表的结果在同一个事务里提交:进程在中间没了,两样都没写进去,下次启动收尾时这张表做完的项
照实记「没写进目录」(catalog_profile_jobs._close_tasks),不会出现目录写了、剖析记录却说没写的情形。"""
if out is not None:
try:
await _write(session, source_id, table, out, result, at=catalog.now_iso(), actor=actor)
except Exception as e: # noqa: BLE001 - 这一张写不进去不影响别的表
logger.exception("剖析结果写入表 %s 的数据目录时出错", table)
await session.rollback()
result.error = WRITE_ERROR.format(line=_brief(e))
result.status = "failed"
result.findings = []
result.added = result.updated = result.removed = 0
_settle_status(result)
if recorded is not None:
await recorded(session, result)
await session.commit()
async def _write(session: AsyncSession, source_id: str, table: str, out: _TableOutcome, result: TableResult, *,
at: str, actor: str | None) -> None:
"""并入并写入(乐观锁)。撞上别人刚改过就重读重并,最多三次;三次仍冲突这张表记 failed。写进事务、不提交(见 _record)。"""
for _attempt in range(3):
entry = await catalog.read_entry(session, source_id, table)
version = entry.version if entry else 0
notes, findings, stats = _apply(entry.notes if entry else {}, out, at=at)
result.findings = findings
result.added, result.updated, result.removed = stats.added, stats.updated, stats.removed
result.version = version
if entry is None and not notes:
return # 什么也没得出,不建空目录
try:
written = await catalog.write_entry(session, source_id, table, notes, if_version=version, actor=actor,
commit=False)
except catalog.CatalogConflict:
continue
result.version = written.version
return
result.error = CONFLICT_ERROR
result.status = "failed"
result.findings = []
result.added = result.updated = result.removed = 0
def ensure_enabled(row: Any, settings: ProfileSettings | None = None) -> ProfileSettings:
"""这个数据源能不能剖析:数据源停用了、或者没开启数据剖析,抛 ProfileDisabled(message 给人看)。
返回要用的设置(不给就按 row.options 读)。建任务之前先调它:开关是第一道门;任务每张表开始前也按它重新看一遍。"""
settings = settings or profile_settings(getattr(row, "options", None))
name = getattr(row, "name", "") or ""
if getattr(row, "enabled", True) is False:
raise ProfileDisabled(f"数据源「{name}」已停用,无法进行数据剖析。请先在数据源设置中启用", code="datasource_inactive")
if not settings.enabled:
raise ProfileDisabled(f"数据源「{name}」未开启数据剖析。剖析会对业务库发查询,请先在数据源设置中开启数据剖析")
return settings
# ==========================================================================
# 核对模型提议的有效行过滤:排除了多少行
# ==========================================================================
#: 有效行过滤核对的说法(写进这一项的 note;前面是模型给的依据)
FILTER_CHECKED = "剖析核对:排除 {share} 的行(共 {rows} 行)"
FILTER_SAMPLED = "剖析核对:按存储顺序取前 {rows} 行统计,排除 {share} 的行"
FILTER_SAMPLED_EDGE = "剖析核对:按存储顺序取前 {rows} 行统计,{what},请人工核对"
FILTER_UNCHECKED = "没有核对排除比例({why})"
FILTER_NOT_ENABLED = "数据源没有开数据剖析"
@dataclass(frozen=True)
class FilterCheck:
"""核对一条有效行过滤的结果。
keep:收不收(排除 0% 或 100% 的不收:前者什么都没筛掉,后者把所有行都筛掉了,多半写错了);note:写进这一项 note
的那句(剖析核对的排除比例,或者没有核对的原因);rows / excluded:数到的行数和其中被排除的行数(没核对是 None);
sampled:表太大或不知道多大,只数了前若干行;dropped:不收的原因(记日志用)。"""
keep: bool
note: str
rows: int | None = None
excluded: int | None = None
sampled: bool = False
dropped: str | None = None
def excluded_share(excluded: int, rows: int) -> str:
"""排除比例写成百分数:留一位小数(整数不带小数);不到 0.1% 的写「不足 0.1%」,不是全部的不四舍五入成 100%。"""
if rows <= 0:
return "0%"
pct = excluded * 100 / rows
if 0 < pct < 0.1:
return "不足 0.1%"
if 99.9 < pct < 100:
return "超过 99.9%"
return f"{pct:.1f}".rstrip("0").rstrip(".") + "%"
def _unchecked(why: str) -> FilterCheck:
return FilterCheck(True, FILTER_UNCHECKED.format(why=why))
def _unsafe_literal(value: str) -> bool:
"""拼不进核对查询的字符串取值:查询层用 SQLAlchemy 的 text() 执行,它会把字符串里的「:名字」当成绑定参数;
反斜杠在 MySQL 上读法不定、守卫会整条拒掉;控制字符不该出现(同 sql_literal 的规矩)。"""
return "\\" in value or bool(_CONTROL.search(value)) or bool(_BIND_LIKE.search(value))
async def check_valid_filter(source: Any, table: str, meta: dict[str, Any] | None, condition: str, *,
settings: ProfileSettings | None = None) -> FilterCheck:
"""开了数据剖析的源:数一数这条有效行过滤排除了多少行(模型起草时提议的那条,data/catalog_draft.py 调)。从不抛异常。
经单条查询的入口(run_single_query:guard.check(readonly=True)、单条时限)发一条查询:只取条件里用到的列,
先取前 max(sample_size, max_scan_rows) 行(大表不做整表扫描),再数总行数和不满足条件的行数(条件为 NULL 也算排除,
和 WHERE 一样)。数到的不满前那么多行,就是整张表。
- 排除 0% 或 100% 的不收(keep 为假);只数了前若干行的照收,写明「请人工核对」;
- 没开剖析、数据源停用、条件里有遮罩的列、视图、取值拼不进查询、查询失败或超时:照收,note 写没有核对的原因。
"""
from app.data import sqlcheck
options = getattr(source, "options", None)
settings = settings or profile_settings(options)
if not settings.enabled:
return _unchecked(FILTER_NOT_ENABLED)
if getattr(source, "enabled", True) is False:
return _unchecked("数据源已停用")
kind = getattr(source, "kind", None)
parsed = sqlcheck.parse_valid_filter(condition, meta, kind=kind, table=table)
if parsed.problem:
return FilterCheck(False, parsed.problem, dropped=parsed.problem)
masked = {c.lower() for c in data_engine.masked_columns(options)}
if hit := [c for c in parsed.columns if c.lower() in masked]:
return _unchecked(f"条件里的 {'、'.join(hit)} 在数据源设置中被遮罩,不取数")
if (meta or {}).get("is_view"):
return _unchecked("这张表是视图,剖析可能触发整个视图的计算")
from sqlglot import exp
if any(n.is_string and _unsafe_literal(str(n.this)) for n in parsed.expression.find_all(exp.Literal)):
return _unchecked("条件里的取值含有冒号或反斜杠,拼不进核对查询")
try:
dialect = SqlDialect(kind)
except ValueError as e:
return _unchecked(str(e))
cap = max(int(settings.sample_size), int(settings.max_scan_rows))
# 和存进目录的是同一条(catalog._model_facts 存的就是这个写法):列换成真名、按方言加引号,别的照原文
# 列按表结构里记的 quote_name 强制加引号(Oracle 小写带引号的列:不加引号 Oracle 转成大写,找的是另一列)
cond = sqlcheck.canonical_valid_filter(parsed, quote=lambda c: dialect.column(meta, c))
cols = ", ".join(dialect.column(meta, c) for c in parsed.columns)
inner = dialect.limit(f"SELECT {cols} FROM {dialect.table(meta, table)}", cap)
sql = f"SELECT COUNT(*) AS n, SUM(CASE WHEN {cond} THEN 0 ELSE 1 END) AS x FROM ({inner}) s"
try:
res = await run_single_query(source, sql, settings=settings, kind=kind)
except QueryFailed as e:
return _unchecked(e.detail.removesuffix(",已跳过"))
except SnapshotTampered as e:
return _unchecked(str(e))
row = res.rows[0] if res.rows else [None, None]
rows, excluded = _as_int(row[0]), _as_int(row[1]) if len(row) > 1 else None
if not rows:
return _unchecked("表里还没有数据")
excluded = excluded or 0
sampled = rows >= cap
counted = f"{rows:,}"
if excluded in (0, rows):
what = "没有排除任何一行" if excluded == 0 else "所有行都被排除了"
if sampled:
return FilterCheck(True, FILTER_SAMPLED_EDGE.format(rows=counted, what=what), rows, excluded, True)
return FilterCheck(False, FILTER_CHECKED.format(share=excluded_share(excluded, rows), rows=counted), rows,
excluded, False, dropped=what)
share = excluded_share(excluded, rows)
note = (FILTER_SAMPLED if sampled else FILTER_CHECKED).format(share=share, rows=counted)
return FilterCheck(True, note, rows, excluded, sampled)
__all__ = ["CODES_MAX", "EMPTY_ERROR", "FILTER_CHECKED", "FILTER_NOT_ENABLED", "FILTER_SAMPLED", "FILTER_UNCHECKED",
"FilterCheck", "IN_CHUNK", "LEGACY_KEYS", "MAX_CODE_LEN", "MAX_LITERAL_LEN", "NOTE_PREFIX", "NOTHING_ERROR",
"PROFILE_OPTION", "PlannedCheck", "ProfileContext", "ProfileDisabled", "ProfileSettings", "ProfileSkip",
"QueryFailed", "SourceRunner", "SqlDialect", "TableBudget", "TableHooks", "TableResult", "TableSize",
"UNFINISHED_REASONS", "VIEW_ERROR", "check_valid_filter", "ensure_enabled", "estimate_size", "excluded_share",
"has_pending_relations", "make_context", "masked_of", "migrate_settings", "pending_relation_paths",
"plan_checks", "profile_settings", "profile_settings_problem", "profile_settings_rejection",
"profile_table", "run_single_query", "sql_literal", "summarize", "without_legacy"]