thoth-dbmanager 0.4.0__py3-none-any.whl → 0.4.1__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.
Files changed (41) hide show
  1. thoth_dbmanager/ThothDbManager.py +459 -0
  2. thoth_dbmanager/__init__.py +136 -0
  3. thoth_dbmanager/adapters/__init__.py +21 -0
  4. thoth_dbmanager/adapters/mariadb.py +165 -0
  5. thoth_dbmanager/adapters/mysql.py +165 -0
  6. thoth_dbmanager/adapters/oracle.py +554 -0
  7. thoth_dbmanager/adapters/postgresql.py +444 -0
  8. thoth_dbmanager/adapters/sqlite.py +385 -0
  9. thoth_dbmanager/adapters/sqlserver.py +583 -0
  10. thoth_dbmanager/adapters/supabase.py +249 -0
  11. thoth_dbmanager/core/__init__.py +13 -0
  12. thoth_dbmanager/core/factory.py +272 -0
  13. thoth_dbmanager/core/interfaces.py +271 -0
  14. thoth_dbmanager/core/registry.py +220 -0
  15. thoth_dbmanager/documents.py +155 -0
  16. thoth_dbmanager/dynamic_imports.py +250 -0
  17. thoth_dbmanager/helpers/__init__.py +0 -0
  18. thoth_dbmanager/helpers/multi_db_generator.py +508 -0
  19. thoth_dbmanager/helpers/preprocess_values.py +159 -0
  20. thoth_dbmanager/helpers/schema.py +376 -0
  21. thoth_dbmanager/helpers/search.py +117 -0
  22. thoth_dbmanager/lsh/__init__.py +21 -0
  23. thoth_dbmanager/lsh/core.py +182 -0
  24. thoth_dbmanager/lsh/factory.py +76 -0
  25. thoth_dbmanager/lsh/manager.py +170 -0
  26. thoth_dbmanager/lsh/storage.py +96 -0
  27. thoth_dbmanager/plugins/__init__.py +23 -0
  28. thoth_dbmanager/plugins/mariadb.py +436 -0
  29. thoth_dbmanager/plugins/mysql.py +408 -0
  30. thoth_dbmanager/plugins/oracle.py +150 -0
  31. thoth_dbmanager/plugins/postgresql.py +145 -0
  32. thoth_dbmanager/plugins/sqlite.py +170 -0
  33. thoth_dbmanager/plugins/sqlserver.py +149 -0
  34. thoth_dbmanager/plugins/supabase.py +224 -0
  35. {thoth_dbmanager-0.4.0.dist-info → thoth_dbmanager-0.4.1.dist-info}/METADATA +6 -6
  36. thoth_dbmanager-0.4.1.dist-info/RECORD +39 -0
  37. thoth_dbmanager-0.4.1.dist-info/top_level.txt +1 -0
  38. thoth_dbmanager-0.4.0.dist-info/RECORD +0 -5
  39. thoth_dbmanager-0.4.0.dist-info/top_level.txt +0 -1
  40. {thoth_dbmanager-0.4.0.dist-info → thoth_dbmanager-0.4.1.dist-info}/WHEEL +0 -0
  41. {thoth_dbmanager-0.4.0.dist-info → thoth_dbmanager-0.4.1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,583 @@
1
+ """
2
+ SQL Server adapter implementation.
3
+ """
4
+ import logging
5
+ from typing import Any, Dict, List, Optional, Union
6
+ from sqlalchemy import create_engine, inspect, text
7
+ from sqlalchemy.exc import SQLAlchemyError
8
+
9
+ from ..core.interfaces import DbAdapter
10
+ from ..documents import TableDocument, ColumnDocument, ForeignKeyDocument, SchemaDocument, IndexDocument
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ class SQLServerAdapter(DbAdapter):
16
+ """SQL Server database adapter implementation."""
17
+
18
+ def __init__(self, connection_params: Dict[str, Any]):
19
+ super().__init__(connection_params)
20
+ self.engine = None
21
+ self.host = connection_params.get('host', 'localhost')
22
+ self.port = connection_params.get('port', 1433)
23
+ self.database = connection_params.get('database')
24
+ self.user = connection_params.get('user')
25
+ self.password = connection_params.get('password')
26
+ self.driver = connection_params.get('driver', 'ODBC Driver 17 for SQL Server')
27
+
28
+ def connect(self) -> None:
29
+ """Establish database connection."""
30
+ try:
31
+ # Build connection string for SQL Server (this will test drivers)
32
+ connection_string = self._build_connection_string()
33
+
34
+ # Create the engine (connection already tested in _build_connection_string)
35
+ self.engine = create_engine(connection_string, pool_pre_ping=True)
36
+
37
+ self._initialized = True
38
+ logger.info("SQL Server connection established successfully")
39
+
40
+ except Exception as e:
41
+ logger.error(f"Failed to connect to SQL Server: {e}")
42
+ raise ConnectionError(f"Failed to connect to SQL Server: {e}")
43
+
44
+ def _build_connection_string(self) -> str:
45
+ """Build SQLAlchemy connection string for SQL Server"""
46
+ if not all([self.database, self.user, self.password]):
47
+ raise ValueError("Missing required connection parameters: database, user, password")
48
+
49
+ # Try different connection methods in order of preference
50
+ connection_methods = [
51
+ # Try pyodbc with ODBC Driver 18 and SSL bypass (for testing with containers)
52
+ lambda: f"mssql+pyodbc://{self.user}:{self.password}@{self.host}:{self.port}/{self.database}?driver=ODBC+Driver+18+for+SQL+Server&TrustServerCertificate=yes&Encrypt=yes",
53
+ # Try pyodbc with ODBC Driver 18 without encryption
54
+ lambda: f"mssql+pyodbc://{self.user}:{self.password}@{self.host}:{self.port}/{self.database}?driver=ODBC+Driver+18+for+SQL+Server&TrustServerCertificate=yes&Encrypt=no",
55
+ # Try pymssql (easier to install, no system dependencies)
56
+ lambda: f"mssql+pymssql://{self.user}:{self.password}@{self.host}:{self.port}/{self.database}",
57
+ # Try pyodbc with other drivers
58
+ lambda: f"mssql+pyodbc://{self.user}:{self.password}@{self.host}:{self.port}/{self.database}?driver=ODBC+Driver+17+for+SQL+Server&TrustServerCertificate=yes",
59
+ lambda: f"mssql+pyodbc://{self.user}:{self.password}@{self.host}:{self.port}/{self.database}?driver=FreeTDS",
60
+ ]
61
+
62
+ # Try each connection method until one works
63
+ for i, method in enumerate(connection_methods):
64
+ try:
65
+ connection_string = method()
66
+
67
+ # Test the connection string by creating a temporary engine
68
+ test_engine = create_engine(connection_string, pool_pre_ping=True)
69
+ with test_engine.connect() as conn:
70
+ conn.execute(text("SELECT 1"))
71
+ test_engine.dispose()
72
+
73
+ method_name = "pymssql" if i == 0 else f"pyodbc method {i}"
74
+ logger.info(f"Successfully connected using {method_name}")
75
+ return connection_string
76
+
77
+ except Exception as e:
78
+ logger.debug(f"Connection method {i+1} failed: {e}")
79
+ continue
80
+
81
+ # If all methods fail, raise an error with helpful information
82
+ raise ConnectionError(
83
+ f"Failed to connect to SQL Server using any available method. "
84
+ f"Please ensure SQL Server is running and accessible, and that either "
85
+ f"pymssql or pyodbc with appropriate drivers is installed."
86
+ )
87
+
88
+ def disconnect(self) -> None:
89
+ """Close database connection."""
90
+ if self.engine:
91
+ self.engine.dispose()
92
+ self.engine = None
93
+ self._initialized = False
94
+
95
+ def execute_query(self, query: str, params: Optional[Dict] = None, fetch: Union[str, int] = "all", timeout: int = 60) -> Any:
96
+ """Execute SQL query"""
97
+ if not self.engine:
98
+ raise RuntimeError("Not connected to database")
99
+
100
+ try:
101
+ with self.engine.connect() as conn:
102
+ # Set query timeout (SQL Server uses seconds)
103
+ conn.execute(text(f"SET LOCK_TIMEOUT {timeout * 1000}")) # SQL Server uses milliseconds
104
+
105
+ # Execute query
106
+ if params:
107
+ result = conn.execute(text(query), params)
108
+ else:
109
+ result = conn.execute(text(query))
110
+
111
+ # Handle different fetch modes
112
+ if query.strip().upper().startswith(('SELECT', 'WITH')):
113
+ if fetch == "all":
114
+ return result.fetchall()
115
+ elif fetch == "one":
116
+ return result.fetchone()
117
+ elif isinstance(fetch, int):
118
+ return result.fetchmany(fetch)
119
+ else:
120
+ return result.fetchall()
121
+ else:
122
+ # For non-SELECT queries, return rowcount
123
+ conn.commit()
124
+ return result.rowcount
125
+
126
+ except SQLAlchemyError as e:
127
+ logger.error(f"SQL Server query error: {e}")
128
+ raise
129
+
130
+ def execute_update(self, query: str, params: Optional[Dict[str, Any]] = None) -> int:
131
+ """Execute an update query and return affected row count."""
132
+ if not self.engine:
133
+ self.connect()
134
+
135
+ try:
136
+ with self.engine.connect() as conn:
137
+ result = conn.execute(text(query), params or {})
138
+ conn.commit()
139
+ return result.rowcount
140
+ except SQLAlchemyError as e:
141
+ raise RuntimeError(f"SQL Server update failed: {e}")
142
+
143
+ def get_tables(self) -> List[str]:
144
+ """Get list of tables in the database."""
145
+ query = """
146
+ SELECT TABLE_NAME as name
147
+ FROM INFORMATION_SCHEMA.TABLES
148
+ WHERE TABLE_TYPE = 'BASE TABLE'
149
+ ORDER BY TABLE_NAME
150
+ """
151
+ result = self.execute_query(query)
152
+ return [row['name'] for row in result]
153
+
154
+ def get_table_schema(self, table_name: str) -> Dict[str, Any]:
155
+ """Get schema information for a specific table."""
156
+ query = f"""
157
+ SELECT
158
+ COLUMN_NAME as name,
159
+ DATA_TYPE as type,
160
+ IS_NULLABLE as nullable,
161
+ COLUMN_DEFAULT as default_value,
162
+ CASE WHEN COLUMNPROPERTY(OBJECT_ID(TABLE_NAME), COLUMN_NAME, 'IsIdentity') = 1 THEN 1 ELSE 0 END as is_identity,
163
+ CASE WHEN EXISTS (
164
+ SELECT 1 FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE
165
+ WHERE TABLE_NAME = '{table_name}'
166
+ AND COLUMN_NAME = c.COLUMN_NAME
167
+ AND CONSTRAINT_NAME LIKE 'PK_%'
168
+ ) THEN 1 ELSE 0 END as is_primary_key
169
+ FROM INFORMATION_SCHEMA.COLUMNS c
170
+ WHERE TABLE_NAME = '{table_name}'
171
+ ORDER BY ORDINAL_POSITION
172
+ """
173
+
174
+ columns = self.execute_query(query)
175
+
176
+ schema = {
177
+ 'table_name': table_name,
178
+ 'columns': []
179
+ }
180
+
181
+ for col in columns:
182
+ schema['columns'].append({
183
+ 'name': col['name'],
184
+ 'type': col['type'],
185
+ 'nullable': col['nullable'] == 'YES',
186
+ 'default': col['default_value'],
187
+ 'primary_key': bool(col['is_primary_key']),
188
+ 'auto_increment': bool(col['is_identity'])
189
+ })
190
+
191
+ return schema
192
+
193
+ def get_indexes(self, table_name: str) -> List[Dict[str, Any]]:
194
+ """Get index information for a table."""
195
+ query = f"""
196
+ SELECT
197
+ i.name as index_name,
198
+ c.name as column_name,
199
+ i.is_unique as unique_index,
200
+ i.type_desc as index_type
201
+ FROM sys.indexes i
202
+ JOIN sys.index_columns ic ON i.object_id = ic.object_id AND i.index_id = ic.index_id
203
+ JOIN sys.columns c ON ic.object_id = c.object_id AND ic.column_id = c.column_id
204
+ JOIN sys.tables t ON i.object_id = t.object_id
205
+ WHERE t.name = '{table_name}'
206
+ ORDER BY i.name, ic.key_ordinal
207
+ """
208
+
209
+ return self.execute_query(query)
210
+
211
+ def get_foreign_keys(self, table_name: str) -> List[Dict[str, Any]]:
212
+ """Get foreign key information for a table."""
213
+ query = f"""
214
+ SELECT
215
+ fk.name as constraint_name,
216
+ c.name as column_name,
217
+ OBJECT_NAME(fk.referenced_object_id) as referenced_table,
218
+ rc.name as referenced_column
219
+ FROM sys.foreign_keys fk
220
+ JOIN sys.foreign_key_columns fkc ON fk.object_id = fkc.constraint_object_id
221
+ JOIN sys.columns c ON fkc.parent_object_id = c.object_id AND fkc.parent_column_id = c.column_id
222
+ JOIN sys.columns rc ON fkc.referenced_object_id = rc.object_id AND fkc.referenced_column_id = rc.column_id
223
+ JOIN sys.tables t ON fk.parent_object_id = t.object_id
224
+ WHERE t.name = '{table_name}'
225
+ """
226
+
227
+ return self.execute_query(query)
228
+
229
+ def create_table(self, table_name: str, schema: Dict[str, Any]) -> None:
230
+ """Create a new table with the given schema."""
231
+ columns = []
232
+ for col in schema.get('columns', []):
233
+ col_def = f"[{col['name']}] {col['type']}"
234
+ if not col.get('nullable', True):
235
+ col_def += " NOT NULL"
236
+ if col.get('default') is not None:
237
+ col_def += f" DEFAULT {col['default']}"
238
+ if col.get('primary_key'):
239
+ col_def += " PRIMARY KEY"
240
+ if col.get('auto_increment'):
241
+ col_def += " IDENTITY(1,1)"
242
+ columns.append(col_def)
243
+
244
+ query = f"CREATE TABLE [{table_name}] ({', '.join(columns)})"
245
+ self.execute_update(query)
246
+
247
+ def drop_table(self, table_name: str) -> None:
248
+ """Drop a table."""
249
+ query = f"DROP TABLE IF EXISTS [{table_name}]"
250
+ self.execute_update(query)
251
+
252
+ def table_exists(self, table_name: str) -> bool:
253
+ """Check if a table exists."""
254
+ query = f"""
255
+ SELECT COUNT(*) as count
256
+ FROM INFORMATION_SCHEMA.TABLES
257
+ WHERE TABLE_NAME = '{table_name}'
258
+ AND TABLE_TYPE = 'BASE TABLE'
259
+ """
260
+ result = self.execute_query(query)
261
+ return result[0]['count'] > 0
262
+
263
+ def get_connection_info(self) -> Dict[str, Any]:
264
+ """Get connection information."""
265
+ return {
266
+ 'type': 'sqlserver',
267
+ 'host': self.host,
268
+ 'port': self.port,
269
+ 'database': self.database,
270
+ 'user': self.user,
271
+ 'connected': self.engine is not None and self._initialized
272
+ }
273
+
274
+ def get_tables_as_documents(self) -> List[TableDocument]:
275
+ """Get tables as TableDocument objects"""
276
+ if not self.engine:
277
+ raise RuntimeError("Not connected to database")
278
+
279
+ query = """
280
+ SELECT
281
+ TABLE_NAME as name,
282
+ TABLE_SCHEMA as schema_name,
283
+ '' as comment
284
+ FROM INFORMATION_SCHEMA.TABLES
285
+ WHERE TABLE_TYPE = 'BASE TABLE'
286
+ ORDER BY TABLE_NAME
287
+ """
288
+
289
+ try:
290
+ result = self.execute_query(query)
291
+ tables = []
292
+
293
+ for row in result:
294
+ # Handle both tuple and dict results from SQLAlchemy
295
+ # Try dict-style access first, fall back to tuple-style if it fails
296
+ try:
297
+ table_name = row['name']
298
+ schema_name = row.get('schema_name', 'dbo')
299
+ comment = row.get('comment', '')
300
+ except (TypeError, KeyError):
301
+ # Fall back to tuple-style access
302
+ table_name = row[0] # name is the first column
303
+ schema_name = row[1] if len(row) > 1 else 'dbo' # schema_name is second
304
+ comment = row[2] if len(row) > 2 else '' # comment is third
305
+
306
+ table_doc = TableDocument(
307
+ table_name=table_name,
308
+ schema_name=schema_name,
309
+ comment=comment,
310
+ columns=[], # Will be populated separately if needed
311
+ foreign_keys=[],
312
+ indexes=[]
313
+ )
314
+ tables.append(table_doc)
315
+
316
+ return tables
317
+
318
+ except Exception as e:
319
+ logger.error(f"Error getting tables: {e}")
320
+ raise
321
+
322
+ def get_example_data(self, table_name: str, number_of_rows: int = 30) -> Dict[str, List[Any]]:
323
+ """Get example data (most frequent values) for each column in a table."""
324
+ inspector = inspect(self.engine)
325
+ try:
326
+ columns = inspector.get_columns(table_name)
327
+ except SQLAlchemyError as e:
328
+ logger.error(f"Error inspecting columns for table {table_name}: {e}")
329
+ raise e
330
+
331
+ if not columns:
332
+ logger.warning(f"No columns found for table {table_name}")
333
+ return {}
334
+
335
+ most_frequent_values: Dict[str, List[Any]] = {}
336
+
337
+ for column in columns:
338
+ column_name = column['name']
339
+ try:
340
+ # Get most frequent values for this column
341
+ query = f"""
342
+ SELECT TOP {number_of_rows} [{column_name}], COUNT(*) as frequency
343
+ FROM [{table_name}]
344
+ WHERE [{column_name}] IS NOT NULL
345
+ GROUP BY [{column_name}]
346
+ ORDER BY COUNT(*) DESC
347
+ """
348
+
349
+ result = self.execute_query(query)
350
+ values = [row[column_name] for row in result]
351
+ most_frequent_values[column_name] = values
352
+
353
+ except Exception as e:
354
+ logger.warning(f"Error getting example data for column {column_name}: {e}")
355
+ most_frequent_values[column_name] = []
356
+
357
+ return most_frequent_values
358
+
359
+ def get_columns_as_documents(self, table_name: str = None) -> List[ColumnDocument]:
360
+ """Get columns as ColumnDocument objects"""
361
+ if not self.engine:
362
+ raise RuntimeError("Not connected to database")
363
+
364
+ if table_name:
365
+ # Get columns for specific table
366
+ query = f"""
367
+ SELECT
368
+ c.TABLE_NAME as table_name,
369
+ c.COLUMN_NAME as column_name,
370
+ c.DATA_TYPE as data_type,
371
+ c.IS_NULLABLE as is_nullable,
372
+ c.COLUMN_DEFAULT as default_value,
373
+ CASE WHEN COLUMNPROPERTY(OBJECT_ID(c.TABLE_SCHEMA + '.' + c.TABLE_NAME), c.COLUMN_NAME, 'IsIdentity') = 1 THEN 1 ELSE 0 END as is_identity,
374
+ CASE WHEN EXISTS (
375
+ SELECT 1 FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE
376
+ WHERE TABLE_NAME = c.TABLE_NAME
377
+ AND COLUMN_NAME = c.COLUMN_NAME
378
+ AND CONSTRAINT_NAME LIKE 'PK_%'
379
+ ) THEN 1 ELSE 0 END as is_primary_key
380
+ FROM INFORMATION_SCHEMA.COLUMNS c
381
+ WHERE c.TABLE_NAME = '{table_name}'
382
+ ORDER BY c.ORDINAL_POSITION
383
+ """
384
+ else:
385
+ # Get all columns
386
+ query = """
387
+ SELECT
388
+ c.TABLE_NAME as table_name,
389
+ c.COLUMN_NAME as column_name,
390
+ c.DATA_TYPE as data_type,
391
+ c.IS_NULLABLE as is_nullable,
392
+ c.COLUMN_DEFAULT as default_value,
393
+ CASE WHEN COLUMNPROPERTY(OBJECT_ID(c.TABLE_SCHEMA + '.' + c.TABLE_NAME), c.COLUMN_NAME, 'IsIdentity') = 1 THEN 1 ELSE 0 END as is_identity,
394
+ CASE WHEN EXISTS (
395
+ SELECT 1 FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE
396
+ WHERE TABLE_NAME = c.TABLE_NAME
397
+ AND COLUMN_NAME = c.COLUMN_NAME
398
+ AND CONSTRAINT_NAME LIKE 'PK_%'
399
+ ) THEN 1 ELSE 0 END as is_primary_key
400
+ FROM INFORMATION_SCHEMA.COLUMNS c
401
+ ORDER BY c.TABLE_NAME, c.ORDINAL_POSITION
402
+ """
403
+
404
+ try:
405
+ result = self.execute_query(query)
406
+ columns = []
407
+
408
+ for row in result:
409
+ # Handle both tuple and dict results from SQLAlchemy
410
+ # Try dict-style access first, fall back to tuple-style if it fails
411
+ try:
412
+ # For dict results, access by key
413
+ column_doc = ColumnDocument(
414
+ table_name=row['table_name'],
415
+ column_name=row['column_name'],
416
+ data_type=row['data_type'],
417
+ is_nullable=row['is_nullable'] == 'YES',
418
+ default_value=row.get('default_value'),
419
+ is_pk=bool(row.get('is_primary_key', 0)), # Use is_pk, not is_primary_key
420
+ comment=''
421
+ )
422
+ except (TypeError, KeyError):
423
+ # For tuple results, access by index based on SELECT order
424
+ # Query order: table_name, column_name, data_type, is_nullable, default_value, is_identity, is_primary_key
425
+ column_doc = ColumnDocument(
426
+ table_name=row[0], # table_name
427
+ column_name=row[1], # column_name
428
+ data_type=row[2], # data_type
429
+ is_nullable=row[3] == 'YES', # is_nullable
430
+ default_value=row[4] if len(row) > 4 else None, # default_value
431
+ is_pk=bool(row[6]) if len(row) > 6 else False, # is_pk (index 6, not 5)
432
+ comment=''
433
+ )
434
+ columns.append(column_doc)
435
+
436
+ return columns
437
+
438
+ except Exception as e:
439
+ logger.error(f"Error getting columns: {e}")
440
+ raise
441
+
442
+ def get_foreign_keys_as_documents(self, table_name: str = None) -> List[ForeignKeyDocument]:
443
+ """Get foreign keys as ForeignKeyDocument objects"""
444
+ if not self.engine:
445
+ raise RuntimeError("Not connected to database")
446
+
447
+ if table_name:
448
+ where_clause = f"WHERE t.name = '{table_name}'"
449
+ else:
450
+ where_clause = ""
451
+
452
+ query = f"""
453
+ SELECT
454
+ fk.name as constraint_name,
455
+ t.name as table_name,
456
+ c.name as column_name,
457
+ OBJECT_NAME(fk.referenced_object_id) as referenced_table,
458
+ rc.name as referenced_column
459
+ FROM sys.foreign_keys fk
460
+ JOIN sys.foreign_key_columns fkc ON fk.object_id = fkc.constraint_object_id
461
+ JOIN sys.columns c ON fkc.parent_object_id = c.object_id AND fkc.parent_column_id = c.column_id
462
+ JOIN sys.columns rc ON fkc.referenced_object_id = rc.object_id AND fkc.referenced_column_id = rc.column_id
463
+ JOIN sys.tables t ON fk.parent_object_id = t.object_id
464
+ {where_clause}
465
+ ORDER BY fk.name
466
+ """
467
+
468
+ try:
469
+ result = self.execute_query(query)
470
+ foreign_keys = []
471
+
472
+ for row in result:
473
+ # Handle both tuple and dict results from SQLAlchemy
474
+ # Try dict-style access first, fall back to tuple-style if it fails
475
+ try:
476
+ fk_doc = ForeignKeyDocument(
477
+ constraint_name=row['constraint_name'],
478
+ source_table_name=row['table_name'],
479
+ source_column_name=row['column_name'],
480
+ target_table_name=row['referenced_table'],
481
+ target_column_name=row['referenced_column']
482
+ )
483
+ except (TypeError, KeyError):
484
+ # Fall back to tuple-style access based on SELECT order
485
+ fk_doc = ForeignKeyDocument(
486
+ constraint_name=row[0], # constraint_name
487
+ source_table_name=row[1], # table_name
488
+ source_column_name=row[2], # column_name
489
+ target_table_name=row[3], # referenced_table
490
+ target_column_name=row[4] # referenced_column
491
+ )
492
+ foreign_keys.append(fk_doc)
493
+
494
+ return foreign_keys
495
+
496
+ except Exception as e:
497
+ logger.error(f"Error getting foreign keys: {e}")
498
+ raise
499
+
500
+ def get_indexes_as_documents(self, table_name: str = None) -> List[IndexDocument]:
501
+ """Get indexes as IndexDocument objects"""
502
+ if not self.engine:
503
+ raise RuntimeError("Not connected to database")
504
+
505
+ if table_name:
506
+ where_clause = f"WHERE t.name = '{table_name}'"
507
+ else:
508
+ where_clause = ""
509
+
510
+ query = f"""
511
+ SELECT
512
+ i.name as index_name,
513
+ t.name as table_name,
514
+ c.name as column_name,
515
+ i.is_unique,
516
+ i.is_primary_key
517
+ FROM sys.indexes i
518
+ JOIN sys.index_columns ic ON i.object_id = ic.object_id AND i.index_id = ic.index_id
519
+ JOIN sys.columns c ON ic.object_id = c.object_id AND ic.column_id = c.column_id
520
+ JOIN sys.tables t ON i.object_id = t.object_id
521
+ {where_clause}
522
+ WHERE i.name IS NOT NULL
523
+ ORDER BY i.name, ic.key_ordinal
524
+ """
525
+
526
+ try:
527
+ result = self.execute_query(query)
528
+ indexes = []
529
+
530
+ for row in result:
531
+ index_doc = IndexDocument(
532
+ index_name=row['index_name'],
533
+ table_name=row['table_name'],
534
+ column_name=row['column_name'],
535
+ is_unique=bool(row['is_unique']),
536
+ is_primary=bool(row['is_primary_key'])
537
+ )
538
+ indexes.append(index_doc)
539
+
540
+ return indexes
541
+
542
+ except Exception as e:
543
+ logger.error(f"Error getting indexes: {e}")
544
+ raise
545
+
546
+ def get_schemas_as_documents(self) -> List[SchemaDocument]:
547
+ """Get schemas as SchemaDocument objects"""
548
+ if not self.engine:
549
+ raise RuntimeError("Not connected to database")
550
+
551
+ query = """
552
+ SELECT
553
+ SCHEMA_NAME as schema_name,
554
+ '' as comment
555
+ FROM INFORMATION_SCHEMA.SCHEMATA
556
+ WHERE SCHEMA_NAME NOT IN ('information_schema', 'sys', 'guest', 'INFORMATION_SCHEMA')
557
+ ORDER BY SCHEMA_NAME
558
+ """
559
+
560
+ try:
561
+ result = self.execute_query(query)
562
+ schemas = []
563
+
564
+ for row in result:
565
+ schema_doc = SchemaDocument(
566
+ schema_name=row['schema_name'],
567
+ comment=row.get('comment', ''),
568
+ tables=[], # Will be populated separately if needed
569
+ views=[]
570
+ )
571
+ schemas.append(schema_doc)
572
+
573
+ return schemas
574
+
575
+ except Exception as e:
576
+ logger.error(f"Error getting schemas: {e}")
577
+ raise
578
+
579
+ def get_unique_values(self) -> Dict[str, Dict[str, List[str]]]:
580
+ """Get unique values from the database."""
581
+ # This is a placeholder implementation.
582
+ # A more sophisticated version should be implemented based on requirements.
583
+ return {}