qodalis-cli-server-plugin-data-explorer-mongo 1.0.0b13__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.
- qodalis_cli_data_explorer_mongo/__init__.py +5 -0
- qodalis_cli_data_explorer_mongo/mongo_provider.py +292 -0
- qodalis_cli_server_plugin_data_explorer_mongo-1.0.0b13.dist-info/METADATA +21 -0
- qodalis_cli_server_plugin_data_explorer_mongo-1.0.0b13.dist-info/RECORD +5 -0
- qodalis_cli_server_plugin_data_explorer_mongo-1.0.0b13.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"""MongoDB-backed Data Explorer provider."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import time
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from motor.motor_asyncio import AsyncIOMotorClient
|
|
11
|
+
from bson import ObjectId, json_util
|
|
12
|
+
|
|
13
|
+
from qodalis_cli_server_abstractions import (
|
|
14
|
+
DataExplorerExecutionContext,
|
|
15
|
+
DataExplorerProviderOptions,
|
|
16
|
+
DataExplorerResult,
|
|
17
|
+
DataExplorerSchemaColumn,
|
|
18
|
+
DataExplorerSchemaResult,
|
|
19
|
+
DataExplorerSchemaTable,
|
|
20
|
+
IDataExplorerProvider,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _bson_to_serializable(obj: Any) -> Any:
|
|
25
|
+
"""Convert BSON types to JSON-serializable types."""
|
|
26
|
+
if isinstance(obj, ObjectId):
|
|
27
|
+
return str(obj)
|
|
28
|
+
if isinstance(obj, dict):
|
|
29
|
+
return {k: _bson_to_serializable(v) for k, v in obj.items()}
|
|
30
|
+
if isinstance(obj, list):
|
|
31
|
+
return [_bson_to_serializable(item) for item in obj]
|
|
32
|
+
if isinstance(obj, bytes):
|
|
33
|
+
return obj.hex()
|
|
34
|
+
return obj
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class MongoDataExplorerProvider(IDataExplorerProvider):
|
|
38
|
+
"""Executes queries against a MongoDB database."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, connection_string: str, database: str) -> None:
|
|
41
|
+
self._connection_string = connection_string
|
|
42
|
+
self._database = database
|
|
43
|
+
|
|
44
|
+
async def execute_async(
|
|
45
|
+
self, context: DataExplorerExecutionContext
|
|
46
|
+
) -> DataExplorerResult:
|
|
47
|
+
"""Execute a MongoDB query or command."""
|
|
48
|
+
start = time.monotonic()
|
|
49
|
+
client: AsyncIOMotorClient | None = None
|
|
50
|
+
try:
|
|
51
|
+
client = AsyncIOMotorClient(self._connection_string)
|
|
52
|
+
db = client[self._database]
|
|
53
|
+
query = context.query.strip()
|
|
54
|
+
|
|
55
|
+
if query.lower() == "show collections":
|
|
56
|
+
names = await db.list_collection_names()
|
|
57
|
+
rows = [{"name": n} for n in sorted(names)]
|
|
58
|
+
return self._success(context, start, None, rows, len(rows))
|
|
59
|
+
|
|
60
|
+
if query.lower() in ("show dbs", "show databases"):
|
|
61
|
+
dbs_info = await client.list_databases()
|
|
62
|
+
rows = [
|
|
63
|
+
_bson_to_serializable(
|
|
64
|
+
{"name": d["name"], "sizeOnDisk": d.get("sizeOnDisk", 0), "empty": d.get("empty", False)}
|
|
65
|
+
)
|
|
66
|
+
for d in dbs_info
|
|
67
|
+
]
|
|
68
|
+
return self._success(context, start, None, rows, len(rows))
|
|
69
|
+
|
|
70
|
+
parsed = self._parse_query(query)
|
|
71
|
+
if parsed is None:
|
|
72
|
+
return self._error(
|
|
73
|
+
context, start,
|
|
74
|
+
"Invalid query syntax. Use: db.collection.find({...}), "
|
|
75
|
+
"db.collection.aggregate([...]), show collections, show dbs",
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
coll_name, operation, args = parsed
|
|
79
|
+
collection = db[coll_name]
|
|
80
|
+
op = operation.lower()
|
|
81
|
+
|
|
82
|
+
if op == "find":
|
|
83
|
+
filt = args[0] if args else {}
|
|
84
|
+
projection = args[1] if len(args) > 1 else None
|
|
85
|
+
max_rows = context.options.max_rows or 1000
|
|
86
|
+
cursor = collection.find(filt, projection)
|
|
87
|
+
docs = await cursor.to_list(length=max_rows + 1)
|
|
88
|
+
truncated = len(docs) > max_rows
|
|
89
|
+
if truncated:
|
|
90
|
+
docs.pop()
|
|
91
|
+
rows = [_bson_to_serializable(d) for d in docs]
|
|
92
|
+
return self._success(context, start, None, rows, len(rows), truncated)
|
|
93
|
+
|
|
94
|
+
if op == "findone":
|
|
95
|
+
filt = args[0] if args else {}
|
|
96
|
+
doc = await collection.find_one(filt)
|
|
97
|
+
rows = [_bson_to_serializable(doc)] if doc else []
|
|
98
|
+
return self._success(context, start, None, rows, len(rows))
|
|
99
|
+
|
|
100
|
+
if op == "aggregate":
|
|
101
|
+
pipeline = args[0] if args else []
|
|
102
|
+
cursor = collection.aggregate(pipeline)
|
|
103
|
+
docs = await cursor.to_list(length=None)
|
|
104
|
+
rows = [_bson_to_serializable(d) for d in docs]
|
|
105
|
+
return self._success(context, start, None, rows, len(rows))
|
|
106
|
+
|
|
107
|
+
if op == "insertone":
|
|
108
|
+
doc = args[0] if args else {}
|
|
109
|
+
result = await collection.insert_one(doc)
|
|
110
|
+
rows = [{"acknowledged": result.acknowledged, "insertedId": str(result.inserted_id)}]
|
|
111
|
+
return self._success(context, start, None, rows, 1)
|
|
112
|
+
|
|
113
|
+
if op == "insertmany":
|
|
114
|
+
docs = args[0] if args else []
|
|
115
|
+
result = await collection.insert_many(docs)
|
|
116
|
+
rows = [{"acknowledged": result.acknowledged, "insertedCount": len(result.inserted_ids)}]
|
|
117
|
+
return self._success(context, start, None, rows, 1)
|
|
118
|
+
|
|
119
|
+
if op == "updateone":
|
|
120
|
+
filt = args[0] if args else {}
|
|
121
|
+
update = args[1] if len(args) > 1 else {}
|
|
122
|
+
result = await collection.update_one(filt, update)
|
|
123
|
+
rows = [{"acknowledged": result.acknowledged, "matchedCount": result.matched_count, "modifiedCount": result.modified_count}]
|
|
124
|
+
return self._success(context, start, None, rows, 1)
|
|
125
|
+
|
|
126
|
+
if op == "updatemany":
|
|
127
|
+
filt = args[0] if args else {}
|
|
128
|
+
update = args[1] if len(args) > 1 else {}
|
|
129
|
+
result = await collection.update_many(filt, update)
|
|
130
|
+
rows = [{"acknowledged": result.acknowledged, "matchedCount": result.matched_count, "modifiedCount": result.modified_count}]
|
|
131
|
+
return self._success(context, start, None, rows, 1)
|
|
132
|
+
|
|
133
|
+
if op == "deleteone":
|
|
134
|
+
filt = args[0] if args else {}
|
|
135
|
+
result = await collection.delete_one(filt)
|
|
136
|
+
rows = [{"acknowledged": result.acknowledged, "deletedCount": result.deleted_count}]
|
|
137
|
+
return self._success(context, start, None, rows, 1)
|
|
138
|
+
|
|
139
|
+
if op == "deletemany":
|
|
140
|
+
filt = args[0] if args else {}
|
|
141
|
+
result = await collection.delete_many(filt)
|
|
142
|
+
rows = [{"acknowledged": result.acknowledged, "deletedCount": result.deleted_count}]
|
|
143
|
+
return self._success(context, start, None, rows, 1)
|
|
144
|
+
|
|
145
|
+
if op == "countdocuments":
|
|
146
|
+
filt = args[0] if args else {}
|
|
147
|
+
count = await collection.count_documents(filt)
|
|
148
|
+
return self._success(context, start, ["count"], [[count]], 1)
|
|
149
|
+
|
|
150
|
+
if op == "distinct":
|
|
151
|
+
field = args[0] if args else ""
|
|
152
|
+
filt = args[1] if len(args) > 1 else {}
|
|
153
|
+
values = await collection.distinct(field, filt)
|
|
154
|
+
rows = [{"value": _bson_to_serializable(v)} for v in values]
|
|
155
|
+
return self._success(context, start, None, rows, len(rows))
|
|
156
|
+
|
|
157
|
+
return self._error(
|
|
158
|
+
context, start,
|
|
159
|
+
f"Unsupported operation: {operation}. Supported: find, findOne, aggregate, "
|
|
160
|
+
"insertOne, insertMany, updateOne, updateMany, deleteOne, deleteMany, countDocuments, distinct",
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
except Exception as exc:
|
|
164
|
+
return self._error(context, start, str(exc))
|
|
165
|
+
finally:
|
|
166
|
+
if client is not None:
|
|
167
|
+
client.close()
|
|
168
|
+
|
|
169
|
+
async def get_schema_async(
|
|
170
|
+
self, options: DataExplorerProviderOptions
|
|
171
|
+
) -> DataExplorerSchemaResult | None:
|
|
172
|
+
"""Return collection names and inferred field schemas from sample documents."""
|
|
173
|
+
client: AsyncIOMotorClient | None = None
|
|
174
|
+
try:
|
|
175
|
+
client = AsyncIOMotorClient(self._connection_string)
|
|
176
|
+
db = client[self._database]
|
|
177
|
+
coll_names = await db.list_collection_names()
|
|
178
|
+
|
|
179
|
+
tables: list[DataExplorerSchemaTable] = []
|
|
180
|
+
for coll_name in sorted(coll_names):
|
|
181
|
+
sample = await db[coll_name].find_one()
|
|
182
|
+
columns: list[DataExplorerSchemaColumn] = []
|
|
183
|
+
if sample:
|
|
184
|
+
for key, value in sample.items():
|
|
185
|
+
if isinstance(value, list):
|
|
186
|
+
col_type = "array"
|
|
187
|
+
elif isinstance(value, dict):
|
|
188
|
+
col_type = "object"
|
|
189
|
+
elif value is None:
|
|
190
|
+
col_type = "null"
|
|
191
|
+
else:
|
|
192
|
+
col_type = type(value).__name__
|
|
193
|
+
columns.append(DataExplorerSchemaColumn(
|
|
194
|
+
name=key,
|
|
195
|
+
type=col_type,
|
|
196
|
+
nullable=True,
|
|
197
|
+
primary_key=key == "_id",
|
|
198
|
+
))
|
|
199
|
+
tables.append(DataExplorerSchemaTable(
|
|
200
|
+
name=coll_name,
|
|
201
|
+
type="collection",
|
|
202
|
+
columns=columns,
|
|
203
|
+
))
|
|
204
|
+
|
|
205
|
+
return DataExplorerSchemaResult(source=options.name, tables=tables)
|
|
206
|
+
finally:
|
|
207
|
+
if client is not None:
|
|
208
|
+
client.close()
|
|
209
|
+
|
|
210
|
+
def _parse_query(self, query: str) -> tuple[str, str, list[Any]] | None:
|
|
211
|
+
"""Parse db.collection.operation(...) syntax."""
|
|
212
|
+
match = re.match(r"^db\.(\w+)\.(\w+)\(([\s\S]*)\)$", query)
|
|
213
|
+
if not match:
|
|
214
|
+
return None
|
|
215
|
+
|
|
216
|
+
collection = match.group(1)
|
|
217
|
+
operation = match.group(2)
|
|
218
|
+
args_str = match.group(3).strip()
|
|
219
|
+
|
|
220
|
+
if not args_str:
|
|
221
|
+
return collection, operation, []
|
|
222
|
+
|
|
223
|
+
try:
|
|
224
|
+
args = self._split_and_parse_args(args_str)
|
|
225
|
+
return collection, operation, args
|
|
226
|
+
except Exception:
|
|
227
|
+
return None
|
|
228
|
+
|
|
229
|
+
def _split_and_parse_args(self, args_str: str) -> list[Any]:
|
|
230
|
+
"""Split comma-separated top-level arguments and parse each as JSON."""
|
|
231
|
+
args: list[str] = []
|
|
232
|
+
depth = 0
|
|
233
|
+
current: list[str] = []
|
|
234
|
+
|
|
235
|
+
for c in args_str:
|
|
236
|
+
if c in "{[":
|
|
237
|
+
depth += 1
|
|
238
|
+
elif c in "}]":
|
|
239
|
+
depth -= 1
|
|
240
|
+
elif c == "," and depth == 0:
|
|
241
|
+
args.append("".join(current).strip())
|
|
242
|
+
current = []
|
|
243
|
+
continue
|
|
244
|
+
current.append(c)
|
|
245
|
+
|
|
246
|
+
if current:
|
|
247
|
+
args.append("".join(current).strip())
|
|
248
|
+
|
|
249
|
+
return [json.loads(a) for a in args]
|
|
250
|
+
|
|
251
|
+
def _success(
|
|
252
|
+
self,
|
|
253
|
+
context: DataExplorerExecutionContext,
|
|
254
|
+
start: float,
|
|
255
|
+
columns: list[str] | None,
|
|
256
|
+
rows: list[Any],
|
|
257
|
+
row_count: int,
|
|
258
|
+
truncated: bool = False,
|
|
259
|
+
) -> DataExplorerResult:
|
|
260
|
+
"""Build a successful DataExplorerResult."""
|
|
261
|
+
return DataExplorerResult(
|
|
262
|
+
success=True,
|
|
263
|
+
source=context.options.name,
|
|
264
|
+
language=context.options.language,
|
|
265
|
+
default_output_format=context.options.default_output_format,
|
|
266
|
+
execution_time=int((time.monotonic() - start) * 1000),
|
|
267
|
+
columns=columns,
|
|
268
|
+
rows=rows,
|
|
269
|
+
row_count=row_count,
|
|
270
|
+
truncated=truncated,
|
|
271
|
+
error=None,
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
def _error(
|
|
275
|
+
self,
|
|
276
|
+
context: DataExplorerExecutionContext,
|
|
277
|
+
start: float,
|
|
278
|
+
error: str,
|
|
279
|
+
) -> DataExplorerResult:
|
|
280
|
+
"""Build a failed DataExplorerResult with the given error message."""
|
|
281
|
+
return DataExplorerResult(
|
|
282
|
+
success=False,
|
|
283
|
+
source=context.options.name,
|
|
284
|
+
language=context.options.language,
|
|
285
|
+
default_output_format=context.options.default_output_format,
|
|
286
|
+
execution_time=int((time.monotonic() - start) * 1000),
|
|
287
|
+
columns=None,
|
|
288
|
+
rows=[],
|
|
289
|
+
row_count=0,
|
|
290
|
+
truncated=False,
|
|
291
|
+
error=error,
|
|
292
|
+
)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: qodalis-cli-server-plugin-data-explorer-mongo
|
|
3
|
+
Version: 1.0.0b13
|
|
4
|
+
Summary: MongoDB Data Explorer provider plugin for Qodalis CLI servers
|
|
5
|
+
Project-URL: Homepage, https://github.com/qodalis-solutions/cli-server-python
|
|
6
|
+
Project-URL: Repository, https://github.com/qodalis-solutions/cli-server-python
|
|
7
|
+
Project-URL: Issues, https://github.com/qodalis-solutions/cli-server-python/issues
|
|
8
|
+
Author-email: Nicolae Lupei <nicolae.lupei@qodalis.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
Keywords: cli,data-explorer,mongodb,plugin,qodalis
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Requires-Dist: motor>=3.0.0
|
|
21
|
+
Requires-Dist: qodalis-cli-server-abstractions
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
qodalis_cli_data_explorer_mongo/__init__.py,sha256=pog31nZl_6Ct4bQ7LsydO0C4VtBCuXeKqgIpwr-IBF4,165
|
|
2
|
+
qodalis_cli_data_explorer_mongo/mongo_provider.py,sha256=7lhTzOw8Np1O33zV8oVR0ZogsYTl_26qYEuYzmYyGpQ,11457
|
|
3
|
+
qodalis_cli_server_plugin_data_explorer_mongo-1.0.0b13.dist-info/METADATA,sha256=KkpYNa5J57u0qRefM9rgRK_FfkAI6DEVKf5ayrG_sQI,1024
|
|
4
|
+
qodalis_cli_server_plugin_data_explorer_mongo-1.0.0b13.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
5
|
+
qodalis_cli_server_plugin_data_explorer_mongo-1.0.0b13.dist-info/RECORD,,
|