sqlsaber 0.34.0__py3-none-any.whl → 0.36.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/application/db_setup.py +38 -2
- sqlsaber/cli/commands.py +5 -1
- sqlsaber/cli/database.py +160 -12
- sqlsaber/cli/display.py +19 -3
- sqlsaber/cli/interactive.py +6 -2
- sqlsaber/cli/threads.py +4 -2
- sqlsaber/config/database.py +14 -1
- sqlsaber/database/__init__.py +13 -6
- sqlsaber/database/base.py +59 -0
- sqlsaber/database/duckdb.py +68 -30
- sqlsaber/database/mysql.py +37 -10
- sqlsaber/database/postgresql.py +14 -18
- sqlsaber/database/resolver.py +17 -7
- sqlsaber/database/schema.py +3 -0
- sqlsaber/database/sqlite.py +18 -5
- sqlsaber/tools/sql_tools.py +32 -21
- {sqlsaber-0.34.0.dist-info → sqlsaber-0.36.0.dist-info}/METADATA +1 -1
- {sqlsaber-0.34.0.dist-info → sqlsaber-0.36.0.dist-info}/RECORD +21 -21
- {sqlsaber-0.34.0.dist-info → sqlsaber-0.36.0.dist-info}/WHEEL +0 -0
- {sqlsaber-0.34.0.dist-info → sqlsaber-0.36.0.dist-info}/entry_points.txt +0 -0
- {sqlsaber-0.34.0.dist-info → sqlsaber-0.36.0.dist-info}/licenses/LICENSE +0 -0
sqlsaber/database/base.py
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
"""Base classes and type definitions for database connections and schema introspection."""
|
|
2
2
|
|
|
3
|
+
import os
|
|
3
4
|
from abc import ABC, abstractmethod
|
|
5
|
+
from collections.abc import Iterable
|
|
4
6
|
from typing import Any, TypedDict
|
|
5
7
|
|
|
6
8
|
# Default query timeout to prevent runaway queries
|
|
@@ -24,6 +26,7 @@ class ColumnInfo(TypedDict):
|
|
|
24
26
|
max_length: int | None
|
|
25
27
|
precision: int | None
|
|
26
28
|
scale: int | None
|
|
29
|
+
comment: str | None
|
|
27
30
|
|
|
28
31
|
|
|
29
32
|
class ForeignKeyInfo(TypedDict):
|
|
@@ -48,6 +51,7 @@ class SchemaInfo(TypedDict):
|
|
|
48
51
|
schema: str
|
|
49
52
|
name: str
|
|
50
53
|
type: str
|
|
54
|
+
comment: str | None
|
|
51
55
|
columns: dict[str, ColumnInfo]
|
|
52
56
|
primary_keys: list[str]
|
|
53
57
|
foreign_keys: list[ForeignKeyInfo]
|
|
@@ -60,6 +64,7 @@ class BaseDatabaseConnection(ABC):
|
|
|
60
64
|
def __init__(self, connection_string: str):
|
|
61
65
|
self.connection_string = connection_string
|
|
62
66
|
self._pool = None
|
|
67
|
+
self._excluded_schemas: list[str] = []
|
|
63
68
|
|
|
64
69
|
@property
|
|
65
70
|
@abstractmethod
|
|
@@ -93,6 +98,27 @@ class BaseDatabaseConnection(ABC):
|
|
|
93
98
|
"""
|
|
94
99
|
pass
|
|
95
100
|
|
|
101
|
+
def set_excluded_schemas(self, schemas: Iterable[str] | None) -> None:
|
|
102
|
+
"""Set schemas to exclude from introspection for this connection."""
|
|
103
|
+
self._excluded_schemas = []
|
|
104
|
+
if not schemas:
|
|
105
|
+
return
|
|
106
|
+
|
|
107
|
+
seen: set[str] = set()
|
|
108
|
+
for schema in schemas:
|
|
109
|
+
clean = schema.strip()
|
|
110
|
+
if not clean:
|
|
111
|
+
continue
|
|
112
|
+
if clean in seen:
|
|
113
|
+
continue
|
|
114
|
+
seen.add(clean)
|
|
115
|
+
self._excluded_schemas.append(clean)
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def excluded_schemas(self) -> list[str]:
|
|
119
|
+
"""Return list of excluded schemas for this connection."""
|
|
120
|
+
return list(self._excluded_schemas)
|
|
121
|
+
|
|
96
122
|
|
|
97
123
|
class BaseSchemaIntrospector(ABC):
|
|
98
124
|
"""Abstract base class for database-specific schema introspection."""
|
|
@@ -128,3 +154,36 @@ class BaseSchemaIntrospector(ABC):
|
|
|
128
154
|
async def list_tables_info(self, connection) -> list[dict[str, Any]]:
|
|
129
155
|
"""Get list of tables with basic information."""
|
|
130
156
|
pass
|
|
157
|
+
|
|
158
|
+
@staticmethod
|
|
159
|
+
def _merge_excluded_schemas(
|
|
160
|
+
connection: BaseDatabaseConnection,
|
|
161
|
+
defaults: Iterable[str],
|
|
162
|
+
env_var: str | None = None,
|
|
163
|
+
) -> list[str]:
|
|
164
|
+
"""Combine default, connection, and environment schema exclusions."""
|
|
165
|
+
|
|
166
|
+
combined: list[str] = []
|
|
167
|
+
seen: set[str] = set()
|
|
168
|
+
|
|
169
|
+
def _add(items: Iterable[str]) -> None:
|
|
170
|
+
for item in items:
|
|
171
|
+
name = item.strip()
|
|
172
|
+
if not name:
|
|
173
|
+
continue
|
|
174
|
+
if name in seen:
|
|
175
|
+
continue
|
|
176
|
+
seen.add(name)
|
|
177
|
+
combined.append(name)
|
|
178
|
+
|
|
179
|
+
_add(defaults)
|
|
180
|
+
_add(getattr(connection, "excluded_schemas", []) or [])
|
|
181
|
+
|
|
182
|
+
if env_var:
|
|
183
|
+
raw = os.getenv(env_var, "")
|
|
184
|
+
if raw:
|
|
185
|
+
# Support comma-separated values
|
|
186
|
+
values = [part.strip() for part in raw.split(",")]
|
|
187
|
+
_add(values)
|
|
188
|
+
|
|
189
|
+
return combined
|
sqlsaber/database/duckdb.py
CHANGED
|
@@ -95,6 +95,13 @@ class DuckDBConnection(BaseDatabaseConnection):
|
|
|
95
95
|
class DuckDBSchemaIntrospector(BaseSchemaIntrospector):
|
|
96
96
|
"""DuckDB-specific schema introspection."""
|
|
97
97
|
|
|
98
|
+
def _get_excluded_schemas(self, connection) -> list[str]:
|
|
99
|
+
"""Return schemas to exclude during introspection."""
|
|
100
|
+
defaults = ["information_schema", "pg_catalog", "duckdb_catalog"]
|
|
101
|
+
return self._merge_excluded_schemas(
|
|
102
|
+
connection, defaults, env_var="SQLSABER_DUCKDB_EXCLUDE_SCHEMAS"
|
|
103
|
+
)
|
|
104
|
+
|
|
98
105
|
async def _execute_query(
|
|
99
106
|
self,
|
|
100
107
|
connection,
|
|
@@ -131,30 +138,43 @@ class DuckDBSchemaIntrospector(BaseSchemaIntrospector):
|
|
|
131
138
|
self, connection, table_pattern: str | None = None
|
|
132
139
|
) -> list[dict[str, Any]]:
|
|
133
140
|
"""Get tables information for DuckDB."""
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
]
|
|
141
|
+
excluded = self._get_excluded_schemas(connection)
|
|
142
|
+
where_conditions: list[str] = []
|
|
137
143
|
params: list[Any] = []
|
|
138
144
|
|
|
145
|
+
if excluded:
|
|
146
|
+
placeholders = ", ".join(["?"] * len(excluded))
|
|
147
|
+
where_conditions.append(f"t.table_schema NOT IN ({placeholders})")
|
|
148
|
+
params.extend(excluded)
|
|
149
|
+
|
|
139
150
|
if table_pattern:
|
|
140
151
|
if "." in table_pattern:
|
|
141
152
|
schema_pattern, table_name_pattern = table_pattern.split(".", 1)
|
|
142
|
-
where_conditions.append(
|
|
153
|
+
where_conditions.append(
|
|
154
|
+
"(t.table_schema LIKE ? AND t.table_name LIKE ?)"
|
|
155
|
+
)
|
|
143
156
|
params.extend([schema_pattern, table_name_pattern])
|
|
144
157
|
else:
|
|
145
158
|
where_conditions.append(
|
|
146
|
-
"(table_name LIKE ? OR table_schema || '.' || table_name LIKE ?)"
|
|
159
|
+
"(t.table_name LIKE ? OR t.table_schema || '.' || t.table_name LIKE ?)"
|
|
147
160
|
)
|
|
148
161
|
params.extend([table_pattern, table_pattern])
|
|
149
162
|
|
|
163
|
+
if not where_conditions:
|
|
164
|
+
where_conditions.append("1=1")
|
|
165
|
+
|
|
150
166
|
query = f"""
|
|
151
167
|
SELECT
|
|
152
|
-
table_schema,
|
|
153
|
-
table_name,
|
|
154
|
-
table_type
|
|
155
|
-
|
|
168
|
+
t.table_schema,
|
|
169
|
+
t.table_name,
|
|
170
|
+
t.table_type,
|
|
171
|
+
dt.comment AS table_comment
|
|
172
|
+
FROM information_schema.tables t
|
|
173
|
+
LEFT JOIN duckdb_tables() dt
|
|
174
|
+
ON t.table_schema = dt.schema_name
|
|
175
|
+
AND t.table_name = dt.table_name
|
|
156
176
|
WHERE {" AND ".join(where_conditions)}
|
|
157
|
-
ORDER BY table_schema, table_name;
|
|
177
|
+
ORDER BY t.table_schema, t.table_name;
|
|
158
178
|
"""
|
|
159
179
|
|
|
160
180
|
return await self._execute_query(connection, query, tuple(params))
|
|
@@ -166,7 +186,7 @@ class DuckDBSchemaIntrospector(BaseSchemaIntrospector):
|
|
|
166
186
|
|
|
167
187
|
table_filters = []
|
|
168
188
|
for table in tables:
|
|
169
|
-
table_filters.append("(table_schema = ? AND table_name = ?)")
|
|
189
|
+
table_filters.append("(c.table_schema = ? AND c.table_name = ?)")
|
|
170
190
|
|
|
171
191
|
params: list[Any] = []
|
|
172
192
|
for table in tables:
|
|
@@ -174,18 +194,23 @@ class DuckDBSchemaIntrospector(BaseSchemaIntrospector):
|
|
|
174
194
|
|
|
175
195
|
query = f"""
|
|
176
196
|
SELECT
|
|
177
|
-
table_schema,
|
|
178
|
-
table_name,
|
|
179
|
-
column_name,
|
|
180
|
-
data_type,
|
|
181
|
-
is_nullable,
|
|
182
|
-
column_default,
|
|
183
|
-
character_maximum_length,
|
|
184
|
-
numeric_precision,
|
|
185
|
-
numeric_scale
|
|
186
|
-
|
|
197
|
+
c.table_schema,
|
|
198
|
+
c.table_name,
|
|
199
|
+
c.column_name,
|
|
200
|
+
c.data_type,
|
|
201
|
+
c.is_nullable,
|
|
202
|
+
c.column_default,
|
|
203
|
+
c.character_maximum_length,
|
|
204
|
+
c.numeric_precision,
|
|
205
|
+
c.numeric_scale,
|
|
206
|
+
dc.comment AS column_comment
|
|
207
|
+
FROM information_schema.columns c
|
|
208
|
+
LEFT JOIN duckdb_columns() dc
|
|
209
|
+
ON c.table_schema = dc.schema_name
|
|
210
|
+
AND c.table_name = dc.table_name
|
|
211
|
+
AND c.column_name = dc.column_name
|
|
187
212
|
WHERE {" OR ".join(table_filters)}
|
|
188
|
-
ORDER BY table_schema, table_name, ordinal_position;
|
|
213
|
+
ORDER BY c.table_schema, c.table_name, c.ordinal_position;
|
|
189
214
|
"""
|
|
190
215
|
|
|
191
216
|
return await self._execute_query(connection, query, tuple(params))
|
|
@@ -305,14 +330,27 @@ class DuckDBSchemaIntrospector(BaseSchemaIntrospector):
|
|
|
305
330
|
|
|
306
331
|
async def list_tables_info(self, connection) -> list[dict[str, Any]]:
|
|
307
332
|
"""Get list of tables with basic information for DuckDB."""
|
|
308
|
-
|
|
333
|
+
excluded = self._get_excluded_schemas(connection)
|
|
334
|
+
params: list[Any] = []
|
|
335
|
+
if excluded:
|
|
336
|
+
placeholders = ", ".join(["?"] * len(excluded))
|
|
337
|
+
where_clause = f"WHERE t.table_schema NOT IN ({placeholders})"
|
|
338
|
+
params.extend(excluded)
|
|
339
|
+
else:
|
|
340
|
+
where_clause = ""
|
|
341
|
+
|
|
342
|
+
query = f"""
|
|
309
343
|
SELECT
|
|
310
|
-
table_schema,
|
|
311
|
-
table_name,
|
|
312
|
-
table_type
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
344
|
+
t.table_schema,
|
|
345
|
+
t.table_name,
|
|
346
|
+
t.table_type,
|
|
347
|
+
dt.comment AS table_comment
|
|
348
|
+
FROM information_schema.tables t
|
|
349
|
+
LEFT JOIN duckdb_tables() dt
|
|
350
|
+
ON t.table_schema = dt.schema_name
|
|
351
|
+
AND t.table_name = dt.table_name
|
|
352
|
+
{where_clause}
|
|
353
|
+
ORDER BY t.table_schema, t.table_name;
|
|
316
354
|
"""
|
|
317
355
|
|
|
318
|
-
return await self._execute_query(connection, query)
|
|
356
|
+
return await self._execute_query(connection, query, tuple(params))
|
sqlsaber/database/mysql.py
CHANGED
|
@@ -153,6 +153,13 @@ class MySQLConnection(BaseDatabaseConnection):
|
|
|
153
153
|
class MySQLSchemaIntrospector(BaseSchemaIntrospector):
|
|
154
154
|
"""MySQL-specific schema introspection."""
|
|
155
155
|
|
|
156
|
+
def _get_excluded_schemas(self, connection) -> list[str]:
|
|
157
|
+
"""Return schemas to exclude during introspection."""
|
|
158
|
+
defaults = ["information_schema", "performance_schema", "mysql", "sys"]
|
|
159
|
+
return self._merge_excluded_schemas(
|
|
160
|
+
connection, defaults, env_var="SQLSABER_MYSQL_EXCLUDE_SCHEMAS"
|
|
161
|
+
)
|
|
162
|
+
|
|
156
163
|
def _build_table_filter_clause(self, tables: list) -> tuple[str, list]:
|
|
157
164
|
"""Build row constructor with bind parameters for table filtering.
|
|
158
165
|
|
|
@@ -178,10 +185,14 @@ class MySQLSchemaIntrospector(BaseSchemaIntrospector):
|
|
|
178
185
|
async with pool.acquire() as conn:
|
|
179
186
|
async with conn.cursor() as cursor:
|
|
180
187
|
# Build WHERE clause for filtering
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
]
|
|
184
|
-
|
|
188
|
+
excluded = self._get_excluded_schemas(connection)
|
|
189
|
+
where_conditions = []
|
|
190
|
+
params: list[Any] = []
|
|
191
|
+
|
|
192
|
+
if excluded:
|
|
193
|
+
placeholders = ", ".join(["%s"] * len(excluded))
|
|
194
|
+
where_conditions.append(f"table_schema NOT IN ({placeholders})")
|
|
195
|
+
params.extend(excluded)
|
|
185
196
|
|
|
186
197
|
if table_pattern:
|
|
187
198
|
# Support patterns like 'schema.table' or just 'table'
|
|
@@ -197,12 +208,16 @@ class MySQLSchemaIntrospector(BaseSchemaIntrospector):
|
|
|
197
208
|
)
|
|
198
209
|
params.extend([table_pattern, table_pattern])
|
|
199
210
|
|
|
211
|
+
if not where_conditions:
|
|
212
|
+
where_conditions.append("1=1")
|
|
213
|
+
|
|
200
214
|
# Get tables
|
|
201
215
|
tables_query = f"""
|
|
202
216
|
SELECT
|
|
203
217
|
table_schema,
|
|
204
218
|
table_name,
|
|
205
|
-
table_type
|
|
219
|
+
table_type,
|
|
220
|
+
table_comment
|
|
206
221
|
FROM information_schema.tables
|
|
207
222
|
WHERE {" AND ".join(where_conditions)}
|
|
208
223
|
ORDER BY table_schema, table_name;
|
|
@@ -230,7 +245,8 @@ class MySQLSchemaIntrospector(BaseSchemaIntrospector):
|
|
|
230
245
|
c.column_default,
|
|
231
246
|
c.character_maximum_length,
|
|
232
247
|
c.numeric_precision,
|
|
233
|
-
c.numeric_scale
|
|
248
|
+
c.numeric_scale,
|
|
249
|
+
c.column_comment
|
|
234
250
|
FROM information_schema.columns c
|
|
235
251
|
WHERE (c.table_schema, c.table_name) IN ({placeholders})
|
|
236
252
|
ORDER BY c.table_schema, c.table_name, c.ordinal_position;
|
|
@@ -327,16 +343,26 @@ class MySQLSchemaIntrospector(BaseSchemaIntrospector):
|
|
|
327
343
|
async with pool.acquire() as conn:
|
|
328
344
|
async with conn.cursor() as cursor:
|
|
329
345
|
# Get tables without row counts for better performance
|
|
330
|
-
|
|
346
|
+
excluded = self._get_excluded_schemas(connection)
|
|
347
|
+
params: list[Any] = []
|
|
348
|
+
if excluded:
|
|
349
|
+
placeholders = ", ".join(["%s"] * len(excluded))
|
|
350
|
+
where_clause = f"WHERE t.table_schema NOT IN ({placeholders})"
|
|
351
|
+
params.extend(excluded)
|
|
352
|
+
else:
|
|
353
|
+
where_clause = ""
|
|
354
|
+
|
|
355
|
+
tables_query = f"""
|
|
331
356
|
SELECT
|
|
332
357
|
t.table_schema,
|
|
333
358
|
t.table_name,
|
|
334
|
-
t.table_type
|
|
359
|
+
t.table_type,
|
|
360
|
+
t.table_comment
|
|
335
361
|
FROM information_schema.tables t
|
|
336
|
-
|
|
362
|
+
{where_clause}
|
|
337
363
|
ORDER BY t.table_schema, t.table_name;
|
|
338
364
|
"""
|
|
339
|
-
await cursor.execute(tables_query)
|
|
365
|
+
await cursor.execute(tables_query, params if params else None)
|
|
340
366
|
rows = await cursor.fetchall()
|
|
341
367
|
|
|
342
368
|
# Convert rows to dictionaries
|
|
@@ -345,6 +371,7 @@ class MySQLSchemaIntrospector(BaseSchemaIntrospector):
|
|
|
345
371
|
"table_schema": row["table_schema"],
|
|
346
372
|
"table_name": row["table_name"],
|
|
347
373
|
"table_type": row["table_type"],
|
|
374
|
+
"table_comment": row["table_comment"],
|
|
348
375
|
}
|
|
349
376
|
for row in rows
|
|
350
377
|
]
|
sqlsaber/database/postgresql.py
CHANGED
|
@@ -135,7 +135,7 @@ class PostgreSQLConnection(BaseDatabaseConnection):
|
|
|
135
135
|
class PostgreSQLSchemaIntrospector(BaseSchemaIntrospector):
|
|
136
136
|
"""PostgreSQL-specific schema introspection."""
|
|
137
137
|
|
|
138
|
-
def _get_excluded_schemas(self) -> list[str]:
|
|
138
|
+
def _get_excluded_schemas(self, connection) -> list[str]:
|
|
139
139
|
"""Return schemas to exclude during introspection.
|
|
140
140
|
|
|
141
141
|
Defaults include PostgreSQL system schemas and TimescaleDB internal
|
|
@@ -143,10 +143,7 @@ class PostgreSQLSchemaIntrospector(BaseSchemaIntrospector):
|
|
|
143
143
|
environment variable `SQLSABER_PG_EXCLUDE_SCHEMAS` to a comma-separated
|
|
144
144
|
list of schema names.
|
|
145
145
|
"""
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
# Base exclusions: system schemas and TimescaleDB internal partitions
|
|
149
|
-
excluded = [
|
|
146
|
+
defaults = [
|
|
150
147
|
"pg_catalog",
|
|
151
148
|
"information_schema",
|
|
152
149
|
"_timescaledb_internal",
|
|
@@ -155,14 +152,9 @@ class PostgreSQLSchemaIntrospector(BaseSchemaIntrospector):
|
|
|
155
152
|
"_timescaledb_catalog",
|
|
156
153
|
]
|
|
157
154
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
name = item.strip()
|
|
162
|
-
if name and name not in excluded:
|
|
163
|
-
excluded.append(name)
|
|
164
|
-
|
|
165
|
-
return excluded
|
|
155
|
+
return self._merge_excluded_schemas(
|
|
156
|
+
connection, defaults, env_var="SQLSABER_PG_EXCLUDE_SCHEMAS"
|
|
157
|
+
)
|
|
166
158
|
|
|
167
159
|
def _build_table_filter_clause(self, tables: list) -> tuple[str, list]:
|
|
168
160
|
"""Build VALUES clause with bind parameters for table filtering.
|
|
@@ -193,7 +185,7 @@ class PostgreSQLSchemaIntrospector(BaseSchemaIntrospector):
|
|
|
193
185
|
where_conditions: list[str] = []
|
|
194
186
|
params: list[Any] = []
|
|
195
187
|
|
|
196
|
-
excluded = self._get_excluded_schemas()
|
|
188
|
+
excluded = self._get_excluded_schemas(connection)
|
|
197
189
|
if excluded:
|
|
198
190
|
placeholders = ", ".join(f"${i + 1}" for i in range(len(excluded)))
|
|
199
191
|
where_conditions.append(f"table_schema NOT IN ({placeholders})")
|
|
@@ -226,7 +218,8 @@ class PostgreSQLSchemaIntrospector(BaseSchemaIntrospector):
|
|
|
226
218
|
SELECT
|
|
227
219
|
table_schema,
|
|
228
220
|
table_name,
|
|
229
|
-
table_type
|
|
221
|
+
table_type,
|
|
222
|
+
obj_description(('"' || table_schema || '"."' || table_name || '"')::regclass, 'pg_class') AS table_comment
|
|
230
223
|
FROM information_schema.tables
|
|
231
224
|
WHERE {" AND ".join(where_conditions)}
|
|
232
225
|
ORDER BY table_schema, table_name;
|
|
@@ -252,7 +245,8 @@ class PostgreSQLSchemaIntrospector(BaseSchemaIntrospector):
|
|
|
252
245
|
c.column_default,
|
|
253
246
|
c.character_maximum_length,
|
|
254
247
|
c.numeric_precision,
|
|
255
|
-
c.numeric_scale
|
|
248
|
+
c.numeric_scale,
|
|
249
|
+
col_description(('"' || c.table_schema || '"."' || c.table_name || '"')::regclass::oid, c.ordinal_position::INT) AS column_comment
|
|
256
250
|
FROM information_schema.columns c
|
|
257
251
|
WHERE (c.table_schema, c.table_name) IN (VALUES {values_clause})
|
|
258
252
|
ORDER BY c.table_schema, c.table_name, c.ordinal_position;
|
|
@@ -352,7 +346,7 @@ class PostgreSQLSchemaIntrospector(BaseSchemaIntrospector):
|
|
|
352
346
|
pool = await connection.get_pool()
|
|
353
347
|
async with pool.acquire() as conn:
|
|
354
348
|
# Exclude system schemas (and TimescaleDB internals) for performance
|
|
355
|
-
excluded = self._get_excluded_schemas()
|
|
349
|
+
excluded = self._get_excluded_schemas(connection)
|
|
356
350
|
params: list[Any] = []
|
|
357
351
|
if excluded:
|
|
358
352
|
placeholders = ", ".join(f"${i + 1}" for i in range(len(excluded)))
|
|
@@ -367,7 +361,8 @@ class PostgreSQLSchemaIntrospector(BaseSchemaIntrospector):
|
|
|
367
361
|
SELECT
|
|
368
362
|
table_schema,
|
|
369
363
|
table_name,
|
|
370
|
-
table_type
|
|
364
|
+
table_type,
|
|
365
|
+
obj_description(('"' || table_schema || '"."' || table_name || '"')::regclass, 'pg_class') AS table_comment
|
|
371
366
|
FROM information_schema.tables
|
|
372
367
|
WHERE {where_clause}
|
|
373
368
|
ORDER BY table_schema, table_name;
|
|
@@ -380,6 +375,7 @@ class PostgreSQLSchemaIntrospector(BaseSchemaIntrospector):
|
|
|
380
375
|
"table_schema": table["table_schema"],
|
|
381
376
|
"table_name": table["table_name"],
|
|
382
377
|
"table_type": table["table_type"],
|
|
378
|
+
"table_comment": table["table_comment"],
|
|
383
379
|
}
|
|
384
380
|
for table in tables
|
|
385
381
|
]
|
sqlsaber/database/resolver.py
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
"""Database connection resolution from CLI input."""
|
|
2
2
|
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
3
|
from dataclasses import dataclass
|
|
6
4
|
from pathlib import Path
|
|
7
5
|
from urllib.parse import urlparse
|
|
@@ -21,6 +19,7 @@ class ResolvedDatabase:
|
|
|
21
19
|
|
|
22
20
|
name: str # Human-readable name for display/logging
|
|
23
21
|
connection_string: str # Canonical connection string for DatabaseConnection factory
|
|
22
|
+
excluded_schemas: list[str]
|
|
24
23
|
|
|
25
24
|
|
|
26
25
|
SUPPORTED_SCHEMES = {"postgresql", "mysql", "sqlite", "duckdb", "csv"}
|
|
@@ -60,6 +59,7 @@ def resolve_database(
|
|
|
60
59
|
return ResolvedDatabase(
|
|
61
60
|
name=db_cfg.name,
|
|
62
61
|
connection_string=db_cfg.to_connection_string(),
|
|
62
|
+
excluded_schemas=list(db_cfg.exclude_schemas),
|
|
63
63
|
)
|
|
64
64
|
|
|
65
65
|
# 1. Connection string?
|
|
@@ -71,22 +71,30 @@ def resolve_database(
|
|
|
71
71
|
db_name = Path(urlparse(spec).path).stem or "database"
|
|
72
72
|
else: # should not happen because of SUPPORTED_SCHEMES
|
|
73
73
|
db_name = "database"
|
|
74
|
-
return ResolvedDatabase(
|
|
74
|
+
return ResolvedDatabase(
|
|
75
|
+
name=db_name, connection_string=spec, excluded_schemas=[]
|
|
76
|
+
)
|
|
75
77
|
|
|
76
78
|
# 2. Raw file path?
|
|
77
79
|
path = Path(spec).expanduser().resolve()
|
|
78
80
|
if path.suffix.lower() == ".csv":
|
|
79
81
|
if not path.exists():
|
|
80
82
|
raise DatabaseResolutionError(f"CSV file '{spec}' not found.")
|
|
81
|
-
return ResolvedDatabase(
|
|
83
|
+
return ResolvedDatabase(
|
|
84
|
+
name=path.stem, connection_string=f"csv:///{path}", excluded_schemas=[]
|
|
85
|
+
)
|
|
82
86
|
if path.suffix.lower() in {".db", ".sqlite", ".sqlite3"}:
|
|
83
87
|
if not path.exists():
|
|
84
88
|
raise DatabaseResolutionError(f"SQLite file '{spec}' not found.")
|
|
85
|
-
return ResolvedDatabase(
|
|
89
|
+
return ResolvedDatabase(
|
|
90
|
+
name=path.stem, connection_string=f"sqlite:///{path}", excluded_schemas=[]
|
|
91
|
+
)
|
|
86
92
|
if path.suffix.lower() in {".duckdb", ".ddb"}:
|
|
87
93
|
if not path.exists():
|
|
88
94
|
raise DatabaseResolutionError(f"DuckDB file '{spec}' not found.")
|
|
89
|
-
return ResolvedDatabase(
|
|
95
|
+
return ResolvedDatabase(
|
|
96
|
+
name=path.stem, connection_string=f"duckdb:///{path}", excluded_schemas=[]
|
|
97
|
+
)
|
|
90
98
|
|
|
91
99
|
# 3. Must be a configured name
|
|
92
100
|
db_cfg: DatabaseConfig | None = config_mgr.get_database(spec)
|
|
@@ -96,5 +104,7 @@ def resolve_database(
|
|
|
96
104
|
"Use 'sqlsaber db list' to see available connections."
|
|
97
105
|
)
|
|
98
106
|
return ResolvedDatabase(
|
|
99
|
-
name=db_cfg.name,
|
|
107
|
+
name=db_cfg.name,
|
|
108
|
+
connection_string=db_cfg.to_connection_string(),
|
|
109
|
+
excluded_schemas=list(db_cfg.exclude_schemas),
|
|
100
110
|
)
|
sqlsaber/database/schema.py
CHANGED
|
@@ -72,6 +72,7 @@ class SchemaManager:
|
|
|
72
72
|
"schema": schema_name,
|
|
73
73
|
"name": table_name,
|
|
74
74
|
"type": table["table_type"],
|
|
75
|
+
"comment": table["table_comment"],
|
|
75
76
|
"columns": {},
|
|
76
77
|
"primary_keys": [],
|
|
77
78
|
"foreign_keys": [],
|
|
@@ -85,6 +86,7 @@ class SchemaManager:
|
|
|
85
86
|
for col in columns:
|
|
86
87
|
full_name = f"{col['table_schema']}.{col['table_name']}"
|
|
87
88
|
if full_name in schema_info:
|
|
89
|
+
# Handle different row types (dict vs Row objects)
|
|
88
90
|
column_info: ColumnInfo = {
|
|
89
91
|
"data_type": col["data_type"],
|
|
90
92
|
"nullable": col.get("is_nullable", "YES") == "YES",
|
|
@@ -92,6 +94,7 @@ class SchemaManager:
|
|
|
92
94
|
"max_length": col.get("character_maximum_length"),
|
|
93
95
|
"precision": col.get("numeric_precision"),
|
|
94
96
|
"scale": col.get("numeric_scale"),
|
|
97
|
+
"comment": col.get("column_comment"),
|
|
95
98
|
}
|
|
96
99
|
# Add type field for display compatibility
|
|
97
100
|
column_info["type"] = col["data_type"]
|
sqlsaber/database/sqlite.py
CHANGED
|
@@ -93,7 +93,10 @@ class SQLiteSchemaIntrospector(BaseSchemaIntrospector):
|
|
|
93
93
|
async def get_tables_info(
|
|
94
94
|
self, connection, table_pattern: str | None = None
|
|
95
95
|
) -> dict[str, Any]:
|
|
96
|
-
"""Get tables information for SQLite.
|
|
96
|
+
"""Get tables information for SQLite.
|
|
97
|
+
|
|
98
|
+
Note: SQLite does not support native table comments, so table_comment is always None.
|
|
99
|
+
"""
|
|
97
100
|
where_conditions = ["type IN ('table', 'view')", "name NOT LIKE 'sqlite_%'"]
|
|
98
101
|
params = ()
|
|
99
102
|
|
|
@@ -105,7 +108,8 @@ class SQLiteSchemaIntrospector(BaseSchemaIntrospector):
|
|
|
105
108
|
SELECT
|
|
106
109
|
'main' as table_schema,
|
|
107
110
|
name as table_name,
|
|
108
|
-
type as table_type
|
|
111
|
+
type as table_type,
|
|
112
|
+
NULL as table_comment
|
|
109
113
|
FROM sqlite_master
|
|
110
114
|
WHERE {" AND ".join(where_conditions)}
|
|
111
115
|
ORDER BY name;
|
|
@@ -114,7 +118,10 @@ class SQLiteSchemaIntrospector(BaseSchemaIntrospector):
|
|
|
114
118
|
return await self._execute_query(connection, query, params)
|
|
115
119
|
|
|
116
120
|
async def get_columns_info(self, connection, tables: list) -> list:
|
|
117
|
-
"""Get columns information for SQLite.
|
|
121
|
+
"""Get columns information for SQLite.
|
|
122
|
+
|
|
123
|
+
Note: SQLite does not support native column comments, so column_comment is always None.
|
|
124
|
+
"""
|
|
118
125
|
if not tables:
|
|
119
126
|
return []
|
|
120
127
|
|
|
@@ -138,6 +145,7 @@ class SQLiteSchemaIntrospector(BaseSchemaIntrospector):
|
|
|
138
145
|
"character_maximum_length": None,
|
|
139
146
|
"numeric_precision": None,
|
|
140
147
|
"numeric_scale": None,
|
|
148
|
+
"column_comment": None,
|
|
141
149
|
}
|
|
142
150
|
)
|
|
143
151
|
|
|
@@ -237,13 +245,17 @@ class SQLiteSchemaIntrospector(BaseSchemaIntrospector):
|
|
|
237
245
|
return indexes
|
|
238
246
|
|
|
239
247
|
async def list_tables_info(self, connection) -> list[dict[str, Any]]:
|
|
240
|
-
"""Get list of tables with basic information for SQLite.
|
|
248
|
+
"""Get list of tables with basic information for SQLite.
|
|
249
|
+
|
|
250
|
+
Note: SQLite does not support native table comments, so table_comment is always None.
|
|
251
|
+
"""
|
|
241
252
|
# Get table names without row counts for better performance
|
|
242
253
|
tables_query = """
|
|
243
254
|
SELECT
|
|
244
255
|
'main' as table_schema,
|
|
245
256
|
name as table_name,
|
|
246
|
-
type as table_type
|
|
257
|
+
type as table_type,
|
|
258
|
+
NULL as table_comment
|
|
247
259
|
FROM sqlite_master
|
|
248
260
|
WHERE type IN ('table', 'view')
|
|
249
261
|
AND name NOT LIKE 'sqlite_%'
|
|
@@ -258,6 +270,7 @@ class SQLiteSchemaIntrospector(BaseSchemaIntrospector):
|
|
|
258
270
|
"table_schema": table["table_schema"],
|
|
259
271
|
"table_name": table["table_name"],
|
|
260
272
|
"table_type": table["table_type"],
|
|
273
|
+
"table_comment": table["table_comment"],
|
|
261
274
|
}
|
|
262
275
|
for table in tables
|
|
263
276
|
]
|
sqlsaber/tools/sql_tools.py
CHANGED
|
@@ -95,27 +95,38 @@ class IntrospectSchemaTool(SQLTool):
|
|
|
95
95
|
# Format the schema information
|
|
96
96
|
formatted_info = {}
|
|
97
97
|
for table_name, table_info in schema_info.items():
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
"
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
98
|
+
table_data = {}
|
|
99
|
+
|
|
100
|
+
# Add table comment if present
|
|
101
|
+
if table_info.get("comment"):
|
|
102
|
+
table_data["comment"] = table_info["comment"]
|
|
103
|
+
|
|
104
|
+
# Add columns with comments if present
|
|
105
|
+
table_data["columns"] = {}
|
|
106
|
+
for col_name, col_info in table_info["columns"].items():
|
|
107
|
+
column_data = {
|
|
108
|
+
"type": col_info["data_type"],
|
|
109
|
+
"nullable": col_info["nullable"],
|
|
110
|
+
"default": col_info["default"],
|
|
111
|
+
}
|
|
112
|
+
if col_info.get("comment"):
|
|
113
|
+
column_data["comment"] = col_info["comment"]
|
|
114
|
+
table_data["columns"][col_name] = column_data
|
|
115
|
+
|
|
116
|
+
# Add other schema information
|
|
117
|
+
table_data["primary_keys"] = table_info["primary_keys"]
|
|
118
|
+
table_data["foreign_keys"] = [
|
|
119
|
+
f"{fk['column']} -> {fk['references']['table']}.{fk['references']['column']}"
|
|
120
|
+
for fk in table_info["foreign_keys"]
|
|
121
|
+
]
|
|
122
|
+
table_data["indexes"] = [
|
|
123
|
+
f"{idx['name']} ({', '.join(idx['columns'])})"
|
|
124
|
+
+ (" UNIQUE" if idx["unique"] else "")
|
|
125
|
+
+ (f" [{idx['type']}]" if idx["type"] else "")
|
|
126
|
+
for idx in table_info["indexes"]
|
|
127
|
+
]
|
|
128
|
+
|
|
129
|
+
formatted_info[table_name] = table_data
|
|
119
130
|
|
|
120
131
|
return json.dumps(formatted_info)
|
|
121
132
|
except Exception as e:
|