mcp-dbutils 0.7.0__py3-none-any.whl → 0.9.0__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/config.py CHANGED
@@ -7,14 +7,14 @@ from dataclasses import dataclass, field
7
7
  from typing import Optional, Dict, Any, Literal
8
8
  from pathlib import Path
9
9
 
10
- # Supported database types
11
- DBType = Literal['sqlite', 'postgres']
10
+ # Supported connection types
11
+ ConnectionType = Literal['sqlite', 'postgres']
12
12
 
13
- class DatabaseConfig(ABC):
14
- """Base class for database configuration"""
13
+ class ConnectionConfig(ABC):
14
+ """Base class for connection configuration"""
15
15
 
16
16
  debug: bool = False
17
- type: DBType # Database type
17
+ type: ConnectionType # Connection type
18
18
 
19
19
  @abstractmethod
20
20
  def get_connection_params(self) -> Dict[str, Any]:
@@ -39,19 +39,19 @@ class DatabaseConfig(ABC):
39
39
  with open(yaml_path, 'r', encoding='utf-8') as f:
40
40
  config = yaml.safe_load(f)
41
41
 
42
- if not config or 'databases' not in config:
43
- raise ValueError("Configuration file must contain 'databases' section")
42
+ if not config or 'connections' not in config:
43
+ raise ValueError("Configuration file must contain 'connections' section")
44
44
 
45
45
  # Validate type field in each database configuration
46
- databases = config['databases']
47
- for db_name, db_config in databases.items():
46
+ connections = config['connections']
47
+ for conn_name, db_config in connections.items():
48
48
  if 'type' not in db_config:
49
- raise ValueError(f"Database configuration {db_name} missing required 'type' field")
49
+ raise ValueError(f"Database configuration {conn_name} missing required 'type' field")
50
50
  db_type = db_config['type']
51
51
  if db_type not in ('sqlite', 'postgres'):
52
- raise ValueError(f"Invalid type value in database configuration {db_name}: {db_type}")
52
+ raise ValueError(f"Invalid type value in database configuration {conn_name}: {db_type}")
53
53
 
54
- return databases
54
+ return connections
55
55
 
56
56
  @classmethod
57
57
  def get_debug_mode(cls) -> bool:
@@ -1,6 +1,6 @@
1
1
  """PostgreSQL module"""
2
2
 
3
- from .handler import PostgresHandler
4
- from .config import PostgresConfig
3
+ from .handler import PostgreSQLHandler
4
+ from .config import PostgreSQLConfig
5
5
 
6
- __all__ = ['PostgresHandler', 'PostgresConfig']
6
+ __all__ = ['PostgreSQLHandler', 'PostgreSQLConfig']
@@ -2,7 +2,7 @@
2
2
  from dataclasses import dataclass
3
3
  from typing import Optional, Dict, Any, Literal
4
4
  from urllib.parse import urlparse
5
- from ..config import DatabaseConfig
5
+ from ..config import ConnectionConfig
6
6
 
7
7
  def parse_jdbc_url(jdbc_url: str) -> Dict[str, str]:
8
8
  """Parse JDBC URL into connection parameters
@@ -31,12 +31,12 @@ def parse_jdbc_url(jdbc_url: str) -> Dict[str, str]:
31
31
  }
32
32
 
33
33
  if not params['dbname']:
34
- raise ValueError("Database name must be specified in URL")
34
+ raise ValueError("PostgreSQL database name must be specified in URL")
35
35
 
36
36
  return params
37
37
 
38
38
  @dataclass
39
- class PostgresConfig(DatabaseConfig):
39
+ class PostgreSQLConfig(ConnectionConfig):
40
40
  dbname: str
41
41
  user: str
42
42
  password: str
@@ -46,32 +46,32 @@ class PostgresConfig(DatabaseConfig):
46
46
  type: Literal['postgres'] = 'postgres'
47
47
 
48
48
  @classmethod
49
- def from_yaml(cls, yaml_path: str, db_name: str, local_host: Optional[str] = None) -> 'PostgresConfig':
49
+ def from_yaml(cls, yaml_path: str, db_name: str, local_host: Optional[str] = None) -> 'PostgreSQLConfig':
50
50
  """Create configuration from YAML file
