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