Coverage for src / osiris_cli / database_manager.py: 0%

215 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2025-12-27 17:41 +0200

1""" 

2Database Integration for Osiris CLI 

3 

4Unified interface for multiple database types with AI-friendly tools. 

5Supports PostgreSQL, MySQL, SQLite, and MongoDB. 

6""" 

7 

8import json 

9from typing import Optional, Dict, List, Any, Union 

10from pathlib import Path 

11from dataclasses import dataclass 

12from enum import Enum 

13 

14from .logger import get_logger 

15from .errors import DatabaseError, DatabaseConnectionError, DatabaseQueryError 

16 

17logger = get_logger() 

18 

19 

20class DatabaseType(Enum): 

21 """Supported database types""" 

22 POSTGRESQL = "postgresql" 

23 MYSQL = "mysql" 

24 SQLITE = "sqlite" 

25 MONGODB = "mongodb" 

26 

27 

28@dataclass 

29class DatabaseConnection: 

30 """Database connection configuration""" 

31 id: str 

32 type: DatabaseType 

33 host: Optional[str] = None 

34 port: Optional[int] = None 

35 database: Optional[str] = None 

36 username: Optional[str] = None 

37 password: Optional[str] = None 

38 connection_string: Optional[str] = None 

39 

40 # SQLite-specific 

41 file_path: Optional[Path] = None 

42 

43 # MongoDB-specific 

44 auth_source: Optional[str] = None 

45 

46 def __post_init__(self): 

47 """Validate configuration""" 

48 if self.type == DatabaseType.SQLITE: 

49 if not self.file_path: 

50 raise ValueError("SQLite requires file_path") 

51 elif self.type == DatabaseType.MONGODB: 

52 if not self.connection_string and not (self.host and self.database): 

53 raise ValueError("MongoDB requires connection_string or host+database") 

54 else: # PostgreSQL, MySQL 

55 if not self.connection_string and not (self.host and self.database): 

56 raise ValueError(f"{self.type.value} requires connection_string or host+database") 

57 

58 

59class DatabaseManager: 

60 """ 

61 Unified database manager for multiple database types. 

62  

63 Features: 

64 - Connection management 

65 - Schema exploration 

66 - Safe query execution 

67 - Result formatting 

68 - Transaction support 

69 """ 

70 

71 def __init__(self): 

72 """Initialize database manager""" 

73 self.connections: Dict[str, Any] = {} 

74 self.configs: Dict[str, DatabaseConnection] = {} 

75 logger.info("Database manager initialized") 

76 

77 def connect( 

78 self, 

79 connection_id: str, 

80 db_type: str, 

81 **kwargs 

82 ) -> bool: 

83 """ 

84 Connect to a database. 

85  

86 Args: 

87 connection_id: Unique connection identifier 

88 db_type: Database type (postgresql, mysql, sqlite, mongodb) 

89 **kwargs: Connection parameters 

90  

91 Returns: 

92 True if connected successfully 

93 """ 

94 try: 

95 db_type_enum = DatabaseType(db_type.lower()) 

96 

97 # Create connection config 

98 config = DatabaseConnection( 

99 id=connection_id, 

100 type=db_type_enum, 

101 **kwargs 

102 ) 

103 

104 # Establish connection based on type 

105 if db_type_enum == DatabaseType.POSTGRESQL: 

106 connection = self._connect_postgresql(config) 

107 elif db_type_enum == DatabaseType.MYSQL: 

108 connection = self._connect_mysql(config) 

109 elif db_type_enum == DatabaseType.SQLITE: 

110 connection = self._connect_sqlite(config) 

111 elif db_type_enum == DatabaseType.MONGODB: 

112 connection = self._connect_mongodb(config) 

113 else: 

114 raise ValueError(f"Unsupported database type: {db_type}") 

115 

116 self.connections[connection_id] = connection 

117 self.configs[connection_id] = config 

118 

119 logger.info(f"Connected to {db_type} database: {connection_id}") 

120 return True 

121 

122 except Exception as e: 

123 logger.error(f"Failed to connect to {db_type}: {e}", exc_info=True) 

