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 ""