4b6817381b
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
658 lines
24 KiB
Python
658 lines
24 KiB
Python
"""Shared MySQL integration helpers.
|
|
|
|
Provides configuration, connectivity validation, and read-only diagnostic
|
|
queries for MySQL instances. All operations are production-safe: read-only,
|
|
timeouts enforced, result sizes capped.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
from dataclasses import dataclass
|
|
from typing import Any
|
|
|
|
from pydantic import Field, field_validator
|
|
|
|
from integrations._relational import (
|
|
RelationalConfigBase,
|
|
env_int,
|
|
env_str,
|
|
resolve_stored_or_env_config,
|
|
)
|
|
from integrations._validation_helpers import report_classify_failure, report_validation_failure
|
|
from platform.common.truncation import truncate
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
DEFAULT_MYSQL_PORT = 3306
|
|
DEFAULT_MYSQL_USER = "root"
|
|
DEFAULT_MYSQL_SSL_MODE = "preferred"
|
|
DEFAULT_MYSQL_TIMEOUT_SECONDS = 10.0
|
|
DEFAULT_MYSQL_MAX_RESULTS = 50
|
|
|
|
_QUERY_TRUNCATE_LEN = 500
|
|
|
|
|
|
class MySQLConfig(RelationalConfigBase):
|
|
"""Normalized MySQL connection settings."""
|
|
|
|
host: str = ""
|
|
port: int = DEFAULT_MYSQL_PORT
|
|
database: str = ""
|
|
username: str = DEFAULT_MYSQL_USER
|
|
password: str = ""
|
|
ssl_mode: str = DEFAULT_MYSQL_SSL_MODE # preferred, required, disabled
|
|
timeout_seconds: float = Field(default=DEFAULT_MYSQL_TIMEOUT_SECONDS, gt=0)
|
|
max_results: int = Field(default=DEFAULT_MYSQL_MAX_RESULTS, gt=0, le=200)
|
|
integration_id: str = ""
|
|
|
|
@field_validator("username", mode="before")
|
|
@classmethod
|
|
def _normalize_username(cls, value: Any) -> str: # type: ignore[override]
|
|
normalized = str(value or DEFAULT_MYSQL_USER).strip()
|
|
return normalized or DEFAULT_MYSQL_USER
|
|
|
|
@field_validator("ssl_mode", mode="before")
|
|
@classmethod
|
|
def _normalize_ssl_mode(cls, value: Any) -> str:
|
|
normalized = str(value or DEFAULT_MYSQL_SSL_MODE).strip()
|
|
return normalized or DEFAULT_MYSQL_SSL_MODE
|
|
|
|
@property
|
|
def is_configured(self) -> bool:
|
|
return bool(self.host and self.database)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class MySQLValidationResult:
|
|
"""Result of validating a MySQL integration."""
|
|
|
|
ok: bool
|
|
detail: str
|
|
|
|
|
|
def build_mysql_config(raw: dict[str, Any] | None) -> MySQLConfig:
|
|
"""Build a normalized MySQL config object from env/store data."""
|
|
return MySQLConfig.model_validate(raw or {})
|
|
|
|
|
|
def mysql_config_from_env() -> MySQLConfig | None:
|
|
"""Load a MySQL config from environment variables."""
|
|
host = env_str("MYSQL_HOST")
|
|
database = env_str("MYSQL_DATABASE")
|
|
if not host or not database:
|
|
return None
|
|
return build_mysql_config(
|
|
{
|
|
"host": host,
|
|
"port": env_int("MYSQL_PORT", DEFAULT_MYSQL_PORT),
|
|
"database": database,
|
|
"username": env_str("MYSQL_USERNAME", DEFAULT_MYSQL_USER),
|
|
"password": os.getenv("MYSQL_PASSWORD", ""),
|
|
"ssl_mode": env_str("MYSQL_SSL_MODE", DEFAULT_MYSQL_SSL_MODE),
|
|
}
|
|
)
|
|
|
|
|
|
def resolve_mysql_config(host: str, database: str, port: int = DEFAULT_MYSQL_PORT) -> MySQLConfig:
|
|
"""Build a config for the given host/database, resolving credentials from store or env.
|
|
|
|
The LLM supplies only identifying params (host, database, port).
|
|
Credentials (username, password, ssl_mode) are resolved from the stored
|
|
integration or environment variables so they never appear in tool signatures.
|
|
"""
|
|
return resolve_stored_or_env_config(
|
|
"mysql",
|
|
host=host,
|
|
database=database,
|
|
port=port,
|
|
build_config=build_mysql_config,
|
|
env_loader=mysql_config_from_env,
|
|
extra_from_credentials=lambda credentials: {
|
|
"username": credentials.get("username", DEFAULT_MYSQL_USER),
|
|
"password": credentials.get("password", ""),
|
|
"ssl_mode": credentials.get("ssl_mode", DEFAULT_MYSQL_SSL_MODE),
|
|
},
|
|
extra_from_env=lambda config: {
|
|
"username": config.username,
|
|
"password": config.password,
|
|
"ssl_mode": config.ssl_mode,
|
|
},
|
|
)
|
|
|
|
|
|
def _build_ssl_context(ssl_mode: str) -> Any:
|
|
"""Return an ssl context suitable for pymysql, or None if SSL is disabled."""
|
|
if ssl_mode == "disabled":
|
|
return None
|
|
import ssl as _ssl
|
|
|
|
ctx = _ssl.create_default_context()
|
|
if ssl_mode == "preferred":
|
|
# Allow connections to servers without trusted certificates
|
|
ctx.check_hostname = False
|
|
ctx.verify_mode = _ssl.CERT_NONE
|
|
return ctx
|
|
|
|
|
|
def _get_connection(config: MySQLConfig) -> Any:
|
|
"""Create a pymysql connection from config. Caller must close."""
|
|
import pymysql
|
|
import pymysql.cursors
|
|
|
|
connect_timeout = max(1, int(config.timeout_seconds))
|
|
ssl_ctx = _build_ssl_context(config.ssl_mode)
|
|
|
|
return pymysql.connect(
|
|
host=config.host,
|
|
port=config.port,
|
|
database=config.database,
|
|
user=config.username,
|
|
password=config.password,
|
|
ssl=ssl_ctx,
|
|
connect_timeout=connect_timeout,
|
|
read_timeout=int(config.timeout_seconds),
|
|
write_timeout=int(config.timeout_seconds),
|
|
charset="utf8mb4",
|
|
autocommit=True,
|
|
cursorclass=pymysql.cursors.DictCursor,
|
|
)
|
|
|
|
|
|
def validate_mysql_config(config: MySQLConfig) -> MySQLValidationResult:
|
|
"""Validate MySQL connectivity with a lightweight query."""
|
|
if not config.host:
|
|
return MySQLValidationResult(ok=False, detail="MySQL host is required.")
|
|
if not config.database:
|
|
return MySQLValidationResult(ok=False, detail="MySQL database is required.")
|
|
|
|
try:
|
|
conn = _get_connection(config)
|
|
try:
|
|
with conn.cursor() as cur:
|
|
cur.execute("SELECT VERSION()")
|
|
row = cur.fetchone()
|
|
version = row["VERSION()"] if row else "unknown"
|
|
return MySQLValidationResult(
|
|
ok=True,
|
|
detail=(f"Connected to MySQL {version}; target database: {config.database}."),
|
|
)
|
|
finally:
|
|
conn.close()
|
|
except Exception as err:
|
|
report_validation_failure(
|
|
err,
|
|
logger=logger,
|
|
integration="mysql",
|
|
method="validate_mysql_config",
|
|
)
|
|
return MySQLValidationResult(ok=False, detail=f"MySQL connection failed: {err}")
|
|
|
|
|
|
def mysql_is_available(sources: dict[str, dict]) -> bool:
|
|
"""Check if MySQL integration identifying params are present."""
|
|
my = sources.get("mysql", {})
|
|
return bool(my.get("host") and my.get("database"))
|
|
|
|
|
|
def mysql_extract_params(sources: dict[str, dict]) -> dict[str, Any]:
|
|
"""Extract MySQL identifying params (host, database, port) from resolved integrations.
|
|
|
|
Credentials (username, password, ssl_mode) are resolved internally by
|
|
``resolve_mysql_config`` from the integration store or environment, so
|
|
they never appear in tool signatures and are never seen by the LLM.
|
|
"""
|
|
my = sources.get("mysql", {})
|
|
return {
|
|
"host": str(my.get("host", "")).strip(),
|
|
"database": str(my.get("database", "")).strip(),
|
|
"port": int(my.get("port") or DEFAULT_MYSQL_PORT),
|
|
}
|
|
|
|
|
|
def get_server_status(config: MySQLConfig) -> dict[str, Any]:
|
|
"""Retrieve server status (connections, uptime, InnoDB buffer pool metrics).
|
|
|
|
Read-only: uses SHOW GLOBAL STATUS and SHOW VARIABLES.
|
|
"""
|
|
if not config.is_configured:
|
|
return {"source": "mysql", "available": False, "error": "Not configured."}
|
|
|
|
_STATUS_KEYS = frozenset(
|
|
{
|
|
"Threads_connected",
|
|
"Threads_running",
|
|
"Threads_created",
|
|
"Connections",
|
|
"Max_used_connections",
|
|
"Slow_queries",
|
|
"Questions",
|
|
"Queries",
|
|
"Aborted_clients",
|
|
"Aborted_connects",
|
|
"Bytes_received",
|
|
"Bytes_sent",
|
|
"Innodb_buffer_pool_reads",
|
|
"Innodb_buffer_pool_read_requests",
|
|
"Innodb_row_lock_waits",
|
|
"Innodb_row_lock_time",
|
|
"Innodb_deadlocks",
|
|
"Uptime",
|
|
}
|
|
)
|
|
_VARIABLE_KEYS = frozenset(
|
|
{
|
|
"max_connections",
|
|
"innodb_buffer_pool_size",
|
|
"version",
|
|
"version_comment",
|
|
}
|
|
)
|
|
|
|
try:
|
|
conn = _get_connection(config)
|
|
try:
|
|
with conn.cursor() as cur:
|
|
cur.execute("SHOW GLOBAL STATUS")
|
|
all_status = {row["Variable_name"]: row["Value"] for row in cur.fetchall()}
|
|
metrics = {k: all_status[k] for k in sorted(_STATUS_KEYS) if k in all_status}
|
|
|
|
cur.execute(
|
|
"SHOW VARIABLES WHERE Variable_name IN ("
|
|
+ ", ".join(f"'{k}'" for k in sorted(_VARIABLE_KEYS))
|
|
+ ")"
|
|
)
|
|
variables = {row["Variable_name"]: row["Value"] for row in cur.fetchall()}
|
|
|
|
# Calculate InnoDB buffer pool hit ratio
|
|
pool_reads = int(all_status.get("Innodb_buffer_pool_reads", 0))
|
|
pool_requests = int(all_status.get("Innodb_buffer_pool_read_requests", 0))
|
|
pool_hit_ratio = 0.0
|
|
if pool_requests > 0:
|
|
pool_hit_ratio = round((1 - pool_reads / pool_requests) * 100, 2)
|
|
|
|
return {
|
|
"source": "mysql",
|
|
"available": True,
|
|
"version": variables.get("version", "unknown"),
|
|
"version_comment": variables.get("version_comment", ""),
|
|
"uptime_seconds": int(all_status.get("Uptime", 0)),
|
|
"connections": {
|
|
"current": int(all_status.get("Threads_connected", 0)),
|
|
"running": int(all_status.get("Threads_running", 0)),
|
|
"max": int(variables.get("max_connections", 0)),
|
|
"max_used": int(all_status.get("Max_used_connections", 0)),
|
|
"aborted_clients": int(all_status.get("Aborted_clients", 0)),
|
|
"aborted_connects": int(all_status.get("Aborted_connects", 0)),
|
|
},
|
|
"queries": {
|
|
"total": int(all_status.get("Questions", 0)),
|
|
"slow": int(all_status.get("Slow_queries", 0)),
|
|
},
|
|
"innodb": {
|
|
"buffer_pool_size_bytes": int(variables.get("innodb_buffer_pool_size", 0)),
|
|
"buffer_pool_hit_ratio_percent": pool_hit_ratio,
|
|
"row_lock_waits": int(all_status.get("Innodb_row_lock_waits", 0)),
|
|
"deadlocks": int(all_status.get("Innodb_deadlocks", 0)),
|
|
},
|
|
"metrics": metrics,
|
|
}
|
|
finally:
|
|
conn.close()
|
|
except Exception as err:
|
|
report_validation_failure(
|
|
err,
|
|
logger=logger,
|
|
integration="mysql",
|
|
method="get_server_status",
|
|
)
|
|
return {"source": "mysql", "available": False, "error": str(err)}
|
|
|
|
|
|
def get_current_processes(
|
|
config: MySQLConfig,
|
|
threshold_seconds: int = 1,
|
|
) -> dict[str, Any]:
|
|
"""Retrieve currently active processes above a duration threshold.
|
|
|
|
Read-only: queries information_schema.PROCESSLIST.
|
|
Results are capped at config.max_results.
|
|
"""
|
|
if not config.is_configured:
|
|
return {"source": "mysql", "available": False, "error": "Not configured."}
|
|
|
|
try:
|
|
conn = _get_connection(config)
|
|
try:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT ID, USER, HOST, DB, COMMAND, TIME, STATE, INFO
|
|
FROM information_schema.PROCESSLIST
|
|
WHERE COMMAND != 'Sleep'
|
|
AND ID != CONNECTION_ID()
|
|
AND TIME >= %s
|
|
ORDER BY TIME DESC
|
|
LIMIT %s
|
|
""",
|
|
(threshold_seconds, config.max_results),
|
|
)
|
|
processes = []
|
|
for row in cur.fetchall():
|
|
processes.append(
|
|
{
|
|
"id": row["ID"],
|
|
"user": row["USER"],
|
|
"host": row["HOST"] or "",
|
|
"database": row["DB"] or "",
|
|
"command": row["COMMAND"],
|
|
"time_seconds": row["TIME"] or 0,
|
|
"state": row["STATE"] or "",
|
|
"query": truncate(row["INFO"] or "", _QUERY_TRUNCATE_LEN),
|
|
}
|
|
)
|
|
|
|
return {
|
|
"source": "mysql",
|
|
"available": True,
|
|
"threshold_seconds": threshold_seconds,
|
|
"total_processes": len(processes),
|
|
"processes": processes,
|
|
}
|
|
finally:
|
|
conn.close()
|
|
except Exception as err:
|
|
report_validation_failure(
|
|
err,
|
|
logger=logger,
|
|
integration="mysql",
|
|
method="get_current_processes",
|
|
)
|
|
return {"source": "mysql", "available": False, "error": str(err)}
|
|
|
|
|
|
def get_replication_status(config: MySQLConfig) -> dict[str, Any]:
|
|
"""Retrieve replication status (replica IO/SQL thread health, lag).
|
|
|
|
Read-only: uses SHOW REPLICA STATUS (MySQL 8.0.22+) with fallback to
|
|
SHOW SLAVE STATUS for older versions.
|
|
Returns a note if the server is not configured as a replica.
|
|
"""
|
|
if not config.is_configured:
|
|
return {"source": "mysql", "available": False, "error": "Not configured."}
|
|
|
|
# Curated fields — includes both old (Slave_*) and new (Replica_*) column names
|
|
_REPLICA_KEYS = (
|
|
"Replica_IO_Running",
|
|
"Replica_SQL_Running",
|
|
"Seconds_Behind_Source",
|
|
"Slave_IO_Running",
|
|
"Slave_SQL_Running",
|
|
"Seconds_Behind_Master",
|
|
"Last_Error",
|
|
"Last_Errno",
|
|
"Source_Host",
|
|
"Master_Host",
|
|
"Source_Port",
|
|
"Master_Port",
|
|
"Retrieved_Gtid_Set",
|
|
"Executed_Gtid_Set",
|
|
"Relay_Log_Space",
|
|
"Exec_Master_Log_Pos",
|
|
)
|
|
|
|
try:
|
|
conn = _get_connection(config)
|
|
try:
|
|
with conn.cursor() as cur:
|
|
rows: list[dict[str, Any]] = []
|
|
|
|
# MySQL 8.0.22+ uses SHOW REPLICA STATUS; older uses SHOW SLAVE STATUS
|
|
for stmt in ("SHOW REPLICA STATUS", "SHOW SLAVE STATUS"):
|
|
try:
|
|
cur.execute(stmt)
|
|
rows = list(cur.fetchall())
|
|
break
|
|
except Exception as stmt_err:
|
|
import pymysql as _pymysql
|
|
|
|
if isinstance(stmt_err, _pymysql.err.ProgrammingError):
|
|
# SHOW REPLICA STATUS not supported on MySQL < 8.0.22; try SHOW SLAVE STATUS fallback
|
|
continue
|
|
raise
|
|
|
|
if not rows:
|
|
return {
|
|
"source": "mysql",
|
|
"available": True,
|
|
"note": "This server is not configured as a replica.",
|
|
"replicas": [],
|
|
}
|
|
|
|
replicas = []
|
|
for row in rows:
|
|
replicas.append({k: row[k] for k in _REPLICA_KEYS if k in row})
|
|
|
|
return {
|
|
"source": "mysql",
|
|
"available": True,
|
|
"replica_count": len(replicas),
|
|
"replicas": replicas,
|
|
}
|
|
finally:
|
|
conn.close()
|
|
except Exception as err:
|
|
report_validation_failure(
|
|
err,
|
|
logger=logger,
|
|
integration="mysql",
|
|
method="get_replication_status",
|
|
)
|
|
return {"source": "mysql", "available": False, "error": str(err)}
|
|
|
|
|
|
def get_slow_queries(
|
|
config: MySQLConfig,
|
|
threshold_ms: float = 1000.0,
|
|
) -> dict[str, Any]:
|
|
"""Retrieve slow query statistics from performance_schema.
|
|
|
|
Read-only: queries events_statements_summary_by_digest.
|
|
Results capped at config.max_results.
|
|
Returns an informative note if performance_schema is disabled.
|
|
"""
|
|
if not config.is_configured:
|
|
return {"source": "mysql", "available": False, "error": "Not configured."}
|
|
|
|
# performance_schema timer uses picoseconds; convert threshold to picoseconds
|
|
threshold_ps = int(threshold_ms * 1_000_000_000)
|
|
|
|
try:
|
|
conn = _get_connection(config)
|
|
try:
|
|
with conn.cursor() as cur:
|
|
# Check if performance_schema is enabled
|
|
cur.execute("SELECT @@performance_schema")
|
|
row = cur.fetchone()
|
|
if not row or not list(row.values())[0]:
|
|
return {
|
|
"source": "mysql",
|
|
"available": True,
|
|
"performance_schema_available": False,
|
|
"note": (
|
|
"performance_schema is disabled. "
|
|
"Enable it in my.cnf to collect slow query data."
|
|
),
|
|
"queries": [],
|
|
}
|
|
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
DIGEST_TEXT,
|
|
SCHEMA_NAME,
|
|
COUNT_STAR,
|
|
ROUND(AVG_TIMER_WAIT / 1000000000, 3) AS avg_time_ms,
|
|
ROUND(SUM_TIMER_WAIT / 1000000000, 3) AS total_time_ms,
|
|
ROUND(MIN_TIMER_WAIT / 1000000000, 3) AS min_time_ms,
|
|
ROUND(MAX_TIMER_WAIT / 1000000000, 3) AS max_time_ms,
|
|
SUM_ROWS_EXAMINED,
|
|
SUM_ROWS_SENT,
|
|
SUM_NO_INDEX_USED,
|
|
SUM_NO_GOOD_INDEX_USED
|
|
FROM performance_schema.events_statements_summary_by_digest
|
|
WHERE AVG_TIMER_WAIT >= %s
|
|
ORDER BY AVG_TIMER_WAIT DESC
|
|
LIMIT %s
|
|
""",
|
|
(threshold_ps, config.max_results),
|
|
)
|
|
|
|
queries = []
|
|
for row in cur.fetchall():
|
|
queries.append(
|
|
{
|
|
"digest_text": truncate(row["DIGEST_TEXT"] or "", _QUERY_TRUNCATE_LEN),
|
|
"schema_name": row["SCHEMA_NAME"] or "",
|
|
"count": row["COUNT_STAR"] or 0,
|
|
"avg_time_ms": float(row["avg_time_ms"])
|
|
if row["avg_time_ms"] is not None
|
|
else 0.0,
|
|
"total_time_ms": float(row["total_time_ms"])
|
|
if row["total_time_ms"] is not None
|
|
else 0.0,
|
|
"min_time_ms": float(row["min_time_ms"])
|
|
if row["min_time_ms"] is not None
|
|
else 0.0,
|
|
"max_time_ms": float(row["max_time_ms"])
|
|
if row["max_time_ms"] is not None
|
|
else 0.0,
|
|
"rows_examined": row["SUM_ROWS_EXAMINED"] or 0,
|
|
"rows_sent": row["SUM_ROWS_SENT"] or 0,
|
|
"no_index_used": row["SUM_NO_INDEX_USED"] or 0,
|
|
"no_good_index_used": row["SUM_NO_GOOD_INDEX_USED"] or 0,
|
|
}
|
|
)
|
|
|
|
return {
|
|
"source": "mysql",
|
|
"available": True,
|
|
"performance_schema_available": True,
|
|
"threshold_ms": threshold_ms,
|
|
"total_queries": len(queries),
|
|
"queries": queries,
|
|
}
|
|
finally:
|
|
conn.close()
|
|
except Exception as err:
|
|
report_validation_failure(
|
|
err,
|
|
logger=logger,
|
|
integration="mysql",
|
|
method="get_slow_queries",
|
|
)
|
|
return {"source": "mysql", "available": False, "error": str(err)}
|
|
|
|
|
|
def get_table_stats(
|
|
config: MySQLConfig,
|
|
) -> dict[str, Any]:
|
|
"""Retrieve table statistics (size, row counts) from information_schema.
|
|
|
|
Read-only: queries information_schema.TABLES for the configured database.
|
|
Results capped at config.max_results.
|
|
"""
|
|
if not config.is_configured:
|
|
return {"source": "mysql", "available": False, "error": "Not configured."}
|
|
|
|
try:
|
|
conn = _get_connection(config)
|
|
try:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
TABLE_NAME,
|
|
ENGINE,
|
|
TABLE_ROWS,
|
|
ROUND(DATA_LENGTH / 1024 / 1024, 3) AS data_mb,
|
|
ROUND(INDEX_LENGTH / 1024 / 1024, 3) AS index_mb,
|
|
ROUND((DATA_LENGTH + INDEX_LENGTH) / 1024 / 1024, 3) AS total_mb,
|
|
AUTO_INCREMENT,
|
|
TABLE_COLLATION,
|
|
CREATE_TIME,
|
|
UPDATE_TIME
|
|
FROM information_schema.TABLES
|
|
WHERE TABLE_SCHEMA = %s
|
|
AND TABLE_TYPE = 'BASE TABLE'
|
|
ORDER BY (DATA_LENGTH + INDEX_LENGTH) DESC
|
|
LIMIT %s
|
|
""",
|
|
(config.database, config.max_results),
|
|
)
|
|
|
|
tables = []
|
|
for row in cur.fetchall():
|
|
tables.append(
|
|
{
|
|
"table_name": row["TABLE_NAME"],
|
|
"engine": row["ENGINE"] or "",
|
|
"row_count_estimate": row["TABLE_ROWS"] or 0,
|
|
"size": {
|
|
"data_mb": float(row["data_mb"])
|
|
if row["data_mb"] is not None
|
|
else 0.0,
|
|
"index_mb": float(row["index_mb"])
|
|
if row["index_mb"] is not None
|
|
else 0.0,
|
|
"total_mb": float(row["total_mb"])
|
|
if row["total_mb"] is not None
|
|
else 0.0,
|
|
},
|
|
"auto_increment": row["AUTO_INCREMENT"],
|
|
"collation": row["TABLE_COLLATION"] or "",
|
|
"created_at": str(row["CREATE_TIME"]) if row["CREATE_TIME"] else None,
|
|
"updated_at": str(row["UPDATE_TIME"]) if row["UPDATE_TIME"] else None,
|
|
}
|
|
)
|
|
|
|
return {
|
|
"source": "mysql",
|
|
"available": True,
|
|
"database": config.database,
|
|
"total_tables": len(tables),
|
|
"tables": tables,
|
|
}
|
|
finally:
|
|
conn.close()
|
|
except Exception as err:
|
|
report_validation_failure(
|
|
err,
|
|
logger=logger,
|
|
integration="mysql",
|
|
method="get_table_stats",
|
|
)
|
|
return {"source": "mysql", "available": False, "error": str(err)}
|
|
|
|
|
|
def classify(credentials: dict[str, Any], record_id: str) -> tuple[MySQLConfig | None, str | None]:
|
|
try:
|
|
cfg = build_mysql_config(
|
|
{
|
|
"host": credentials.get("host", ""),
|
|
"port": credentials.get("port", 3306),
|
|
"database": credentials.get("database", ""),
|
|
"username": credentials.get("username", "root"),
|
|
"password": credentials.get("password", ""),
|
|
"ssl_mode": credentials.get("ssl_mode", "preferred"),
|
|
"integration_id": record_id,
|
|
}
|
|
)
|
|
except Exception as exc:
|
|
report_classify_failure(exc, logger=logger, integration="mysql", record_id=record_id)
|
|
return None, None
|
|
if cfg.host and cfg.database:
|
|
return cfg, "mysql"
|
|
return None, None
|