Files
mcphub/plugins/wordpress_advanced/handlers/database.py
airano c73e39f6e4 fix(ci): fix black/ruff failures, add CoC and PR template
- Fix Python formatting (sync no longer strips blank lines from .py files)
- Remove test_community_build.py (tests private sync module)
- Fix ruff warnings in test files
- Add CODE_OF_CONDUCT.md
- Add .github/PULL_REQUEST_TEMPLATE.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 18:19:39 +03:30

592 lines
22 KiB
Python

"""
Database Operations Handler
Manages WordPress database operations including:
- Export/Import
- Backup/Restore
- Optimization and Repair
- Search and Query operations
All operations require 'write' or 'admin' scope for security.
"""
import json
from typing import Any
from plugins.wordpress.client import WordPressClient
from plugins.wordpress.wp_cli import WPCLIManager
def get_tool_specifications() -> list[dict[str, Any]]:
"""Return tool specifications for ToolGenerator"""
return [
# DB Export (already exists in wp_cli.py, documented here for completeness)
{
"name": "wp_db_export",
"method_name": "wp_db_export",
"description": "Export WordPress database to SQL file. Creates a timestamped backup file in /tmp directory.",
"schema": {
"type": "object",
"properties": {
"tables": {
"type": "array",
"items": {"type": "string"},
"description": "Specific tables to export (default: all tables)",
},
"exclude_tables": {
"type": "array",
"items": {"type": "string"},
"description": "Tables to exclude from export",
},
"add_drop_table": {
"type": "boolean",
"default": True,
"description": "Include DROP TABLE statements",
},
},
},
"scope": "write",
},
# DB Import
{
"name": "wp_db_import",
"method_name": "wp_db_import",
"description": "Import database from SQL file. DESTRUCTIVE: replaces current database. Requires admin scope.",
"schema": {
"type": "object",
"properties": {
"file_path": {"type": "string", "description": "Path to SQL file on server"},
"url": {"type": "string", "description": "URL to download SQL file from"},
"skip_optimization": {
"type": "boolean",
"default": False,
"description": "Skip database optimization after import",
},
},
},
"scope": "admin",
},
# DB Size Info
{
"name": "wp_db_size",
"method_name": "wp_db_size",
"description": "Get database size statistics including total size, table sizes, and row counts.",
"schema": {"type": "object", "properties": {}},
"scope": "read",
},
# DB Tables List
{
"name": "wp_db_tables",
"method_name": "wp_db_tables",
"description": "List all database tables with detailed information (size, engine, rows, collation).",
"schema": {
"type": "object",
"properties": {
"prefix_only": {
"type": "boolean",
"default": True,
"description": "Show only WordPress tables (with wp_ prefix)",
}
},
},
"scope": "read",
},
# DB Search
{
"name": "wp_db_search",
"method_name": "wp_db_search",
"description": "Search database for specific strings. Useful for finding content, debugging, or data migration.",
"schema": {
"type": "object",
"properties": {
"search_string": {"type": "string", "description": "String to search for"},
"tables": {
"type": "array",
"items": {"type": "string"},
"description": "Specific tables to search",
},
"regex": {
"type": "boolean",
"default": False,
"description": "Use regex pattern matching",
},
"case_sensitive": {
"type": "boolean",
"default": False,
"description": "Case-sensitive search",
},
"max_results": {
"type": "integer",
"default": 100,
"minimum": 1,
"maximum": 1000,
"description": "Maximum results to return",
},
},
"required": ["search_string"],
},
"scope": "read",
},
# DB Query (read-only SELECT)
{
"name": "wp_db_query",
"method_name": "wp_db_query",
"description": "Execute read-only SQL query (SELECT, SHOW, DESCRIBE only). For advanced users and debugging.",
"schema": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "SQL query to execute (SELECT only)",
},
"max_rows": {
"type": "integer",
"default": 1000,
"minimum": 1,
"maximum": 10000,
"description": "Maximum rows to return",
},
},
"required": ["query"],
},
"scope": "write",
},
# DB Repair
{
"name": "wp_db_repair",
"method_name": "wp_db_repair",
"description": "Repair corrupted database tables. Runs REPAIR TABLE on all WordPress tables.",
"schema": {
"type": "object",
"properties": {
"tables": {
"type": "array",
"items": {"type": "string"},
"description": "Specific tables to repair (default: all tables)",
}
},
},
"scope": "write",
},
]
class DatabaseHandler:
"""Handles WordPress database operations"""
def __init__(self, client: WordPressClient, wp_cli: WPCLIManager | None = None):
"""
Initialize Database Handler
Args:
client: WordPress REST API client
wp_cli: WP-CLI manager (optional, for advanced operations)
"""
self.client = client
self.wp_cli = wp_cli
self.logger = client.logger
async def wp_db_export(
self,
tables: list[str] | None = None,
exclude_tables: list[str] | None = None,
add_drop_table: bool = True,
) -> dict[str, Any]:
"""
Export WordPress database to SQL file
Uses WP-CLI: wp db export
"""
if not self.wp_cli:
return {
"success": False,
"error": "WP-CLI not available. Container name not configured.",
}
try:
# Build WP-CLI command
cmd = "db export /tmp/backup-$(date +%Y%m%d-%H%M%S).sql"
if tables:
cmd += f" --tables={','.join(tables)}"
if exclude_tables:
cmd += f" --exclude_tables={','.join(exclude_tables)}"
if not add_drop_table:
cmd += " --no-add-drop-table"
# Execute export
result = await self.wp_cli.execute_command(cmd)
return {
"success": True,
"message": "Database exported successfully",
"file": result.get("output", ""),
"command": cmd,
}
except Exception as e:
self.logger.error(f"Database export failed: {str(e)}")
return {"success": False, "error": str(e)}
async def wp_db_import(
self, file_path: str | None = None, url: str | None = None, skip_optimization: bool = False
) -> dict[str, Any]:
"""
Import database from SQL file
⚠️ DESTRUCTIVE OPERATION - Requires admin scope
"""
if not self.wp_cli:
return {"success": False, "error": "WP-CLI not available"}
if not file_path and not url:
return {"success": False, "error": "Either file_path or url is required"}
try:
# Download file if URL provided
if url:
# Use wget or curl to download
download_cmd = f"wget -O /tmp/import.sql '{url}'"
await self.wp_cli.execute_command(f"eval '{download_cmd}'")
file_path = "/tmp/import.sql"
# Import database
cmd = f"db import {file_path}"
await self.wp_cli.execute_command(cmd)
# Optimize unless skipped
if not skip_optimization:
await self.wp_cli.execute_command("db optimize")
return {
"success": True,
"message": "Database imported successfully",
"file": file_path,
"optimized": not skip_optimization,
}
except Exception as e:
self.logger.error(f"Database import failed: {str(e)}")
return {"success": False, "error": str(e)}
async def wp_db_size(self) -> dict[str, Any]:
"""Get database size statistics"""
if not self.wp_cli:
return {"success": False, "error": "WP-CLI not available"}
try:
# Get total database size first
total_query = """
SELECT ROUND(SUM(data_length + index_length) / 1024 / 1024, 2) AS total_mb,
SUM(table_rows) AS total_rows,
COUNT(*) AS table_count
FROM information_schema.TABLES
WHERE table_schema = DATABASE()
"""
total_cmd = f'db query "{total_query}" --skip-column-names'
total_result = await self.wp_cli.execute_command(total_cmd)
# Parse tab-separated output
total_output = total_result.get("output", "0\t0\t0").strip()
total_parts = total_output.split("\t")
total_size_mb = (
float(total_parts[0]) if len(total_parts) > 0 and total_parts[0] else 0.0
)
total_rows = int(total_parts[1]) if len(total_parts) > 1 and total_parts[1] else 0
table_count = int(total_parts[2]) if len(total_parts) > 2 and total_parts[2] else 0
# Get individual table sizes (top 50 largest tables)
tables_query = """
SELECT table_name,
ROUND((data_length + index_length) / 1024 / 1024, 2) AS size_mb,
table_rows
FROM information_schema.TABLES
WHERE table_schema = DATABASE()
ORDER BY (data_length + index_length) DESC
LIMIT 50
"""
tables_cmd = f'db query "{tables_query}" --skip-column-names'
tables_result = await self.wp_cli.execute_command(tables_cmd)
# Parse table results (tab-separated)
tables_output = tables_result.get("output", "").strip()
tables = []
if tables_output:
for line in tables_output.split("\n"):
parts = line.split("\t")
if len(parts) >= 3:
tables.append(
{
"table_name": parts[0],
"size_mb": float(parts[1]) if parts[1] else 0.0,
"table_rows": int(parts[2]) if parts[2] else 0,
}
)
return {
"success": True,
"total_size_mb": total_size_mb,
"total_rows": total_rows,
"table_count": table_count,
"tables": tables,
"note": "Showing top 50 largest tables",
}
except Exception as e:
self.logger.error(f"Database size check failed: {str(e)}")
return {"success": False, "error": str(e)}
async def wp_db_tables(self, prefix_only: bool = True) -> dict[str, Any]:
"""List all database tables with details"""
if not self.wp_cli:
return {"success": False, "error": "WP-CLI not available"}
try:
# Query for table information
query = """
SELECT
table_name,
engine,
table_rows,
ROUND((data_length / 1024 / 1024), 2),
ROUND((index_length / 1024 / 1024), 2),
ROUND(((data_length + index_length) / 1024 / 1024), 2),
table_collation
FROM information_schema.TABLES
WHERE table_schema = DATABASE()
"""
if prefix_only:
# Get WordPress table prefix
prefix_result = await self.wp_cli.execute_command("config get table_prefix")
prefix = prefix_result.get("output", "wp_").strip()
query += f" AND table_name LIKE '{prefix}%'"
query += " ORDER BY (data_length + index_length) DESC"
# Use --skip-column-names for MariaDB compatibility (no --format=json)
cmd = f'db query "{query}" --skip-column-names'
result = await self.wp_cli.execute_command(cmd)
# Parse tab-separated output
tables_output = result.get("output", "").strip()
tables = []
if tables_output:
for line in tables_output.split("\n"):
parts = line.split("\t")
if len(parts) >= 7:
tables.append(
{
"name": parts[0],
"engine": parts[1],
"rows": int(parts[2]) if parts[2] and parts[2] != "NULL" else 0,
"data_size_mb": (
float(parts[3]) if parts[3] and parts[3] != "NULL" else 0.0
),
"index_size_mb": (
float(parts[4]) if parts[4] and parts[4] != "NULL" else 0.0
),
"total_size_mb": (
float(parts[5]) if parts[5] and parts[5] != "NULL" else 0.0
),
"collation": parts[6] if parts[6] != "NULL" else None,
}
)
return {"success": True, "tables": tables, "total": len(tables)}
except Exception as e:
self.logger.error(f"Database tables list failed: {str(e)}")
return {"success": False, "error": str(e)}
async def wp_db_search(
self,
search_string: str,
tables: list[str] | None = None,
regex: bool = False,
case_sensitive: bool = False,
max_results: int = 100,
) -> dict[str, Any]:
"""Search database for specific strings"""
if not self.wp_cli:
return {"success": False, "error": "WP-CLI not available"}
try:
# Build search-replace command in dry-run mode
cmd = f'search-replace "{search_string}" "{search_string}" --dry-run --format=count'
if tables:
cmd += f" {' '.join(tables)}"
if regex:
cmd += " --regex"
if not case_sensitive:
cmd += " --skip-columns=guid" # Common practice
result = await self.wp_cli.execute_command(cmd)
return {
"success": True,
"search_string": search_string,
"matches_found": result.get("output", "0"),
"dry_run": True,
}
except Exception as e:
self.logger.error(f"Database search failed: {str(e)}")
return {"success": False, "error": str(e)}
async def wp_db_query(self, query: str, max_rows: int = 1000) -> dict[str, Any]:
"""
Execute read-only SQL query
Security: Only SELECT, SHOW, DESCRIBE queries allowed
"""
if not self.wp_cli:
return {"success": False, "error": "WP-CLI not available"}
# Validate query (additional server-side validation)
query_upper = query.strip().upper()
allowed_starts = ("SELECT", "SHOW", "DESCRIBE", "DESC", "EXPLAIN")
if not any(query_upper.startswith(cmd) for cmd in allowed_starts):
return {
"success": False,
"error": "Only SELECT, SHOW, DESCRIBE, and EXPLAIN queries allowed",
}
forbidden = [
"INSERT",
"UPDATE",
"DELETE",
"DROP",
"ALTER",
"CREATE",
"TRUNCATE",
"REPLACE",
"GRANT",
"REVOKE",
]
for keyword in forbidden:
if keyword in query_upper:
return {"success": False, "error": f"Forbidden keyword in query: {keyword}"}
try:
# Add LIMIT if not present
if "LIMIT" not in query_upper:
query = f"{query.rstrip(';')} LIMIT {max_rows}"
# Execute query with --skip-column-names for MariaDB compatibility
# First, get column names separately if it's a SELECT
results = []
if query_upper.startswith("SELECT"):
# For SELECT queries, we need to parse the tab-separated output
cmd = f'db query "{query}" --skip-column-names'
result = await self.wp_cli.execute_command(cmd)
# Get output
output = result.get("output", "").strip()
if output:
# Parse tab-separated values
lines = output.split("\n")
# For simple queries, try to detect column structure
# We'll return as a list of dictionaries with column indices
for idx, line in enumerate(lines):
values = line.split("\t")
# Create a row dict with column indices
row = {f"col_{i}": val for i, val in enumerate(values)}
results.append(row)
# Limit results
if idx >= max_rows - 1:
break
else:
# For SHOW, DESCRIBE, etc., just return raw output
cmd = f'db query "{query}"'
result = await self.wp_cli.execute_command(cmd)
output = result.get("output", "").strip()
# Return as formatted message
return {
"success": True,
"output": output,
"query": query,
"note": "Results displayed as plain text",
}
return {
"success": True,
"results": results,
"row_count": len(results),
"query": query,
"note": "Columns are numbered as col_0, col_1, etc. due to MariaDB compatibility mode.",
}
except Exception as e:
self.logger.error(f"Database query failed: {str(e)}")
return {"success": False, "error": str(e)}
async def wp_db_repair(self, tables: list[str] | None = None) -> dict[str, Any]:
"""Repair corrupted database tables"""
if not self.wp_cli:
return {"success": False, "error": "WP-CLI not available"}
try:
# Get list of tables to repair
if not tables:
# Get all WordPress tables
table_list = await self.wp_db_tables(prefix_only=True)
if not table_list.get("success"):
return table_list
tables = [t["name"] for t in table_list.get("tables", [])]
# Repair each table
results = []
for table in tables:
try:
query = f"REPAIR TABLE {table}"
cmd = f'db query "{query}" --format=json'
result = await self.wp_cli.execute_command(cmd)
repair_result = json.loads(result.get("output", "[]"))
results.append(
{
"table": table,
"status": "Repaired" if repair_result else "OK",
"message": str(repair_result),
}
)
except Exception as e:
results.append({"table": table, "status": "Failed", "message": str(e)})
# Count successes/failures
success_count = sum(1 for r in results if r["status"] != "Failed")
failed_count = len(results) - success_count
return {
"success": True,
"total_tables": len(results),
"success_count": success_count,
"failed_count": failed_count,
"results": results,
}
except Exception as e:
self.logger.error(f"Database repair failed: {str(e)}")
return {"success": False, "error": str(e)}