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
dbagent/config.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Configuration and database profile manager.
|
|
3
|
+
Stores profiles and credentials in user home directory (~/.dbagent/profiles.json).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Dict, Any, Optional
|
|
10
|
+
from dotenv import load_dotenv
|
|
11
|
+
|
|
12
|
+
load_dotenv()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ConfigManager:
|
|
16
|
+
"""Manages local profiles and AI API credentials."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, config_dir: Optional[Path] = None):
|
|
19
|
+
self.config_dir = config_dir or Path.home() / ".dbagent"
|
|
20
|
+
self.config_file = self.config_dir / "config.json"
|
|
21
|
+
self.profiles_file = self.config_dir / "profiles.json"
|
|
22
|
+
self._ensure_dirs()
|
|
23
|
+
|
|
24
|
+
def _ensure_dirs(self) -> None:
|
|
25
|
+
self.config_dir.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
if not self.config_file.exists():
|
|
27
|
+
self._save_json(self.config_file, {
|
|
28
|
+
"default_provider": "ollama",
|
|
29
|
+
"default_model": None,
|
|
30
|
+
"ollama_base_url": "http://localhost:11434",
|
|
31
|
+
"gemini_api_key": None,
|
|
32
|
+
"groq_api_key": None,
|
|
33
|
+
"openrouter_api_key": None,
|
|
34
|
+
})
|
|
35
|
+
if not self.profiles_file.exists():
|
|
36
|
+
self._save_json(self.profiles_file, {})
|
|
37
|
+
|
|
38
|
+
def _load_json(self, path: Path) -> Dict[str, Any]:
|
|
39
|
+
try:
|
|
40
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
41
|
+
return json.load(f)
|
|
42
|
+
except Exception:
|
|
43
|
+
return {}
|
|
44
|
+
|
|
45
|
+
def _save_json(self, path: Path, data: Dict[str, Any]) -> None:
|
|
46
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
47
|
+
json.dump(data, f, indent=2)
|
|
48
|
+
|
|
49
|
+
# --- LLM Settings ---
|
|
50
|
+
|
|
51
|
+
def get_setting(self, key: str, default: Any = None) -> Any:
|
|
52
|
+
# Check environment variable first
|
|
53
|
+
env_key = key.upper()
|
|
54
|
+
if os.getenv(env_key):
|
|
55
|
+
return os.getenv(env_key)
|
|
56
|
+
|
|
57
|
+
config = self._load_json(self.config_file)
|
|
58
|
+
return config.get(key, default)
|
|
59
|
+
|
|
60
|
+
def set_setting(self, key: str, value: Any) -> None:
|
|
61
|
+
config = self._load_json(self.config_file)
|
|
62
|
+
config[key] = value
|
|
63
|
+
self._save_json(self.config_file, config)
|
|
64
|
+
|
|
65
|
+
# --- Database Profiles ---
|
|
66
|
+
|
|
67
|
+
def save_profile(self, name: str, connection_url: str, description: Optional[str] = None) -> None:
|
|
68
|
+
profiles = self._load_json(self.profiles_file)
|
|
69
|
+
profiles[name] = {
|
|
70
|
+
"name": name,
|
|
71
|
+
"url": connection_url,
|
|
72
|
+
"description": description or f"Profile for {name}",
|
|
73
|
+
}
|
|
74
|
+
self._save_json(self.profiles_file, profiles)
|
|
75
|
+
|
|
76
|
+
def get_profile(self, name: str) -> Optional[Dict[str, Any]]:
|
|
77
|
+
profiles = self._load_json(self.profiles_file)
|
|
78
|
+
return profiles.get(name)
|
|
79
|
+
|
|
80
|
+
def list_profiles(self) -> Dict[str, Dict[str, Any]]:
|
|
81
|
+
return self._load_json(self.profiles_file)
|
|
82
|
+
|
|
83
|
+
def delete_profile(self, name: str) -> bool:
|
|
84
|
+
profiles = self._load_json(self.profiles_file)
|
|
85
|
+
if name in profiles:
|
|
86
|
+
del profiles[name]
|
|
87
|
+
self._save_json(self.profiles_file, profiles)
|
|
88
|
+
return True
|
|
89
|
+
return False
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Base connector interface for database introspection and query execution.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from typing import Dict, Any, List, Optional, Tuple, Set
|
|
7
|
+
from dbagent.schema.models import DatabaseSchema
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class BaseConnector(ABC):
|
|
11
|
+
"""Abstract interface for database connectors."""
|
|
12
|
+
|
|
13
|
+
@abstractmethod
|
|
14
|
+
def test_connection(self) -> Tuple[bool, str]:
|
|
15
|
+
"""Test if the database connection is working. Returns (success, message)."""
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
@abstractmethod
|
|
19
|
+
def get_table_names(self) -> List[str]:
|
|
20
|
+
"""Get fast list of all table names without full column introspection."""
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
@abstractmethod
|
|
24
|
+
def inspect_schema(
|
|
25
|
+
self,
|
|
26
|
+
table_names: Optional[List[str]] = None,
|
|
27
|
+
include_samples: bool = True,
|
|
28
|
+
max_samples: int = 2,
|
|
29
|
+
include_views: bool = True,
|
|
30
|
+
include_row_counts: bool = False,
|
|
31
|
+
) -> DatabaseSchema:
|
|
32
|
+
"""Introspect the database schema for all or specified tables."""
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
@abstractmethod
|
|
36
|
+
def inspect_targeted(
|
|
37
|
+
self,
|
|
38
|
+
user_prompt: str,
|
|
39
|
+
max_tables: int = 8,
|
|
40
|
+
include_samples: bool = True,
|
|
41
|
+
) -> DatabaseSchema:
|
|
42
|
+
"""Introspect only the tables relevant to the user query on demand."""
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
def resolve_tables(self, user_prompt: str) -> Tuple[List[str], List[str]]:
|
|
46
|
+
"""
|
|
47
|
+
Resolve which tables the user is referring to.
|
|
48
|
+
Returns (exact_matches, fuzzy_matches).
|
|
49
|
+
Default implementation uses get_table_names with simple substring matching.
|
|
50
|
+
"""
|
|
51
|
+
all_tables = self.get_table_names()
|
|
52
|
+
prompt_lower = user_prompt.lower()
|
|
53
|
+
exact = [t for t in all_tables if t.lower() in prompt_lower]
|
|
54
|
+
return exact, []
|
|
55
|
+
|
|
56
|
+
def refresh_cache(self) -> None:
|
|
57
|
+
"""Invalidate any cached metadata. Override in subclasses."""
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
@abstractmethod
|
|
61
|
+
def execute_query(
|
|
62
|
+
self,
|
|
63
|
+
query: str,
|
|
64
|
+
limit: int = 100,
|
|
65
|
+
) -> Tuple[List[str], List[Dict[str, Any]], Optional[str]]:
|
|
66
|
+
"""
|
|
67
|
+
Execute a read query.
|
|
68
|
+
Returns (columns, rows, error_message).
|
|
69
|
+
"""
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
@abstractmethod
|
|
73
|
+
def close(self) -> None:
|
|
74
|
+
"""Close any open connections."""
|
|
75
|
+
pass
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Connector factory for instantiating the right database connector.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from dbagent.connectors.base import BaseConnector
|
|
6
|
+
from dbagent.connectors.relational import RelationalConnector
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def create_connector(connection_url: str, **kwargs) -> BaseConnector:
|
|
10
|
+
"""Create a database connector based on the connection string URL."""
|
|
11
|
+
url = connection_url.strip()
|
|
12
|
+
|
|
13
|
+
# Normalize SQLite file paths
|
|
14
|
+
if url.endswith(".db") or url.endswith(".sqlite") or url.endswith(".sqlite3"):
|
|
15
|
+
if not url.startswith("sqlite:///"):
|
|
16
|
+
url = f"sqlite:///{url}"
|
|
17
|
+
|
|
18
|
+
# MongoDB
|
|
19
|
+
if url.startswith("mongodb://") or url.startswith("mongodb+srv://"):
|
|
20
|
+
from dbagent.connectors.mongo import MongoConnector
|
|
21
|
+
db_name = kwargs.get("database") or kwargs.get("db_name")
|
|
22
|
+
return MongoConnector(connection_url=url, db_name=db_name)
|
|
23
|
+
|
|
24
|
+
# All relational databases
|
|
25
|
+
return RelationalConnector(connection_url=url)
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MongoDB Connector for document database schema introspection and query execution.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Dict, Any, List, Optional, Tuple, Set
|
|
6
|
+
from dbagent.connectors.base import BaseConnector
|
|
7
|
+
from dbagent.schema.models import (
|
|
8
|
+
DatabaseSchema,
|
|
9
|
+
TableModel,
|
|
10
|
+
ColumnModel,
|
|
11
|
+
IndexModel,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _infer_bson_type(val: Any) -> str:
|
|
16
|
+
"""Infer BSON type as a human-readable string."""
|
|
17
|
+
if val is None:
|
|
18
|
+
return "Null"
|
|
19
|
+
if isinstance(val, bool):
|
|
20
|
+
return "Boolean"
|
|
21
|
+
if isinstance(val, int):
|
|
22
|
+
return "Integer"
|
|
23
|
+
if isinstance(val, float):
|
|
24
|
+
return "Double"
|
|
25
|
+
if isinstance(val, str):
|
|
26
|
+
return "String"
|
|
27
|
+
if isinstance(val, list):
|
|
28
|
+
if val:
|
|
29
|
+
inner_type = _infer_bson_type(val[0])
|
|
30
|
+
return f"Array<{inner_type}>"
|
|
31
|
+
return "Array"
|
|
32
|
+
if isinstance(val, dict):
|
|
33
|
+
return "Object"
|
|
34
|
+
return type(val).__name__
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class MongoConnector(BaseConnector):
|
|
38
|
+
"""Connector for MongoDB instances."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, connection_url: str, db_name: Optional[str] = None):
|
|
41
|
+
self.connection_url = connection_url
|
|
42
|
+
self.explicit_db_name = db_name
|
|
43
|
+
self._client = None
|
|
44
|
+
self._cached_collection_names: Optional[List[str]] = None
|
|
45
|
+
|
|
46
|
+
def _get_client(self):
|
|
47
|
+
if self._client is None:
|
|
48
|
+
import pymongo
|
|
49
|
+
self._client = pymongo.MongoClient(self.connection_url, serverSelectionTimeoutMS=5000)
|
|
50
|
+
return self._client
|
|
51
|
+
|
|
52
|
+
def _get_database(self):
|
|
53
|
+
client = self._get_client()
|
|
54
|
+
if self.explicit_db_name:
|
|
55
|
+
return client[self.explicit_db_name]
|
|
56
|
+
default_db = client.get_default_database(default=None)
|
|
57
|
+
if default_db is not None:
|
|
58
|
+
return default_db
|
|
59
|
+
# Fall back to first non-admin/non-local db
|
|
60
|
+
db_names = [d for d in client.list_database_names() if d not in ["admin", "config", "local"]]
|
|
61
|
+
if db_names:
|
|
62
|
+
return client[db_names[0]]
|
|
63
|
+
return client["test"]
|
|
64
|
+
|
|
65
|
+
def test_connection(self) -> Tuple[bool, str]:
|
|
66
|
+
try:
|
|
67
|
+
client = self._get_client()
|
|
68
|
+
client.admin.command("ping")
|
|
69
|
+
db = self._get_database()
|
|
70
|
+
return True, f"Successfully connected to MongoDB (database: '{db.name}')."
|
|
71
|
+
except Exception as e:
|
|
72
|
+
return False, f"MongoDB connection failed: {str(e)}"
|
|
73
|
+
|
|
74
|
+
def get_table_names(self) -> List[str]:
|
|
75
|
+
if self._cached_collection_names is not None:
|
|
76
|
+
return self._cached_collection_names
|
|
77
|
+
db = self._get_database()
|
|
78
|
+
self._cached_collection_names = db.list_collection_names()
|
|
79
|
+
return self._cached_collection_names
|
|
80
|
+
|
|
81
|
+
def refresh_cache(self) -> None:
|
|
82
|
+
"""Invalidate cached collection names."""
|
|
83
|
+
self._cached_collection_names = None
|
|
84
|
+
|
|
85
|
+
def resolve_tables(self, user_prompt: str) -> Tuple[List[str], List[str]]:
|
|
86
|
+
"""Resolve which collections the user is referring to with fuzzy matching."""
|
|
87
|
+
from dbagent.connectors.relational import _fuzzy_match_tables
|
|
88
|
+
all_cols = self.get_table_names()
|
|
89
|
+
return _fuzzy_match_tables(all_cols, user_prompt)
|
|
90
|
+
|
|
91
|
+
def inspect_schema(
|
|
92
|
+
self,
|
|
93
|
+
table_names: Optional[List[str]] = None,
|
|
94
|
+
include_samples: bool = True,
|
|
95
|
+
max_samples: int = 2,
|
|
96
|
+
include_views: bool = True,
|
|
97
|
+
include_row_counts: bool = False,
|
|
98
|
+
) -> DatabaseSchema:
|
|
99
|
+
"""Introspect MongoDB collections by sampling documents and inspecting indexes."""
|
|
100
|
+
db = self._get_database()
|
|
101
|
+
collection_names = db.list_collection_names()
|
|
102
|
+
|
|
103
|
+
target_collections = collection_names
|
|
104
|
+
if table_names is not None:
|
|
105
|
+
lower_targets = {t.lower() for t in table_names}
|
|
106
|
+
target_collections = [c for c in collection_names if c.lower() in lower_targets]
|
|
107
|
+
|
|
108
|
+
tables: List[TableModel] = []
|
|
109
|
+
|
|
110
|
+
for c_name in target_collections:
|
|
111
|
+
col = db[c_name]
|
|
112
|
+
doc_count = col.estimated_document_count() if include_row_counts else None
|
|
113
|
+
|
|
114
|
+
# Sample documents to infer schema
|
|
115
|
+
sample_docs = list(col.find().limit(max(max_samples, 10)))
|
|
116
|
+
fields_map: Dict[str, set] = {}
|
|
117
|
+
for doc in sample_docs:
|
|
118
|
+
for k, v in doc.items():
|
|
119
|
+
if k not in fields_map:
|
|
120
|
+
fields_map[k] = set()
|
|
121
|
+
fields_map[k].add(_infer_bson_type(v))
|
|
122
|
+
|
|
123
|
+
columns: List[ColumnModel] = []
|
|
124
|
+
for field_name, types in fields_map.items():
|
|
125
|
+
type_str = " | ".join(sorted(types)) if types else "Unknown"
|
|
126
|
+
is_pk = field_name == "_id"
|
|
127
|
+
columns.append(
|
|
128
|
+
ColumnModel(
|
|
129
|
+
name=field_name,
|
|
130
|
+
data_type=type_str,
|
|
131
|
+
is_nullable=not is_pk,
|
|
132
|
+
is_primary_key=is_pk,
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
# Indexes
|
|
137
|
+
indexes: List[IndexModel] = []
|
|
138
|
+
try:
|
|
139
|
+
raw_indexes = col.index_information()
|
|
140
|
+
for idx_name, idx_info in raw_indexes.items():
|
|
141
|
+
key_cols = [k[0] for k in idx_info.get("key", [])]
|
|
142
|
+
indexes.append(
|
|
143
|
+
IndexModel(
|
|
144
|
+
name=idx_name,
|
|
145
|
+
columns=key_cols,
|
|
146
|
+
is_unique=idx_info.get("unique", False),
|
|
147
|
+
)
|
|
148
|
+
)
|
|
149
|
+
except Exception:
|
|
150
|
+
pass
|
|
151
|
+
|
|
152
|
+
# Serialize sample rows
|
|
153
|
+
clean_samples = []
|
|
154
|
+
if include_samples:
|
|
155
|
+
for doc in sample_docs[:max_samples]:
|
|
156
|
+
clean_doc = {}
|
|
157
|
+
for k, v in doc.items():
|
|
158
|
+
clean_doc[k] = str(v) if not isinstance(v, (int, float, bool, str)) else v
|
|
159
|
+
clean_samples.append(clean_doc)
|
|
160
|
+
|
|
161
|
+
tables.append(
|
|
162
|
+
TableModel(
|
|
163
|
+
name=c_name,
|
|
164
|
+
is_view=False,
|
|
165
|
+
columns=columns,
|
|
166
|
+
primary_key=["_id"],
|
|
167
|
+
foreign_keys=[],
|
|
168
|
+
indexes=indexes,
|
|
169
|
+
row_count=doc_count,
|
|
170
|
+
sample_rows=clean_samples,
|
|
171
|
+
)
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
return DatabaseSchema(
|
|
175
|
+
dialect_name="mongodb",
|
|
176
|
+
database_name=db.name,
|
|
177
|
+
tables=tables,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
def inspect_targeted(
|
|
181
|
+
self,
|
|
182
|
+
user_prompt: str,
|
|
183
|
+
max_tables: int = 8,
|
|
184
|
+
include_samples: bool = True,
|
|
185
|
+
) -> DatabaseSchema:
|
|
186
|
+
"""On-demand targeted MongoDB collection inspection."""
|
|
187
|
+
import re
|
|
188
|
+
all_cols = self.get_table_names()
|
|
189
|
+
prompt_lower = user_prompt.lower()
|
|
190
|
+
|
|
191
|
+
matched = []
|
|
192
|
+
for c in all_cols:
|
|
193
|
+
c_lower = c.lower()
|
|
194
|
+
if re.search(r"\b" + re.escape(c_lower) + r"\b", prompt_lower) or c_lower in prompt_lower:
|
|
195
|
+
matched.append(c)
|
|
196
|
+
|
|
197
|
+
if matched:
|
|
198
|
+
return self.inspect_schema(
|
|
199
|
+
table_names=matched[:max_tables],
|
|
200
|
+
include_samples=include_samples,
|
|
201
|
+
max_samples=2,
|
|
202
|
+
include_row_counts=False,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
return self.inspect_schema(
|
|
206
|
+
table_names=all_cols[:max_tables],
|
|
207
|
+
include_samples=include_samples,
|
|
208
|
+
max_samples=2,
|
|
209
|
+
include_row_counts=False,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
def execute_query(
|
|
213
|
+
self,
|
|
214
|
+
query: str,
|
|
215
|
+
limit: int = 100,
|
|
216
|
+
) -> Tuple[List[str], List[Dict[str, Any]], Optional[str]]:
|
|
217
|
+
"""For MongoDB, queries can be formatted as JSON filter or JS collection queries."""
|
|
218
|
+
try:
|
|
219
|
+
import json
|
|
220
|
+
# If query is JSON formatted e.g. {"collection": "users", "filter": {}}
|
|
221
|
+
parsed = json.loads(query)
|
|
222
|
+
col_name = parsed.get("collection")
|
|
223
|
+
filt = parsed.get("filter", {})
|
|
224
|
+
db = self._get_database()
|
|
225
|
+
cursor = db[col_name].find(filt).limit(limit)
|
|
226
|
+
docs = list(cursor)
|
|
227
|
+
if not docs:
|
|
228
|
+
return [], [], None
|
|
229
|
+
columns = list(docs[0].keys())
|
|
230
|
+
rows = [{k: str(v) for k, v in d.items()} for d in docs]
|
|
231
|
+
return columns, rows, None
|
|
232
|
+
except Exception as e:
|
|
233
|
+
return [], [], f"MongoDB execution error: {str(e)}"
|
|
234
|
+
|
|
235
|
+
def close(self) -> None:
|
|
236
|
+
if self._client:
|
|
237
|
+
self._client.close()
|