124 raise DatabaseConnectionError(db_type, str(e)) 

125 

126 def _connect_postgresql(self, config: DatabaseConnection) -> Any: 

127 """Connect to PostgreSQL""" 

128 try: 

129 import psycopg2 

130 

131 if config.connection_string: 

132 conn = psycopg2.connect(config.connection_string) 

133 else: 

134 conn = psycopg2.connect( 

135 host=config.host, 

136 port=config.port or 5432, 

137 database=config.database, 

138 user=config.username, 

139 password=config.password 

140 ) 

141 

142 return conn 

143 

144 except ImportError: 

145 raise DatabaseConnectionError( 

146 "PostgreSQL", 

147 "psycopg2 not installed. Install with: pip install psycopg2-binary" 

148 ) 

149 

150 def _connect_mysql(self, config: DatabaseConnection) -> Any: 

151 """Connect to MySQL""" 

152 try: 

153 import mysql.connector 

154 

155 if config.connection_string: 

156 # Parse connection string 

157 conn = mysql.connector.connect(config.connection_string) 

158 else: 

159 conn = mysql.connector.connect( 

160 host=config.host, 

161 port=config.port or 3306, 

162 database=config.database, 

163 user=config.username, 

164 password=config.password 

165 ) 

166 

167 return conn 

168 

169 except ImportError: 

170 raise DatabaseConnectionError( 

171 "MySQL", 

172 "mysql-connector-python not installed. Install with: pip install mysql-connector-python" 

173 ) 

174 

175 def _connect_sqlite(self, config: DatabaseConnection) -> Any: 

176 """Connect to SQLite""" 

177 import sqlite3 

178 

179 file_path = Path(config.file_path) 

180 file_path.parent.mkdir(parents=True, exist_ok=True) 

181 

182 conn = sqlite3.connect(str(file_path)) 

183 conn.row_factory = sqlite3.Row # Enable column access by name 

184 

185 return conn 

186 

187 def _connect_mongodb(self, config: DatabaseConnection) -> Any: 

188 """Connect to MongoDB""" 

189 try: 

190 from pymongo import MongoClient 

191 

192 if config.connection_string: 

193 client = MongoClient(config.connection_string) 

194 else: 

195 client = MongoClient( 

196 host=config.host, 

197 port=config.port or 27017, 

198 username=config.username, 

199 password=config.password, 

200 authSource=config.auth_source or "admin" 

201 ) 

202 

203 # Get database 

204 db = client[config.database] 

205 return db 

206 

207 except ImportError: 

208 raise DatabaseConnectionError( 

209 "MongoDB", 

210 "pymongo not installed. Install with: pip install pymongo" 

211 ) 

212 

213 def disconnect(self, connection_id: str) -> bool: 

214 """Disconnect from database""" 

215 if connection_id not in self.connections: 

216 return False 

217 

218 try: 

219 conn = self.connections[connection_id] 

220 config = self.configs[connection_id] 

221 

222 if config.type == DatabaseType.MONGODB: 

223 conn.client.close() 

224 else: 

225 conn.close() 

226 

227 del self.connections[connection_id] 

228 del self.configs[connection_id] 

229 

230 logger.info(f"Disconnected: {connection_id}") 

231 return True 

232 

233 except Exception as e: 

234 logger.error(f"Error disconnecting {connection_id}: {e}") 

235 return False 

236 

237 def execute_query( 

238 self, 

239 connection_id: str, 

240 query: str, 

241 params: Optional[Union[tuple, dict]] = None, 

242 safe_mode: bool = True 

243 ) -> Dict[str, Any]: 

244 """ 

245 Execute SQL query. 

246  

247 Args: 

248 connection_id: Connection identifier 

249 query: SQL query to execute 

250 params: Query parameters 

251 safe_mode: If True, only allow SELECT queries 

252  

253 Returns: 

254 Query result dict 

255 """ 

256 if connection_id not in self.connections: 

257 raise DatabaseError(f"Connection not found: {connection_id}") 

258 

259 conn = self.connections[connection_id] 

260 config = self.configs[connection_id] 

261 

