mcp-dbutils 0.2.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mcp_dbutils/__init__.py +62 -0
- mcp_dbutils/base.py +182 -0
- mcp_dbutils/config.py +59 -0
- mcp_dbutils/log.py +33 -0
- mcp_dbutils/postgres/__init__.py +6 -0
- mcp_dbutils/postgres/config.py +66 -0
- mcp_dbutils/postgres/handler.py +150 -0
- mcp_dbutils/postgres/server.py +205 -0
- mcp_dbutils/sqlite/__init__.py +6 -0
- mcp_dbutils/sqlite/config.py +74 -0
- mcp_dbutils/sqlite/handler.py +117 -0
- mcp_dbutils/sqlite/server.py +202 -0
- mcp_dbutils-0.2.3.dist-info/METADATA +218 -0
- mcp_dbutils-0.2.3.dist-info/RECORD +17 -0
- mcp_dbutils-0.2.3.dist-info/WHEEL +4 -0
- mcp_dbutils-0.2.3.dist-info/entry_points.txt +2 -0
- mcp_dbutils-0.2.3.dist-info/licenses/LICENSE +21 -0
mcp_dbutils/__init__.py
ADDED
@@ -0,0 +1,62 @@
|
|
1
|
+
"""MCP Database Utilities Service"""
|
2
|
+
|
3
|
+
import asyncio
|
4
|
+
import argparse
|
5
|
+
import os
|
6
|
+
import sys
|
7
|
+
from pathlib import Path
|
8
|
+
import yaml
|
9
|
+
|
10
|
+
from .log import create_logger
|
11
|
+
from .base import DatabaseServer
|
12
|
+
|
13
|
+
# 创建全局logger
|
14
|
+
log = create_logger("mcp-dbutils")
|
15
|
+
|
16
|
+
async def run_server():
|
17
|
+
"""服务器运行逻辑"""
|
18
|
+
parser = argparse.ArgumentParser(description='MCP Database Server')
|
19
|
+
parser.add_argument('--config', required=True, help='YAML配置文件路径')
|
20
|
+
parser.add_argument('--local-host', help='本地主机地址')
|
21
|
+
|
22
|
+
args = parser.parse_args()
|
23
|
+
|
24
|
+
# 检查是否开启debug模式
|
25
|
+
debug = os.getenv('MCP_DEBUG', '').lower() in ('1', 'true', 'yes')
|
26
|
+
|
27
|
+
# 更新logger的debug状态
|
28
|
+
global log
|
29
|
+
log = create_logger("mcp-dbutils", debug)
|
30
|
+
|
31
|
+
if debug:
|
32
|
+
log("debug", "Debug模式已开启")
|
33
|
+
|
34
|
+
# 验证配置文件
|
35
|
+
try:
|
36
|
+
with open(args.config, 'r') as f:
|
37
|
+
config = yaml.safe_load(f)
|
38
|
+
if not config or 'databases' not in config:
|
39
|
+
log("error", "配置文件必须包含 databases 配置")
|
40
|
+
sys.exit(1)
|
41
|
+
if not config['databases']:
|
42
|
+
log("error", "配置文件必须包含至少一个数据库配置")
|
43
|
+
sys.exit(1)
|
44
|
+
except Exception as e:
|
45
|
+
log("error", f"读取配置文件失败: {str(e)}")
|
46
|
+
sys.exit(1)
|
47
|
+
|
48
|
+
# 创建并运行服务器
|
49
|
+
try:
|
50
|
+
server = DatabaseServer(args.config, debug)
|
51
|
+
await server.run()
|
52
|
+
except KeyboardInterrupt:
|
53
|
+
log("info", "服务器已停止")
|
54
|
+
except Exception as e:
|
55
|
+
log("error", str(e))
|
56
|
+
sys.exit(1)
|
57
|
+
|
58
|
+
def main():
|
59
|
+
"""命令行入口函数"""
|
60
|
+
asyncio.run(run_server())
|
61
|
+
|
62
|
+
__all__ = ['main']
|
mcp_dbutils/base.py
ADDED
@@ -0,0 +1,182 @@
|
|
1
|
+
"""Database server base class"""
|
2
|
+
|
3
|
+
from abc import ABC, abstractmethod
|
4
|
+
from typing import Any, List, Optional, AsyncContextManager
|
5
|
+
from contextlib import asynccontextmanager
|
6
|
+
import yaml
|
7
|
+
from mcp.server import Server, NotificationOptions
|
8
|
+
import mcp.server.stdio
|
9
|
+
import mcp.types as types
|
10
|
+
from .log import create_logger
|
11
|
+
|
12
|
+
class DatabaseHandler(ABC):
|
13
|
+
"""Abstract base class defining common interface for database handlers"""
|
14
|
+
|
15
|
+
def __init__(self, config_path: str, database: str, debug: bool = False):
|
16
|
+
"""Initialize database handler
|
17
|
+
|
18
|
+
Args:
|
19
|
+
config_path: Path to configuration file
|
20
|
+
database: Database configuration name
|
21
|
+
debug: Enable debug mode
|
22
|
+
"""
|
23
|
+
self.config_path = config_path
|
24
|
+
self.database = database
|
25
|
+
self.debug = debug
|
26
|
+
self.log = create_logger(f"db-handler-{database}", debug)
|
27
|
+
|
28
|
+
@abstractmethod
|
29
|
+
async def get_tables(self) -> list[types.Resource]:
|
30
|
+
"""Get list of table resources from database"""
|
31
|
+
pass
|
32
|
+
|
33
|
+
@abstractmethod
|
34
|
+
async def get_schema(self, table_name: str) -> str:
|
35
|
+
"""Get schema information for specified table"""
|
36
|
+
pass
|
37
|
+
|
38
|
+
@abstractmethod
|
39
|
+
async def execute_query(self, sql: str) -> str:
|
40
|
+
"""Execute SQL query"""
|
41
|
+
pass
|
42
|
+
|
43
|
+
@abstractmethod
|
44
|
+
async def cleanup(self):
|
45
|
+
"""Cleanup resources"""
|
46
|
+
pass
|
47
|
+
|
48
|
+
class DatabaseServer:
|
49
|
+
"""Unified database server class"""
|
50
|
+
|
51
|
+
def __init__(self, config_path: str, debug: bool = False):
|
52
|
+
"""Initialize database server
|
53
|
+
|
54
|
+
Args:
|
55
|
+
config_path: Path to configuration file
|
56
|
+
debug: Enable debug mode
|
57
|
+
"""
|
58
|
+
self.config_path = config_path
|
59
|
+
self.debug = debug
|
60
|
+
self.log = create_logger("db-server", debug)
|
61
|
+
self.server = Server("database-server")
|
62
|
+
self._setup_handlers()
|
63
|
+
|
64
|
+
@asynccontextmanager
|
65
|
+
async def get_handler(self, database: str) -> AsyncContextManager[DatabaseHandler]:
|
66
|
+
"""Get database handler
|
67
|
+
|
68
|
+
Get appropriate database handler based on configuration name
|
69
|
+
|
70
|
+
Args:
|
71
|
+
database: Database configuration name
|
72
|
+
|
73
|
+
Returns:
|
74
|
+
AsyncContextManager[DatabaseHandler]: Context manager for database handler
|
75
|
+
"""
|
76
|
+
# Read configuration file to determine database type
|
77
|
+
with open(self.config_path, 'r') as f:
|
78
|
+
config = yaml.safe_load(f)
|
79
|
+
if not config or 'databases' not in config:
|
80
|
+
raise ValueError("Configuration file must contain 'databases' section")
|
81
|
+
if database not in config['databases']:
|
82
|
+
available_dbs = list(config['databases'].keys())
|
83
|
+
raise ValueError(f"Database configuration not found: {database}. Available configurations: {available_dbs}")
|
84
|
+
|
85
|
+
db_config = config['databases'][database]
|
86
|
+
|
87
|
+
handler = None
|
88
|
+
try:
|
89
|
+
# Create appropriate handler based on configuration
|
90
|
+
if 'path' in db_config:
|
91
|
+
from .sqlite.handler import SqliteHandler
|
92
|
+
handler = SqliteHandler(self.config_path, database, self.debug)
|
93
|
+
elif 'dbname' in db_config or 'host' in db_config:
|
94
|
+
from .postgres.handler import PostgresHandler
|
95
|
+
handler = PostgresHandler(self.config_path, database, self.debug)
|
96
|
+
else:
|
97
|
+
raise ValueError("Cannot determine database type, missing required parameters in configuration")
|
98
|
+
|
99
|
+
yield handler
|
100
|
+
finally:
|
101
|
+
if handler:
|
102
|
+
await handler.cleanup()
|
103
|
+
|
104
|
+
def _setup_handlers(self):
|
105
|
+
"""Setup MCP handlers"""
|
106
|
+
@self.server.list_resources()
|
107
|
+
async def handle_list_resources(arguments: dict | None = None) -> list[types.Resource]:
|
108
|
+
if not arguments or 'database' not in arguments:
|
109
|
+
# Return empty list when no database specified
|
110
|
+
return []
|
111
|
+
|
112
|
+
database = arguments['database']
|
113
|
+
async with self.get_handler(database) as handler:
|
114
|
+
return await handler.get_tables()
|
115
|
+
|
116
|
+
@self.server.read_resource()
|
117
|
+
async def handle_read_resource(uri: str, arguments: dict | None = None) -> str:
|
118
|
+
if not arguments or 'database' not in arguments:
|
119
|
+
raise ValueError("Database configuration name must be specified")
|
120
|
+
|
121
|
+
parts = uri.split('/')
|
122
|
+
if len(parts) < 3:
|
123
|
+
raise ValueError("Invalid resource URI")
|
124
|
+
|
125
|
+
database = arguments['database']
|
126
|
+
table_name = parts[-2] # URI format: xxx/table_name/schema
|
127
|
+
|
128
|
+
async with self.get_handler(database) as handler:
|
129
|
+
return await handler.get_schema(table_name)
|
130
|
+
|
131
|
+
@self.server.list_tools()
|
132
|
+
async def handle_list_tools() -> list[types.Tool]:
|
133
|
+
return [
|
134
|
+
types.Tool(
|
135
|
+
name="query",
|
136
|
+
description="Execute read-only SQL query",
|
137
|
+
inputSchema={
|
138
|
+
"type": "object",
|
139
|
+
"properties": {
|
140
|
+
"database": {
|
141
|
+
"type": "string",
|
142
|
+
"description": "Database configuration name"
|
143
|
+
},
|
144
|
+
"sql": {
|
145
|
+
"type": "string",
|
146
|
+
"description": "SQL query (SELECT only)"
|
147
|
+
}
|
148
|
+
},
|
149
|
+
"required": ["database", "sql"]
|
150
|
+
}
|
151
|
+
)
|
152
|
+
]
|
153
|
+
|
154
|
+
@self.server.call_tool()
|
155
|
+
async def handle_call_tool(name: str, arguments: dict) -> list[types.TextContent]:
|
156
|
+
if name != "query":
|
157
|
+
raise ValueError(f"Unknown tool: {name}")
|
158
|
+
|
159
|
+
if "database" not in arguments:
|
160
|
+
raise ValueError("Database configuration name must be specified")
|
161
|
+
|
162
|
+
sql = arguments.get("sql", "").strip()
|
163
|
+
if not sql:
|
164
|
+
raise ValueError("SQL query cannot be empty")
|
165
|
+
|
166
|
+
# Only allow SELECT statements
|
167
|
+
if not sql.lower().startswith("select"):
|
168
|
+
raise ValueError("Only SELECT queries are supported")
|
169
|
+
|
170
|
+
database = arguments["database"]
|
171
|
+
async with self.get_handler(database) as handler:
|
172
|
+
result = await handler.execute_query(sql)
|
173
|
+
return [types.TextContent(type="text", text=result)]
|
174
|
+
|
175
|
+
async def run(self):
|
176
|
+
"""Run server"""
|
177
|
+
async with mcp.server.stdio.stdio_server() as streams:
|
178
|
+
await self.server.run(
|
179
|
+
streams[0],
|
180
|
+
streams[1],
|
181
|
+
self.server.create_initialization_options()
|
182
|
+
)
|
mcp_dbutils/config.py
ADDED
@@ -0,0 +1,59 @@
|
|
1
|
+
"""Common configuration utilities"""
|
2
|
+
|
3
|
+
import os
|
4
|
+
import yaml
|
5
|
+
from abc import ABC, abstractmethod
|
6
|
+
from dataclasses import dataclass, field
|
7
|
+
from typing import Optional, Dict, Any, Literal
|
8
|
+
from pathlib import Path
|
9
|
+
|
10
|
+
# Supported database types
|
11
|
+
DBType = Literal['sqlite', 'postgres']
|
12
|
+
|
13
|
+
class DatabaseConfig(ABC):
|
14
|
+
"""Base class for database configuration"""
|
15
|
+
|
16
|
+
debug: bool = False
|
17
|
+
type: DBType # Database type
|
18
|
+
|
19
|
+
@abstractmethod
|
20
|
+
def get_connection_params(self) -> Dict[str, Any]:
|
21
|
+
"""Get connection parameters"""
|
22
|
+
pass
|
23
|
+
|
24
|
+
@abstractmethod
|
25
|
+
def get_masked_connection_info(self) -> Dict[str, Any]:
|
26
|
+
"""Get masked connection information for logging"""
|
27
|
+
pass
|
28
|
+
|
29
|
+
@classmethod
|
30
|
+
def load_yaml_config(cls, yaml_path: str) -> Dict[str, Any]:
|
31
|
+
"""Load YAML configuration file
|
32
|
+
|
33
|
+
Args:
|
34
|
+
yaml_path: Path to YAML file
|
35
|
+
|
36
|
+
Returns:
|
37
|
+
Parsed configuration dictionary
|
38
|
+
"""
|
39
|
+
with open(yaml_path, 'r', encoding='utf-8') as f:
|
40
|
+
config = yaml.safe_load(f)
|
41
|
+
|
42
|
+
if not config or 'databases' not in config:
|
43
|
+
raise ValueError("Configuration file must contain 'databases' section")
|
44
|
+
|
45
|
+
# Validate type field in each database configuration
|
46
|
+
databases = config['databases']
|
47
|
+
for db_name, db_config in databases.items():
|
48
|
+
if 'type' not in db_config:
|
49
|
+
raise ValueError(f"Database configuration {db_name} missing required 'type' field")
|
50
|
+
db_type = db_config['type']
|
51
|
+
if db_type not in ('sqlite', 'postgres'):
|
52
|
+
raise ValueError(f"Invalid type value in database configuration {db_name}: {db_type}")
|
53
|
+
|
54
|
+
return databases
|
55
|
+
|
56
|
+
@classmethod
|
57
|
+
def get_debug_mode(cls) -> bool:
|
58
|
+
"""Get debug mode status"""
|
59
|
+
return os.environ.get('MCP_DEBUG', '').lower() in ('1', 'true')
|
mcp_dbutils/log.py
ADDED
@@ -0,0 +1,33 @@
|
|
1
|
+
"""日志处理模块"""
|
2
|
+
|
3
|
+
import sys
|
4
|
+
from datetime import datetime
|
5
|
+
from typing import Callable, Optional
|
6
|
+
|
7
|
+
def create_logger(name: str, is_debug: bool = False) -> Callable:
|
8
|
+
"""创建日志函数
|
9
|
+
Args:
|
10
|
+
name: 服务名称
|
11
|
+
is_debug: 是否输出debug级别日志
|
12
|
+
"""
|
13
|
+
def log(level: str, message: str, notify: Optional[Callable] = None):
|
14
|
+
"""输出日志
|
15
|
+
Args:
|
16
|
+
level: 日志级别 (debug/info/warning/error)
|
17
|
+
message: 日志内容
|
18
|
+
notify: MCP通知函数(可选)
|
19
|
+
"""
|
20
|
+
if level == "debug" and not is_debug:
|
21
|
+
return
|
22
|
+
|
23
|
+
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S.%f')[:-3]
|
24
|
+
log_message = f"[{timestamp}] [{name}] [{level}] {message}"
|
25
|
+
|
26
|
+
# 始终输出到stderr
|
27
|
+
print(log_message, file=sys.stderr, flush=True)
|
28
|
+
|
29
|
+
# 如果提供了notify函数,同时发送MCP通知
|
30
|
+
if notify:
|
31
|
+
notify(level=level, data=message)
|
32
|
+
|
33
|
+
return log
|
@@ -0,0 +1,66 @@
|
|
1
|
+
"""PostgreSQL configuration module"""
|
2
|
+
from dataclasses import dataclass
|
3
|
+
from typing import Optional, Dict, Any, Literal
|
4
|
+
from ..config import DatabaseConfig
|
5
|
+
|
6
|
+
@dataclass
|
7
|
+
class PostgresConfig(DatabaseConfig):
|
8
|
+
dbname: str
|
9
|
+
user: str
|
10
|
+
password: str
|
11
|
+
host: str = 'localhost'
|
12
|
+
port: str = '5432'
|
13
|
+
local_host: Optional[str] = None
|
14
|
+
type: Literal['postgres'] = 'postgres'
|
15
|
+
|
16
|
+
@classmethod
|
17
|
+
def from_yaml(cls, yaml_path: str, db_name: str, local_host: Optional[str] = None) -> 'PostgresConfig':
|
18
|
+
"""Create configuration from YAML file
|
19
|
+
|
20
|
+
Args:
|
21
|
+
yaml_path: Path to YAML configuration file
|
22
|
+
db_name: Database configuration name to use
|
23
|
+
local_host: Optional local host address
|
24
|
+
"""
|
25
|
+
configs = cls.load_yaml_config(yaml_path)
|
26
|
+
if not db_name:
|
27
|
+
raise ValueError("Database name must be specified")
|
28
|
+
if db_name not in configs:
|
29
|
+
available_dbs = list(configs.keys())
|
30
|
+
raise ValueError(f"Database configuration not found: {db_name}. Available configurations: {available_dbs}")
|
31
|
+
|
32
|
+
db_config = configs[db_name]
|
33
|
+
if 'type' not in db_config:
|
34
|
+
raise ValueError("Database configuration must include 'type' field")
|
35
|
+
if db_config['type'] != 'postgres':
|
36
|
+
raise ValueError(f"Configuration is not PostgreSQL type: {db_config['type']}")
|
37
|
+
|
38
|
+
config = cls(
|
39
|
+
dbname=db_config.get('dbname', ''),
|
40
|
+
user=db_config.get('user', ''),
|
41
|
+
password=db_config.get('password', ''),
|
42
|
+
host=db_config.get('host', 'localhost'),
|
43
|
+
port=str(db_config.get('port', 5432)),
|
44
|
+
local_host=local_host,
|
45
|
+
)
|
46
|
+
config.debug = cls.get_debug_mode()
|
47
|
+
return config
|
48
|
+
|
49
|
+
def get_connection_params(self) -> Dict[str, Any]:
|
50
|
+
"""Get psycopg2 connection parameters"""
|
51
|
+
params = {
|
52
|
+
'dbname': self.dbname,
|
53
|
+
'user': self.user,
|
54
|
+
'password': self.password,
|
55
|
+
'host': self.local_host or self.host,
|
56
|
+
'port': self.port
|
57
|
+
}
|
58
|
+
return {k: v for k, v in params.items() if v}
|
59
|
+
|
60
|
+
def get_masked_connection_info(self) -> Dict[str, Any]:
|
61
|
+
"""Return masked connection information for logging"""
|
62
|
+
return {
|
63
|
+
'dbname': self.dbname,
|
64
|
+
'host': self.local_host or self.host,
|
65
|
+
'port': self.port
|
66
|
+
}
|
@@ -0,0 +1,150 @@
|
|
1
|
+
"""PostgreSQL database handler implementation"""
|
2
|
+
|
3
|
+
import psycopg2
|
4
|
+
from psycopg2.pool import SimpleConnectionPool
|
5
|
+
import mcp.types as types
|
6
|
+
|
7
|
+
from ..base import DatabaseHandler
|
8
|
+
from .config import PostgresConfig
|
9
|
+
|
10
|
+
class PostgresHandler(DatabaseHandler):
|
11
|
+
def __init__(self, config_path: str, database: str, debug: bool = False):
|
12
|
+
"""Initialize PostgreSQL handler
|
13
|
+
|
14
|
+
Args:
|
15
|
+
config_path: Path to configuration file
|
16
|
+
database: Database configuration name
|
17
|
+
debug: Enable debug mode
|
18
|
+
"""
|
19
|
+
super().__init__(config_path, database, debug)
|
20
|
+
self.config = PostgresConfig.from_yaml(config_path, database)
|
21
|
+
|
22
|
+
# No connection pool creation during initialization
|
23
|
+
masked_params = self.config.get_masked_connection_info()
|
24
|
+
self.log("debug", f"Configuring database with parameters: {masked_params}")
|
25
|
+
self.pool = None
|
26
|
+
|
27
|
+
async def get_tables(self) -> list[types.Resource]:
|
28
|
+
"""Get all table resources"""
|
29
|
+
try:
|
30
|
+
conn_params = self.config.get_connection_params()
|
31
|
+
conn = psycopg2.connect(**conn_params)
|
32
|
+
with conn.cursor() as cur:
|
33
|
+
cur.execute("""
|
34
|
+
SELECT
|
35
|
+
table_name,
|
36
|
+
obj_description(
|
37
|
+
(quote_ident(table_schema) || '.' || quote_ident(table_name))::regclass,
|
38
|
+
'pg_class'
|
39
|
+
) as description
|
40
|
+
FROM information_schema.tables
|
41
|
+
WHERE table_schema = 'public'
|
42
|
+
""")
|
43
|
+
tables = cur.fetchall()
|
44
|
+
return [
|
45
|
+
types.Resource(
|
46
|
+
uri=f"postgres://{self.database}/{table[0]}/schema",
|
47
|
+
name=f"{table[0]} schema",
|
48
|
+
description=table[1] if table[1] else None,
|
49
|
+
mimeType="application/json"
|
50
|
+
) for table in tables
|
51
|
+
]
|
52
|
+
except psycopg2.Error as e:
|
53
|
+
error_msg = f"Failed to get table list: [Code: {e.pgcode}] {e.pgerror or str(e)}"
|
54
|
+
self.log("error", error_msg)
|
55
|
+
raise
|
56
|
+
finally:
|
57
|
+
if conn:
|
58
|
+
conn.close()
|
59
|
+
|
60
|
+
async def get_schema(self, table_name: str) -> str:
|
61
|
+
"""Get table schema information"""
|
62
|
+
try:
|
63
|
+
conn_params = self.config.get_connection_params()
|
64
|
+
conn = psycopg2.connect(**conn_params)
|
65
|
+
with conn.cursor() as cur:
|
66
|
+
# Get column information
|
67
|
+
cur.execute("""
|
68
|
+
SELECT
|
69
|
+
column_name,
|
70
|
+
data_type,
|
71
|
+
is_nullable,
|
72
|
+
col_description(
|
73
|
+
(quote_ident(table_schema) || '.' || quote_ident(table_name))::regclass,
|
74
|
+
ordinal_position
|
75
|
+
) as description
|
76
|
+
FROM information_schema.columns
|
77
|
+
WHERE table_name = %s
|
78
|
+
ORDER BY ordinal_position
|
79
|
+
""", (table_name,))
|
80
|
+
columns = cur.fetchall()
|
81
|
+
|
82
|
+
# Get constraint information
|
83
|
+
cur.execute("""
|
84
|
+
SELECT
|
85
|
+
conname as constraint_name,
|
86
|
+
contype as constraint_type
|
87
|
+
FROM pg_constraint c
|
88
|
+
JOIN pg_class t ON c.conrelid = t.oid
|
89
|
+
WHERE t.relname = %s
|
90
|
+
""", (table_name,))
|
91
|
+
constraints = cur.fetchall()
|
92
|
+
|
93
|
+
return str({
|
94
|
+
'columns': [{
|
95
|
+
'name': col[0],
|
96
|
+
'type': col[1],
|
97
|
+
'nullable': col[2] == 'YES',
|
98
|
+
'description': col[3]
|
99
|
+
} for col in columns],
|
100
|
+
'constraints': [{
|
101
|
+
'name': con[0],
|
102
|
+
'type': con[1]
|
103
|
+
} for con in constraints]
|
104
|
+
})
|
105
|
+
except psycopg2.Error as e:
|
106
|
+
error_msg = f"Failed to read table schema: [Code: {e.pgcode}] {e.pgerror or str(e)}"
|
107
|
+
self.log("error", error_msg)
|
108
|
+
raise
|
109
|
+
finally:
|
110
|
+
if conn:
|
111
|
+
conn.close()
|
112
|
+
|
113
|
+
async def execute_query(self, sql: str) -> str:
|
114
|
+
"""Execute SQL query"""
|
115
|
+
try:
|
116
|
+
conn_params = self.config.get_connection_params()
|
117
|
+
conn = psycopg2.connect(**conn_params)
|
118
|
+
self.log("info", f"Executing query: {sql}")
|
119
|
+
|
120
|
+
with conn.cursor() as cur:
|
121
|
+
# Start read-only transaction
|
122
|
+
cur.execute("BEGIN TRANSACTION READ ONLY")
|
123
|
+
try:
|
124
|
+
cur.execute(sql)
|
125
|
+
results = cur.fetchall()
|
126
|
+
columns = [desc[0] for desc in cur.description]
|
127
|
+
formatted_results = [dict(zip(columns, row)) for row in results]
|
128
|
+
|
129
|
+
result_text = str({
|
130
|
+
'columns': columns,
|
131
|
+
'rows': formatted_results,
|
132
|
+
'row_count': len(results)
|
133
|
+
})
|
134
|
+
|
135
|
+
self.log("info", f"Query completed, returned {len(results)} rows")
|
136
|
+
return result_text
|
137
|
+
finally:
|
138
|
+
cur.execute("ROLLBACK")
|
139
|
+
except psycopg2.Error as e:
|
140
|
+
error_msg = f"Query execution failed: [Code: {e.pgcode}] {e.pgerror or str(e)}"
|
141
|
+
self.log("error", error_msg)
|
142
|
+
raise
|
143
|
+
finally:
|
144
|
+
if conn:
|
145
|
+
conn.close()
|
146
|
+
|
147
|
+
async def cleanup(self):
|
148
|
+
"""Cleanup resources"""
|
149
|
+
# No special cleanup needed since we're not using connection pool
|
150
|
+
pass
|