thoth-dbmanager 0.4.0__py3-none-any.whl → 0.4.2__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.
- thoth_dbmanager/ThothDbManager.py +459 -0
- thoth_dbmanager/__init__.py +136 -0
- thoth_dbmanager/adapters/__init__.py +21 -0
- thoth_dbmanager/adapters/mariadb.py +165 -0
- thoth_dbmanager/adapters/mysql.py +165 -0
- thoth_dbmanager/adapters/oracle.py +554 -0
- thoth_dbmanager/adapters/postgresql.py +444 -0
- thoth_dbmanager/adapters/qdrant.py +189 -0
- thoth_dbmanager/adapters/sqlite.py +385 -0
- thoth_dbmanager/adapters/sqlserver.py +583 -0
- thoth_dbmanager/adapters/supabase.py +249 -0
- thoth_dbmanager/core/__init__.py +13 -0
- thoth_dbmanager/core/factory.py +272 -0
- thoth_dbmanager/core/interfaces.py +271 -0
- thoth_dbmanager/core/registry.py +220 -0
- thoth_dbmanager/documents.py +155 -0
- thoth_dbmanager/dynamic_imports.py +250 -0
- thoth_dbmanager/helpers/__init__.py +0 -0
- thoth_dbmanager/helpers/multi_db_generator.py +508 -0
- thoth_dbmanager/helpers/preprocess_values.py +159 -0
- thoth_dbmanager/helpers/schema.py +376 -0
- thoth_dbmanager/helpers/search.py +117 -0
- thoth_dbmanager/lsh/__init__.py +21 -0
- thoth_dbmanager/lsh/core.py +182 -0
- thoth_dbmanager/lsh/factory.py +76 -0
- thoth_dbmanager/lsh/manager.py +170 -0
- thoth_dbmanager/lsh/storage.py +96 -0
- thoth_dbmanager/plugins/__init__.py +23 -0
- thoth_dbmanager/plugins/mariadb.py +436 -0
- thoth_dbmanager/plugins/mysql.py +408 -0
- thoth_dbmanager/plugins/oracle.py +150 -0
- thoth_dbmanager/plugins/postgresql.py +145 -0
- thoth_dbmanager/plugins/qdrant.py +41 -0
- thoth_dbmanager/plugins/sqlite.py +170 -0
- thoth_dbmanager/plugins/sqlserver.py +149 -0
- thoth_dbmanager/plugins/supabase.py +224 -0
- {thoth_dbmanager-0.4.0.dist-info → thoth_dbmanager-0.4.2.dist-info}/METADATA +9 -6
- thoth_dbmanager-0.4.2.dist-info/RECORD +41 -0
- thoth_dbmanager-0.4.2.dist-info/top_level.txt +1 -0
- thoth_dbmanager-0.4.0.dist-info/RECORD +0 -5
- thoth_dbmanager-0.4.0.dist-info/top_level.txt +0 -1
- {thoth_dbmanager-0.4.0.dist-info → thoth_dbmanager-0.4.2.dist-info}/WHEEL +0 -0
- {thoth_dbmanager-0.4.0.dist-info → thoth_dbmanager-0.4.2.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 {}
|