hy-app-monitor/monitors/yumi/mysql_common.py

422 lines
15 KiB
Python
Raw 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.

from __future__ import annotations
from datetime import date, datetime, time, timedelta
from decimal import Decimal
import json
from typing import Any
from core.config import (
MYSQL_CONNECT_TIMEOUT_SECONDS,
MYSQL_DEFAULT_DATABASE,
MYSQL_HOST,
MYSQL_PASSWORD,
MYSQL_PORT,
MYSQL_READ_TIMEOUT_SECONDS,
MYSQL_USER,
MYSQL_WRITE_TIMEOUT_SECONDS,
utc_now,
)
# 系统库默认排在业务库后面,避免主列表被系统库占满。
SYSTEM_DATABASES = {"information_schema", "performance_schema", "mysql", "sys"}
def mysql_error_payload(error: str) -> dict[str, Any]:
# MySQL 基础设施页统一返回同一套失败结构,前端不需要额外分支。
return {
"ok": False,
"updatedAt": utc_now(),
"info": {},
"connections": {},
"throughput": {},
"replication": {"configured": False, "role": "", "readOnly": False, "members": []},
"summary": {
"databaseTotal": 0,
"tableTotal": 0,
"dataSizeMb": 0.0,
"indexSizeMb": 0.0,
"totalSizeMb": 0.0,
},
"databases": [],
"error": error,
}
def require_pymysql() -> Any:
try:
# 运行时再导入驱动,避免缺少依赖时整个服务无法启动。
import pymysql
except Exception as exc: # noqa: BLE001
raise RuntimeError(f"pymysql unavailable: {exc}") from exc
# 导入成功后直接返回模块对象,调用方继续拿 DictCursor。
return pymysql
def build_mysql_connection(database: str | None = None, *, autocommit: bool = True) -> Any:
# 主机和账号缺失时直接给出明确错误,避免后面连库报一串底层异常。
missing = [name for name, value in {"MYSQL_HOST": MYSQL_HOST, "MYSQL_USER": MYSQL_USER}.items() if not str(value).strip()]
if missing:
raise RuntimeError(f"missing mysql config in .env: {', '.join(missing)}")
# 默认库允许为空;表浏览场景会显式选择数据库。
target_database = str(database or MYSQL_DEFAULT_DATABASE or "").strip() or None
pymysql = require_pymysql()
# 统一走 utf8mb4避免表结构和数据浏览时乱码。
return pymysql.connect(
host=MYSQL_HOST,
port=MYSQL_PORT,
user=MYSQL_USER,
password=MYSQL_PASSWORD,
database=target_database,
charset="utf8mb4",
cursorclass=pymysql.cursors.DictCursor,
autocommit=autocommit,
connect_timeout=MYSQL_CONNECT_TIMEOUT_SECONDS,
read_timeout=MYSQL_READ_TIMEOUT_SECONDS,
write_timeout=MYSQL_WRITE_TIMEOUT_SECONDS,
)
def quote_identifier(name: str) -> str:
# 库、表、列名都统一走反引号包裹,并对内层反引号做转义。
actual = str(name or "").strip()
if not actual:
raise ValueError("identifier is required")
return f"`{actual.replace('`', '``')}`"
def normalize_database_name(name: str) -> str:
# 数据库名作为路由参数时必须非空,统一在这里兜底校验。
actual = str(name or "").strip()
if not actual:
raise ValueError("database is required")
return actual
def normalize_table_name(name: str) -> str:
# 表名同样必须非空。
actual = str(name or "").strip()
if not actual:
raise ValueError("table is required")
return actual
def normalize_column_name(name: str) -> str:
# 列名校验单独抽出来,便于 schema 变更和排序共用。
actual = str(name or "").strip()
if not actual:
raise ValueError("column is required")
return actual
def is_system_database(name: str) -> bool:
# 系统库统一小写比较,避免大小写混淆。
return str(name or "").strip().lower() in SYSTEM_DATABASES
def normalize_mysql_value(value: Any) -> Any:
# Decimal 保留成字符串,避免金额精度被前端浮点化。
if isinstance(value, Decimal):
return str(value)
# 日期和时间都压成易读字符串。
if isinstance(value, datetime):
return value.strftime("%Y-%m-%d %H:%M:%S")
if isinstance(value, date):
return value.strftime("%Y-%m-%d")
if isinstance(value, time):
return value.strftime("%H:%M:%S")
if isinstance(value, timedelta):
return str(value)
# 字节内容尽量按 UTF-8 展示;失败时回退成十六进制。
if isinstance(value, (bytes, bytearray, memoryview)):
raw = bytes(value)
try:
return raw.decode("utf-8")
except UnicodeDecodeError:
return f"0x{raw.hex()}"
# dict / list 理论上常见于 JSON 列,这里直接转成 JSON 文本方便展示和编辑。
if isinstance(value, (dict, list)):
return json.dumps(value, ensure_ascii=False)
# 其余标量直接原样返回。
return value
def normalize_mysql_row(row: dict[str, Any]) -> dict[str, Any]:
# 一行记录中的每个字段都做 JSON 安全转换。
return {key: normalize_mysql_value(value) for key, value in row.items()}
def list_database_summaries(connection: Any) -> list[dict[str, Any]]:
# 数据库级别汇总统一从 information_schema.tables 读取,能同时拿到表数和容量。
query = """
SELECT
s.SCHEMA_NAME AS schemaName,
COALESCE(t.tableTotal, 0) AS tableTotal,
COALESCE(t.dataBytes, 0) AS dataBytes,
COALESCE(t.indexBytes, 0) AS indexBytes,
COALESCE(t.rowEstimate, 0) AS rowEstimate
FROM information_schema.SCHEMATA AS s
LEFT JOIN (
SELECT
table_schema AS schemaName,
COUNT(*) AS tableTotal,
SUM(COALESCE(data_length, 0)) AS dataBytes,
SUM(COALESCE(index_length, 0)) AS indexBytes,
SUM(COALESCE(table_rows, 0)) AS rowEstimate
FROM information_schema.TABLES
GROUP BY table_schema
) AS t
ON t.schemaName = s.SCHEMA_NAME
ORDER BY
CASE
WHEN s.SCHEMA_NAME IN ('information_schema', 'performance_schema', 'mysql', 'sys') THEN 1
ELSE 0
END,
COALESCE(t.dataBytes, 0) + COALESCE(t.indexBytes, 0) DESC,
s.SCHEMA_NAME ASC
"""
with connection.cursor() as cursor:
# 直接读取全部库级摘要,前端 sidebar 需要完整列表。
cursor.execute(query)
rows = cursor.fetchall() or []
# 把字节数转换成 MiB并附带是否系统库的标记。
return [
{
"name": str(item.get("schemaName") or "").strip(),
"system": is_system_database(item.get("schemaName")),
"tableTotal": int(item.get("tableTotal") or 0),
"rowEstimate": int(item.get("rowEstimate") or 0),
"dataSizeMb": round(float(item.get("dataBytes") or 0) / 1024 / 1024, 2),
"indexSizeMb": round(float(item.get("indexBytes") or 0) / 1024 / 1024, 2),
"totalSizeMb": round(
(float(item.get("dataBytes") or 0) + float(item.get("indexBytes") or 0)) / 1024 / 1024,
2,
),
}
for item in rows
]
def list_table_summaries(connection: Any, database: str) -> list[dict[str, Any]]:
target_database = normalize_database_name(database)
# 表级摘要统一从 information_schema.tables 读取,便于在浏览器里快速切表。
query = """
SELECT
TABLE_NAME AS tableName,
ENGINE AS engine,
TABLE_ROWS AS rowEstimate,
DATA_LENGTH AS dataBytes,
INDEX_LENGTH AS indexBytes,
AUTO_INCREMENT AS autoIncrementValue,
CREATE_TIME AS createTime,
UPDATE_TIME AS updateTime,
TABLE_COLLATION AS tableCollation
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = %s
ORDER BY COALESCE(DATA_LENGTH, 0) + COALESCE(INDEX_LENGTH, 0) DESC, TABLE_NAME ASC
"""
with connection.cursor() as cursor:
# 同一库内表数量通常有限,这里直接全量读取。
cursor.execute(query, (target_database,))
rows = cursor.fetchall() or []
return [
{
"name": str(item.get("tableName") or "").strip(),
"engine": str(item.get("engine") or "").strip(),
"rowEstimate": int(item.get("rowEstimate") or 0),
"dataSizeMb": round(float(item.get("dataBytes") or 0) / 1024 / 1024, 2),
"indexSizeMb": round(float(item.get("indexBytes") or 0) / 1024 / 1024, 2),
"totalSizeMb": round(
(float(item.get("dataBytes") or 0) + float(item.get("indexBytes") or 0)) / 1024 / 1024,
2,
),
"autoIncrement": item.get("autoIncrementValue"),
"createTime": normalize_mysql_value(item.get("createTime")),
"updateTime": normalize_mysql_value(item.get("updateTime")),
"collation": str(item.get("tableCollation") or "").strip(),
}
for item in rows
]
def list_table_columns(connection: Any, database: str, table: str) -> list[dict[str, Any]]:
target_database = normalize_database_name(database)
target_table = normalize_table_name(table)
# 列元数据以 ordinal_position 排序,保证浏览器和真实表结构一致。
query = """
SELECT
COLUMN_NAME AS columnName,
DATA_TYPE AS dataType,
COLUMN_TYPE AS columnType,
IS_NULLABLE AS isNullable,
COLUMN_DEFAULT AS columnDefault,
COLUMN_KEY AS columnKey,
EXTRA AS extraValue,
COLUMN_COMMENT AS columnComment,
ORDINAL_POSITION AS ordinalPosition
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s
ORDER BY ORDINAL_POSITION ASC
"""
with connection.cursor() as cursor:
# 表结构操作都依赖列元数据,这里统一一次性拿齐。
cursor.execute(query, (target_database, target_table))
rows = cursor.fetchall() or []
return [
{
"name": str(item.get("columnName") or "").strip(),
"dataType": str(item.get("dataType") or "").strip(),
"columnType": str(item.get("columnType") or "").strip(),
"nullable": str(item.get("isNullable") or "").strip().upper() == "YES",
"default": normalize_mysql_value(item.get("columnDefault")),
"key": str(item.get("columnKey") or "").strip(),
"extra": str(item.get("extraValue") or "").strip(),
"comment": str(item.get("columnComment") or "").strip(),
"ordinalPosition": int(item.get("ordinalPosition") or 0),
}
for item in rows
]
def list_table_indexes(connection: Any, database: str, table: str) -> list[dict[str, Any]]:
target_database = normalize_database_name(database)
target_table = normalize_table_name(table)
# 索引明细先按序号取出,再在 Python 侧聚合成前端更容易消费的结构。
query = """
SELECT
INDEX_NAME AS indexName,
COLUMN_NAME AS columnName,
NON_UNIQUE AS nonUnique,
INDEX_TYPE AS indexType,
SEQ_IN_INDEX AS seqInIndex,
SUB_PART AS subPart
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s
ORDER BY INDEX_NAME ASC, SEQ_IN_INDEX ASC
"""
with connection.cursor() as cursor:
# information_schema.STATISTICS 能完整覆盖 primary / unique / 普通索引。
cursor.execute(query, (target_database, target_table))
rows = cursor.fetchall() or []
grouped: dict[str, dict[str, Any]] = {}
for item in rows:
index_name = str(item.get("indexName") or "").strip()
target = grouped.setdefault(
index_name,
{
"name": index_name,
"primary": index_name == "PRIMARY",
"unique": int(item.get("nonUnique") or 0) == 0,
"type": str(item.get("indexType") or "").strip(),
"columns": [],
},
)
target["columns"].append(
{
"name": str(item.get("columnName") or "").strip(),
"subPart": int(item.get("subPart") or 0) or None,
"position": int(item.get("seqInIndex") or 0),
}
)
# 输出时保证 primary 优先,再按名字排序。
return sorted(
grouped.values(),
key=lambda item: (0 if item["primary"] else 1, item["name"].lower()),
)
def get_preferred_identity_columns(columns: list[dict[str, Any]], indexes: list[dict[str, Any]]) -> dict[str, Any]:
# 行级更新优先使用主键;没有主键时退到第一个唯一索引;再不行才退到全列比对。
primary = next((item for item in indexes if item.get("primary")), None)
if primary:
return {"mode": "primary", "columns": [column["name"] for column in primary["columns"]]}
unique = next((item for item in indexes if item.get("unique")), None)
if unique:
return {"mode": "unique", "columns": [column["name"] for column in unique["columns"]]}
return {"mode": "all", "columns": [column["name"] for column in columns]}
def build_row_identity(row: dict[str, Any], identity_spec: dict[str, Any]) -> dict[str, Any]:
# identity 只保留用于匹配的字段,避免前端把整行作为 where 条件带回。
columns = [str(item or "").strip() for item in identity_spec.get("columns") or [] if str(item or "").strip()]
return {
"mode": str(identity_spec.get("mode") or "").strip(),
"columns": columns,
"values": {column: normalize_mysql_value(row.get(column)) for column in columns},
}
def table_exists(connection: Any, database: str, table: str) -> bool:
target_database = normalize_database_name(database)
target_table = normalize_table_name(table)
# DDL 和浏览接口都需要一个轻量存在性校验,避免后面拼 SQL 才报错。
with connection.cursor() as cursor:
cursor.execute(
"""
SELECT 1
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s
LIMIT 1
""",
(target_database, target_table),
)
return cursor.fetchone() is not None
def database_exists(connection: Any, database: str) -> bool:
target_database = normalize_database_name(database)
# 库级存在性校验单独抽出来,浏览器切库时可以复用。
with connection.cursor() as cursor:
cursor.execute(
"""
SELECT 1
FROM information_schema.SCHEMATA
WHERE SCHEMA_NAME = %s
LIMIT 1
""",
(target_database,),
)
return cursor.fetchone() is not None
def normalize_sql_text(sql: str) -> str:
# SQL 工作台当前只允许单条语句,避免一键提交多段脚本。
text = str(sql or "").strip()
if not text:
raise ValueError("sql is required")
# 只允许尾部分号,正文里再出现分号就拒绝执行。
stripped = text.rstrip().rstrip(";").rstrip()
if ";" in stripped:
raise ValueError("only single sql statement is allowed")
return stripped
def sql_statement_kind(sql: str) -> str:
# 读取首个关键字即可分辨查询类还是变更类语句。
parts = normalize_sql_text(sql).split(None, 1)
return parts[0].strip().lower() if parts else ""