51
51
 
52
52
  Args:
53
53
  yaml_path: Path to YAML configuration file
54
- db_name: Database configuration name to use
54
+ db_name: Connection configuration name to use
55
55
  local_host: Optional local host address
56
56
  """
57
57
  configs = cls.load_yaml_config(yaml_path)
58
58
  if not db_name:
59
- raise ValueError("Database name must be specified")
59
+ raise ValueError("Connection name must be specified")
60
60
  if db_name not in configs:
61
61
  available_dbs = list(configs.keys())
62
- raise ValueError(f"Database configuration not found: {db_name}. Available configurations: {available_dbs}")
62
+ raise ValueError(f"Connection configuration not found: {db_name}. Available configurations: {available_dbs}")
63
63
 
64
64
  db_config = configs[db_name]
65
65
  if 'type' not in db_config:
66
- raise ValueError("Database configuration must include 'type' field")
66
+ raise ValueError("Connection configuration must include 'type' field")
67
67
  if db_config['type'] != 'postgres':
68
68
  raise ValueError(f"Configuration is not PostgreSQL type: {db_config['type']}")
69
69
 
70
70
  # Check required credentials
71
71
  if not db_config.get('user'):
72
- raise ValueError("User must be specified in database configuration")
72
+ raise ValueError("User must be specified in connection configuration")
73
73
  if not db_config.get('password'):
74
- raise ValueError("Password must be specified in database configuration")
74
+ raise ValueError("Password must be specified in connection configuration")
75
75
 
76
76
  # Get connection parameters
77
77
  if 'jdbc_url' in db_config:
@@ -87,11 +87,11 @@ class PostgresConfig(DatabaseConfig):
87
87
  )
88
88
  else:
89
89
  if not db_config.get('dbname'):
90
- raise ValueError("Database name must be specified in configuration")
90
+ raise ValueError("PostgreSQL database name must be specified in configuration")
91
91
  if not db_config.get('host'):
92
- raise ValueError("Host must be specified in configuration")
92
+ raise ValueError("Host must be specified in connection configuration")
93
93
  if not db_config.get('port'):
94
- raise ValueError("Port must be specified in configuration")
94
+ raise ValueError("Port must be specified in connection configuration")
95
95
  config = cls(
96
96
  dbname=db_config['dbname'],
97
97
  user=db_config['user'],
@@ -105,13 +105,13 @@ class PostgresConfig(DatabaseConfig):
105
105
 
106
106
  @classmethod
107
107
  def from_jdbc_url(cls, jdbc_url: str, user: str, password: str,
108
- local_host: Optional[str] = None) -> 'PostgresConfig':
108
+ local_host: Optional[str] = None) -> 'PostgreSQLConfig':
109
109
  """Create configuration from JDBC URL and credentials
110
110
 
111
111
  Args:
