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.
@@ -0,0 +1,5 @@
1
+ """Qodalis CLI Data Explorer MongoDB plugin — MongoDB provider."""
2
+
3
+ from .mongo_provider import MongoDataExplorerProvider
4
+
5
+ __all__ = ["MongoDataExplorerProvider"]
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any