Skip to content

Commit c21717c

Browse files
committed
feat: add functionality to export Data Explorer filters as PyMongo query string
1 parent 2c00ecb commit c21717c

5 files changed

Lines changed: 175 additions & 21 deletions

File tree

backend/models/data_documents.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ class DataDocumentsRequest(BaseModel):
1818
filter: Optional[DataDocumentsFilter] = None
1919
filters: Optional[List[DataDocumentsFilter]] = None
2020

21+
class DataDocumentsQueryResponse(BaseModel):
22+
query_code: str
23+
2124

2225
class DataDocumentsResponse(BaseModel):
2326
documents: List[Dict[str, Any]]

backend/routes/data_documents.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from models.data_documents import (
55
DataDocumentsRequest,
66
DataDocumentsResponse,
7+
DataDocumentsQueryResponse,
78
FindByIdRequest,
89
FindByIdResponse,
910
UpdateDocumentRequest,
@@ -17,6 +18,8 @@
1718
from services.data_documents_service import (
1819
find_document_by_id,
1920
fetch_documents,
21+
build_mongo_query,
22+
generate_mongo_query_string,
2023
update_document,
2124
get_single_document,
2225
insert_document,
@@ -49,6 +52,31 @@ def get_documents(
4952
filters=[f.model_dump() for f in body.filters] if body.filters else None,
5053
)
5154

55+
@router.post("/documents/query_code", response_model=DataDocumentsQueryResponse)
56+
def get_documents_query_code(
57+
body: DataDocumentsRequest = Body(...), authorization: str = Header(...)
58+
):
59+
if not authorization.startswith("Bearer "):
60+
raise HTTPException(status_code=401, detail="Invalid token format")
61+
user_token = authorization.replace("Bearer ", "")
62+
access_token = exchange_token_obo(user_token)
63+
connection_string = get_connection_string(body.account_id, access_token)
64+
65+
# Needs to initialize MongoClient briefly to access find_one if 'all' keys are used in the algorithm
66+
from pymongo import MongoClient
67+
client = MongoClient(connection_string)
68+
db = client[body.database_name]
69+
collection = db[body.collection_name]
70+
71+
query = build_mongo_query(
72+
collection=collection,
73+
filter=body.filter.model_dump() if body.filter else None,
74+
filters=[f.model_dump() for f in body.filters] if body.filters else None,
75+
)
76+
77+
query_str = generate_mongo_query_string(body.collection_name, query)
78+
return DataDocumentsQueryResponse(query_code=query_str)
79+
5280

5381
@router.put("/documents", response_model=dict)
5482
def put_update_document(

backend/services/data_documents_service.py

Lines changed: 54 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,41 @@
1414

1515
ALL_DOCUMENTS_CACHES = [_find_by_id_cache]
1616

17+
def _format_value_for_pymongo(v: any) -> str:
18+
if isinstance(v, dict):
19+
items = []
20+
for key, val in v.items():
21+
items.append(f"'{key}': {_format_value_for_pymongo(val)}")
22+
return "{" + ", ".join(items) + "}"
23+
elif isinstance(v, list):
24+
items = [_format_value_for_pymongo(x) for x in v]
25+
return "[" + ", ".join(items) + "]"
26+
elif isinstance(v, datetime):
27+
# E.g. datetime(2026, 3, 12, 10, 0, tzinfo=timezone.utc)
28+
return repr(v)
29+
elif isinstance(v, ObjectId):
30+
return f"ObjectId('{str(v)}')"
31+
elif isinstance(v, str):
32+
# simple escape
33+
if v.startswith("ObjectId(") or v.startswith("datetime("):
34+
return v # fallback if it was already stringified somehow
35+
36+
escaped = v.replace("'", "\\'")
37+
return f"'{escaped}'"
38+
elif isinstance(v, bool):
39+
return "True" if v else "False"
40+
elif v is None:
41+
return "None"
42+
elif hasattr(v, "pattern"): # Regex object
43+
return f"re.compile(r'{v.pattern}', {v.flags})"
44+
else:
45+
return repr(v)
46+
47+
def generate_mongo_query_string(collection_name: str, query: dict) -> str:
48+
query_str = _format_value_for_pymongo(query)
49+
# The user specifically requested exactly the db['project'].find({......}) format
50+
return f"db['{collection_name}'].find({query_str})"
51+
1752

1853
def dict_diff(before, after):
1954
"""
@@ -82,19 +117,7 @@ def json_dumps_safe(obj):
82117
return None
83118

84119

85-
def fetch_documents(
86-
connection_string: str,
87-
database_name: str,
88-
collection_name: str,
89-
page: int,
90-
limit: int,
91-
filter: dict = None,
92-
filters: list = None,
93-
) -> DataDocumentsResponse:
94-
client = MongoClient(connection_string)
95-
db = client[database_name]
96-
collection = db[collection_name]
97-
120+
def build_mongo_query(collection, filter: dict = None, filters: list = None) -> dict:
98121
query = {}
99122
and_clauses = []
100123

@@ -182,6 +205,24 @@ def get_query_for_filter(f: dict):
182205
query = and_clauses[0]
183206
else:
184207
query = {"$and": and_clauses}
208+
209+
return query
210+
211+
212+
def fetch_documents(
213+
connection_string: str,
214+
database_name: str,
215+
collection_name: str,
216+
page: int,
217+
limit: int,
218+
filter: dict = None,
219+
filters: list = None,
220+
) -> DataDocumentsResponse:
221+
client = MongoClient(connection_string)
222+
db = client[database_name]
223+
collection = db[collection_name]
224+
225+
query = build_mongo_query(collection, filter, filters)
185226

186227
total_documents = collection.count_documents(query)
187228
total_pages = max(1, (total_documents + limit - 1) // limit)

frontend/pages/DataExplorerPage.tsx

Lines changed: 43 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
22
import { useNavigate } from 'react-router-dom';
33
import { SelectedResource, DbInfo, BreadcrumbItem, CosmosDBAccount, CollectionInfo } from '../types';
4-
import { getDocuments, getCollectionInfo, findDocumentById, getDatabasesForAccount, clearDocumentsCache, getSingleDocument } from '../services/dbService';
4+
import { getDocuments, getCollectionInfo, findDocumentById, getDatabasesForAccount, clearDocumentsCache, getSingleDocument, getDocumentsQueryCode } from '../services/dbService';
55
import { extractSchemaTree, SchemaKeyNode } from '../utils/schemaUtils';
66
import MongoIcon from '../components/icons/MongoIcon';
77
import { isEqual, omit } from 'lodash';
@@ -351,6 +351,9 @@ const DataExplorerPage: React.FC<DataExplorerPageProps> = ({
351351
const maxCardWidth = 600;
352352
const prevPinnedCount = useRef(pinnedDocuments.length);
353353

354+
// --- Export Query State ---
355+
const [copiedQuery, setCopiedQuery] = useState(false);
356+
354357
// --- Theme State ---
355358
const { theme, toggleTheme } = useTheme();
356359

@@ -1352,13 +1355,45 @@ const DataExplorerPage: React.FC<DataExplorerPageProps> = ({
13521355
)}
13531356
</div>
13541357
))}
1355-
<button
1356-
onClick={addFilter}
1357-
disabled={!selectedCollection || isFetchingSchema}
1358-
className="w-full flex justify-center items-center gap-1 p-2 text-sm font-medium text-slate-600 dark:text-slate-300 bg-slate-100 dark:bg-slate-700/50 hover:bg-slate-200 dark:hover:bg-slate-600 border border-slate-300 dark:border-slate-600 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
1359-
>
1360-
<span>+ Add Filter</span>
1361-
</button>
1358+
<div className="flex gap-2">
1359+
<button
1360+
onClick={addFilter}
1361+
disabled={!selectedCollection || isFetchingSchema}
1362+
className="flex-1 flex justify-center items-center gap-1 p-2 text-sm font-medium text-slate-600 dark:text-slate-300 bg-slate-100 dark:bg-slate-700/50 hover:bg-slate-200 dark:hover:bg-slate-600 border border-slate-300 dark:border-slate-600 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
1363+
>
1364+
<span>+ Add Filter</span>
1365+
</button>
1366+
<button
1367+
onClick={() => {
1368+
if (!selectedCollection) return;
1369+
1370+
const validFilters = debouncedFilters.map(f => ({
1371+
key: f.key,
1372+
value: getCoercedFilterValue(f.value),
1373+
operator: f.operator || 'equals',
1374+
type: f.type || 'string'
1375+
}));
1376+
1377+
getDocumentsQueryCode(selectedCollection, currentResource, undefined, validFilters)
1378+
.then((queryCodeStr) => {
1379+
navigator.clipboard.writeText(queryCodeStr).then(() => {
1380+
setCopiedQuery(true);
1381+
setTimeout(() => setCopiedQuery(false), 2000);
1382+
});
1383+
})
1384+
.catch(err => {
1385+
console.error("Failed to generate query code:", err);
1386+
alert("Failed to generate query code: " + err.message);
1387+
});
1388+
}}
1389+
disabled={!selectedCollection || isFetchingSchema}
1390+
className="flex justify-center items-center gap-2 p-2 px-3 text-sm font-medium text-slate-600 dark:text-slate-300 bg-white dark:bg-slate-700 hover:bg-slate-50 dark:hover:bg-slate-600 border border-slate-300 dark:border-slate-600 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
1391+
title="Export current filter criteria as Python script (MongoDB PyMongo)"
1392+
>
1393+
{copiedQuery ? <CheckIcon className="w-4 h-4 text-green-500" /> : <FileCopyIcon className="w-4 h-4" />}
1394+
<span>Export</span>
1395+
</button>
1396+
</div>
13621397
</div>
13631398
</div>
13641399
<div className="flex-grow overflow-hidden">

frontend/services/dbService.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -431,6 +431,53 @@ export const getDocuments = async (
431431
return response.json();
432432
};
433433

434+
export const getDocumentsQueryCode = async (
435+
collectionName: string,
436+
resource: SelectedResource,
437+
filter?: { key: string, value: any, operator?: string, type?: string },
438+
filters?: { key: string, value: any, operator?: string, type?: string }[]
439+
): Promise<string> => {
440+
if (!USE_MSAL_AUTH) {
441+
return Promise.resolve("db['" + collectionName + "'].find({\n // Exporting is only fully supported dynamically in remote mode.\n})");
442+
}
443+
444+
const accessToken = await getAuthenticatedToken();
445+
446+
const bodyQuery: any = {
447+
account_id: resource.accountId,
448+
database_name: resource.databaseName,
449+
collection_name: collectionName,
450+
page: 1,
451+
limit: 1,
452+
};
453+
454+
if (filter && ((filter.value !== '' && filter.value !== null && filter.value !== undefined) || filter.operator === 'exists' || filter.operator === 'not_exists')) {
455+
bodyQuery.filter = filter;
456+
}
457+
458+
if (filters && filters.length > 0) {
459+
bodyQuery.filters = filters.filter(f => (f.value !== '' && f.value !== null && f.value !== undefined) || f.operator === 'exists' || f.operator === 'not_exists');
460+
}
461+
462+
const response = await fetch(`${API_BASE_URL}/data/documents/query_code`, {
463+
method: 'POST',
464+
headers: {
465+
'Content-Type': 'application/json',
466+
'Authorization': `Bearer ${accessToken}`,
467+
},
468+
body: JSON.stringify(bodyQuery),
469+
});
470+
471+
if (!response.ok) {
472+
const errorData = await response.json().catch(() => ({}));
473+
const errorMessage = errorData.detail || errorData.message || `Failed to generate query code. Status: ${response.status}`;
474+
throw new Error(errorMessage);
475+
}
476+
477+
const result = await response.json();
478+
return result.query_code;
479+
};
480+
434481
/**
435482
* Finds a single document by its ID, searching across all provided collections.
436483
* @param documentId The string representation of the document's ObjectId.

0 commit comments

Comments
 (0)