262 # Safety check 

263 if safe_mode: 

264 query_upper = query.strip().upper() 

265 if not query_upper.startswith('SELECT') and not query_upper.startswith('EXPLAIN'): 

266 raise DatabaseQueryError( 

267 query, 

268 "Only SELECT and EXPLAIN queries allowed in safe mode" 

269 ) 

270 

271 try: 

272 if config.type == DatabaseType.MONGODB: 

273 return self._execute_mongodb_query(conn, query) 

274 else: 

275 return self._execute_sql_query(conn, query, params) 

276 

277 except Exception as e: 

278 logger.error(f"Query failed: {e}", exc_info=True) 

279 raise DatabaseQueryError(query, str(e)) 

280 

281 def _execute_sql_query( 

282 self, 

283 conn: Any, 

284 query: str, 

285 params: Optional[Union[tuple, dict]] = None 

286 ) -> Dict[str, Any]: 

287 """Execute SQL query (PostgreSQL, MySQL, SQLite)""" 

288 cursor = conn.cursor() 

289 

290 try: 

291 if params: 

292 cursor.execute(query, params) 

293 else: 

294 cursor.execute(query) 

295 

296 # Fetch results for SELECT 

297 if query.strip().upper().startswith('SELECT'): 

298 columns = [desc[0] for desc in cursor.description] 

299 rows = cursor.fetchall() 

300 

301 # Convert rows to dicts 

302 results = [ 

303 dict(zip(columns, row)) 

304 for row in rows 

305 ] 

306 

307 return { 

308 "success": True, 

309 "row_count": len(results), 

310 "columns": columns, 

311 "rows": results 

312 } 

313 else: 

314 # For INSERT, UPDATE, DELETE 

315 conn.commit() 

316 return { 

317 "success": True, 

318 "rows_affected": cursor.rowcount 

319 } 

320 

321 finally: 

322 cursor.close() 

323 

324 def _execute_mongodb_query(self, db: Any, query: str) -> Dict[str, Any]: 

325 """Execute MongoDB query (JSON format)""" 

326 # Parse JSON query 

327 query_doc = json.loads(query) 

328 

329 collection_name = query_doc.get("collection") 

330 operation = query_doc.get("operation", "find") 

331 filter_doc = query_doc.get("filter", {}) 

332 

333 if not collection_name: 

334 raise ValueError("MongoDB query must specify 'collection'") 

335 

336 collection = db[collection_name] 

337 

338 # Execute operation 

339 if operation == "find": 

340 limit = query_doc.get("limit", 100) 

341 results = list(collection.find(filter_doc).limit(limit)) 

342 

343 # Convert ObjectId to string 

344 for doc in results: 

345 if "_id" in doc: 

346 doc["_id"] = str(doc["_id"]) 

347 

348 return { 

349 "success": True, 

350 "row_count": len(results), 

351 "documents": results 

352 } 

353 

354 elif operation == "count": 

355 count = collection.count_documents(filter_doc) 

356 return { 

357 "success": True, 

358 "count": count 

359 } 

360 

361 else: 

362 raise ValueError(f"Unsupported MongoDB operation: {operation}") 

363 

364 def get_schema(self, connection_id: str) -> Dict[str, Any]: 

365 """Get database schema (tables, columns, types)""" 

366 if connection_id not in self.connections: 

367 raise DatabaseError(f"Connection not found: {connection_id}") 

368 

369 config = self.configs[connection_id] 

370 

371 if config.type == DatabaseType.POSTGRESQL: 

372 return self._get_postgresql_schema(connection_id) 

373 elif config.type == DatabaseType.MYSQL: 

374 return self._get_mysql_schema(connection_id) 

375 elif config.type == DatabaseType.SQLITE: 

376 return self._get_sqlite_schema(connection_id) 

377 elif config.type == DatabaseType.MONGODB: 

378 return self._get_mongodb_schema(connection_id) 

379 

380 def _get_postgresql_schema(self, connection_id: str) -> Dict[str, Any]: 

381 """Get PostgreSQL schema""" 

