Community edition generated from private repo via sync pipeline. Includes 9 plugins (WordPress, WooCommerce, WP Advanced, Gitea, n8n, Supabase, OpenPanel, Appwrite, Directus) with ~587 tools. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
590 lines
22 KiB
Python
590 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)}
|