dbagent-cli 0.1.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.
- dbagent/__init__.py +5 -0
- dbagent/agent/generator.py +264 -0
- dbagent/agent/pipeline.py +259 -0
- dbagent/agent/validator.py +116 -0
- dbagent/cli.py +717 -0
- dbagent/config.py +89 -0
- dbagent/connectors/base.py +75 -0
- dbagent/connectors/factory.py +25 -0
- dbagent/connectors/mongo.py +237 -0
- dbagent/connectors/relational.py +472 -0
- dbagent/llm/auto_setup.py +269 -0
- dbagent/llm/base.py +37 -0
- dbagent/llm/factory.py +73 -0
- dbagent/llm/gemini_provider.py +110 -0
- dbagent/llm/groq_provider.py +99 -0
- dbagent/llm/mock_provider.py +42 -0
- dbagent/llm/ollama_provider.py +145 -0
- dbagent/llm/openrouter_provider.py +100 -0
- dbagent/schema/formatter.py +123 -0
- dbagent/schema/models.py +72 -0
- dbagent/schema/selector.py +70 -0
- dbagent/ui/console.py +77 -0
- dbagent/ui/viewer.py +93 -0
- dbagent_cli-0.1.0.dist-info/METADATA +242 -0
- dbagent_cli-0.1.0.dist-info/RECORD +28 -0
- dbagent_cli-0.1.0.dist-info/WHEEL +5 -0
- dbagent_cli-0.1.0.dist-info/entry_points.txt +3 -0
- dbagent_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Universal Relational Database Connector using SQLAlchemy.
|
|
3
|
+
Supports PostgreSQL, MySQL, MariaDB, SQLite, SQL Server (MSSQL), Oracle, DuckDB, Snowflake.
|
|
4
|
+
Enhanced with table name caching, fuzzy matching, and speed optimizations.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Dict, Any, List, Optional, Tuple, Set
|
|
8
|
+
import datetime
|
|
9
|
+
import decimal
|
|
10
|
+
import re
|
|
11
|
+
import uuid
|
|
12
|
+
import sqlalchemy as sa
|
|
13
|
+
from sqlalchemy import inspect, text
|
|
14
|
+
from sqlalchemy.engine import Engine
|
|
15
|
+
|
|
16
|
+
from dbagent.connectors.base import BaseConnector
|
|
17
|
+
from dbagent.schema.models import (
|
|
18
|
+
DatabaseSchema,
|
|
19
|
+
TableModel,
|
|
20
|
+
ColumnModel,
|
|
21
|
+
ForeignKeyModel,
|
|
22
|
+
IndexModel,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _serialize_sample_val(val: Any) -> Any:
|
|
27
|
+
"""Convert database values into clean displayable Python types."""
|
|
28
|
+
if val is None:
|
|
29
|
+
return None
|
|
30
|
+
if isinstance(val, (datetime.date, datetime.datetime, datetime.time)):
|
|
31
|
+
return val.isoformat()
|
|
32
|
+
if isinstance(val, decimal.Decimal):
|
|
33
|
+
return float(val)
|
|
34
|
+
if isinstance(val, uuid.UUID):
|
|
35
|
+
return str(val)
|
|
36
|
+
if isinstance(val, bytes):
|
|
37
|
+
return f"<bytes len={len(val)}>"
|
|
38
|
+
return val
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _fuzzy_match_tables(table_names: List[str], user_prompt: str) -> Tuple[List[str], List[str]]:
|
|
42
|
+
"""
|
|
43
|
+
Match table names from user prompt with fuzzy/partial/singular-plural normalization.
|
|
44
|
+
Returns (exact_matches, fuzzy_matches).
|
|
45
|
+
"""
|
|
46
|
+
prompt_lower = user_prompt.lower()
|
|
47
|
+
exact: List[str] = []
|
|
48
|
+
fuzzy: List[str] = []
|
|
49
|
+
|
|
50
|
+
# Extract meaningful words from the prompt (3+ chars, skip SQL keywords)
|
|
51
|
+
sql_keywords = {
|
|
52
|
+
"select", "from", "where", "join", "left", "right", "inner", "outer",
|
|
53
|
+
"order", "group", "having", "limit", "offset", "insert", "update",
|
|
54
|
+
"delete", "into", "values", "set", "and", "not", "the", "all",
|
|
55
|
+
"show", "list", "find", "get", "recent", "last", "first", "count",
|
|
56
|
+
"with", "like", "between", "table", "column", "create", "alter",
|
|
57
|
+
"drop", "index", "primary", "foreign", "key", "null", "default",
|
|
58
|
+
"hour", "minute", "day", "week", "month", "year", "ago", "today",
|
|
59
|
+
"give", "tell", "want", "need", "please", "display", "fetch",
|
|
60
|
+
}
|
|
61
|
+
prompt_words = set()
|
|
62
|
+
for word in re.findall(r"\b[a-z_][a-z0-9_]*\b", prompt_lower):
|
|
63
|
+
if len(word) >= 3 and word not in sql_keywords:
|
|
64
|
+
prompt_words.add(word)
|
|
65
|
+
|
|
66
|
+
for t_name in table_names:
|
|
67
|
+
t_lower = t_name.lower()
|
|
68
|
+
|
|
69
|
+
# 1. Exact word boundary match
|
|
70
|
+
if re.search(r"\b" + re.escape(t_lower) + r"\b", prompt_lower):
|
|
71
|
+
exact.append(t_name)
|
|
72
|
+
continue
|
|
73
|
+
|
|
74
|
+
# 2. Substring match (table name mentioned in prompt)
|
|
75
|
+
if t_lower in prompt_lower:
|
|
76
|
+
exact.append(t_name)
|
|
77
|
+
continue
|
|
78
|
+
|
|
79
|
+
# 3. Singular/plural normalization
|
|
80
|
+
# "users" matches "user", "categories" matches "category"
|
|
81
|
+
t_singular = t_lower.rstrip("s")
|
|
82
|
+
t_no_ies = re.sub(r"ies$", "y", t_lower)
|
|
83
|
+
for word in prompt_words:
|
|
84
|
+
w_singular = word.rstrip("s")
|
|
85
|
+
w_no_ies = re.sub(r"ies$", "y", word)
|
|
86
|
+
# Check various forms
|
|
87
|
+
if (word == t_lower or
|
|
88
|
+
word == t_singular or
|
|
89
|
+
w_singular == t_lower or
|
|
90
|
+
w_singular == t_singular or
|
|
91
|
+
word == t_no_ies or
|
|
92
|
+
w_no_ies == t_lower):
|
|
93
|
+
fuzzy.append(t_name)
|
|
94
|
+
break
|
|
95
|
+
else:
|
|
96
|
+
# 4. Partial prefix/suffix match (e.g. "user" matches "user_logins", "app_users")
|
|
97
|
+
for word in prompt_words:
|
|
98
|
+
if len(word) >= 4:
|
|
99
|
+
if t_lower.startswith(word) or t_lower.endswith(word):
|
|
100
|
+
fuzzy.append(t_name)
|
|
101
|
+
break
|
|
102
|
+
# Check if word is a component of underscore-separated name
|
|
103
|
+
t_parts = t_lower.split("_")
|
|
104
|
+
if word in t_parts or word.rstrip("s") in t_parts:
|
|
105
|
+
fuzzy.append(t_name)
|
|
106
|
+
break
|
|
107
|
+
|
|
108
|
+
return exact, fuzzy
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class RelationalConnector(BaseConnector):
|
|
112
|
+
"""Universal connector for all SQL databases supported by SQLAlchemy."""
|
|
113
|
+
|
|
114
|
+
def __init__(self, connection_url: str):
|
|
115
|
+
self.connection_url = connection_url
|
|
116
|
+
self._engine: Optional[Engine] = None
|
|
117
|
+
self._cached_table_names: Optional[List[str]] = None
|
|
118
|
+
self._cached_view_names: Optional[List[str]] = None
|
|
119
|
+
self._init_engine()
|
|
120
|
+
|
|
121
|
+
def _init_engine(self) -> None:
|
|
122
|
+
url = self.connection_url
|
|
123
|
+
if url.startswith("sqlite:///") or url.startswith("sqlite://"):
|
|
124
|
+
self._engine = sa.create_engine(url)
|
|
125
|
+
elif url.startswith("duckdb:///"):
|
|
126
|
+
self._engine = sa.create_engine(url)
|
|
127
|
+
else:
|
|
128
|
+
self._engine = sa.create_engine(
|
|
129
|
+
url,
|
|
130
|
+
pool_pre_ping=True,
|
|
131
|
+
pool_size=2,
|
|
132
|
+
max_overflow=3,
|
|
133
|
+
connect_args={"connect_timeout": 10} if "mysql" in url or "postgres" in url else {},
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def engine(self) -> Engine:
|
|
138
|
+
if self._engine is None:
|
|
139
|
+
self._init_engine()
|
|
140
|
+
return self._engine
|
|
141
|
+
|
|
142
|
+
def test_connection(self) -> Tuple[bool, str]:
|
|
143
|
+
"""Test database connection with a lightweight SELECT 1 query."""
|
|
144
|
+
try:
|
|
145
|
+
with self.engine.connect() as conn:
|
|
146
|
+
conn.execute(text("SELECT 1"))
|
|
147
|
+
return True, f"Successfully connected to {self.engine.dialect.name} database."
|
|
148
|
+
except Exception as e:
|
|
149
|
+
return False, f"Connection failed: {str(e)}"
|
|
150
|
+
|
|
151
|
+
def get_table_names(self) -> List[str]:
|
|
152
|
+
"""Fast lookup of all table names with caching."""
|
|
153
|
+
if self._cached_table_names is not None:
|
|
154
|
+
return self._cached_table_names
|
|
155
|
+
insp = inspect(self.engine)
|
|
156
|
+
try:
|
|
157
|
+
self._cached_table_names = insp.get_table_names()
|
|
158
|
+
except Exception:
|
|
159
|
+
self._cached_table_names = []
|
|
160
|
+
return self._cached_table_names
|
|
161
|
+
|
|
162
|
+
def get_view_names(self) -> List[str]:
|
|
163
|
+
"""Fast lookup of all view names with caching."""
|
|
164
|
+
if self._cached_view_names is not None:
|
|
165
|
+
return self._cached_view_names
|
|
166
|
+
insp = inspect(self.engine)
|
|
167
|
+
try:
|
|
168
|
+
self._cached_view_names = insp.get_view_names()
|
|
169
|
+
except Exception:
|
|
170
|
+
self._cached_view_names = []
|
|
171
|
+
return self._cached_view_names
|
|
172
|
+
|
|
173
|
+
def refresh_cache(self) -> None:
|
|
174
|
+
"""Invalidate cached table/view names."""
|
|
175
|
+
self._cached_table_names = None
|
|
176
|
+
self._cached_view_names = None
|
|
177
|
+
|
|
178
|
+
def inspect_schema(
|
|
179
|
+
self,
|
|
180
|
+
table_names: Optional[List[str]] = None,
|
|
181
|
+
include_samples: bool = True,
|
|
182
|
+
max_samples: int = 2,
|
|
183
|
+
include_views: bool = True,
|
|
184
|
+
include_row_counts: bool = False,
|
|
185
|
+
) -> DatabaseSchema:
|
|
186
|
+
"""Introspect schema metadata for all or specified tables."""
|
|
187
|
+
insp = inspect(self.engine)
|
|
188
|
+
dialect_name = self.engine.dialect.name
|
|
189
|
+
db_name = self.engine.url.database or "default"
|
|
190
|
+
|
|
191
|
+
server_version = None
|
|
192
|
+
try:
|
|
193
|
+
server_version = ".".join(str(v) for v in self.engine.dialect.server_version_info)
|
|
194
|
+
except Exception:
|
|
195
|
+
pass
|
|
196
|
+
|
|
197
|
+
tables: List[TableModel] = []
|
|
198
|
+
|
|
199
|
+
# 1. Inspect regular tables
|
|
200
|
+
all_table_names = self.get_table_names()
|
|
201
|
+
|
|
202
|
+
target_tables = all_table_names
|
|
203
|
+
if table_names is not None:
|
|
204
|
+
lower_targets = {t.lower() for t in table_names}
|
|
205
|
+
target_tables = [t for t in all_table_names if t.lower() in lower_targets]
|
|
206
|
+
|
|
207
|
+
for t_name in target_tables:
|
|
208
|
+
table_model = self._inspect_single_table(
|
|
209
|
+
insp=insp,
|
|
210
|
+
table_name=t_name,
|
|
211
|
+
is_view=False,
|
|
212
|
+
include_samples=include_samples,
|
|
213
|
+
max_samples=max_samples,
|
|
214
|
+
include_row_counts=include_row_counts,
|
|
215
|
+
)
|
|
216
|
+
tables.append(table_model)
|
|
217
|
+
|
|
218
|
+
# 2. Inspect views
|
|
219
|
+
if include_views:
|
|
220
|
+
all_view_names = self.get_view_names()
|
|
221
|
+
|
|
222
|
+
target_views = all_view_names
|
|
223
|
+
if table_names is not None:
|
|
224
|
+
lower_targets = {t.lower() for t in table_names}
|
|
225
|
+
target_views = [v for v in all_view_names if v.lower() in lower_targets]
|
|
226
|
+
|
|
227
|
+
for v_name in target_views:
|
|
228
|
+
view_model = self._inspect_single_table(
|
|
229
|
+
insp=insp,
|
|
230
|
+
table_name=v_name,
|
|
231
|
+
is_view=True,
|
|
232
|
+
include_samples=include_samples,
|
|
233
|
+
max_samples=max_samples,
|
|
234
|
+
include_row_counts=include_row_counts,
|
|
235
|
+
)
|
|
236
|
+
try:
|
|
237
|
+
view_model.view_definition = insp.get_view_definition(v_name)
|
|
238
|
+
except Exception:
|
|
239
|
+
pass
|
|
240
|
+
tables.append(view_model)
|
|
241
|
+
|
|
242
|
+
return DatabaseSchema(
|
|
243
|
+
dialect_name=dialect_name,
|
|
244
|
+
database_name=db_name,
|
|
245
|
+
server_version=server_version,
|
|
246
|
+
tables=tables,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
def inspect_targeted(
|
|
250
|
+
self,
|
|
251
|
+
user_prompt: str,
|
|
252
|
+
max_tables: int = 8,
|
|
253
|
+
include_samples: bool = True,
|
|
254
|
+
) -> DatabaseSchema:
|
|
255
|
+
"""
|
|
256
|
+
On-demand targeted inspection with fuzzy matching.
|
|
257
|
+
Returns (schema, exact_matches, fuzzy_matches) for conflict resolution.
|
|
258
|
+
"""
|
|
259
|
+
all_tables = self.get_table_names()
|
|
260
|
+
exact, fuzzy = _fuzzy_match_tables(all_tables, user_prompt)
|
|
261
|
+
|
|
262
|
+
# Combine exact + fuzzy, but prefer exact
|
|
263
|
+
matched = exact[:]
|
|
264
|
+
for f in fuzzy:
|
|
265
|
+
if f not in matched:
|
|
266
|
+
matched.append(f)
|
|
267
|
+
|
|
268
|
+
if matched:
|
|
269
|
+
# Also pull in FK-related tables for completeness
|
|
270
|
+
fk_related = self._get_fk_related_tables(matched[:max_tables])
|
|
271
|
+
all_to_inspect = matched[:max_tables]
|
|
272
|
+
for r in fk_related:
|
|
273
|
+
if r not in all_to_inspect and len(all_to_inspect) < max_tables:
|
|
274
|
+
all_to_inspect.append(r)
|
|
275
|
+
|
|
276
|
+
return self.inspect_schema(
|
|
277
|
+
table_names=all_to_inspect,
|
|
278
|
+
include_samples=include_samples,
|
|
279
|
+
max_samples=2,
|
|
280
|
+
include_views=False,
|
|
281
|
+
include_row_counts=False,
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
# Fallback: inspect top max_tables
|
|
285
|
+
return self.inspect_schema(
|
|
286
|
+
table_names=all_tables[:max_tables],
|
|
287
|
+
include_samples=include_samples,
|
|
288
|
+
max_samples=2,
|
|
289
|
+
include_views=True,
|
|
290
|
+
include_row_counts=False,
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
def resolve_tables(self, user_prompt: str) -> Tuple[List[str], List[str]]:
|
|
294
|
+
"""
|
|
295
|
+
Resolve which tables the user is referring to.
|
|
296
|
+
Returns (exact_matches, fuzzy_matches) for the pipeline to handle ambiguity.
|
|
297
|
+
"""
|
|
298
|
+
all_tables = self.get_table_names()
|
|
299
|
+
return _fuzzy_match_tables(all_tables, user_prompt)
|
|
300
|
+
|
|
301
|
+
def _get_fk_related_tables(self, table_names: List[str]) -> List[str]:
|
|
302
|
+
"""Find tables related by foreign keys to the given tables."""
|
|
303
|
+
related: Set[str] = set()
|
|
304
|
+
insp = inspect(self.engine)
|
|
305
|
+
for t_name in table_names:
|
|
306
|
+
try:
|
|
307
|
+
fks = insp.get_foreign_keys(t_name)
|
|
308
|
+
for fk in fks:
|
|
309
|
+
ref = fk.get("referred_table")
|
|
310
|
+
if ref and ref not in table_names:
|
|
311
|
+
related.add(ref)
|
|
312
|
+
except Exception:
|
|
313
|
+
pass
|
|
314
|
+
return list(related)
|
|
315
|
+
|
|
316
|
+
def _inspect_single_table(
|
|
317
|
+
self,
|
|
318
|
+
insp: Any,
|
|
319
|
+
table_name: str,
|
|
320
|
+
is_view: bool,
|
|
321
|
+
include_samples: bool,
|
|
322
|
+
max_samples: int,
|
|
323
|
+
include_row_counts: bool = False,
|
|
324
|
+
) -> TableModel:
|
|
325
|
+
"""Inspect a single table/view."""
|
|
326
|
+
# Columns
|
|
327
|
+
raw_columns = []
|
|
328
|
+
try:
|
|
329
|
+
raw_columns = insp.get_columns(table_name)
|
|
330
|
+
except Exception:
|
|
331
|
+
pass
|
|
332
|
+
|
|
333
|
+
# Primary keys
|
|
334
|
+
pk_cols = []
|
|
335
|
+
try:
|
|
336
|
+
pk_constraint = insp.get_pk_constraint(table_name)
|
|
337
|
+
pk_cols = pk_constraint.get("constrained_columns", []) or []
|
|
338
|
+
except Exception:
|
|
339
|
+
pass
|
|
340
|
+
|
|
341
|
+
columns: List[ColumnModel] = []
|
|
342
|
+
for col in raw_columns:
|
|
343
|
+
c_name = col.get("name", "")
|
|
344
|
+
c_type = str(col.get("type", "UNKNOWN"))
|
|
345
|
+
is_pk = c_name in pk_cols or col.get("primary_key", False)
|
|
346
|
+
col_model = ColumnModel(
|
|
347
|
+
name=c_name,
|
|
348
|
+
data_type=c_type,
|
|
349
|
+
is_nullable=col.get("nullable", True),
|
|
350
|
+
is_primary_key=is_pk,
|
|
351
|
+
default_value=str(col.get("default")) if col.get("default") is not None else None,
|
|
352
|
+
comment=col.get("comment"),
|
|
353
|
+
is_autoincrement=bool(col.get("autoincrement", False)),
|
|
354
|
+
)
|
|
355
|
+
columns.append(col_model)
|
|
356
|
+
|
|
357
|
+
# Foreign Keys
|
|
358
|
+
foreign_keys: List[ForeignKeyModel] = []
|
|
359
|
+
if not is_view:
|
|
360
|
+
try:
|
|
361
|
+
raw_fks = insp.get_foreign_keys(table_name)
|
|
362
|
+
for fk in raw_fks:
|
|
363
|
+
foreign_keys.append(
|
|
364
|
+
ForeignKeyModel(
|
|
365
|
+
name=fk.get("name"),
|
|
366
|
+
constrained_columns=fk.get("constrained_columns", []),
|
|
367
|
+
referred_table=fk.get("referred_table", ""),
|
|
368
|
+
referred_columns=fk.get("referred_columns", []),
|
|
369
|
+
)
|
|
370
|
+
)
|
|
371
|
+
except Exception:
|
|
372
|
+
pass
|
|
373
|
+
|
|
374
|
+
# Indexes
|
|
375
|
+
indexes: List[IndexModel] = []
|
|
376
|
+
if not is_view:
|
|
377
|
+
try:
|
|
378
|
+
raw_indexes = insp.get_indexes(table_name)
|
|
379
|
+
for idx in raw_indexes:
|
|
380
|
+
indexes.append(
|
|
381
|
+
IndexModel(
|
|
382
|
+
name=idx.get("name") or "unnamed_idx",
|
|
383
|
+
columns=idx.get("column_names", []),
|
|
384
|
+
is_unique=idx.get("unique", False),
|
|
385
|
+
)
|
|
386
|
+
)
|
|
387
|
+
except Exception:
|
|
388
|
+
pass
|
|
389
|
+
|
|
390
|
+
# Table Comment
|
|
391
|
+
comment = None
|
|
392
|
+
try:
|
|
393
|
+
tbl_comment = insp.get_table_comment(table_name)
|
|
394
|
+
if tbl_comment and isinstance(tbl_comment, dict):
|
|
395
|
+
comment = tbl_comment.get("text")
|
|
396
|
+
except Exception:
|
|
397
|
+
pass
|
|
398
|
+
|
|
399
|
+
# Row count & Sample rows
|
|
400
|
+
row_count = None
|
|
401
|
+
sample_rows = []
|
|
402
|
+
try:
|
|
403
|
+
with self.engine.connect() as conn:
|
|
404
|
+
if include_row_counts:
|
|
405
|
+
try:
|
|
406
|
+
count_res = conn.execute(text(f'SELECT COUNT(*) FROM "{table_name}"')).scalar()
|
|
407
|
+
row_count = int(count_res) if count_res is not None else None
|
|
408
|
+
except Exception:
|
|
409
|
+
try:
|
|
410
|
+
count_res = conn.execute(text(f"SELECT COUNT(*) FROM `{table_name}`")).scalar()
|
|
411
|
+
row_count = int(count_res) if count_res is not None else None
|
|
412
|
+
except Exception:
|
|
413
|
+
pass
|
|
414
|
+
|
|
415
|
+
if include_samples:
|
|
416
|
+
try:
|
|
417
|
+
q = text(f'SELECT * FROM "{table_name}" LIMIT {max_samples}')
|
|
418
|
+
rows = conn.execute(q).mappings().all()
|
|
419
|
+
for r in rows:
|
|
420
|
+
sample_rows.append({k: _serialize_sample_val(v) for k, v in dict(r).items()})
|
|
421
|
+
except Exception:
|
|
422
|
+
try:
|
|
423
|
+
q = text(f"SELECT * FROM `{table_name}` LIMIT {max_samples}")
|
|
424
|
+
rows = conn.execute(q).mappings().all()
|
|
425
|
+
for r in rows:
|
|
426
|
+
sample_rows.append({k: _serialize_sample_val(v) for k, v in dict(r).items()})
|
|
427
|
+
except Exception:
|
|
428
|
+
pass
|
|
429
|
+
except Exception:
|
|
430
|
+
pass
|
|
431
|
+
|
|
432
|
+
return TableModel(
|
|
433
|
+
name=table_name,
|
|
434
|
+
is_view=is_view,
|
|
435
|
+
columns=columns,
|
|
436
|
+
primary_key=pk_cols,
|
|
437
|
+
foreign_keys=foreign_keys,
|
|
438
|
+
indexes=indexes,
|
|
439
|
+
row_count=row_count,
|
|
440
|
+
sample_rows=sample_rows,
|
|
441
|
+
comment=comment,
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
def execute_query(
|
|
445
|
+
self,
|
|
446
|
+
query: str,
|
|
447
|
+
limit: int = 100,
|
|
448
|
+
) -> Tuple[List[str], List[Dict[str, Any]], Optional[str]]:
|
|
449
|
+
"""Safely execute a query with row limit."""
|
|
450
|
+
try:
|
|
451
|
+
with self.engine.connect() as conn:
|
|
452
|
+
result = conn.execute(text(query))
|
|
453
|
+
if not result.returns_rows:
|
|
454
|
+
# DML: commit and report affected rows
|
|
455
|
+
try:
|
|
456
|
+
conn.commit()
|
|
457
|
+
except Exception:
|
|
458
|
+
pass
|
|
459
|
+
return ["status"], [{"status": "Query executed successfully."}], None
|
|
460
|
+
columns = list(result.keys())
|
|
461
|
+
raw_rows = result.fetchmany(limit)
|
|
462
|
+
rows = [
|
|
463
|
+
{k: _serialize_sample_val(v) for k, v in zip(columns, row)}
|
|
464
|
+
for row in raw_rows
|
|
465
|
+
]
|
|
466
|
+
return columns, rows, None
|
|
467
|
+
except Exception as e:
|
|
468
|
+
return [], [], str(e)
|
|
469
|
+
|
|
470
|
+
def close(self) -> None:
|
|
471
|
+
if self._engine:
|
|
472
|
+
self._engine.dispose()
|