112
112
  jdbc_url: JDBC URL (jdbc:postgresql://host:port/dbname)
113
- user: Username for database connection
114
- password: Password for database connection
113
+ user: Username for connection
114
+ password: Password for connection
115
115
  local_host: Optional local host address
116
116
 
117
117
  Raises:
@@ -1,31 +1,31 @@
1
- """PostgreSQL database handler implementation"""
1
+ """PostgreSQL connection handler implementation"""
2
2
 
3
3
  import psycopg2
4
4
  from psycopg2.pool import SimpleConnectionPool
5
5
  import mcp.types as types
6
6
 
7
- from ..base import DatabaseHandler, DatabaseError
8
- from .config import PostgresConfig
7
+ from ..base import ConnectionHandler, ConnectionHandlerError
8
+ from .config import PostgreSQLConfig
9
9
 
10
- class PostgresHandler(DatabaseHandler):
10
+ class PostgreSQLHandler(ConnectionHandler):
11
11
  @property
12
12
  def db_type(self) -> str:
13
13
  return 'postgres'
14
14
 
15
- def __init__(self, config_path: str, database: str, debug: bool = False):
15
+ def __init__(self, config_path: str, connection: str, debug: bool = False):
16
16
  """Initialize PostgreSQL handler
17
17
 
18
18
  Args:
19
19
  config_path: Path to configuration file
20
- database: Database configuration name
20
+ connection: Database connection name
21
21
  debug: Enable debug mode
22
22
  """
23
- super().__init__(config_path, database, debug)
24
- self.config = PostgresConfig.from_yaml(config_path, database)
23
+ super().__init__(config_path, connection, debug)
24
+ self.config = PostgreSQLConfig.from_yaml(config_path, connection)
25
25
 
26
26
  # No connection pool creation during initialization
27
27
  masked_params = self.config.get_masked_connection_info()
28
- self.log("debug", f"Configuring database with parameters: {masked_params}")
28
+ self.log("debug", f"Configuring connection with parameters: {masked_params}")
29
29
  self.pool = None
30
30
 
31
31
  async def get_tables(self) -> list[types.Resource]:
@@ -47,16 +47,16 @@ class PostgresHandler(DatabaseHandler):
47
47
  tables = cur.fetchall()
48
48
  return [
49
49
  types.Resource(
50
- uri=f"postgres://{self.database}/{table[0]}/schema",
50
+ uri=f"postgres://{self.connection}/{table[0]}/schema",
51
51
  name=f"{table[0]} schema",
52
52
  description=table[1] if table[1] else None,
53
53
  mimeType="application/json"
54
54
  ) for table in tables
55
55
  ]
56
56
  except psycopg2.Error as e:
57
- error_msg = f"Failed to get table list: [Code: {e.pgcode}] {e.pgerror or str(e)}"
57
+ error_msg = f"Failed to get constraint information: [Code: {e.pgcode}] {e.pgerror or str(e)}"
58
58
  self.stats.record_error(e.__class__.__name__)
59
- raise DatabaseError(error_msg)
59
+ raise ConnectionHandlerError(error_msg)
60
60
  finally:
61
61
  if conn:
62
62
  conn.close()
@@ -109,7 +109,7 @@ class PostgresHandler(DatabaseHandler):
109
109
  except psycopg2.Error as e:
110
110
  error_msg = f"Failed to read table schema: [Code: {e.pgcode}] {e.pgerror or str(e)}"
111
111
  self.stats.record_error(e.__class__.__name__)
112
- raise DatabaseError(error_msg)
112
+ raise ConnectionHandlerError(error_msg)
113
113
  finally:
114
114
  if conn:
115
115
  conn.close()
@@ -144,7 +144,439 @@ class PostgresHandler(DatabaseHandler):
144
144
  cur.execute("ROLLBACK")
145
145
  except psycopg2.Error as e:
146
146
  error_msg = f"[{self.db_type}] Query execution failed: [Code: {e.pgcode}] {e.pgerror or str(e)}"
147
- raise DatabaseError(error_msg)
147
+ raise ConnectionHandlerError(error_msg)
148
+ finally:
149
+ if conn:
150
+ conn.close()
151
+
152
+ async def get_table_description(self, table_name: str) -> str:
153
+ """Get detailed table description"""
154
+ conn = None
155
+ try:
156
+ conn_params = self.config.get_connection_params()
157
+ conn = psycopg2.connect(**conn_params)
158
+ with conn.cursor() as cur:
159
+ # 获取表的基本信息和注释
160
+ cur.execute("""
161
+ SELECT obj_description(
162
+ (quote_ident(table_schema) || '.' || quote_ident(table_name))::regclass,
163
+ 'pg_class'
164
+ ) as table_comment
165
+ FROM information_schema.tables
166
+ WHERE table_name = %s
167
+ """, (table_name,))
168
+ table_info = cur.fetchone()
169
+ table_comment = table_info[0] if table_info else None
170
+
171
+ # 获取列信息
172
+ cur.execute("""
173
+ SELECT
174
+ column_name,
175
+ data_type,
176
+ column_default,
177
+ is_nullable,
178
+ character_maximum_length,
179
+ numeric_precision,
180
+ numeric_scale,
181
+ col_description(
182
+ (quote_ident(table_schema) || '.' || quote_ident(table_name))::regclass,
183
+ ordinal_position
184
+ ) as column_comment
185
+ FROM information_schema.columns
186
+ WHERE table_name = %s
187
+ ORDER BY ordinal_position
188
+ """, (table_name,))
189
+ columns = cur.fetchall()
190
+
191
+ # 格式化输出
192
+ description = [
193
+ f"Table: {table_name}",
194
+ f"Comment: {table_comment or 'No comment'}\n",
195
+ "Columns:"
196
+ ]
197
+
198
+ for col in columns:
199
+ col_info = [
200
+ f" {col[0]} ({col[1]})",
201
+ f" Nullable: {col[3]}",
202
+ f" Default: {col[2] or 'None'}"
203
+ ]
204
+
205
+ if col[4]: # character_maximum_length
206
+ col_info.append(f" Max Length: {col[4]}")
207
+ if col[5]: # numeric_precision
208
+ col_info.append(f" Precision: {col[5]}")
209
+ if col[6]: # numeric_scale
210
+ col_info.append(f" Scale: {col[6]}")
211
+ if col[7]: # column_comment
212
+ col_info.append(f" Comment: {col[7]}")
213
+
214
+ description.extend(col_info)
215
+ description.append("") # Empty line between columns
216
+
217
+ return "\n".join(description)
218
+
219
+ except psycopg2.Error as e:
220
+ error_msg = f"Failed to get index information: [Code: {e.pgcode}] {e.pgerror or str(e)}"
221
+ self.stats.record_error(e.__class__.__name__)
222
+ raise ConnectionHandlerError(error_msg)
223
+ finally:
224
+ if conn:
225
+ conn.close()
226
+
227
+ async def get_table_ddl(self, table_name: str) -> str:
228
+ """Get DDL statement for creating table"""
229
+ conn = None
230
+ try:
231
+ conn_params = self.config.get_connection_params()
232
+ conn = psycopg2.connect(**conn_params)
233
+ with conn.cursor() as cur:
234
+ # 获取列定义
235
+ cur.execute("""
236
+ SELECT
237
+ column_name,
238
+ data_type,
239
+ column_default,
240
+ is_nullable,
241
+ character_maximum_length,
242
+ numeric_precision,
243
+ numeric_scale
244
+ FROM information_schema.columns
245
+ WHERE table_name = %s
246
+ ORDER BY ordinal_position
247
+ """, (table_name,))
248
+ columns = cur.fetchall()
249
+
250
+ # 获取约束
251
+ cur.execute("""
252
+ SELECT
253
+ conname as constraint_name,
254
+ pg_get_constraintdef(c.oid) as constraint_def
255
+ FROM pg_constraint c
256
+ JOIN pg_class t ON c.conrelid = t.oid
257
+ WHERE t.relname = %s
258
+ """, (table_name,))
259
+ constraints = cur.fetchall()
260
+
261
+ # 构建CREATE TABLE语句
262
+ ddl = [f"CREATE TABLE {table_name} ("]
263
+
264
+ # 添加列定义
265
+ column_defs = []
266
+ for col in columns:
267
+ col_def = [f" {col[0]} {col[1]}"]
268
+
269
+ if col[4]: # character_maximum_length
270
+ col_def[0] = f"{col_def[0]}({col[4]})"
271
+ elif col[5]: # numeric_precision
272
+ if col[6]: # numeric_scale
273
+ col_def[0] = f"{col_def[0]}({col[5]},{col[6]})"
274
+ else:
275
+ col_def[0] = f"{col_def[0]}({col[5]})"
276
+
277
+ if col[2]: # default
278
+ col_def.append(f"DEFAULT {col[2]}")
279
+ if col[3] == 'NO': # not null
280
+ col_def.append("NOT NULL")
281
+
282
+ column_defs.append(" ".join(col_def))
283
+
284
+ # 添加约束定义
285
+ for con in constraints:
286
+ column_defs.append(f" CONSTRAINT {con[0]} {con[1]}")
287
+
288
+ ddl.append(",\n".join(column_defs))
289
+ ddl.append(");")
290
+
291
+ # 添加注释
292
+ cur.execute("""
293
+ SELECT
294
+ c.column_name,
295
+ col_description(
296
+ (quote_ident(table_schema) || '.' || quote_ident(table_name))::regclass,
297
+ c.ordinal_position
298
+ ) as column_comment,
299
+ obj_description(
300
+ (quote_ident(table_schema) || '.' || quote_ident(table_name))::regclass,
301
+ 'pg_class'
302
+ ) as table_comment
303
+ FROM information_schema.columns c
304
+ WHERE c.table_name = %s
305
+ """, (table_name,))
306
+ comments = cur.fetchall()
307
+
308
+ for comment in comments:
309
+ if comment[2]: # table comment
310
+ ddl.append(f"\nCOMMENT ON TABLE {table_name} IS '{comment[2]}';")
311
+ if comment[1]: # column comment
312
+ ddl.append(f"COMMENT ON COLUMN {table_name}.{comment[0]} IS '{comment[1]}';")
313
+
314
+ return "\n".join(ddl)
315
+
316
+ except psycopg2.Error as e:
317
+ error_msg = f"Failed to get table DDL: [Code: {e.pgcode}] {e.pgerror or str(e)}"
318
+ self.stats.record_error(e.__class__.__name__)
319
+ raise ConnectionHandlerError(error_msg)
320
+ finally:
321
+ if conn:
322
+ conn.close()
323
+
324
+ async def get_table_indexes(self, table_name: str) -> str:
325
+ """Get index information for table"""
326
+ conn = None
327
+ try:
328
+ conn_params = self.config.get_connection_params()
329
+ conn = psycopg2.connect(**conn_params)
330
+ with conn.cursor() as cur:
331
+ # 获取索引信息
332
+ cur.execute("""
333
+ SELECT
334
+ i.relname as index_name,
335
+ a.attname as column_name,
336
+ CASE
337
+ WHEN ix.indisprimary THEN 'PRIMARY KEY'
338
+ WHEN ix.indisunique THEN 'UNIQUE'
339
+ ELSE 'INDEX'
340
+ END as index_type,
341
+ am.amname as index_method,
342
+ pg_get_indexdef(ix.indexrelid) as index_def,
343
+ obj_description(i.oid, 'pg_class') as index_comment
344
+ FROM pg_class t
345
+ JOIN pg_index ix ON t.oid = ix.indrelid
346
+ JOIN pg_class i ON ix.indexrelid = i.oid
347
+ JOIN pg_am am ON i.relam = am.oid
348
+ JOIN pg_attribute a ON t.oid = a.attrelid
349
+ WHERE t.relname = %s
350
+ AND a.attnum = ANY(ix.indkey)
351
+ ORDER BY i.relname, a.attnum
352
+ """, (table_name,))
353
+ indexes = cur.fetchall()
354
+
355
+ if not indexes:
356
+ return f"No indexes found on table {table_name}"
357
+
358
+ # 按索引名称分组
359
+ current_index = None
360
+ formatted_indexes = []
361
+ index_info = []
362
+
363
+ for idx in indexes:
364
+ if current_index != idx[0]:
365
+ if index_info:
366
+ formatted_indexes.extend(index_info)
367
+ formatted_indexes.append("")
368
+ current_index = idx[0]
369
+ index_info = [
370
+ f"Index: {idx[0]}",
371
+ f"Type: {idx[2]}",
372
+ f"Method: {idx[3]}",
373
+ "Columns:",
374
+ ]
375
+ if idx[5]: # index comment
376
+ index_info.insert(1, f"Comment: {idx[5]}")
377
+
378
+ index_info.append(f" - {idx[1]}")
379
+
380
+ if index_info:
381
+ formatted_indexes.extend(index_info)
382
+
383
+ return "\n".join(formatted_indexes)
384
+
385
+ except psycopg2.Error as e:
386
+ error_msg = f"Failed to get index information: [Code: {e.pgcode}] {e.pgerror or str(e)}"
387
+ self.stats.record_error(e.__class__.__name__)
388
+ raise ConnectionHandlerError(error_msg)
389
+ finally:
390
+ if conn:
391
+ conn.close()
392
+
393
+ async def get_table_stats(self, table_name: str) -> str:
394
+ """Get table statistics information"""
395
+ conn = None
396
+ try:
397
+ conn_params = self.config.get_connection_params()
398
+ conn = psycopg2.connect(**conn_params)
399
+ with conn.cursor() as cur:
400
+ # Get table statistics
401
+ cur.execute("""
402
+ SELECT
403
+ c.reltuples::bigint as row_estimate,
404
+ pg_size_pretty(pg_total_relation_size(c.oid)) as total_size,
405
+ pg_size_pretty(pg_table_size(c.oid)) as table_size,
406
+ pg_size_pretty(pg_indexes_size(c.oid)) as index_size,
407
+ age(c.relfrozenxid) as xid_age,
408
+ c.relhasindex as has_indexes,
409
+ c.relpages::bigint as pages,
410
+ c.relallvisible::bigint as visible_pages
411
+ FROM pg_class c
412
+ JOIN pg_namespace n ON n.oid = c.relnamespace
413
+ WHERE c.relname = %s AND n.nspname = 'public'
414
+ """, (table_name,))
415
+ stats = cur.fetchone()
416
+
417
+ if not stats:
418
+ return f"No statistics found for table {table_name}"
419
+
420
+ # Get column statistics
421
+ cur.execute("""
422
+ SELECT
423
+ a.attname as column_name,
424
+ s.null_frac * 100 as null_percent,
425
+ s.n_distinct as distinct_values,
426
+ pg_column_size(a.attname::text) as approx_width
427
+ FROM pg_stats s
428
+ JOIN pg_attribute a ON a.attrelid = %s::regclass
429
+ AND a.attnum > 0
430
+ AND a.attname = s.attname
431
+ WHERE s.schemaname = 'public'
432
+ AND s.tablename = %s
433
+ ORDER BY a.attnum;
434
+ """, (table_name, table_name))
435
+ column_stats = cur.fetchall()
436
+
437
+ # Format the output
438
+ output = [
439
+ f"Table Statistics for {table_name}:",
440
+ f" Estimated Row Count: {stats[0]:,}",
441
+ f" Total Size: {stats[1]}",
442
+ f" Table Size: {stats[2]}",
443
+ f" Index Size: {stats[3]}",
444
+ f" Transaction ID Age: {stats[4]:,}",
445
+ f" Has Indexes: {stats[5]}",
446
+ f" Total Pages: {stats[6]:,}",
447
+ f" Visible Pages: {stats[7]:,}\n",
448
+ "Column Statistics:"
449
+ ]
450
+
451
+ for col in column_stats:
452
+ col_info = [
453
+ f" {col[0]}:",
454
+ f" Null Values: {col[1]:.1f}%",
455
+ f" Distinct Values: {col[2] if col[2] >= 0 else 'Unknown'}",
456
+ f" Average Width: {col[3]}"
457
+ ]
458
+ output.extend(col_info)
459
+ output.append("") # Empty line between columns
460
+
461
+ return "\n".join(output)
462
+
463
+ except psycopg2.Error as e:
464
+ error_msg = f"Failed to get table statistics: [Code: {e.pgcode}] {e.pgerror or str(e)}"
465
+ self.stats.record_error(e.__class__.__name__)
466
+ raise ConnectionHandlerError(error_msg)
467
+ finally:
468
+ if conn:
469
+ conn.close()
470
+
471
+ async def get_table_constraints(self, table_name: str) -> str:
472
+ """Get constraint information for table"""
473
+ conn = None
474
+ try:
475
+ conn_params = self.config.get_connection_params()
476
+ conn = psycopg2.connect(**conn_params)
477
+ with conn.cursor() as cur:
478
+ # Get all constraints
479
+ cur.execute("""
480
+ SELECT
481
+ con.conname as constraint_name,
482
+ con.contype as constraint_type,
483
+ pg_get_constraintdef(con.oid) as definition,
484
+ CASE con.contype
485
+ WHEN 'p' THEN 'Primary Key'
486
+ WHEN 'f' THEN 'Foreign Key'
487
+ WHEN 'u' THEN 'Unique'
488
+ WHEN 'c' THEN 'Check'
489
+ WHEN 't' THEN 'Trigger'
490
+ ELSE 'Unknown'
491
+ END as type_desc,
492
+ con.condeferrable as is_deferrable,
493
+ con.condeferred as is_deferred,
494
+ obj_description(con.oid, 'pg_constraint') as comment
495
+ FROM pg_constraint con
496
+ JOIN pg_class rel ON rel.oid = con.conrelid
497
+ JOIN pg_namespace nsp ON nsp.oid = rel.relnamespace
498
+ WHERE rel.relname = %s
499
+ ORDER BY con.contype, con.conname
500
+ """, (table_name,))
501
+ constraints = cur.fetchall()
502
+
503
+ if not constraints:
504
+ return f"No constraints found on table {table_name}"
505
+
506
+ # Format constraints by type
507
+ output = [f"Constraints for {table_name}:"]
508
+ current_type = None
509
+
510
+ for con in constraints:
511
+ if current_type != con[3]:
512
+ current_type = con[3]
513
+ output.append(f"\n{current_type} Constraints:")
514
+
515
+ output.extend([
516
+ f" {con[0]}:",
517
+ f" Definition: {con[2]}"
518
+ ])
519
+
520
+ if con[4]: # is_deferrable
521
+ output.append(f" Deferrable: {'Deferred' if con[5] else 'Immediate'}")
522
+
523
+ if con[6]: # comment
524
+ output.append(f" Comment: {con[6]}")
525
+
526
+ output.append("") # Empty line between constraints
527
+
528
+ return "\n".join(output)
529
+
530
+ except psycopg2.Error as e:
531
+ error_msg = f"Failed to get constraint information: [Code: {e.pgcode}] {e.pgerror or str(e)}"
532
+ self.stats.record_error(e.__class__.__name__)
533
+ raise ConnectionHandlerError(error_msg)
534
+ finally:
535
+ if conn:
536
+ conn.close()
537
+
538
+ async def explain_query(self, sql: str) -> str:
539
+ """Get query execution plan"""
540
+ conn = None
541
+ try:
542
+ conn_params = self.config.get_connection_params()
543
+ conn = psycopg2.connect(**conn_params)
544
+ with conn.cursor() as cur:
545
+ # Get both regular and analyze explain plans
546
+ # Get EXPLAIN output (without execution)
547
+ cur.execute("""
548
+ EXPLAIN (FORMAT TEXT, VERBOSE, COSTS)
549
+ {}
550
+ """.format(sql))
551
+ regular_plan = cur.fetchall()
552
+
553
+ # Get EXPLAIN ANALYZE output (with actual execution)
554
+ cur.execute("""
555
+ EXPLAIN (ANALYZE, FORMAT TEXT, VERBOSE, COSTS, TIMING)
556
+ {}
557
+ """.format(sql))
558
+ analyze_plan = cur.fetchall()
559
+
560
+ output = [
561
+ "Query Execution Plan:",
562
+ "==================",
563
+ "\nEstimated Plan:",
564
+ "----------------"
565
+ ]
566
+ output.extend(line[0] for line in regular_plan)
567
+
568
+ output.extend([
569
+ "\nActual Plan (ANALYZE):",
570
+ "----------------------"
571
+ ])
572
+ output.extend(line[0] for line in analyze_plan)
573
+
574
+ return "\n".join(output)
575
+
576
+ except psycopg2.Error as e:
577
+ error_msg = f"Failed to explain query: [Code: {e.pgcode}] {e.pgerror or str(e)}"
578
+ self.stats.record_error(e.__class__.__name__)
579
+ raise ConnectionHandlerError(error_msg)
148
580
  finally:
149
581
  if conn:
150
582
  conn.close()