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.
@@ -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()