382 query = """ 

383 SELECT  

384 table_name, 

385 column_name, 

386 data_type, 

387 is_nullable 

388 FROM information_schema.columns 

389 WHERE table_schema = 'public' 

390 ORDER BY table_name, ordinal_position 

391 """ 

392 

393 result = self.execute_query(connection_id, query, safe_mode=False) 

394 

395 # Group by table 

396 tables = {} 

397 for row in result["rows"]: 

398 table = row["table_name"] 

399 if table not in tables: 

400 tables[table] = [] 

401 

402 tables[table].append({ 

403 "column": row["column_name"], 

404 "type": row["data_type"], 

405 "nullable": row["is_nullable"] == "YES" 

406 }) 

407 

408 return { 

409 "database_type": "PostgreSQL", 

410 "table_count": len(tables), 

411 "tables": tables 

412 } 

413 

414 def _get_mysql_schema(self, connection_id: str) -> Dict[str, Any]: 

415 """Get MySQL schema""" 

416 query = """ 

417 SELECT  

418 TABLE_NAME as table_name, 

419 COLUMN_NAME as column_name, 

420 DATA_TYPE as data_type, 

421 IS_NULLABLE as is_nullable 

422 FROM information_schema.COLUMNS 

423 WHERE TABLE_SCHEMA = DATABASE() 

424 ORDER BY TABLE_NAME, ORDINAL_POSITION 

425 """ 

426 

427 result = self.execute_query(connection_id, query, safe_mode=False) 

428 

429 # Group by table 

430 tables = {} 

431 for row in result["rows"]: 

432 table = row["table_name"] 

433 if table not in tables: 

434 tables[table] = [] 

435 

436 tables[table].append({ 

437 "column": row["column_name"], 

438 "type": row["data_type"], 

439 "nullable": row["is_nullable"] == "YES" 

440 }) 

441 

442 return { 

443 "database_type": "MySQL", 

444 "table_count": len(tables), 

445 "tables": tables 

446 } 

447 

448 def _get_sqlite_schema(self, connection_id: str) -> Dict[str, Any]: 

449 """Get SQLite schema""" 

450 # Get table list 

451 tables_query = "SELECT name FROM sqlite_master WHERE type='table'" 

452 tables_result = self.execute_query(connection_id, tables_query, safe_mode=False) 

453 

454 tables = {} 

455 for row in tables_result["rows"]: 

456 table_name = row["name"] 

457 

458 # Get columns for this table 

459 pragma_query = f"PRAGMA table_info({table_name})" 

460 columns_result = self.execute_query(connection_id, pragma_query, safe_mode=False) 

461 

462 tables[table_name] = [ 

463 { 

464 "column": col["name"], 

465 "type": col["type"], 

466 "nullable": col["notnull"] == 0 

467 } 

468 for col in columns_result["rows"] 

469 ] 

470 

471 return { 

472 "database_type": "SQLite", 

473 "table_count": len(tables), 

474 "tables": tables 

475 } 

476 

477 def _get_mongodb_schema(self, connection_id: str) -> Dict[str, Any]: 

478 """Get MongoDB schema (collections)""" 

479 db = self.connections[connection_id] 

480 

481 collections = db.list_collection_names() 

482 

483 # Sample documents from each collection to infer schema 

484 schema = {} 

485 for coll_name in collections: 

486 collection = db[coll_name] 

487 

488 # Sample first document 

489 sample = collection.find_one() 

490 if sample: 

491 # Get field types 

492 fields = { 

493 key: type(value).__name__ 

494 for key, value in sample.items() 

495 } 

496 schema[coll_name] = fields 

497 

498 return { 

499 "database_type": "MongoDB", 

500 "collection_count": len(collections), 

501 "collections": schema 

502 } 

503 

504 def list_connections(self) -> List[Dict[str, Any]]: 

505 """List all active connections""" 

506 return [ 

507 { 

508 "id": conn_id, 

509 "type": config.type.value, 

510 "database": config.database, 

511 "host": config.host 

512 } 

513 for conn_id, config in self.configs.items() 

514 ] 

515 

516 

517# Global database manager instance 

518db_manager = DatabaseManager()