sqlsaber 0.24.0__py3-none-any.whl → 0.26.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.
Potentially problematic release.
This version of sqlsaber might be problematic. Click here for more details.
- sqlsaber/agents/__init__.py +2 -2
- sqlsaber/agents/base.py +5 -2
- sqlsaber/agents/mcp.py +1 -1
- sqlsaber/agents/pydantic_ai_agent.py +208 -133
- sqlsaber/cli/commands.py +17 -26
- sqlsaber/cli/completers.py +2 -0
- sqlsaber/cli/database.py +18 -7
- sqlsaber/cli/display.py +29 -9
- sqlsaber/cli/interactive.py +28 -16
- sqlsaber/cli/streaming.py +15 -17
- sqlsaber/cli/threads.py +10 -6
- sqlsaber/config/database.py +3 -1
- sqlsaber/config/settings.py +25 -2
- sqlsaber/database/__init__.py +55 -1
- sqlsaber/database/base.py +124 -0
- sqlsaber/database/csv.py +133 -0
- sqlsaber/database/duckdb.py +313 -0
- sqlsaber/database/mysql.py +345 -0
- sqlsaber/database/postgresql.py +328 -0
- sqlsaber/database/resolver.py +7 -3
- sqlsaber/database/schema.py +69 -742
- sqlsaber/database/sqlite.py +258 -0
- sqlsaber/mcp/mcp.py +1 -1
- sqlsaber/tools/sql_tools.py +1 -1
- {sqlsaber-0.24.0.dist-info → sqlsaber-0.26.0.dist-info}/METADATA +45 -10
- sqlsaber-0.26.0.dist-info/RECORD +52 -0
- sqlsaber/database/connection.py +0 -511
- sqlsaber-0.24.0.dist-info/RECORD +0 -47
- {sqlsaber-0.24.0.dist-info → sqlsaber-0.26.0.dist-info}/WHEEL +0 -0
- {sqlsaber-0.24.0.dist-info → sqlsaber-0.26.0.dist-info}/entry_points.txt +0 -0
- {sqlsaber-0.24.0.dist-info → sqlsaber-0.26.0.dist-info}/licenses/LICENSE +0 -0
sqlsaber/database/schema.py
CHANGED
|
@@ -1,685 +1,19 @@
|
|
|
1
|
-
"""Database schema
|
|
1
|
+
"""Database schema management."""
|
|
2
2
|
|
|
3
|
-
from
|
|
4
|
-
from typing import Any, TypedDict
|
|
3
|
+
from typing import Any
|
|
5
4
|
|
|
6
|
-
import
|
|
7
|
-
|
|
8
|
-
from sqlsaber.database.connection import (
|
|
5
|
+
from .base import (
|
|
9
6
|
BaseDatabaseConnection,
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
7
|
+
ColumnInfo,
|
|
8
|
+
ForeignKeyInfo,
|
|
9
|
+
IndexInfo,
|
|
10
|
+
SchemaInfo,
|
|
14
11
|
)
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
data_type: str
|
|
21
|
-
nullable: bool
|
|
22
|
-
default: str | None
|
|
23
|
-
max_length: int | None
|
|
24
|
-
precision: int | None
|
|
25
|
-
scale: int | None
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
class ForeignKeyInfo(TypedDict):
|
|
29
|
-
"""Type definition for foreign key information."""
|
|
30
|
-
|
|
31
|
-
column: str
|
|
32
|
-
references: dict[str, str] # {"table": "schema.table", "column": "column_name"}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
class IndexInfo(TypedDict):
|
|
36
|
-
"""Type definition for index information."""
|
|
37
|
-
|
|
38
|
-
name: str
|
|
39
|
-
columns: list[str] # ordered
|
|
40
|
-
unique: bool
|
|
41
|
-
type: str | None # btree, gin, FULLTEXT, etc. None if unknown
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
class SchemaInfo(TypedDict):
|
|
45
|
-
"""Type definition for schema information."""
|
|
46
|
-
|
|
47
|
-
schema: str
|
|
48
|
-
name: str
|
|
49
|
-
type: str
|
|
50
|
-
columns: dict[str, ColumnInfo]
|
|
51
|
-
primary_keys: list[str]
|
|
52
|
-
foreign_keys: list[ForeignKeyInfo]
|
|
53
|
-
indexes: list[IndexInfo]
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
class BaseSchemaIntrospector(ABC):
|
|
57
|
-
"""Abstract base class for database-specific schema introspection."""
|
|
58
|
-
|
|
59
|
-
@abstractmethod
|
|
60
|
-
async def get_tables_info(
|
|
61
|
-
self, connection, table_pattern: str | None = None
|
|
62
|
-
) -> dict[str, Any]:
|
|
63
|
-
"""Get tables information for the specific database type."""
|
|
64
|
-
pass
|
|
65
|
-
|
|
66
|
-
@abstractmethod
|
|
67
|
-
async def get_columns_info(self, connection, tables: list) -> list:
|
|
68
|
-
"""Get columns information for the specific database type."""
|
|
69
|
-
pass
|
|
70
|
-
|
|
71
|
-
@abstractmethod
|
|
72
|
-
async def get_foreign_keys_info(self, connection, tables: list) -> list:
|
|
73
|
-
"""Get foreign keys information for the specific database type."""
|
|
74
|
-
pass
|
|
75
|
-
|
|
76
|
-
@abstractmethod
|
|
77
|
-
async def get_primary_keys_info(self, connection, tables: list) -> list:
|
|
78
|
-
"""Get primary keys information for the specific database type."""
|
|
79
|
-
pass
|
|
80
|
-
|
|
81
|
-
@abstractmethod
|
|
82
|
-
async def get_indexes_info(self, connection, tables: list) -> list:
|
|
83
|
-
"""Get indexes information for the specific database type."""
|
|
84
|
-
pass
|
|
85
|
-
|
|
86
|
-
@abstractmethod
|
|
87
|
-
async def list_tables_info(self, connection) -> list[dict[str, Any]]:
|
|
88
|
-
"""Get list of tables with basic information."""
|
|
89
|
-
pass
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
class PostgreSQLSchemaIntrospector(BaseSchemaIntrospector):
|
|
93
|
-
"""PostgreSQL-specific schema introspection."""
|
|
94
|
-
|
|
95
|
-
async def get_tables_info(
|
|
96
|
-
self, connection, table_pattern: str | None = None
|
|
97
|
-
) -> dict[str, Any]:
|
|
98
|
-
"""Get tables information for PostgreSQL."""
|
|
99
|
-
pool = await connection.get_pool()
|
|
100
|
-
async with pool.acquire() as conn:
|
|
101
|
-
# Build WHERE clause for filtering
|
|
102
|
-
where_conditions = [
|
|
103
|
-
"table_schema NOT IN ('pg_catalog', 'information_schema')"
|
|
104
|
-
]
|
|
105
|
-
params = []
|
|
106
|
-
|
|
107
|
-
if table_pattern:
|
|
108
|
-
# Support patterns like 'schema.table' or just 'table'
|
|
109
|
-
if "." in table_pattern:
|
|
110
|
-
schema_pattern, table_name_pattern = table_pattern.split(".", 1)
|
|
111
|
-
where_conditions.append(
|
|
112
|
-
"(table_schema LIKE $1 AND table_name LIKE $2)"
|
|
113
|
-
)
|
|
114
|
-
params.extend([schema_pattern, table_name_pattern])
|
|
115
|
-
else:
|
|
116
|
-
where_conditions.append(
|
|
117
|
-
"(table_name LIKE $1 OR table_schema || '.' || table_name LIKE $1)"
|
|
118
|
-
)
|
|
119
|
-
params.append(table_pattern)
|
|
120
|
-
|
|
121
|
-
# Get tables
|
|
122
|
-
tables_query = f"""
|
|
123
|
-
SELECT
|
|
124
|
-
table_schema,
|
|
125
|
-
table_name,
|
|
126
|
-
table_type
|
|
127
|
-
FROM information_schema.tables
|
|
128
|
-
WHERE {" AND ".join(where_conditions)}
|
|
129
|
-
ORDER BY table_schema, table_name;
|
|
130
|
-
"""
|
|
131
|
-
return await conn.fetch(tables_query, *params)
|
|
132
|
-
|
|
133
|
-
async def get_columns_info(self, connection, tables: list) -> list:
|
|
134
|
-
"""Get columns information for PostgreSQL."""
|
|
135
|
-
if not tables:
|
|
136
|
-
return []
|
|
137
|
-
|
|
138
|
-
pool = await connection.get_pool()
|
|
139
|
-
async with pool.acquire() as conn:
|
|
140
|
-
# Build IN clause for the tables we found
|
|
141
|
-
table_filters = []
|
|
142
|
-
for table in tables:
|
|
143
|
-
table_filters.append(
|
|
144
|
-
f"(table_schema = '{table['table_schema']}' AND table_name = '{table['table_name']}')"
|
|
145
|
-
)
|
|
146
|
-
|
|
147
|
-
columns_query = f"""
|
|
148
|
-
SELECT
|
|
149
|
-
table_schema,
|
|
150
|
-
table_name,
|
|
151
|
-
column_name,
|
|
152
|
-
data_type,
|
|
153
|
-
is_nullable,
|
|
154
|
-
column_default,
|
|
155
|
-
character_maximum_length,
|
|
156
|
-
numeric_precision,
|
|
157
|
-
numeric_scale
|
|
158
|
-
FROM information_schema.columns
|
|
159
|
-
WHERE ({" OR ".join(table_filters)})
|
|
160
|
-
ORDER BY table_schema, table_name, ordinal_position;
|
|
161
|
-
"""
|
|
162
|
-
return await conn.fetch(columns_query)
|
|
163
|
-
|
|
164
|
-
async def get_foreign_keys_info(self, connection, tables: list) -> list:
|
|
165
|
-
"""Get foreign keys information for PostgreSQL."""
|
|
166
|
-
if not tables:
|
|
167
|
-
return []
|
|
168
|
-
|
|
169
|
-
pool = await connection.get_pool()
|
|
170
|
-
async with pool.acquire() as conn:
|
|
171
|
-
# Build proper table filters with tc. prefix
|
|
172
|
-
fk_table_filters = []
|
|
173
|
-
for table in tables:
|
|
174
|
-
fk_table_filters.append(
|
|
175
|
-
f"(tc.table_schema = '{table['table_schema']}' AND tc.table_name = '{table['table_name']}')"
|
|
176
|
-
)
|
|
177
|
-
|
|
178
|
-
fk_query = f"""
|
|
179
|
-
SELECT
|
|
180
|
-
tc.table_schema,
|
|
181
|
-
tc.table_name,
|
|
182
|
-
kcu.column_name,
|
|
183
|
-
ccu.table_schema AS foreign_table_schema,
|
|
184
|
-
ccu.table_name AS foreign_table_name,
|
|
185
|
-
ccu.column_name AS foreign_column_name
|
|
186
|
-
FROM information_schema.table_constraints AS tc
|
|
187
|
-
JOIN information_schema.key_column_usage AS kcu
|
|
188
|
-
ON tc.constraint_name = kcu.constraint_name
|
|
189
|
-
AND tc.table_schema = kcu.table_schema
|
|
190
|
-
JOIN information_schema.constraint_column_usage AS ccu
|
|
191
|
-
ON ccu.constraint_name = tc.constraint_name
|
|
192
|
-
AND ccu.table_schema = tc.table_schema
|
|
193
|
-
WHERE tc.constraint_type = 'FOREIGN KEY'
|
|
194
|
-
AND ({" OR ".join(fk_table_filters)});
|
|
195
|
-
"""
|
|
196
|
-
return await conn.fetch(fk_query)
|
|
197
|
-
|
|
198
|
-
async def get_primary_keys_info(self, connection, tables: list) -> list:
|
|
199
|
-
"""Get primary keys information for PostgreSQL."""
|
|
200
|
-
if not tables:
|
|
201
|
-
return []
|
|
202
|
-
|
|
203
|
-
pool = await connection.get_pool()
|
|
204
|
-
async with pool.acquire() as conn:
|
|
205
|
-
# Build proper table filters with tc. prefix
|
|
206
|
-
pk_table_filters = []
|
|
207
|
-
for table in tables:
|
|
208
|
-
pk_table_filters.append(
|
|
209
|
-
f"(tc.table_schema = '{table['table_schema']}' AND tc.table_name = '{table['table_name']}')"
|
|
210
|
-
)
|
|
211
|
-
|
|
212
|
-
pk_query = f"""
|
|
213
|
-
SELECT
|
|
214
|
-
tc.table_schema,
|
|
215
|
-
tc.table_name,
|
|
216
|
-
kcu.column_name
|
|
217
|
-
FROM information_schema.table_constraints AS tc
|
|
218
|
-
JOIN information_schema.key_column_usage AS kcu
|
|
219
|
-
ON tc.constraint_name = kcu.constraint_name
|
|
220
|
-
AND tc.table_schema = kcu.table_schema
|
|
221
|
-
WHERE tc.constraint_type = 'PRIMARY KEY'
|
|
222
|
-
AND ({" OR ".join(pk_table_filters)})
|
|
223
|
-
ORDER BY tc.table_schema, tc.table_name, kcu.ordinal_position;
|
|
224
|
-
"""
|
|
225
|
-
return await conn.fetch(pk_query)
|
|
226
|
-
|
|
227
|
-
async def get_indexes_info(self, connection, tables: list) -> list:
|
|
228
|
-
"""Get indexes information for PostgreSQL."""
|
|
229
|
-
if not tables:
|
|
230
|
-
return []
|
|
231
|
-
|
|
232
|
-
pool = await connection.get_pool()
|
|
233
|
-
async with pool.acquire() as conn:
|
|
234
|
-
# Build proper table filters
|
|
235
|
-
idx_table_filters = []
|
|
236
|
-
for table in tables:
|
|
237
|
-
idx_table_filters.append(
|
|
238
|
-
f"(ns.nspname = '{table['table_schema']}' AND t.relname = '{table['table_name']}')"
|
|
239
|
-
)
|
|
240
|
-
|
|
241
|
-
idx_query = f"""
|
|
242
|
-
SELECT
|
|
243
|
-
ns.nspname AS table_schema,
|
|
244
|
-
t.relname AS table_name,
|
|
245
|
-
i.relname AS index_name,
|
|
246
|
-
ix.indisunique AS is_unique,
|
|
247
|
-
am.amname AS index_type,
|
|
248
|
-
array_agg(a.attname ORDER BY ord.ordinality) AS column_names
|
|
249
|
-
FROM pg_class t
|
|
250
|
-
JOIN pg_namespace ns ON ns.oid = t.relnamespace
|
|
251
|
-
JOIN pg_index ix ON ix.indrelid = t.oid
|
|
252
|
-
JOIN pg_class i ON i.oid = ix.indexrelid
|
|
253
|
-
JOIN pg_am am ON am.oid = i.relam
|
|
254
|
-
JOIN LATERAL unnest(ix.indkey) WITH ORDINALITY AS ord(attnum, ordinality)
|
|
255
|
-
ON TRUE
|
|
256
|
-
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ord.attnum
|
|
257
|
-
WHERE ns.nspname NOT IN ('pg_catalog', 'information_schema')
|
|
258
|
-
AND ({" OR ".join(idx_table_filters)})
|
|
259
|
-
GROUP BY table_schema, table_name, index_name, is_unique, index_type
|
|
260
|
-
ORDER BY table_schema, table_name, index_name;
|
|
261
|
-
"""
|
|
262
|
-
return await conn.fetch(idx_query)
|
|
263
|
-
|
|
264
|
-
async def list_tables_info(self, connection) -> list[dict[str, Any]]:
|
|
265
|
-
"""Get list of tables with basic information for PostgreSQL."""
|
|
266
|
-
pool = await connection.get_pool()
|
|
267
|
-
async with pool.acquire() as conn:
|
|
268
|
-
# Get tables without row counts for better performance
|
|
269
|
-
tables_query = """
|
|
270
|
-
SELECT
|
|
271
|
-
t.table_schema,
|
|
272
|
-
t.table_name,
|
|
273
|
-
t.table_type
|
|
274
|
-
FROM information_schema.tables t
|
|
275
|
-
WHERE t.table_schema NOT IN ('pg_catalog', 'information_schema')
|
|
276
|
-
ORDER BY t.table_schema, t.table_name;
|
|
277
|
-
"""
|
|
278
|
-
records = await conn.fetch(tables_query)
|
|
279
|
-
|
|
280
|
-
# Convert asyncpg.Record objects to dictionaries
|
|
281
|
-
return [
|
|
282
|
-
{
|
|
283
|
-
"table_schema": record["table_schema"],
|
|
284
|
-
"table_name": record["table_name"],
|
|
285
|
-
"table_type": record["table_type"],
|
|
286
|
-
}
|
|
287
|
-
for record in records
|
|
288
|
-
]
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
class MySQLSchemaIntrospector(BaseSchemaIntrospector):
|
|
292
|
-
"""MySQL-specific schema introspection."""
|
|
293
|
-
|
|
294
|
-
async def get_tables_info(
|
|
295
|
-
self, connection, table_pattern: str | None = None
|
|
296
|
-
) -> dict[str, Any]:
|
|
297
|
-
"""Get tables information for MySQL."""
|
|
298
|
-
pool = await connection.get_pool()
|
|
299
|
-
async with pool.acquire() as conn:
|
|
300
|
-
async with conn.cursor() as cursor:
|
|
301
|
-
# Build WHERE clause for filtering
|
|
302
|
-
where_conditions = [
|
|
303
|
-
"table_schema NOT IN ('information_schema', 'performance_schema', 'mysql', 'sys')"
|
|
304
|
-
]
|
|
305
|
-
params = []
|
|
306
|
-
|
|
307
|
-
if table_pattern:
|
|
308
|
-
# Support patterns like 'schema.table' or just 'table'
|
|
309
|
-
if "." in table_pattern:
|
|
310
|
-
schema_pattern, table_name_pattern = table_pattern.split(".", 1)
|
|
311
|
-
where_conditions.append(
|
|
312
|
-
"(table_schema LIKE %s AND table_name LIKE %s)"
|
|
313
|
-
)
|
|
314
|
-
params.extend([schema_pattern, table_name_pattern])
|
|
315
|
-
else:
|
|
316
|
-
where_conditions.append(
|
|
317
|
-
"(table_name LIKE %s OR CONCAT(table_schema, '.', table_name) LIKE %s)"
|
|
318
|
-
)
|
|
319
|
-
params.extend([table_pattern, table_pattern])
|
|
320
|
-
|
|
321
|
-
# Get tables
|
|
322
|
-
tables_query = f"""
|
|
323
|
-
SELECT
|
|
324
|
-
table_schema,
|
|
325
|
-
table_name,
|
|
326
|
-
table_type
|
|
327
|
-
FROM information_schema.tables
|
|
328
|
-
WHERE {" AND ".join(where_conditions)}
|
|
329
|
-
ORDER BY table_schema, table_name;
|
|
330
|
-
"""
|
|
331
|
-
await cursor.execute(tables_query, params)
|
|
332
|
-
return await cursor.fetchall()
|
|
333
|
-
|
|
334
|
-
async def get_columns_info(self, connection, tables: list) -> list:
|
|
335
|
-
"""Get columns information for MySQL."""
|
|
336
|
-
if not tables:
|
|
337
|
-
return []
|
|
338
|
-
|
|
339
|
-
pool = await connection.get_pool()
|
|
340
|
-
async with pool.acquire() as conn:
|
|
341
|
-
async with conn.cursor() as cursor:
|
|
342
|
-
# Build IN clause for the tables we found
|
|
343
|
-
table_filters = []
|
|
344
|
-
for table in tables:
|
|
345
|
-
table_filters.append(
|
|
346
|
-
f"(table_schema = '{table['table_schema']}' AND table_name = '{table['table_name']}')"
|
|
347
|
-
)
|
|
348
|
-
|
|
349
|
-
columns_query = f"""
|
|
350
|
-
SELECT
|
|
351
|
-
table_schema,
|
|
352
|
-
table_name,
|
|
353
|
-
column_name,
|
|
354
|
-
data_type,
|
|
355
|
-
is_nullable,
|
|
356
|
-
column_default,
|
|
357
|
-
character_maximum_length,
|
|
358
|
-
numeric_precision,
|
|
359
|
-
numeric_scale
|
|
360
|
-
FROM information_schema.columns
|
|
361
|
-
WHERE ({" OR ".join(table_filters)})
|
|
362
|
-
ORDER BY table_schema, table_name, ordinal_position;
|
|
363
|
-
"""
|
|
364
|
-
await cursor.execute(columns_query)
|
|
365
|
-
return await cursor.fetchall()
|
|
366
|
-
|
|
367
|
-
async def get_foreign_keys_info(self, connection, tables: list) -> list:
|
|
368
|
-
"""Get foreign keys information for MySQL."""
|
|
369
|
-
if not tables:
|
|
370
|
-
return []
|
|
371
|
-
|
|
372
|
-
pool = await connection.get_pool()
|
|
373
|
-
async with pool.acquire() as conn:
|
|
374
|
-
async with conn.cursor() as cursor:
|
|
375
|
-
# Build proper table filters
|
|
376
|
-
fk_table_filters = []
|
|
377
|
-
for table in tables:
|
|
378
|
-
fk_table_filters.append(
|
|
379
|
-
f"(tc.table_schema = '{table['table_schema']}' AND tc.table_name = '{table['table_name']}')"
|
|
380
|
-
)
|
|
381
|
-
|
|
382
|
-
fk_query = f"""
|
|
383
|
-
SELECT
|
|
384
|
-
tc.table_schema,
|
|
385
|
-
tc.table_name,
|
|
386
|
-
kcu.column_name,
|
|
387
|
-
rc.unique_constraint_schema AS foreign_table_schema,
|
|
388
|
-
rc.referenced_table_name AS foreign_table_name,
|
|
389
|
-
kcu.referenced_column_name AS foreign_column_name
|
|
390
|
-
FROM information_schema.table_constraints AS tc
|
|
391
|
-
JOIN information_schema.key_column_usage AS kcu
|
|
392
|
-
ON tc.constraint_name = kcu.constraint_name
|
|
393
|
-
AND tc.table_schema = kcu.table_schema
|
|
394
|
-
JOIN information_schema.referential_constraints AS rc
|
|
395
|
-
ON tc.constraint_name = rc.constraint_name
|
|
396
|
-
AND tc.table_schema = rc.constraint_schema
|
|
397
|
-
WHERE tc.constraint_type = 'FOREIGN KEY'
|
|
398
|
-
AND ({" OR ".join(fk_table_filters)});
|
|
399
|
-
"""
|
|
400
|
-
await cursor.execute(fk_query)
|
|
401
|
-
return await cursor.fetchall()
|
|
402
|
-
|
|
403
|
-
async def get_primary_keys_info(self, connection, tables: list) -> list:
|
|
404
|
-
"""Get primary keys information for MySQL."""
|
|
405
|
-
if not tables:
|
|
406
|
-
return []
|
|
407
|
-
|
|
408
|
-
pool = await connection.get_pool()
|
|
409
|
-
async with pool.acquire() as conn:
|
|
410
|
-
async with conn.cursor() as cursor:
|
|
411
|
-
# Build proper table filters
|
|
412
|
-
pk_table_filters = []
|
|
413
|
-
for table in tables:
|
|
414
|
-
pk_table_filters.append(
|
|
415
|
-
f"(tc.table_schema = '{table['table_schema']}' AND tc.table_name = '{table['table_name']}')"
|
|
416
|
-
)
|
|
417
|
-
|
|
418
|
-
pk_query = f"""
|
|
419
|
-
SELECT
|
|
420
|
-
tc.table_schema,
|
|
421
|
-
tc.table_name,
|
|
422
|
-
kcu.column_name
|
|
423
|
-
FROM information_schema.table_constraints AS tc
|
|
424
|
-
JOIN information_schema.key_column_usage AS kcu
|
|
425
|
-
ON tc.constraint_name = kcu.constraint_name
|
|
426
|
-
AND tc.table_schema = kcu.table_schema
|
|
427
|
-
WHERE tc.constraint_type = 'PRIMARY KEY'
|
|
428
|
-
AND ({" OR ".join(pk_table_filters)})
|
|
429
|
-
ORDER BY tc.table_schema, tc.table_name, kcu.ordinal_position;
|
|
430
|
-
"""
|
|
431
|
-
await cursor.execute(pk_query)
|
|
432
|
-
return await cursor.fetchall()
|
|
433
|
-
|
|
434
|
-
async def get_indexes_info(self, connection, tables: list) -> list:
|
|
435
|
-
"""Get indexes information for MySQL."""
|
|
436
|
-
if not tables:
|
|
437
|
-
return []
|
|
438
|
-
|
|
439
|
-
pool = await connection.get_pool()
|
|
440
|
-
async with pool.acquire() as conn:
|
|
441
|
-
async with conn.cursor() as cursor:
|
|
442
|
-
# Build proper table filters
|
|
443
|
-
idx_table_filters = []
|
|
444
|
-
for table in tables:
|
|
445
|
-
idx_table_filters.append(
|
|
446
|
-
f"(TABLE_SCHEMA = '{table['table_schema']}' AND TABLE_NAME = '{table['table_name']}')"
|
|
447
|
-
)
|
|
448
|
-
|
|
449
|
-
idx_query = f"""
|
|
450
|
-
SELECT
|
|
451
|
-
TABLE_SCHEMA AS table_schema,
|
|
452
|
-
TABLE_NAME AS table_name,
|
|
453
|
-
INDEX_NAME AS index_name,
|
|
454
|
-
(NON_UNIQUE = 0) AS is_unique,
|
|
455
|
-
INDEX_TYPE AS index_type,
|
|
456
|
-
GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX) AS column_names
|
|
457
|
-
FROM INFORMATION_SCHEMA.STATISTICS
|
|
458
|
-
WHERE ({" OR ".join(idx_table_filters)})
|
|
459
|
-
GROUP BY table_schema, table_name, index_name, is_unique, index_type
|
|
460
|
-
ORDER BY table_schema, table_name, index_name;
|
|
461
|
-
"""
|
|
462
|
-
await cursor.execute(idx_query)
|
|
463
|
-
return await cursor.fetchall()
|
|
464
|
-
|
|
465
|
-
async def list_tables_info(self, connection) -> list[dict[str, Any]]:
|
|
466
|
-
"""Get list of tables with basic information for MySQL."""
|
|
467
|
-
pool = await connection.get_pool()
|
|
468
|
-
async with pool.acquire() as conn:
|
|
469
|
-
async with conn.cursor() as cursor:
|
|
470
|
-
# Get tables without row counts for better performance
|
|
471
|
-
tables_query = """
|
|
472
|
-
SELECT
|
|
473
|
-
t.table_schema,
|
|
474
|
-
t.table_name,
|
|
475
|
-
t.table_type
|
|
476
|
-
FROM information_schema.tables t
|
|
477
|
-
WHERE t.table_schema NOT IN ('information_schema', 'performance_schema', 'mysql', 'sys')
|
|
478
|
-
ORDER BY t.table_schema, t.table_name;
|
|
479
|
-
"""
|
|
480
|
-
await cursor.execute(tables_query)
|
|
481
|
-
rows = await cursor.fetchall()
|
|
482
|
-
|
|
483
|
-
# Convert rows to dictionaries
|
|
484
|
-
return [
|
|
485
|
-
{
|
|
486
|
-
"table_schema": row["table_schema"],
|
|
487
|
-
"table_name": row["table_name"],
|
|
488
|
-
"table_type": row["table_type"],
|
|
489
|
-
}
|
|
490
|
-
for row in rows
|
|
491
|
-
]
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
class SQLiteSchemaIntrospector(BaseSchemaIntrospector):
|
|
495
|
-
"""SQLite-specific schema introspection."""
|
|
496
|
-
|
|
497
|
-
async def _execute_query(self, connection, query: str, params=()) -> list:
|
|
498
|
-
"""Helper method to execute queries on both SQLite and CSV connections."""
|
|
499
|
-
# Handle both SQLite and CSV connections
|
|
500
|
-
if hasattr(connection, "database_path"):
|
|
501
|
-
# Regular SQLite connection
|
|
502
|
-
async with aiosqlite.connect(connection.database_path) as conn:
|
|
503
|
-
conn.row_factory = aiosqlite.Row
|
|
504
|
-
cursor = await conn.execute(query, params)
|
|
505
|
-
return await cursor.fetchall()
|
|
506
|
-
else:
|
|
507
|
-
# CSV connection - use the existing connection
|
|
508
|
-
conn = await connection.get_pool()
|
|
509
|
-
cursor = await conn.execute(query, params)
|
|
510
|
-
return await cursor.fetchall()
|
|
511
|
-
|
|
512
|
-
async def get_tables_info(
|
|
513
|
-
self, connection, table_pattern: str | None = None
|
|
514
|
-
) -> dict[str, Any]:
|
|
515
|
-
"""Get tables information for SQLite."""
|
|
516
|
-
where_conditions = ["type IN ('table', 'view')", "name NOT LIKE 'sqlite_%'"]
|
|
517
|
-
params = ()
|
|
518
|
-
|
|
519
|
-
if table_pattern:
|
|
520
|
-
where_conditions.append("name LIKE ?")
|
|
521
|
-
params = (table_pattern,)
|
|
522
|
-
|
|
523
|
-
query = f"""
|
|
524
|
-
SELECT
|
|
525
|
-
'main' as table_schema,
|
|
526
|
-
name as table_name,
|
|
527
|
-
type as table_type
|
|
528
|
-
FROM sqlite_master
|
|
529
|
-
WHERE {" AND ".join(where_conditions)}
|
|
530
|
-
ORDER BY name;
|
|
531
|
-
"""
|
|
532
|
-
|
|
533
|
-
return await self._execute_query(connection, query, params)
|
|
534
|
-
|
|
535
|
-
async def get_columns_info(self, connection, tables: list) -> list:
|
|
536
|
-
"""Get columns information for SQLite."""
|
|
537
|
-
if not tables:
|
|
538
|
-
return []
|
|
539
|
-
|
|
540
|
-
columns = []
|
|
541
|
-
for table in tables:
|
|
542
|
-
table_name = table["table_name"]
|
|
543
|
-
|
|
544
|
-
# Get table info using PRAGMA
|
|
545
|
-
pragma_query = f"PRAGMA table_info({table_name})"
|
|
546
|
-
table_columns = await self._execute_query(connection, pragma_query)
|
|
547
|
-
|
|
548
|
-
for col in table_columns:
|
|
549
|
-
columns.append(
|
|
550
|
-
{
|
|
551
|
-
"table_schema": "main",
|
|
552
|
-
"table_name": table_name,
|
|
553
|
-
"column_name": col["name"],
|
|
554
|
-
"data_type": col["type"],
|
|
555
|
-
"is_nullable": "YES" if not col["notnull"] else "NO",
|
|
556
|
-
"column_default": col["dflt_value"],
|
|
557
|
-
"character_maximum_length": None,
|
|
558
|
-
"numeric_precision": None,
|
|
559
|
-
"numeric_scale": None,
|
|
560
|
-
}
|
|
561
|
-
)
|
|
562
|
-
|
|
563
|
-
return columns
|
|
564
|
-
|
|
565
|
-
async def get_foreign_keys_info(self, connection, tables: list) -> list:
|
|
566
|
-
"""Get foreign keys information for SQLite."""
|
|
567
|
-
if not tables:
|
|
568
|
-
return []
|
|
569
|
-
|
|
570
|
-
foreign_keys = []
|
|
571
|
-
for table in tables:
|
|
572
|
-
table_name = table["table_name"]
|
|
573
|
-
|
|
574
|
-
# Get foreign key info using PRAGMA
|
|
575
|
-
pragma_query = f"PRAGMA foreign_key_list({table_name})"
|
|
576
|
-
table_fks = await self._execute_query(connection, pragma_query)
|
|
577
|
-
|
|
578
|
-
for fk in table_fks:
|
|
579
|
-
foreign_keys.append(
|
|
580
|
-
{
|
|
581
|
-
"table_schema": "main",
|
|
582
|
-
"table_name": table_name,
|
|
583
|
-
"column_name": fk["from"],
|
|
584
|
-
"foreign_table_schema": "main",
|
|
585
|
-
"foreign_table_name": fk["table"],
|
|
586
|
-
"foreign_column_name": fk["to"],
|
|
587
|
-
}
|
|
588
|
-
)
|
|
589
|
-
|
|
590
|
-
return foreign_keys
|
|
591
|
-
|
|
592
|
-
async def get_primary_keys_info(self, connection, tables: list) -> list:
|
|
593
|
-
"""Get primary keys information for SQLite."""
|
|
594
|
-
if not tables:
|
|
595
|
-
return []
|
|
596
|
-
|
|
597
|
-
primary_keys = []
|
|
598
|
-
for table in tables:
|
|
599
|
-
table_name = table["table_name"]
|
|
600
|
-
|
|
601
|
-
# Get table info using PRAGMA to find primary keys
|
|
602
|
-
pragma_query = f"PRAGMA table_info({table_name})"
|
|
603
|
-
table_columns = await self._execute_query(connection, pragma_query)
|
|
604
|
-
|
|
605
|
-
for col in table_columns:
|
|
606
|
-
if col["pk"]: # Primary key indicator
|
|
607
|
-
primary_keys.append(
|
|
608
|
-
{
|
|
609
|
-
"table_schema": "main",
|
|
610
|
-
"table_name": table_name,
|
|
611
|
-
"column_name": col["name"],
|
|
612
|
-
}
|
|
613
|
-
)
|
|
614
|
-
|
|
615
|
-
return primary_keys
|
|
616
|
-
|
|
617
|
-
async def get_indexes_info(self, connection, tables: list) -> list:
|
|
618
|
-
"""Get indexes information for SQLite."""
|
|
619
|
-
if not tables:
|
|
620
|
-
return []
|
|
621
|
-
|
|
622
|
-
indexes = []
|
|
623
|
-
for table in tables:
|
|
624
|
-
table_name = table["table_name"]
|
|
625
|
-
|
|
626
|
-
# Get index list using PRAGMA
|
|
627
|
-
pragma_query = f"PRAGMA index_list({table_name})"
|
|
628
|
-
table_indexes = await self._execute_query(connection, pragma_query)
|
|
629
|
-
|
|
630
|
-
for idx in table_indexes:
|
|
631
|
-
idx_name = idx["name"]
|
|
632
|
-
unique = bool(idx["unique"])
|
|
633
|
-
|
|
634
|
-
# Skip auto-generated primary key indexes
|
|
635
|
-
if idx_name.startswith("sqlite_autoindex_"):
|
|
636
|
-
continue
|
|
637
|
-
|
|
638
|
-
# Get index columns using PRAGMA
|
|
639
|
-
pragma_info_query = f"PRAGMA index_info({idx_name})"
|
|
640
|
-
idx_cols = await self._execute_query(connection, pragma_info_query)
|
|
641
|
-
columns = [
|
|
642
|
-
c["name"] for c in sorted(idx_cols, key=lambda r: r["seqno"])
|
|
643
|
-
]
|
|
644
|
-
|
|
645
|
-
indexes.append(
|
|
646
|
-
{
|
|
647
|
-
"table_schema": "main",
|
|
648
|
-
"table_name": table_name,
|
|
649
|
-
"index_name": idx_name,
|
|
650
|
-
"is_unique": unique,
|
|
651
|
-
"index_type": None, # SQLite only has B-tree currently
|
|
652
|
-
"column_names": columns,
|
|
653
|
-
}
|
|
654
|
-
)
|
|
655
|
-
|
|
656
|
-
return indexes
|
|
657
|
-
|
|
658
|
-
async def list_tables_info(self, connection) -> list[dict[str, Any]]:
|
|
659
|
-
"""Get list of tables with basic information for SQLite."""
|
|
660
|
-
# Get table names without row counts for better performance
|
|
661
|
-
tables_query = """
|
|
662
|
-
SELECT
|
|
663
|
-
'main' as table_schema,
|
|
664
|
-
name as table_name,
|
|
665
|
-
type as table_type
|
|
666
|
-
FROM sqlite_master
|
|
667
|
-
WHERE type IN ('table', 'view')
|
|
668
|
-
AND name NOT LIKE 'sqlite_%'
|
|
669
|
-
ORDER BY name;
|
|
670
|
-
"""
|
|
671
|
-
|
|
672
|
-
tables = await self._execute_query(connection, tables_query)
|
|
673
|
-
|
|
674
|
-
# Convert to expected format
|
|
675
|
-
return [
|
|
676
|
-
{
|
|
677
|
-
"table_schema": table["table_schema"],
|
|
678
|
-
"table_name": table["table_name"],
|
|
679
|
-
"table_type": table["table_type"],
|
|
680
|
-
}
|
|
681
|
-
for table in tables
|
|
682
|
-
]
|
|
12
|
+
from .csv import CSVConnection
|
|
13
|
+
from .duckdb import DuckDBConnection, DuckDBSchemaIntrospector
|
|
14
|
+
from .mysql import MySQLConnection, MySQLSchemaIntrospector
|
|
15
|
+
from .postgresql import PostgreSQLConnection, PostgreSQLSchemaIntrospector
|
|
16
|
+
from .sqlite import SQLiteConnection, SQLiteSchemaIntrospector
|
|
683
17
|
|
|
684
18
|
|
|
685
19
|
class SchemaManager:
|
|
@@ -693,8 +27,10 @@ class SchemaManager:
|
|
|
693
27
|
self.introspector = PostgreSQLSchemaIntrospector()
|
|
694
28
|
elif isinstance(db_connection, MySQLConnection):
|
|
695
29
|
self.introspector = MySQLSchemaIntrospector()
|
|
696
|
-
elif isinstance(db_connection,
|
|
30
|
+
elif isinstance(db_connection, SQLiteConnection):
|
|
697
31
|
self.introspector = SQLiteSchemaIntrospector()
|
|
32
|
+
elif isinstance(db_connection, (DuckDBConnection, CSVConnection)):
|
|
33
|
+
self.introspector = DuckDBSchemaIntrospector()
|
|
698
34
|
else:
|
|
699
35
|
raise ValueError(
|
|
700
36
|
f"Unsupported database connection type: {type(db_connection)}"
|
|
@@ -741,98 +77,89 @@ class SchemaManager:
|
|
|
741
77
|
"foreign_keys": [],
|
|
742
78
|
"indexes": [],
|
|
743
79
|
}
|
|
80
|
+
|
|
744
81
|
return schema_info
|
|
745
82
|
|
|
746
|
-
def _add_columns_to_schema(
|
|
747
|
-
|
|
748
|
-
) -> None:
|
|
749
|
-
"""Add column information to schema."""
|
|
83
|
+
def _add_columns_to_schema(self, schema_info: dict, columns: list) -> None:
|
|
84
|
+
"""Add column information to schema structure."""
|
|
750
85
|
for col in columns:
|
|
751
86
|
full_name = f"{col['table_schema']}.{col['table_name']}"
|
|
752
87
|
if full_name in schema_info:
|
|
753
|
-
|
|
88
|
+
column_info: ColumnInfo = {
|
|
754
89
|
"data_type": col["data_type"],
|
|
755
|
-
"nullable": col
|
|
756
|
-
"default": col
|
|
90
|
+
"nullable": col.get("is_nullable", "YES") == "YES",
|
|
91
|
+
"default": col.get("column_default"),
|
|
92
|
+
"max_length": col.get("character_maximum_length"),
|
|
93
|
+
"precision": col.get("numeric_precision"),
|
|
94
|
+
"scale": col.get("numeric_scale"),
|
|
757
95
|
}
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
("character_maximum_length", "max_length"),
|
|
762
|
-
("numeric_precision", "precision"),
|
|
763
|
-
("numeric_scale", "scale"),
|
|
764
|
-
]:
|
|
765
|
-
if col.get(attr_map[0]):
|
|
766
|
-
col_info[attr_map[1]] = col[attr_map[0]]
|
|
767
|
-
|
|
768
|
-
schema_info[full_name]["columns"][col["column_name"]] = col_info
|
|
96
|
+
# Add type field for display compatibility
|
|
97
|
+
column_info["type"] = col["data_type"]
|
|
98
|
+
schema_info[full_name]["columns"][col["column_name"]] = column_info
|
|
769
99
|
|
|
770
100
|
def _add_primary_keys_to_schema(
|
|
771
|
-
self, schema_info: dict
|
|
101
|
+
self, schema_info: dict, primary_keys: list
|
|
772
102
|
) -> None:
|
|
773
|
-
"""Add primary key information to schema."""
|
|
103
|
+
"""Add primary key information to schema structure."""
|
|
774
104
|
for pk in primary_keys:
|
|
775
105
|
full_name = f"{pk['table_schema']}.{pk['table_name']}"
|
|
776
106
|
if full_name in schema_info:
|
|
777
107
|
schema_info[full_name]["primary_keys"].append(pk["column_name"])
|
|
778
108
|
|
|
779
109
|
def _add_foreign_keys_to_schema(
|
|
780
|
-
self, schema_info: dict
|
|
110
|
+
self, schema_info: dict, foreign_keys: list
|
|
781
111
|
) -> None:
|
|
782
|
-
"""Add foreign key information to schema."""
|
|
112
|
+
"""Add foreign key information to schema structure."""
|
|
783
113
|
for fk in foreign_keys:
|
|
784
114
|
full_name = f"{fk['table_schema']}.{fk['table_name']}"
|
|
785
115
|
if full_name in schema_info:
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
"
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
)
|
|
116
|
+
fk_info: ForeignKeyInfo = {
|
|
117
|
+
"column": fk["column_name"],
|
|
118
|
+
"references": {
|
|
119
|
+
"table": f"{fk['foreign_table_schema']}.{fk['foreign_table_name']}",
|
|
120
|
+
"column": fk["foreign_column_name"],
|
|
121
|
+
},
|
|
122
|
+
}
|
|
123
|
+
schema_info[full_name]["foreign_keys"].append(fk_info)
|
|
795
124
|
|
|
796
|
-
def _add_indexes_to_schema(
|
|
797
|
-
|
|
798
|
-
) -> None:
|
|
799
|
-
"""Add index information to schema."""
|
|
125
|
+
def _add_indexes_to_schema(self, schema_info: dict, indexes: list) -> None:
|
|
126
|
+
"""Add index information to schema structure."""
|
|
800
127
|
for idx in indexes:
|
|
801
128
|
full_name = f"{idx['table_schema']}.{idx['table_name']}"
|
|
802
129
|
if full_name in schema_info:
|
|
803
|
-
# Handle
|
|
804
|
-
if isinstance(idx
|
|
130
|
+
# Handle column names - could be comma-separated string or list
|
|
131
|
+
if isinstance(idx.get("column_names"), str):
|
|
132
|
+
columns = [
|
|
133
|
+
col.strip()
|
|
134
|
+
for col in idx["column_names"].split(",")
|
|
135
|
+
if col.strip()
|
|
136
|
+
]
|
|
137
|
+
elif isinstance(idx.get("column_names"), list):
|
|
805
138
|
columns = idx["column_names"]
|
|
806
139
|
else:
|
|
807
|
-
|
|
808
|
-
columns = (
|
|
809
|
-
idx["column_names"].split(",") if idx["column_names"] else []
|
|
810
|
-
)
|
|
140
|
+
columns = []
|
|
811
141
|
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
)
|
|
142
|
+
index_info: IndexInfo = {
|
|
143
|
+
"name": idx["index_name"],
|
|
144
|
+
"columns": columns,
|
|
145
|
+
"unique": bool(idx.get("is_unique", False)),
|
|
146
|
+
"type": idx.get("index_type"),
|
|
147
|
+
}
|
|
148
|
+
schema_info[full_name]["indexes"].append(index_info)
|
|
820
149
|
|
|
821
150
|
async def list_tables(self) -> dict[str, Any]:
|
|
822
|
-
"""Get
|
|
823
|
-
|
|
151
|
+
"""Get list of tables with basic information."""
|
|
152
|
+
tables_list = await self.introspector.list_tables_info(self.db)
|
|
824
153
|
|
|
825
|
-
#
|
|
826
|
-
|
|
154
|
+
# Add full_name and name fields for backwards compatibility
|
|
155
|
+
for table in tables_list:
|
|
156
|
+
table["full_name"] = f"{table['table_schema']}.{table['table_name']}"
|
|
157
|
+
table["name"] = table["table_name"]
|
|
158
|
+
table["schema"] = table["table_schema"]
|
|
159
|
+
table["type"] = table["table_type"] # Map table_type to type for display
|
|
827
160
|
|
|
828
|
-
|
|
829
|
-
result["tables"].append(
|
|
830
|
-
{
|
|
831
|
-
"schema": table["table_schema"],
|
|
832
|
-
"name": table["table_name"],
|
|
833
|
-
"full_name": f"{table['table_schema']}.{table['table_name']}",
|
|
834
|
-
"type": table["table_type"],
|
|
835
|
-
}
|
|
836
|
-
)
|
|
161
|
+
return {"tables": tables_list}
|
|
837
162
|
|
|
838
|
-
|
|
163
|
+
async def close(self):
|
|
164
|
+
"""Close database connection."""
|
|
165
|
+
await self.db.close()
|