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

720 lines
28 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.

from __future__ import annotations
import json
from typing import Any
from core.config import MYSQL_DEFAULT_DATABASE, MYSQL_DEFAULT_PAGE_SIZE, MYSQL_MAX_PAGE_SIZE, MYSQL_QUERY_MAX_ROWS, utc_now
from .mysql_common import (
build_mysql_connection,
build_row_identity,
database_exists,
get_preferred_identity_columns,
list_database_summaries,
list_table_columns,
list_table_indexes,
list_table_summaries,
normalize_column_name,
normalize_database_name,
normalize_mysql_row,
normalize_sql_text,
normalize_table_name,
quote_identifier,
sql_statement_kind,
table_exists,
)
from .mysql_metrics import clear_mysql_metrics_cache, get_mysql_overview_payload
def browser_error_payload(error: str) -> dict[str, Any]:
# 浏览器和 SQL 工作台都统一返回这套最小错误结构。
return {"ok": False, "updatedAt": utc_now(), "error": error}
def clamp_page_size(value: Any) -> int:
try:
# 前端没传时回到默认 100过大时强制截断避免一次拖太多数据。
return max(1, min(int(value or MYSQL_DEFAULT_PAGE_SIZE), MYSQL_MAX_PAGE_SIZE))
except Exception: # noqa: BLE001
return MYSQL_DEFAULT_PAGE_SIZE
def clamp_page_number(value: Any) -> int:
try:
# 页码最小固定为 1。
return max(1, int(value or 1))
except Exception: # noqa: BLE001
return 1
def normalize_sql_fragment(value: Any, *, field_name: str) -> str:
# DDL 片段允许空格和括号,但不允许多语句。
actual = str(value or "").strip()
if not actual:
raise ValueError(f"{field_name} is required")
if ";" in actual:
raise ValueError(f"{field_name} must be a single fragment")
return actual
def get_mysql_databases_payload() -> dict[str, Any]:
# 概览里已经做了缓存,这里直接复用,避免重复查 information_schema。
overview = get_mysql_overview_payload()
if not overview.get("ok"):
return {
"ok": False,
"updatedAt": overview.get("updatedAt"),
"selectedDatabase": "",
"items": [],
"error": overview.get("error") or "mysql unavailable",
}
items = list(overview.get("databases") or [])
business_items = [item for item in items if not item.get("system")]
preferred = str(MYSQL_DEFAULT_DATABASE or "").strip()
# 默认优先选 .env 指定库;没有时选第一个业务库,再退到第一个可见库。
selected_database = (
preferred
if preferred and any(item.get("name") == preferred for item in items)
else (business_items[0]["name"] if business_items else (items[0]["name"] if items else ""))
)
return {
"ok": True,
"updatedAt": overview.get("updatedAt"),
"selectedDatabase": selected_database,
"items": items,
"error": "",
}
def get_mysql_tables_payload(database: str) -> dict[str, Any]:
target_database = normalize_database_name(database)
try:
with build_mysql_connection(autocommit=True) as connection:
if not database_exists(connection, target_database):
raise ValueError(f"database not found: {target_database}")
items = list_table_summaries(connection, target_database)
except Exception as exc: # noqa: BLE001
return {
"ok": False,
"updatedAt": utc_now(),
"database": target_database,
"items": [],
"error": str(exc),
}
return {
"ok": True,
"updatedAt": utc_now(),
"database": target_database,
"selectedTable": items[0]["name"] if items else "",
"items": items,
"error": "",
}
def get_mysql_table_schema_payload(database: str, table: str) -> dict[str, Any]:
target_database = normalize_database_name(database)
target_table = normalize_table_name(table)
try:
with build_mysql_connection(target_database, autocommit=True) as connection:
if not table_exists(connection, target_database, target_table):
raise ValueError(f"table not found: {target_database}.{target_table}")
tables = list_table_summaries(connection, target_database)
table_summary = next((item for item in tables if item.get("name") == target_table), None)
columns = list_table_columns(connection, target_database, target_table)
indexes = list_table_indexes(connection, target_database, target_table)
with connection.cursor() as cursor:
# SHOW CREATE TABLE 能拿到最完整的 DDL方便页面直接展示和复制。
cursor.execute(f"SHOW CREATE TABLE {quote_identifier(target_table)}")
create_row = cursor.fetchone() or {}
except Exception as exc: # noqa: BLE001
return {
"ok": False,
"updatedAt": utc_now(),
"database": target_database,
"table": target_table,
"columns": [],
"indexes": [],
"createTable": "",
"error": str(exc),
}
return {
"ok": True,
"updatedAt": utc_now(),
"database": target_database,
"table": target_table,
"tableSummary": table_summary or {},
"columns": columns,
"indexes": indexes,
"identity": get_preferred_identity_columns(columns, indexes),
"createTable": str(create_row.get("Create Table") or "").strip(),
"error": "",
}
def build_filter_clause(
columns: list[dict[str, Any]],
keyword: str,
filters: list[dict[str, Any]],
) -> tuple[str, list[Any]]:
column_names = {column["name"] for column in columns}
clauses: list[str] = []
params: list[Any] = []
normalized_keyword = str(keyword or "").strip()
# 关键字搜索默认对当前表全部列做模糊匹配,适合值班快速扫数据。
if normalized_keyword:
search_clauses: list[str] = []
for column in columns:
search_clauses.append(f"CAST({quote_identifier(column['name'])} AS CHAR) LIKE %s")
params.append(f"%{normalized_keyword}%")
if search_clauses:
clauses.append(f"({' OR '.join(search_clauses)})")
# 附加过滤器支持前端后续扩展成多条件。
for item in filters:
column_name = normalize_column_name(item.get("column"))
if column_name not in column_names:
raise ValueError(f"unknown filter column: {column_name}")
operator = str(item.get("operator") or "eq").strip().lower()
sql_column = quote_identifier(column_name)
value = item.get("value")
if operator == "eq":
if value is None:
clauses.append(f"{sql_column} IS NULL")
else:
clauses.append(f"{sql_column} = %s")
params.append(value)
continue
if operator == "ne":
if value is None:
clauses.append(f"{sql_column} IS NOT NULL")
else:
clauses.append(f"{sql_column} <> %s")
params.append(value)
continue
if operator == "gt":
clauses.append(f"{sql_column} > %s")
params.append(value)
continue
if operator == "ge":
clauses.append(f"{sql_column} >= %s")
params.append(value)
continue
if operator == "lt":
clauses.append(f"{sql_column} < %s")
params.append(value)
continue
if operator == "le":
clauses.append(f"{sql_column} <= %s")
params.append(value)
continue
if operator == "in":
if not isinstance(value, list) or not value:
raise ValueError("in filter requires a non-empty array value")
placeholders = ", ".join(["%s"] * len(value))
clauses.append(f"{sql_column} IN ({placeholders})")
params.extend(value)
continue
if operator == "not_in":
if not isinstance(value, list) or not value:
raise ValueError("not_in filter requires a non-empty array value")
placeholders = ", ".join(["%s"] * len(value))
clauses.append(f"{sql_column} NOT IN ({placeholders})")
params.extend(value)
continue
if operator == "between":
if not isinstance(value, list) or len(value) != 2:
raise ValueError("between filter requires a two-item array value")
clauses.append(f"{sql_column} BETWEEN %s AND %s")
params.extend(value)
continue
if operator == "not_between":
if not isinstance(value, list) or len(value) != 2:
raise ValueError("not_between filter requires a two-item array value")
clauses.append(f"{sql_column} NOT BETWEEN %s AND %s")
params.extend(value)
continue
if operator == "like":
clauses.append(f"CAST({sql_column} AS CHAR) LIKE %s")
params.append(f"%{value}%")
continue
if operator == "not_like":
clauses.append(f"CAST({sql_column} AS CHAR) NOT LIKE %s")
params.append(f"%{value}%")
continue
if operator == "is_null":
clauses.append(f"{sql_column} IS NULL")
continue
if operator == "is_not_null":
clauses.append(f"{sql_column} IS NOT NULL")
continue
raise ValueError(f"unsupported filter operator: {operator}")
if not clauses:
return "", params
return f"WHERE {' AND '.join(clauses)}", params
def query_mysql_table_rows_payload(payload: dict[str, Any]) -> dict[str, Any]:
target_database = normalize_database_name(payload.get("database"))
target_table = normalize_table_name(payload.get("table"))
page = clamp_page_number(payload.get("page"))
page_size = clamp_page_size(payload.get("pageSize"))
keyword = str(payload.get("keyword") or "").strip()
filters = payload.get("filters") if isinstance(payload.get("filters"), list) else []
try:
with build_mysql_connection(target_database, autocommit=True) as connection:
if not table_exists(connection, target_database, target_table):
raise ValueError(f"table not found: {target_database}.{target_table}")
columns = list_table_columns(connection, target_database, target_table)
indexes = list_table_indexes(connection, target_database, target_table)
identity_spec = get_preferred_identity_columns(columns, indexes)
column_names = {column["name"] for column in columns}
requested_sort = str(payload.get("orderBy") or "").strip()
order_by = requested_sort if requested_sort in column_names else (identity_spec["columns"][0] if identity_spec["columns"] else columns[0]["name"])
order_direction = "DESC" if str(payload.get("orderDirection") or "").strip().lower() == "desc" else "ASC"
where_clause, params = build_filter_clause(columns, keyword, filters)
offset = (page - 1) * page_size
quoted_table = quote_identifier(target_table)
select_columns = ", ".join(quote_identifier(column["name"]) for column in columns)
with connection.cursor() as cursor:
# 先统计总量,分页器和页数都依赖这个值。
cursor.execute(
f"SELECT COUNT(*) AS totalRows FROM {quoted_table} {where_clause}",
tuple(params),
)
total_rows = int((cursor.fetchone() or {}).get("totalRows") or 0)
# 行浏览默认按指定列排序,避免翻页时顺序漂移。
cursor.execute(
f"""
SELECT {select_columns}
FROM {quoted_table}
{where_clause}
ORDER BY {quote_identifier(order_by)} {order_direction}
LIMIT %s OFFSET %s
""",
tuple([*params, page_size, offset]),
)
rows = cursor.fetchall() or []
except Exception as exc: # noqa: BLE001
return {
"ok": False,
"updatedAt": utc_now(),
"database": target_database,
"table": target_table,
"rows": [],
"totalRows": 0,
"error": str(exc),
}
# 每一行都带 identity后续 update / delete 不需要前端自己猜 where 条件。
normalized_rows = []
for row in rows:
normalized_rows.append(
{
"values": normalize_mysql_row(row),
"identity": build_row_identity(row, identity_spec),
}
)
return {
"ok": True,
"updatedAt": utc_now(),
"database": target_database,
"table": target_table,
"page": page,
"pageSize": page_size,
"totalRows": total_rows,
"totalPages": max(1, (total_rows + page_size - 1) // page_size) if total_rows > 0 else 1,
"keyword": keyword,
"orderBy": order_by,
"orderDirection": order_direction.lower(),
"columns": columns,
"indexes": indexes,
"identity": identity_spec,
"rows": normalized_rows,
"error": "",
}
def normalize_write_values(values: Any, columns: list[dict[str, Any]]) -> list[tuple[str, Any]]:
if not isinstance(values, dict):
raise ValueError("values must be an object")
writable_columns = {
column["name"]: column
for column in columns
if "generated" not in str(column.get("extra") or "").strip().lower()
}
normalized: list[tuple[str, Any]] = []
# 只允许写入真实存在的列,避免页面或调用方传进来脏字段。
for raw_name, raw_value in values.items():
column_name = normalize_column_name(raw_name)
if column_name not in writable_columns:
raise ValueError(f"unknown writable column: {column_name}")
# JSON 对象和数组转成字符串后再交给 MySQL自定义 SQL 控制台仍可手写更复杂格式。
if isinstance(raw_value, (dict, list)):
normalized.append((column_name, json.dumps(raw_value, ensure_ascii=False)))
else:
normalized.append((column_name, raw_value))
return normalized
def build_identity_clause(identity: dict[str, Any], columns: list[dict[str, Any]]) -> tuple[str, list[Any]]:
if not isinstance(identity, dict):
raise ValueError("identity is required")
allowed_columns = {column["name"] for column in columns}
identity_columns = [normalize_column_name(item) for item in identity.get("columns") or []]
identity_values = identity.get("values") if isinstance(identity.get("values"), dict) else {}
if not identity_columns:
raise ValueError("identity columns are required")
clauses: list[str] = []
params: list[Any] = []
# where 条件只按 identity 指定列构造,避免把整行都塞进 where。
for column_name in identity_columns:
if column_name not in allowed_columns:
raise ValueError(f"unknown identity column: {column_name}")
if column_name not in identity_values:
raise ValueError(f"missing identity value: {column_name}")
value = identity_values.get(column_name)
quoted = quote_identifier(column_name)
if value is None:
clauses.append(f"{quoted} IS NULL")
else:
clauses.append(f"{quoted} = %s")
params.append(value)
return " AND ".join(clauses), params
def insert_mysql_row_payload(payload: dict[str, Any]) -> dict[str, Any]:
target_database = normalize_database_name(payload.get("database"))
target_table = normalize_table_name(payload.get("table"))
try:
with build_mysql_connection(target_database, autocommit=False) as connection:
columns = list_table_columns(connection, target_database, target_table)
pairs = normalize_write_values(payload.get("values"), columns)
if not pairs:
raise ValueError("at least one column value is required")
quoted_table = quote_identifier(target_table)
column_sql = ", ".join(quote_identifier(name) for name, _ in pairs)
placeholders = ", ".join(["%s"] * len(pairs))
params = [value for _, value in pairs]
with connection.cursor() as cursor:
# 插入时只拼接列名,值全部走参数化。
cursor.execute(
f"INSERT INTO {quoted_table} ({column_sql}) VALUES ({placeholders})",
tuple(params),
)
affected_rows = int(cursor.rowcount or 0)
last_insert_id = cursor.lastrowid
connection.commit()
except Exception as exc: # noqa: BLE001
return browser_error_payload(str(exc))
clear_mysql_metrics_cache()
return {
"ok": True,
"updatedAt": utc_now(),
"message": f"已插入 {target_database}.{target_table}",
"affectedRows": affected_rows,
"lastInsertId": last_insert_id,
"error": "",
}
def update_mysql_row_payload(payload: dict[str, Any]) -> dict[str, Any]:
target_database = normalize_database_name(payload.get("database"))
target_table = normalize_table_name(payload.get("table"))
try:
with build_mysql_connection(target_database, autocommit=False) as connection:
columns = list_table_columns(connection, target_database, target_table)
pairs = normalize_write_values(payload.get("values"), columns)
if not pairs:
raise ValueError("at least one column value is required")
where_clause, where_params = build_identity_clause(payload.get("identity"), columns)
set_clause = ", ".join(f"{quote_identifier(name)} = %s" for name, _ in pairs)
params = [value for _, value in pairs] + where_params
with connection.cursor() as cursor:
# 更新固定只影响一行identity 使用主键或唯一索引时语义最稳定。
cursor.execute(
f"UPDATE {quote_identifier(target_table)} SET {set_clause} WHERE {where_clause} LIMIT 1",
tuple(params),
)
affected_rows = int(cursor.rowcount or 0)
connection.commit()
except Exception as exc: # noqa: BLE001
return browser_error_payload(str(exc))
clear_mysql_metrics_cache()
return {
"ok": True,
"updatedAt": utc_now(),
"message": f"已更新 {target_database}.{target_table}",
"affectedRows": affected_rows,
"error": "",
}
def delete_mysql_row_payload(payload: dict[str, Any]) -> dict[str, Any]:
target_database = normalize_database_name(payload.get("database"))
target_table = normalize_table_name(payload.get("table"))
try:
with build_mysql_connection(target_database, autocommit=False) as connection:
columns = list_table_columns(connection, target_database, target_table)
where_clause, where_params = build_identity_clause(payload.get("identity"), columns)
with connection.cursor() as cursor:
# 删除同样只允许命中一行,避免前端误带了过宽的 where 条件。
cursor.execute(
f"DELETE FROM {quote_identifier(target_table)} WHERE {where_clause} LIMIT 1",
tuple(where_params),
)
affected_rows = int(cursor.rowcount or 0)
connection.commit()
except Exception as exc: # noqa: BLE001
return browser_error_payload(str(exc))
clear_mysql_metrics_cache()
return {
"ok": True,
"updatedAt": utc_now(),
"message": f"已删除 {target_database}.{target_table} 中的 1 行",
"affectedRows": affected_rows,
"error": "",
}
def add_mysql_column_payload(payload: dict[str, Any]) -> dict[str, Any]:
target_database = normalize_database_name(payload.get("database"))
target_table = normalize_table_name(payload.get("table"))
column_name = normalize_column_name(payload.get("columnName"))
column_type = normalize_sql_fragment(payload.get("columnType"), field_name="columnType")
nullable = bool(payload.get("nullable", True))
comment = str(payload.get("comment") or "").strip()
position = str(payload.get("position") or "last").strip().lower()
default_mode = str(payload.get("defaultMode") or "none").strip().lower()
default_value = payload.get("defaultValue")
clauses = [
f"ALTER TABLE {quote_identifier(target_table)} ADD COLUMN {quote_identifier(column_name)} {column_type}",
"NULL" if nullable else "NOT NULL",
]
params: list[Any] = []
# 默认值支持最常用的三种模式;更复杂表达式留给 SQL 工作台。
if default_mode == "literal":
clauses.append("DEFAULT %s")
params.append(default_value)
elif default_mode == "null":
clauses.append("DEFAULT NULL")
elif default_mode != "none":
raise ValueError(f"unsupported default mode: {default_mode}")
if comment:
clauses.append("COMMENT %s")
params.append(comment)
if position == "first":
clauses.append("FIRST")
elif position == "after":
after_column = normalize_column_name(payload.get("afterColumn"))
clauses.append(f"AFTER {quote_identifier(after_column)}")
elif position != "last":
raise ValueError(f"unsupported position: {position}")
try:
with build_mysql_connection(target_database, autocommit=False) as connection:
with connection.cursor() as cursor:
# 列类型本身是 SQL 语法片段,只做单语句校验,不做过度限制。
cursor.execute(" ".join(clauses), tuple(params))
connection.commit()
except Exception as exc: # noqa: BLE001
return browser_error_payload(str(exc))
clear_mysql_metrics_cache()
return {
"ok": True,
"updatedAt": utc_now(),
"message": f"已新增列 {target_table}.{column_name}",
"error": "",
}
def drop_mysql_column_payload(payload: dict[str, Any]) -> dict[str, Any]:
target_database = normalize_database_name(payload.get("database"))
target_table = normalize_table_name(payload.get("table"))
column_name = normalize_column_name(payload.get("columnName"))
try:
with build_mysql_connection(target_database, autocommit=False) as connection:
with connection.cursor() as cursor:
# 删除列是高影响操作,但用户明确要求支持,这里保持单列单次执行。
cursor.execute(
f"ALTER TABLE {quote_identifier(target_table)} DROP COLUMN {quote_identifier(column_name)}",
)
connection.commit()
except Exception as exc: # noqa: BLE001
return browser_error_payload(str(exc))
clear_mysql_metrics_cache()
return {
"ok": True,
"updatedAt": utc_now(),
"message": f"已删除列 {target_table}.{column_name}",
"error": "",
}
def add_mysql_index_payload(payload: dict[str, Any]) -> dict[str, Any]:
target_database = normalize_database_name(payload.get("database"))
target_table = normalize_table_name(payload.get("table"))
index_name = normalize_column_name(payload.get("indexName"))
index_mode = str(payload.get("indexMode") or "normal").strip().lower()
columns = payload.get("columns") if isinstance(payload.get("columns"), list) else []
normalized_columns = [normalize_column_name(item) for item in columns]
if not normalized_columns:
raise ValueError("at least one index column is required")
if index_mode == "unique":
prefix = f"ADD UNIQUE INDEX {quote_identifier(index_name)}"
elif index_mode == "fulltext":
prefix = f"ADD FULLTEXT INDEX {quote_identifier(index_name)}"
elif index_mode == "normal":
prefix = f"ADD INDEX {quote_identifier(index_name)}"
else:
raise ValueError(f"unsupported index mode: {index_mode}")
column_sql = ", ".join(quote_identifier(item) for item in normalized_columns)
try:
with build_mysql_connection(target_database, autocommit=False) as connection:
with connection.cursor() as cursor:
# 索引列名都走标识符转义,避免前端选择值被直接拼进 SQL。
cursor.execute(
f"ALTER TABLE {quote_identifier(target_table)} {prefix} ({column_sql})",
)
connection.commit()
except Exception as exc: # noqa: BLE001
return browser_error_payload(str(exc))
clear_mysql_metrics_cache()
return {
"ok": True,
"updatedAt": utc_now(),
"message": f"已新增索引 {target_table}.{index_name}",
"error": "",
}
def drop_mysql_index_payload(payload: dict[str, Any]) -> dict[str, Any]:
target_database = normalize_database_name(payload.get("database"))
target_table = normalize_table_name(payload.get("table"))
index_name = normalize_column_name(payload.get("indexName"))
try:
with build_mysql_connection(target_database, autocommit=False) as connection:
with connection.cursor() as cursor:
# PRIMARY KEY 的删除语法和普通索引不同,这里单独分支。
if index_name == "PRIMARY":
cursor.execute(
f"ALTER TABLE {quote_identifier(target_table)} DROP PRIMARY KEY",
)
else:
cursor.execute(
f"ALTER TABLE {quote_identifier(target_table)} DROP INDEX {quote_identifier(index_name)}",
)
connection.commit()
except Exception as exc: # noqa: BLE001
return browser_error_payload(str(exc))
clear_mysql_metrics_cache()
return {
"ok": True,
"updatedAt": utc_now(),
"message": f"已删除索引 {target_table}.{index_name}",
"error": "",
}
def execute_mysql_sql_payload(payload: dict[str, Any]) -> dict[str, Any]:
target_database = str(payload.get("database") or MYSQL_DEFAULT_DATABASE or "").strip() or None
sql = normalize_sql_text(payload.get("sql"))
kind = sql_statement_kind(sql)
mutating_kinds = {"insert", "update", "delete", "replace", "alter", "create", "drop", "truncate", "rename"}
try:
with build_mysql_connection(target_database, autocommit=False) as connection:
with connection.cursor() as cursor:
# SQL 工作台当前只执行单条语句,结果集最多保留固定行数。
cursor.execute(sql)
if cursor.description:
columns = [str(item[0] or "").strip() for item in cursor.description]
fetched_rows = cursor.fetchmany(MYSQL_QUERY_MAX_ROWS + 1)
truncated = len(fetched_rows) > MYSQL_QUERY_MAX_ROWS
rows = [normalize_mysql_row(item) for item in fetched_rows[:MYSQL_QUERY_MAX_ROWS]]
return {
"ok": True,
"updatedAt": utc_now(),
"database": target_database or "",
"kind": kind,
"columns": columns,
"rows": rows,
"rowCount": len(rows),
"truncated": truncated,
"error": "",
}
connection.commit()
affected_rows = int(cursor.rowcount or 0)
last_insert_id = cursor.lastrowid
except Exception as exc: # noqa: BLE001
return browser_error_payload(str(exc))
if kind in mutating_kinds:
clear_mysql_metrics_cache()
return {
"ok": True,
"updatedAt": utc_now(),
"database": target_database or "",
"kind": kind,
"affectedRows": affected_rows,
"lastInsertId": last_insert_id,
"message": f"{kind.upper()} 执行完成",
"error": "",
}