diff --git a/backend/models/schemas.py b/backend/models/schemas.py index 5024b2a..73d3ef1 100644 --- a/backend/models/schemas.py +++ b/backend/models/schemas.py @@ -28,10 +28,11 @@ class CollectionContext(BaseModel): class QueryPrompt(BaseModel): user_input: str + account_id: str # Added for cross-collection schema fetching db_context: DBContext - collection_context: CollectionContext | None = ( - None # Optional context for specific collection - ) + collection_context: list[CollectionContext] = ( + [] + ) # List of contexts for selected collections intermediate_context: object | None = ( None # Optional intermediate context for complex queries ) @@ -54,3 +55,22 @@ class DebugQueryRequest(BaseModel): class DebugSuggestionResponse(BaseModel): suggestion: str + + +class SchemaRelationshipsRequest(BaseModel): + account_id: str + database_name: str + collection_names: list[str] + + +class Relationship(BaseModel): + source_collection: str + source_field: str + target_collection: str + target_field: str + description: str + confidence: float # 0.0 to 1.0 + + +class SchemaRelationshipsResponse(BaseModel): + relationships: list[Relationship] diff --git a/backend/routes/query.py b/backend/routes/query.py index 529f488..af3df51 100644 --- a/backend/routes/query.py +++ b/backend/routes/query.py @@ -8,12 +8,19 @@ ExecuteInput, DebugQueryRequest, DebugSuggestionResponse, + SchemaRelationshipsRequest, + SchemaRelationshipsResponse, ) from services.gemini_service import ( generate_query_from_prompt, generate_suggestion_from_query_error, + generate_schema_relationships, +) +from services.mongo_service import ( + execute_mongo_query, + transform_mongo_result, + get_database_schema_summary, ) -from services.mongo_service import execute_mongo_query, transform_mongo_result from models.analyze import AnalyzeRequest, AnalyzeResponse from services.analyze_service import analyze_query_result @@ -21,17 +28,70 @@ @router.post("/nl2query") -def nl2query(prompt: QueryPrompt = Body(...)): +def nl2query(prompt: QueryPrompt = Body(...), authorization: str = Header(...)): + if not authorization.startswith("Bearer "): + raise HTTPException(status_code=401, detail="Invalid token format") + + try: + user_token = authorization.replace("Bearer ", "") + access_token = exchange_token_obo(user_token) + # Use provided contexts if available to avoid re-fetch and ensure consistency + if prompt.collection_context: + summary = [] + for ctx in prompt.collection_context: + doc_str = ( + str(ctx.sampleDocument) + if ctx.sampleDocument + else "No documents found" + ) + summary.append(f"Collection: {ctx.name}\nSample Document: {doc_str}") + schema_summary = "\n\n".join(summary) + # Fallback: fetch schema summary from DB + else: + schema_summary = get_database_schema_summary( + prompt.account_id, prompt.db_context.name, access_token + ) + except Exception as e: + print(f"Error fetching schema context: {e}") + schema_summary = "Could not fetch schema summary." + collections = [col.name for col in prompt.db_context.collections] return generate_query_from_prompt( prompt.user_input, collections, prompt.db_context.name, - prompt.collection_context, - prompt.intermediate_context, + collection_context=None, + intermediate_context=prompt.intermediate_context, + all_collections_schema=schema_summary, ) +@router.post("/infer-relationships", response_model=SchemaRelationshipsResponse) +def infer_relationships( + request: SchemaRelationshipsRequest = Body(...), authorization: str = Header(...) +): + if not authorization.startswith("Bearer "): + raise HTTPException(status_code=401, detail="Invalid token format") + + try: + user_token = authorization.replace("Bearer ", "") + access_token = exchange_token_obo(user_token) + + # Fetch schema summary ONLY for correct collections + schema_summary = get_database_schema_summary( + request.account_id, + request.database_name, + access_token, + collection_filter=request.collection_names, + ) + + return generate_schema_relationships(schema_summary) + + except Exception as e: + print(f"Error inferring relationships: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + @router.post("/execute") def execute(query: ExecuteInput = Body(...), authorization: str = Header(...)): if not authorization.startswith("Bearer "): diff --git a/backend/services/gemini_service.py b/backend/services/gemini_service.py index 91e4726..0040c6d 100644 --- a/backend/services/gemini_service.py +++ b/backend/services/gemini_service.py @@ -1,6 +1,11 @@ from google import genai from google.genai import types -from models.schemas import GeneratedCode, CollectionContext, DebugSuggestionResponse +from models.schemas import ( + GeneratedCode, + CollectionContext, + DebugSuggestionResponse, + SchemaRelationshipsResponse, +) from pydantic import BaseModel, Field from typing import Optional, List @@ -31,6 +36,7 @@ class AuditSummaryResponse(BaseModel): Database: {database} Available collections: {collections} Sample collection document (optional): {collection_context} +Schema summary for ALL collections (for JOINs/lookups): {all_collections_schema} Intermediate context (optional): {intermediate_context} Return: only one line of pure pymongo query code (e.g., db["collection"].find(...)) @@ -112,6 +118,7 @@ def generate_query_from_prompt( database: str, collection_context: CollectionContext = None, intermediate_context: dict = None, + all_collections_schema: str = "", ) -> GeneratedCode: # Prune intermediate_context to remove image/large data safe_intermediate_context = ( @@ -124,6 +131,7 @@ def generate_query_from_prompt( collection_context=( collection_context.sampleDocument if collection_context else "" ), + all_collections_schema=all_collections_schema, intermediate_context=safe_intermediate_context, ) client = genai.Client() @@ -244,3 +252,58 @@ def summarize_audit_results( summary="Could not generate summary due to parsing error.", visualization=VisualizationConfig(available=False), ) + + +PROMPT_TEMPLATE_RELATIONSHIPS = """ +You are a database architect. Analyze the provided MongoDB document samples to identify likely foreign key relationships and JOIN conditions between collections. + +Schema/Samples: +{schema_summary} + +Tasks: +1. Identify likely relationships (e.g., `userId` in `orders` -> `_id` in `users`). +2. Provide a confidence score (0.0 - 1.0) and a brief description for each. +3. Return a JSON object with a "relationships" key containing a list of these findings. + +Output Format (Json): +{{ + "relationships": [ + {{ + "source_collection": "orders", + "source_field": "userId", + "target_collection": "users", + "target_field": "_id", + "description": "Orders belong to Users", + "confidence": 0.95 + }} + ] +}} +""" + + +def generate_schema_relationships(schema_summary: str) -> SchemaRelationshipsResponse: + from models.schemas import SchemaRelationshipsResponse + + full_prompt = PROMPT_TEMPLATE_RELATIONSHIPS.format(schema_summary=schema_summary) + client = genai.Client() + response = client.models.generate_content( + model="gemini-2.5-flash", + contents=full_prompt, + config=types.GenerateContentConfig( + response_mime_type="application/json", + response_schema=SchemaRelationshipsResponse, + thinking_config=types.ThinkingConfig(thinking_budget=0), + ), + ) + + if hasattr(response, "parsed") and response.parsed: + return response.parsed + + import json + + try: + data = json.loads(response.text) + return SchemaRelationshipsResponse(**data) + except Exception as e: + print(f"Error parsing Gemini relationship response: {e}") + return SchemaRelationshipsResponse(relationships=[]) diff --git a/backend/services/mongo_service.py b/backend/services/mongo_service.py index a0a5b56..5a60bfa 100644 --- a/backend/services/mongo_service.py +++ b/backend/services/mongo_service.py @@ -56,3 +56,38 @@ def transform_mongo_result(result): elif isinstance(result, DeleteResult): return {"deleted_count": result.deleted_count} return result + + +def get_database_schema_summary( + account_id: str, + database: str, + access_token: str, + collection_filter: list[str] = None, +) -> str: + from services.azure_cosmos_resources import get_connection_string + + try: + connection_string = get_connection_string(account_id, access_token) + client = pymongo.MongoClient(connection_string) + db = client[database] + summary = [] + + # Determine which collections to scan + if collection_filter: + target_collections = collection_filter + else: + target_collections = db.list_collection_names() + + for collection_name in target_collections: + # Skip system collections if scanning all (if explicit filter, try to fetch) + if not collection_filter and collection_name.startswith("system."): + continue + + doc = db[collection_name].find_one() + doc_str = str(doc) if doc else "No documents found" + summary.append(f"Collection: {collection_name}\nSample Document: {doc_str}") + + return "\n\n".join(summary) + except Exception as e: + print(f"Error fetching schema summary: {e}") + return "Could not fetch schema summary." diff --git a/backend/tests/test_query_routes.py b/backend/tests/test_query_routes.py index a13e8d5..32880b0 100644 --- a/backend/tests/test_query_routes.py +++ b/backend/tests/test_query_routes.py @@ -15,7 +15,10 @@ def test_nl2query(client): """Test natural language to query conversion.""" # Mock dependencies - with patch("routes.query.generate_query_from_prompt") as mock_generate: + with ( + patch("routes.query.generate_query_from_prompt") as mock_generate, + patch("routes.query.exchange_token_obo") as mock_exchange, + ): mock_generate.return_value = {"generated_code": "db.users.find({})"} # Create test data @@ -25,11 +28,15 @@ def test_nl2query(client): prompt = QueryPrompt( user_input="Find all users", + account_id="test-account", db_context=db_context, - collection_context=collection_context, + collection_context=[collection_context], ) - response = client.post("/query/nl2query", json=prompt.model_dump()) + headers = {"authorization": "Bearer valid-token"} + response = client.post( + "/query/nl2query", json=prompt.model_dump(), headers=headers + ) assert response.status_code == 200 data = response.json() diff --git a/backend/tests/test_schemas.py b/backend/tests/test_schemas.py index a4a7f0f..cd5c0ee 100644 --- a/backend/tests/test_schemas.py +++ b/backend/tests/test_schemas.py @@ -91,19 +91,22 @@ def test_query_prompt(): prompt = QueryPrompt( user_input="Find all users", + account_id="test-account", db_context=db_context, - collection_context=collection_context, + collection_context=[collection_context], intermediate_context={"key": "value"}, ) assert prompt.user_input == "Find all users" assert prompt.db_context.name == "test-db" - assert prompt.collection_context.name == "users" + assert prompt.collection_context[0].name == "users" assert prompt.intermediate_context == {"key": "value"} # Test with minimal required fields - minimal_prompt = QueryPrompt(user_input="Find all users", db_context=db_context) - assert minimal_prompt.collection_context is None + minimal_prompt = QueryPrompt( + user_input="Find all users", account_id="test-account", db_context=db_context + ) + assert minimal_prompt.collection_context == [] assert minimal_prompt.intermediate_context is None diff --git a/frontend/components/SchemaRelationshipGraph.tsx b/frontend/components/SchemaRelationshipGraph.tsx new file mode 100644 index 0000000..5fe1ff3 --- /dev/null +++ b/frontend/components/SchemaRelationshipGraph.tsx @@ -0,0 +1,242 @@ + +import React, { useMemo, useState } from 'react'; +import { Relationship, SchemaRelationshipsResponse } from '../types'; + +interface SchemaRelationshipGraphProps { + relationships: SchemaRelationshipsResponse; + selectedCollections: string[]; +} + +interface Node { + id: string; + x: number; + y: number; +} + +interface Edge { + source: Node; + target: Node; + data: Relationship; +} + +const SchemaRelationshipGraph: React.FC = ({ relationships, selectedCollections }) => { + const [hoveredNode, setHoveredNode] = useState(null); + const [hoveredEdge, setHoveredEdge] = useState(null); + + // Filter collections to only those selected (or involved in relationships between selected) + // Actually, we should show all "selected" collections as nodes, even if isolated. + const nodes: Node[] = useMemo(() => { + const uniqueCols = Array.from(new Set(selectedCollections)); + const count = uniqueCols.length; + const radius = 120; // Radius of the circle layout + const centerX = 200; + const centerY = 150; // Reduced height + + if (count === 2) { + return [ + { id: uniqueCols[0], x: 80, y: centerY }, + { id: uniqueCols[1], x: 320, y: centerY } + ]; + } + + return uniqueCols.map((col, i) => { + const angle = (i / count) * 2 * Math.PI - Math.PI / 2; // Start from top + return { + id: col, + x: centerX + radius * Math.cos(angle), + y: centerY + radius * Math.sin(angle), + }; + }); + }, [selectedCollections]); + + const edges: Edge[] = useMemo(() => { + return relationships.relationships + .filter(rel => selectedCollections.includes(rel.source_collection) && selectedCollections.includes(rel.target_collection)) + .map(rel => { + const sourceNode = nodes.find(n => n.id === rel.source_collection); + const targetNode = nodes.find(n => n.id === rel.target_collection); + if (!sourceNode || !targetNode) return null; + return { source: sourceNode, target: targetNode, data: rel }; + }) + .filter((e): e is Edge => e !== null); + }, [relationships, nodes, selectedCollections]); + + // Helper to calculate bezier curve control point + const getPath = (source: Node, target: Node, index: number) => { + const dx = target.x - source.x; + const dy = target.y - source.y; + const dist = Math.sqrt(dx * dx + dy * dy); + + // Curvature logic: Straight line if direct, curved if multiple or loops + let cx = (source.x + target.x) / 2; + let cy = (source.y + target.y) / 2; + + // Add offset for multiple edges or just for style + // If it's a bidirectional pair, we need curvature. + // Simple logic: curve upwards/downwards based on direction or index + + // A simple consistent curve offset + // const offset = 40; // This variable was not used, removed. + // Calculate perpendicular vector + const px = -dy / dist; + const py = dx / dist; + + // Small randomish offset based on data string hash or index to allow separate lines for multiple rels + const curveAmount = 30 + (index * 20); + + cx += px * curveAmount; + cy += py * curveAmount; + + // Calculate intersection point on the edge of the target circle (radius 30 + arrow size approx 10) + // We want the arrow to point to the edge, efficiently. + // Actually, Q curves are hard to chop exactly. + // Easier approach: Use a marker-end that has refX properly set, OR calculate the point on the circle. + + // Let's recalculate target/source points to be on the circumference. + const radius = 30 + 5; // Node radius + buffer + const angleSource = Math.atan2(cy - source.y, cx - source.x); + const angleTarget = Math.atan2(cy - target.y, cx - target.x); + + const sx = source.x + radius * Math.cos(angleSource); + const sy = source.y + radius * Math.sin(angleSource); + + const tx = target.x + radius * Math.cos(angleTarget); + const ty = target.y + radius * Math.sin(angleTarget); + + return `M ${sx} ${sy} Q ${cx} ${cy} ${tx} ${ty}`; + }; + + return ( +
+
+
+ + AI Inferred Connection +
+
+ + + + + + + + + + + + + + + + + + + {/* Edges */} + {edges.map((edge, i) => { + const isHovered = hoveredEdge === i; + const pathData = getPath(edge.source, edge.target, i); + + return ( + setHoveredEdge(i)} + onMouseLeave={() => setHoveredEdge(null)} + className="transition-opacity duration-300" + style={{ opacity: (hoveredEdge !== null && !isHovered) ? 0.3 : 1 }} + > + {/* Invisible wideline for easier hovering */} + + + {/* Visible line - No arrowheads */} + + + {/* Animated particle flow only on hover */} + {isHovered && ( + + + + )} + + ); + })} + + {/* Nodes */} + {nodes.map(node => { + const isHovered = hoveredNode === node.id; + const isRelatedToHoveredEdge = hoveredEdge !== null && (edges[hoveredEdge!].source.id === node.id || edges[hoveredEdge!].target.id === node.id); + + return ( + setHoveredNode(node.id)} + onMouseLeave={() => setHoveredNode(null)} + className="cursor-pointer transition-all duration-300" + style={{ + opacity: (hoveredEdge !== null && !isRelatedToHoveredEdge) ? 0.4 : 1 + }} + > + + {/* Full Name */} + + {node.id} + + + ); + })} + + + {/* Detail Card Overlay - Appears when hovering an edge */} +
+ {hoveredEdge !== null && ( +
+
+ + {edges[hoveredEdge].data.source_collection}.{edges[hoveredEdge].data.source_field} + + + + {edges[hoveredEdge].data.target_collection}.{edges[hoveredEdge].data.target_field} + +
+

+ {edges[hoveredEdge].data.description} +

+
+ )} +
+ + {/* Empty State Overlay */} + {edges.length === 0 && ( +
+
+

No connections found

+
+
+ )} + +
+ ); +}; + +export default SchemaRelationshipGraph; diff --git a/frontend/pages/QueryGeneratorPage.tsx b/frontend/pages/QueryGeneratorPage.tsx index 9bb4823..bf45a37 100644 --- a/frontend/pages/QueryGeneratorPage.tsx +++ b/frontend/pages/QueryGeneratorPage.tsx @@ -1,11 +1,10 @@ - import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react'; import { createPortal } from 'react-dom'; -import { generateMongoQuery, debugMongoQuery, analyzeQueryResult } from '../services/geminiService'; +import { generateMongoQuery, debugMongoQuery, analyzeQueryResult, inferSchemaRelationships } from '../services/geminiService'; import { getAzureCosmosAccounts, getDatabasesForAccount, runMongoQuery, getCollectionInfo, clearSystemCache } from '../services/dbService'; import { getSavedQueries, saveQuery, updateSavedQuery, deleteSavedQuery } from '../services/userDataService'; import { generateIpynbContent, downloadFile } from '../services/notebookService'; -import { QueryResultData, DbInfo, CollectionInfo, CosmosDBAccount, SelectedResource, DebuggingResult, AnalysisResult, NotebookStep, SavedQuery } from '../types'; +import { QueryResultData, DbInfo, CollectionInfo, CosmosDBAccount, SelectedResource, DebuggingResult, AnalysisResult, NotebookStep, SavedQuery, SchemaRelationshipsResponse } from '../types'; import { mockECommerceDbInfo, mockCollectionInfoMap, mockFindUsersQuery, mockUserFindResult, mockSavedQueries } from '../services/mockData'; import { getAuthErrorMessage, isAuthenticationExpiredError } from '../utils/authErrorHandler'; import QueryDisplay from '../components/QueryDisplay'; @@ -14,6 +13,7 @@ import Loader from '../components/Loader'; import Tutorial from '../components/Tutorial'; import JsonDisplay from '../components/JsonDisplay'; import CollectionActionPanel from '../components/CollectionActionPanel'; +import SchemaRelationshipGraph from '../components/SchemaRelationshipGraph'; import SavedQueriesPanel from '../components/SavedQueriesPanel'; import SaveQueryDialog from '../components/SaveQueryDialog'; import ShareQueryDialog from '../components/ShareQueryDialog'; @@ -73,15 +73,15 @@ const UserMenu: React.FC<{ }, [menuRef]); const getCacheButtonContent = () => { - if (isClearingCache) return <> Clearing...; - if (cacheClearStatus === 'success') return <> Cleared!; - if (cacheClearStatus === 'error') return <>Error; - return <> Clear Cache; + if (isClearingCache) return <> Clearing...; + if (cacheClearStatus === 'success') return <> Cleared!; + if (cacheClearStatus === 'error') return <>Error; + return <> Clear Cache; }; - + return (
- - +
- + @@ -138,54 +138,43 @@ interface HeaderUIProps { onStartTutorial: () => void; onShowSavedQueries: () => void; onShowShortcuts: () => void; - onNavigateToExplorer: () => void; - isExplorerNavEnabled: boolean; isUserMenuForcedOpen?: boolean; } -const HeaderUI: React.FC = ({ name, onLogout, onClearCache, isClearingCache, cacheClearStatus, onStartTutorial, onShowSavedQueries, onShowShortcuts, onNavigateToExplorer, isExplorerNavEnabled, isUserMenuForcedOpen }) => { +const HeaderUI: React.FC = ({ name, onLogout, onClearCache, isClearingCache, cacheClearStatus, onStartTutorial, onShowSavedQueries, onShowShortcuts, isUserMenuForcedOpen }) => { const { theme, toggleTheme } = useTheme(); return (
-
- -
-

QueryPal

-

Your AI-powered database assistant.

-
-
-
- - - - +
+ +
+

QueryPal

+

Your AI-powered database assistant.

+
+
+ + + +
); }; @@ -211,11 +200,11 @@ const NotebookStepCard: React.FC = ({ step, index, onRemo
{step.isEditing ? ( ) : ( )} - -
-
- - -
- -
-
- {steps.length > 0 ? ( - steps.map((step, index) => ( - - )) - ) : ( -
-

No steps recorded yet.

-

Run a query or add a note to begin.

-
- )} + <> + + - + +
+
+ {steps.length > 0 ? ( + steps.map((step, index) => ( + + )) + ) : ( +
+

No steps recorded yet.

+

Run a query or add a note to begin.

+
+ )} +
+ + ); export interface QueryGeneratorPageProps { @@ -347,7 +336,7 @@ const QueryGeneratorPage: React.FC = ({ name, email, on const [userInput, setUserInput] = useState(''); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); - + // State for DB resources & connection const [azureAccounts, setAzureAccounts] = useState([]); const [isLoadingAccounts, setIsLoadingAccounts] = useState(true); @@ -370,7 +359,7 @@ const QueryGeneratorPage: React.FC = ({ name, email, on const [isExecuting, setIsExecuting] = useState(false); const [executionResult, setExecutionResult] = useState(null); const [executionError, setExecutionError] = useState(null); - + // State for intermediate context (multi-step queries) const [intermediateContext, setIntermediateContext] = useState<{ data: any; source: string; } | null>(null); const [currentQueryContextSource, setCurrentQueryContextSource] = useState(null); @@ -388,11 +377,18 @@ const QueryGeneratorPage: React.FC = ({ name, email, on const [analysisError, setAnalysisError] = useState(null); // State for collection details - const [selectedCollection, setSelectedCollection] = useState(null); - const [collectionInfo, setCollectionInfo] = useState(null); - const [isFetchingCollectionInfo, setIsFetchingCollectionInfo] = useState(false); + const [selectedCollections, setSelectedCollections] = useState([]); + + const [collectionDetailsMap, setCollectionDetailsMap] = useState>({}); + const [loadingCollections, setLoadingCollections] = useState>({}); + const [expandedCollectionSchemas, setExpandedCollectionSchemas] = useState>({}); // Track open/close state for stacking const [collectionInfoError, setCollectionInfoError] = useState(null); - + + // State for relationship inference + const [relationships, setRelationships] = useState(null); + const [isAnalyzingRelationships, setIsAnalyzingRelationships] = useState(false); + const [relationshipError, setRelationshipError] = useState(null); + // State for cache clearing const [isClearingCache, setIsClearingCache] = useState(false); const [cacheClearStatus, setCacheClearStatus] = useState<'idle' | 'success' | 'error'>('idle'); @@ -411,7 +407,7 @@ const QueryGeneratorPage: React.FC = ({ name, email, on const [savedQueries, setSavedQueries] = useState([]); const [isLoadingSavedQueries, setIsLoadingSavedQueries] = useState(false); const [isSavedQueriesPanelOpen, setIsSavedQueriesPanelOpen] = useState(false); - const [saveDialogState, setSaveDialogState] = useState<{ isOpen: boolean; data?: Partial & { prompt: string; code: string }}>({ isOpen: false }); + const [saveDialogState, setSaveDialogState] = useState<{ isOpen: boolean; data?: Partial & { prompt: string; code: string } }>({ isOpen: false }); const [shareDialogState, setShareDialogState] = useState<{ isOpen: boolean; query?: SavedQuery }>({ isOpen: false }); const [isSavingQuery, setIsSavingQuery] = useState(false); @@ -423,45 +419,45 @@ const QueryGeneratorPage: React.FC = ({ name, email, on if (!connectedResource) return ''; return azureAccounts.find(acc => acc.id === connectedResource.accountId)?.name ?? 'Unknown Account'; }, [connectedResource, azureAccounts]); - + const fetchAccounts = useCallback(async () => { - setIsLoadingAccounts(true); - setDbError(null); - try { - const accounts = await getAzureCosmosAccounts(); - setAzureAccounts(accounts); - } catch (e) { - if (e instanceof Error) { - // Check for authentication-related errors - if (isAuthenticationExpiredError(e)) { - setDbError(getAuthErrorMessage(e)); - } else if (e.message.includes('AuthorizationFailed') || e.message.includes('403')) { - setDbError("Permission Denied: You may not have the required permissions to list Azure resources. Please contact your administrator."); - } else { - setDbError("Could not load Azure accounts from server. Ensure the backend is running and you have permissions."); - } + setIsLoadingAccounts(true); + setDbError(null); + try { + const accounts = await getAzureCosmosAccounts(); + setAzureAccounts(accounts); + } catch (e) { + if (e instanceof Error) { + // Check for authentication-related errors + if (isAuthenticationExpiredError(e)) { + setDbError(getAuthErrorMessage(e)); + } else if (e.message.includes('AuthorizationFailed') || e.message.includes('403')) { + setDbError("Permission Denied: You may not have the required permissions to list Azure resources. Please contact your administrator."); } else { - setDbError("An unknown error occurred while fetching Azure accounts."); + setDbError("Could not load Azure accounts from server. Ensure the backend is running and you have permissions."); } - } finally { - setIsLoadingAccounts(false); + } else { + setDbError("An unknown error occurred while fetching Azure accounts."); } - }, []); - + } finally { + setIsLoadingAccounts(false); + } + }, []); + const fetchSavedQueries = useCallback(async () => { setIsLoadingSavedQueries(true); try { - const queries = await getSavedQueries(); - setSavedQueries(queries); - } catch(e) { - // Log error details for debugging - if (e instanceof Error && isAuthenticationExpiredError(e)) { - console.error("Failed to fetch saved queries due to authentication error:", getAuthErrorMessage(e)); - } else { - console.error("Failed to fetch saved queries:", e); - } + const queries = await getSavedQueries(); + setSavedQueries(queries); + } catch (e) { + // Log error details for debugging + if (e instanceof Error && isAuthenticationExpiredError(e)) { + console.error("Failed to fetch saved queries due to authentication error:", getAuthErrorMessage(e)); + } else { + console.error("Failed to fetch saved queries:", e); + } } finally { - setIsLoadingSavedQueries(false); + setIsLoadingSavedQueries(false); } }, []); @@ -479,29 +475,29 @@ const QueryGeneratorPage: React.FC = ({ name, email, on useEffect(() => { // If the tutorial is active and on a step that targets an item inside the user menu, force it open. if (isTutorialActive && tutorialStepIndex === 6) { - setIsUserMenuOpenForTutorial(true); + setIsUserMenuOpenForTutorial(true); } else { - setIsUserMenuOpenForTutorial(false); + setIsUserMenuOpenForTutorial(false); } }, [isTutorialActive, tutorialStepIndex]); - + const handleClearCache = useCallback(async () => { - setIsClearingCache(true); - setCacheClearStatus('idle'); - setDbError(null); // Clear old DB errors - try { - await clearSystemCache(); - setCacheClearStatus('success'); - // Refresh accounts list after clearing cache - await fetchAccounts(); - } catch (e) { - if (e instanceof Error) setDbError(e.message); - else setDbError("An unknown error occurred while clearing the cache."); - } finally { - setIsClearingCache(false); - // Reset the button state after 3 seconds - setTimeout(() => setCacheClearStatus('idle'), 3000); - } + setIsClearingCache(true); + setCacheClearStatus('idle'); + setDbError(null); // Clear old DB errors + try { + await clearSystemCache(); + setCacheClearStatus('success'); + // Refresh accounts list after clearing cache + await fetchAccounts(); + } catch (e) { + if (e instanceof Error) setDbError(e.message); + else setDbError("An unknown error occurred while clearing the cache."); + } finally { + setIsClearingCache(false); + // Reset the button state after 3 seconds + setTimeout(() => setCacheClearStatus('idle'), 3000); + } }, [fetchAccounts]); const clearQueryState = useCallback(() => { @@ -512,30 +508,32 @@ const QueryGeneratorPage: React.FC = ({ name, email, on setExecutionError(null); setDebuggingResult(null); setDebugError(null); + setAnalysisResult(null); + setAnalysisError(null); setCodeHistory([]); setHistoryIndex(-1); setIntermediateContext(null); setQuerySourceCollection(null); - setAnalysisResult(null); - setAnalysisError(null); setCurrentQueryContextSource(null); + setRelationships(null); // Clear relationships + setRelationshipError(null); // Clear relationship error }, []); - + const handleDisconnect = useCallback(() => { setConnectedDbInfo(null); setConnectedResource(null); clearQueryState(); setUserInput(''); - setSelectedCollection(null); - setCollectionInfo(null); + setSelectedCollections([]); + setCollectionDetailsMap({}); }, [clearQueryState]); - + const handleSelectAccount = useCallback(async (accountId: string) => { if (selectedAccountId === accountId) { - // Deselect if clicking the same account again - setSelectedAccountId(null); - setAccountDatabases([]); - return; + // Deselect if clicking the same account again + setSelectedAccountId(null); + setAccountDatabases([]); + return; } setSelectedAccountId(accountId); @@ -545,34 +543,34 @@ const QueryGeneratorPage: React.FC = ({ name, email, on const account = azureAccounts.find(acc => acc.id === accountId); if (!account) { - setDbError("An error occurred: Could not find the selected account details."); - setIsLoadingDatabases(false); - return; + setDbError("An error occurred: Could not find the selected account details."); + setIsLoadingDatabases(false); + return; } - + // If an old database was connected from another account, disconnect it - if(connectedResource?.accountId !== account.id) { - handleDisconnect(); + if (connectedResource?.accountId !== account.id) { + handleDisconnect(); } - + try { - const dbs = await getDatabasesForAccount(account.id); - setAccountDatabases(dbs); - } catch(e) { - if (e instanceof Error) { - // Check for authentication-related errors first - if (isAuthenticationExpiredError(e)) { - setDbError(getAuthErrorMessage(e)); - } else if (e.message.includes('AuthorizationFailed') || e.message.includes('403')) { - setDbError("Permission Denied: You may not have the required Azure role (e.g., 'Cosmos DB Operator') to access databases for this account. Please check your permissions."); - } else { - setDbError(e.message); - } + const dbs = await getDatabasesForAccount(account.id); + setAccountDatabases(dbs); + } catch (e) { + if (e instanceof Error) { + // Check for authentication-related errors first + if (isAuthenticationExpiredError(e)) { + setDbError(getAuthErrorMessage(e)); + } else if (e.message.includes('AuthorizationFailed') || e.message.includes('403')) { + setDbError("Permission Denied: You may not have the required Azure role (e.g., 'Cosmos DB Operator') to access databases for this account. Please check your permissions."); } else { - setDbError("Could not load databases for this account."); + setDbError(e.message); } + } else { + setDbError("Could not load databases for this account."); + } } finally { - setIsLoadingDatabases(false); + setIsLoadingDatabases(false); } }, [selectedAccountId, connectedResource, azureAccounts, handleDisconnect]); @@ -581,8 +579,8 @@ const QueryGeneratorPage: React.FC = ({ name, email, on if (!account) return; setConnectedResource({ - accountId: account.id, - databaseName: dbInfo.name, + accountId: account.id, + databaseName: dbInfo.name, }); setConnectedDbInfo(dbInfo); clearQueryState(); @@ -605,7 +603,24 @@ const QueryGeneratorPage: React.FC = ({ name, email, on try { - const result = await generateMongoQuery(prompt, connectedDbInfo ?? undefined, collectionCtx, intermediateContext?.data); + const accountId = connectedResource?.accountId || selectedAccountId; + if (!accountId) { + throw new Error("No account ID available for query generation."); + } + + // Map selected collection names to their full info objects + const selectedCollectionInfos = selectedCollections + .map(name => collectionDetailsMap[name]) + .filter((info): info is CollectionInfo => !!info); + + const result = await generateMongoQuery( + prompt, + accountId, + connectedDbInfo ?? undefined, + collectionCtx, + intermediateContext?.data, + selectedCollectionInfos // Pass full info objects + ); setQueryResult(result); setIntermediateContext(null); // Clear context after use @@ -623,20 +638,22 @@ const QueryGeneratorPage: React.FC = ({ name, email, on } finally { setIsLoading(false); } - }, [connectedDbInfo, codeHistory, historyIndex, intermediateContext]); - + }, [connectedDbInfo, codeHistory, historyIndex, intermediateContext, connectedResource, selectedAccountId]); + const handleGenerateQueryClick = useCallback(() => { if (intermediateContext) { - setCurrentQueryContextSource(intermediateContext.source); + setCurrentQueryContextSource(intermediateContext.source); } else { - setCurrentQueryContextSource(null); + setCurrentQueryContextSource(null); } - setQuerySourceCollection(selectedCollection); + // For now, if multiple are selected, we might want to prioritize one for the "source" tag or change how it works. + // Let's us the first selected one, or a joined string. + setQuerySourceCollection(selectedCollections.length > 0 ? selectedCollections.join(', ') : null); setLastSuccessfulPrompt(userInput); // If a collection is selected, pass its info as context. - // Otherwise, this will be undefined, and the query will be against the whole DB. - handleGenerateQuery(userInput, collectionInfo ?? undefined); - }, [userInput, intermediateContext, selectedCollection, collectionInfo, handleGenerateQuery]); + const primaryContext = selectedCollections.length > 0 ? collectionDetailsMap[selectedCollections[0]] : undefined; + handleGenerateQuery(userInput, primaryContext); + }, [userInput, intermediateContext, selectedCollections, collectionDetailsMap, handleGenerateQuery]); const handleRunQuery = useCallback(async () => { if (!editableCode.trim() || !connectedDbInfo || !connectedResource) { @@ -652,7 +669,7 @@ const QueryGeneratorPage: React.FC = ({ name, email, on setAnalysisError(null); try { - const result = await runMongoQuery(selectedAccountId, editableCode, connectedResource); + const result = await runMongoQuery(connectedResource.accountId, editableCode, connectedResource); setExecutionResult(result); // --- Add step to notebook --- @@ -663,25 +680,25 @@ const QueryGeneratorPage: React.FC = ({ name, email, on prompt: lastSuccessfulPrompt || 'Query executed without a new prompt.', query: editableCode, resultSample: resultSample, - contextSource: currentQueryContextSource, + contextSource: currentQueryContextSource ?? undefined, }; setNotebookSteps(prev => [...prev, newStep]); setCurrentQueryContextSource(null); // Reset after use } catch (e) { - if (e instanceof Error) { - if (e.message.includes('AuthorizationFailed') || e.message.includes('403')) { - setExecutionError("Permission Denied: You do not have permission to execute queries against this database."); - } else { - setExecutionError(e.message); - } + if (e instanceof Error) { + if (e.message.includes('AuthorizationFailed') || e.message.includes('403')) { + setExecutionError("Permission Denied: You do not have permission to execute queries against this database."); } else { - setExecutionError("An unknown error occurred while running the query."); + setExecutionError(e.message); } + } else { + setExecutionError("An unknown error occurred while running the query."); + } } finally { setIsExecuting(false); } - }, [editableCode, connectedDbInfo, connectedResource, selectedAccountId, lastSuccessfulPrompt, currentQueryContextSource]); + }, [editableCode, connectedDbInfo, connectedResource, lastSuccessfulPrompt, currentQueryContextSource]); const handleDebugQuery = useCallback(async () => { if (!editableCode || !executionError) return; @@ -691,13 +708,13 @@ const QueryGeneratorPage: React.FC = ({ name, email, on setDebuggingResult(null); try { - const result = await debugMongoQuery(editableCode, executionError); - setDebuggingResult(result); + const result = await debugMongoQuery(editableCode, executionError); + setDebuggingResult(result); } catch (e) { - if (e instanceof Error) setDebugError(e.message); - else setDebugError("An unexpected error occurred while debugging."); + if (e instanceof Error) setDebugError(e.message); + else setDebugError("An unexpected error occurred while debugging."); } finally { - setIsDebugging(false); + setIsDebugging(false); } }, [editableCode, executionError]); @@ -709,44 +726,117 @@ const QueryGeneratorPage: React.FC = ({ name, email, on setAnalysisResult(null); try { - const result = await analyzeQueryResult(dataToAnalyze); - setAnalysisResult(result); + const result = await analyzeQueryResult(dataToAnalyze); + setAnalysisResult(result); } catch (e) { - if (e instanceof Error) setAnalysisError(e.message); - else setAnalysisError("An unexpected error occurred during AI analysis."); + if (e instanceof Error) setAnalysisError(e.message); + else setAnalysisError("An unexpected error occurred during AI analysis."); } finally { - setIsAnalyzing(false); + setIsAnalyzing(false); } }, []); - const handleCollectionClick = useCallback(async (collectionName: string) => { + const handleCollectionClick = useCallback(async (collectionName: string, event?: React.MouseEvent) => { if (!connectedResource) return; - if (selectedCollection === collectionName) { - setSelectedCollection(null); - setCollectionInfo(null); - return; + + // Clear previous relationship analysis when selection changes + setRelationships(null); + setRelationshipError(null); + + const isModifierPressed = event ? (event.ctrlKey || event.metaKey) : false; + + let newSelection: string[] = []; + + if (isModifierPressed) { + // Multi-selection logic + if (selectedCollections.includes(collectionName)) { + newSelection = selectedCollections.filter(c => c !== collectionName); + } else { + newSelection = [...selectedCollections, collectionName]; + } + } else { + // Single selection functionality (toggle off if same clicked, otherwise set new) + if (selectedCollections.length === 1 && selectedCollections[0] === collectionName) { + newSelection = []; + } else { + newSelection = [collectionName]; + } } - setSelectedCollection(collectionName); - setIsFetchingCollectionInfo(true); - setCollectionInfoError(null); - setCollectionInfo(null); - try { - const info = await getCollectionInfo(collectionName, connectedResource); - setCollectionInfo(info); - } catch (e) { - if (e instanceof Error) { - if (e.message.includes('AuthorizationFailed') || e.message.includes('403')) { - setCollectionInfoError("Permission Denied: You do not have permission to view details for this collection."); - } else { - setCollectionInfoError(e.message); - } - } else { - setCollectionInfoError("Failed to fetch collection details."); + + setSelectedCollections(newSelection); + + if (newSelection.length === 0) { + // Clear errors if any + return; + } + + // Fetch details for any newly selected collection that we don't have yet + newSelection.forEach(async (col) => { + if (!collectionDetailsMap[col] && !loadingCollections[col]) { + setLoadingCollections(prev => ({ ...prev, [col]: true })); + try { + // Use the existing getCollectionInfo. + // Note: getCollectionInfo signature might be (accountId, dbName, collectionName) or (collectionName, resource). + // I need to check the import or usage elsewhere. + // Line 4 import says: import { ... getCollectionInfo ... } from '../services/dbService'; + // Line 764 usage (in previous view) was: getCollectionInfo(newSelection[0], connectedResource); + // But typically it is (accountId, databaseName, collectionName). + // Let's assume (collectionName, connectedResource) based on previous usage or check dbService. + // Actually in Step 382 I wrote: getCollectionInfo(connectedResource!.accountId, connectedResource!.databaseName, col); + // Let's stick to what works. I will use the pattern that was there or verify signature. + // In Step 374, import is from dbService. + // In Step 382, I tried to use explicit args. + // Let's assume signature: getCollectionInfo(collectionName, connectedResource) based on line 764 of previous original code? + // Wait, original code was: `const info = await getCollectionInfo(newSelection[0], connectedResource);` in Step 382 diff (LEFT side). + // So I should use that signature. + const info = await getCollectionInfo(col, connectedResource); + setCollectionDetailsMap(prev => ({ ...prev, [col]: info })); + } catch (e: any) { + console.error(`Failed to load details for ${col}`, e); + } finally { + setLoadingCollections(prev => ({ ...prev, [col]: false })); } + } + }); + }, [connectedResource, selectedCollections, collectionDetailsMap, loadingCollections]); + + const handleAnalyzeRelationships = useCallback(async () => { + if (!connectedResource || selectedCollections.length < 2) return; + + setIsAnalyzingRelationships(true); + setRelationshipError(null); + // Don't clear immediately to prevent flashing if we are just refreshing? + // Actually for new selection we want to clear. + + try { + const result = await inferSchemaRelationships( + connectedResource.accountId, + connectedResource.databaseName, + selectedCollections + ); + setRelationships(result); + } catch (e: any) { + setRelationshipError(e.message || "Failed to analyze relationships."); } finally { - setIsFetchingCollectionInfo(false); + setIsAnalyzingRelationships(false); } - }, [selectedCollection, connectedResource]); + }, [connectedResource, selectedCollections]); + + // Debounced effect to trigger analysis when selections change + useEffect(() => { + // Clean up previous state if selection is insufficient + if (selectedCollections.length < 2) { + setRelationships(null); + setRelationshipError(null); + return; + } + + const timer = setTimeout(() => { + handleAnalyzeRelationships(); + }, 800); // 800ms debounce + + return () => clearTimeout(timer); + }, [selectedCollections, handleAnalyzeRelationships]); const handleNavigateHistory = useCallback((direction: 'prev' | 'next') => { const newIndex = direction === 'prev' ? historyIndex - 1 : historyIndex + 1; @@ -755,23 +845,23 @@ const QueryGeneratorPage: React.FC = ({ name, email, on setEditableCode(codeHistory[newIndex]); } }, [historyIndex, codeHistory]); - + const handleSetIntermediateContext = useCallback((data: any, source: string) => { - setIntermediateContext({ data, source }); + setIntermediateContext({ data, source }); }, []); // --- Notebook Handlers --- const handleExportNotebook = useCallback(() => { if (notebookSteps.length === 0) return; // Turn off editing mode for all notes before exporting - const stepsToExport = notebookSteps.map(step => ({...step, isEditing: false })); + const stepsToExport = notebookSteps.map(step => ({ ...step, isEditing: false })); const dbName = connectedDbInfo?.name; const content = generateIpynbContent(stepsToExport, dbName); downloadFile(content, 'querypal-notebook.ipynb', 'application/json'); }, [notebookSteps, connectedDbInfo]); const handleClearNotebook = () => { - setNotebookSteps([]); + setNotebookSteps([]); }; const handleRemoveNotebookStep = useCallback((id: string) => { @@ -786,17 +876,17 @@ const QueryGeneratorPage: React.FC = ({ name, email, on isEditing: true, // Start in editing mode }; // Turn off editing for all other notes - setNotebookSteps(prev => [...prev.map(s => ({...s, isEditing: false})), newNote]); + setNotebookSteps(prev => [...prev.map(s => ({ ...s, isEditing: false })), newNote]); }, []); const handleUpdateNotebookStep = (id: string, content: string) => { setNotebookSteps(prev => prev.map(s => s.id === id ? { ...s, prompt: content } : s)); }; - + const handleSetEditingStep = (id: string, isEditing: boolean) => { setNotebookSteps(prev => prev.map(s => s.id === id ? { ...s, isEditing } : { ...s, isEditing: false })); }; - + // --- Saved Queries Handlers --- const handleOpenSaveDialog = useCallback(() => { if (!editableCode) return; // Don't open if there's no code to save @@ -809,25 +899,25 @@ const QueryGeneratorPage: React.FC = ({ name, email, on const handleEditSavedQuery = (query: SavedQuery) => { setSaveDialogState({ isOpen: true, data: query }); }; - + const handleSaveOrUpdateQuery = useCallback(async (data: Pick | SavedQuery) => { setIsSavingQuery(true); try { - if ('id' in data) { - // Update - const updated = await updateSavedQuery(data as SavedQuery); - setSavedQueries(prev => prev.map(q => q.id === updated.id ? updated : q)); - } else { - // Create - const newQuery = await saveQuery(data as Pick); - setSavedQueries(prev => [...prev, newQuery]); - } - setSaveDialogState({ isOpen: false }); - } catch(e) { - console.error("Failed to save or update query:", e); - // Maybe set an error in the dialog in the future + if ('id' in data) { + // Update + const updated = await updateSavedQuery(data as SavedQuery); + setSavedQueries(prev => prev.map(q => q.id === updated.id ? updated : q)); + } else { + // Create + const newQuery = await saveQuery(data as Pick); + setSavedQueries(prev => [...prev, newQuery]); + } + setSaveDialogState({ isOpen: false }); + } catch (e) { + console.error("Failed to save or update query:", e); + // Maybe set an error in the dialog in the future } finally { - setIsSavingQuery(false); + setIsSavingQuery(false); } }, []); @@ -836,10 +926,10 @@ const QueryGeneratorPage: React.FC = ({ name, email, on const originalQueries = savedQueries; setSavedQueries(prev => prev.filter(q => q.id !== queryId)); try { - await deleteSavedQuery(queryId); - } catch(e) { - console.error("Failed to delete query:", e); - setSavedQueries(originalQueries); // Revert on failure + await deleteSavedQuery(queryId); + } catch (e) { + console.error("Failed to delete query:", e); + setSavedQueries(originalQueries); // Revert on failure } }, [savedQueries]); @@ -848,11 +938,11 @@ const QueryGeneratorPage: React.FC = ({ name, email, on setUserInput(query.prompt); setLastSuccessfulPrompt(query.prompt); setEditableCode(query.code); - + // Set code history for the loaded query setCodeHistory([query.code]); setHistoryIndex(0); - + setIsSavedQueriesPanelOpen(false); // Close panel after loading }; @@ -865,28 +955,55 @@ const QueryGeneratorPage: React.FC = ({ name, email, on // Optimistic update for a snappy UI setSavedQueries(prev => prev.map(q => q.id === queryToUpdate.id ? queryToUpdate : q)); setShareDialogState({ isOpen: false }); // Close dialog immediately - + try { - // Now call the backend - await updateSavedQuery(queryToUpdate); + // Now call the backend + await updateSavedQuery(queryToUpdate); } catch (e) { - console.error("Failed to update sharing settings:", e); - // On failure, revert the change by re-fetching from the source of truth - fetchSavedQueries(); + console.error("Failed to update sharing settings:", e); + // On failure, revert the change by re-fetching from the source of truth + fetchSavedQueries(); } }; - const handleNavigateToExplorer = () => { - if (connectedResource && connectedDbInfo) { - onNavigateToExplorer({ - resource: connectedResource, - dbInfo: connectedDbInfo, - accountName: connectedAccountName, - availableDbs: accountDatabases, - availableAccounts: azureAccounts, - }); + const handleLaunchExplorer = useCallback((targetDb: DbInfo, targetAccount: CosmosDBAccount, explicitDbs?: DbInfo[]) => { + // Determine the list of available DBs. + // If explicitDbs is provided (e.g. from quick explore fetch), use it. + // Else if we are launching for the currently selected account (in the list), use the state accountDatabases. + // Otherwise fallback to just the target DB (which limits switching capabilities). + const dbsForExplorer = explicitDbs || + ((selectedAccountId === targetAccount.id && accountDatabases.length > 0) + ? accountDatabases + : [targetDb]); + + onNavigateToExplorer({ + resource: { accountId: targetAccount.id, databaseName: targetDb.name }, + dbInfo: targetDb, + accountName: targetAccount.name, + availableDbs: dbsForExplorer, + availableAccounts: azureAccounts, + }); + }, [onNavigateToExplorer, selectedAccountId, accountDatabases, azureAccounts]); + + const handleQuickExploreAccount = useCallback(async (account: CosmosDBAccount) => { + // If we already have the DBs for this account in state (because it's selected) + if (selectedAccountId === account.id && accountDatabases.length > 0) { + handleLaunchExplorer(accountDatabases[0], account, accountDatabases); + return; } - }; + + try { + const dbs = await getDatabasesForAccount(account.id); + if (dbs.length > 0) { + handleLaunchExplorer(dbs[0], account, dbs); + } else { + setError(`No databases found in '${account.name}'. Cannot launch Data Explorer.`); + } + } catch (e) { + console.error("Quick explorer failed", e); + setError(`Failed to load databases for '${account.name}'.`); + } + }, [selectedAccountId, accountDatabases, handleLaunchExplorer]); // --- Tutorial Demo Mode Logic --- const isDemoModeForCollectionStep = isTutorialActive && tutorialStepIndex === 2; @@ -898,7 +1015,7 @@ const QueryGeneratorPage: React.FC = ({ name, email, on const isDemoModeForContextActiveStep = isTutorialActive && tutorialStepIndex === 12; const isDemoModeForNotebookButtonStep = isTutorialActive && tutorialStepIndex === 13; const isDemoModeForNotebookPanelStep = isTutorialActive && tutorialStepIndex === 14; - + const demoActive = isDemoModeForCollectionStep || isDemoModeForResultsStep || isDemoModeForContextActiveStep || isDemoModeForDebugStep || isDemoModeForNotebookButtonStep || isDemoModeForNotebookPanelStep || isDemoModeForRunStep || isDemoModeForSaveStep || isDemoModeForSavedQueriesPanelStep; const isConnectedForRender = (connectedDbInfo && connectedResource) || demoActive; @@ -907,62 +1024,72 @@ const QueryGeneratorPage: React.FC = ({ name, email, on // --- Global Keyboard Shortcuts --- useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { - const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0; - const isModifier = isMac ? event.metaKey : event.ctrlKey; - - // Escape key to close modals/panels (topmost) - if (event.key === 'Escape') { - if (saveDialogState.isOpen) setSaveDialogState({ isOpen: false }); - else if (shareDialogState.isOpen) setShareDialogState({ isOpen: false }); - else if (isShortcutCheatsheetOpen) setIsShortcutCheatsheetOpen(false); - else if (isNotebookPanelOpen) setIsNotebookPanelOpen(false); - else if (isSavedQueriesPanelOpen) setIsSavedQueriesPanelOpen(false); - else if (isContextViewerOpen) setIsContextViewerOpen(false); - else if (isTutorialActive) setIsTutorialActive(false); // Also close tutorial - return; + const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0; + const isModifier = isMac ? event.metaKey : event.ctrlKey; + + // Escape key to close modals/panels (topmost) + if (event.key === 'Escape') { + if (saveDialogState.isOpen) setSaveDialogState({ isOpen: false }); + else if (shareDialogState.isOpen) setShareDialogState({ isOpen: false }); + else if (isShortcutCheatsheetOpen) setIsShortcutCheatsheetOpen(false); + else if (isNotebookPanelOpen) setIsNotebookPanelOpen(false); + else if (isSavedQueriesPanelOpen) setIsSavedQueriesPanelOpen(false); + else if (isContextViewerOpen) setIsContextViewerOpen(false); + else if (isTutorialActive) setIsTutorialActive(false); // Also close tutorial + return; + } + + // Prevent shortcuts from firing while typing in dialogs/modals + const isTypingInDialog = (event.target as HTMLElement).closest('[role="dialog"]'); + if (isTypingInDialog) return; + + if (isModifier) { + // Cmd/Ctrl + S to Save + if (event.key === 's') { + event.preventDefault(); + if (editableCode && !isQuerySectionDisabled) { + handleOpenSaveDialog(); + } } - - // Prevent shortcuts from firing while typing in dialogs/modals - const isTypingInDialog = (event.target as HTMLElement).closest('[role="dialog"]'); - if (isTypingInDialog) return; - - if (isModifier) { - // Cmd/Ctrl + S to Save - if (event.key === 's') { - event.preventDefault(); - if (editableCode && !isQuerySectionDisabled) { - handleOpenSaveDialog(); - } - } - - // Cmd/Ctrl + / to open shortcuts - if (event.key === '/') { - event.preventDefault(); - setIsShortcutCheatsheetOpen(true); - } + + // Cmd/Ctrl + / to open shortcuts + if (event.key === '/') { + event.preventDefault(); + setIsShortcutCheatsheetOpen(true); } + } }; window.addEventListener('keydown', handleKeyDown); return () => { - window.removeEventListener('keydown', handleKeyDown); + window.removeEventListener('keydown', handleKeyDown); }; }, [editableCode, isQuerySectionDisabled, handleOpenSaveDialog, saveDialogState.isOpen, shareDialogState.isOpen, isShortcutCheatsheetOpen, isNotebookPanelOpen, isSavedQueriesPanelOpen, isContextViewerOpen, isTutorialActive]); const dbInfoForRender = demoActive ? mockECommerceDbInfo : connectedDbInfo; const accountNameForRender = demoActive ? 'prod-ecommerce-db' : connectedAccountName; - const selectedCollectionForRender = isDemoModeForCollectionStep ? 'users' : selectedCollection; - const collectionInfoForRender = isDemoModeForCollectionStep ? mockCollectionInfoMap.get('users')! : collectionInfo; - const showCollectionPanel = isDemoModeForCollectionStep || isFetchingCollectionInfo || (collectionInfo && selectedCollection === collectionInfo.name) || collectionInfoError; - + const selectedCollectionsForRender = isDemoModeForCollectionStep ? ['users'] : selectedCollections; + + // For demo/tutorial purposes, ensure appropriate data is in the map if simulated + const collectionDetailsMapForRender = useMemo(() => { + if (isDemoModeForCollectionStep) { + return { 'users': mockCollectionInfoMap.get('users')! }; + } + return collectionDetailsMap; + }, [isDemoModeForCollectionStep, collectionDetailsMap]); + + const isPromptUnchanged = userInput.trim() === lastSuccessfulPrompt.trim() && !!editableCode; + const generateButtonText = useMemo(() => { if (isLoading) return 'Generating...'; - if (selectedCollection) { - return `Generate Query for ${selectedCollection} collection`; + if (isPromptUnchanged) return 'Query Generated'; + if (selectedCollections.length > 0) { + if (selectedCollections.length === 1) return `Generate Query for ${selectedCollections[0]} collection`; + return `Generate Query across ${selectedCollections.length} collections`; } return 'Generate Query'; - }, [isLoading, selectedCollection]); + }, [isLoading, selectedCollections, isPromptUnchanged]); // --- Demo Mode Context Banner --- const showContextBanner = intermediateContext || isDemoModeForContextActiveStep; @@ -997,12 +1124,12 @@ const QueryGeneratorPage: React.FC = ({ name, email, on , document.body ) : null; - + // --- Demo Mode Notebook Panel --- const showNotebookPanel = isNotebookPanelOpen || isDemoModeForNotebookPanelStep; const demoNotebookSteps: NotebookStep[] = [ - { id: 'demo-1', type: 'query', prompt: 'Find all users from Canada', query: "db['users'].find({'country': 'Canada'})", resultSample: mockUserFindResult.slice(0, 1) }, - { id: 'demo-2', type: 'query', prompt: 'From those users, find the ones named Alice', query: "db['users'].find({'name': 'Alice'})", resultSample: mockUserFindResult.slice(0, 1), contextSource: "'users' collection" }, + { id: 'demo-1', type: 'query', prompt: 'Find all users from Canada', query: "db['users'].find({'country': 'Canada'})", resultSample: mockUserFindResult.slice(0, 1) }, + { id: 'demo-2', type: 'query', prompt: 'From those users, find the ones named Alice', query: "db['users'].find({'name': 'Alice'})", resultSample: mockUserFindResult.slice(0, 1), contextSource: "'users' collection" }, ]; const notebookPanelDrawer = showNotebookPanel ? createPortal( @@ -1022,64 +1149,62 @@ const QueryGeneratorPage: React.FC = ({ name, email, on const showSavedQueriesPanel = isSavedQueriesPanelOpen || isDemoModeForSavedQueriesPanelStep; const savedQueriesPanelDrawer = showSavedQueriesPanel ? createPortal( setIsSavedQueriesPanelOpen(false)} - queries={isDemoModeForSavedQueriesPanelStep ? mockSavedQueries : savedQueries} - onLoad={handleLoadSavedQuery} - onEdit={handleEditSavedQuery} - onDelete={handleDeleteSavedQuery} - onShare={handleOpenShareDialog} - isLoading={isDemoModeForSavedQueriesPanelStep ? false : isLoadingSavedQueries} - currentUserEmail={email || 'dev.user@example.com'} + onClose={() => setIsSavedQueriesPanelOpen(false)} + queries={isDemoModeForSavedQueriesPanelStep ? mockSavedQueries : savedQueries} + onLoad={handleLoadSavedQuery} + onEdit={handleEditSavedQuery} + onDelete={handleDeleteSavedQuery} + onShare={handleOpenShareDialog} + isLoading={isDemoModeForSavedQueriesPanelStep ? false : isLoadingSavedQueries} + currentUserEmail={email || 'dev.user@example.com'} />, document.body ) : null; const saveQueryDialog = saveDialogState.isOpen ? createPortal( setSaveDialogState({ isOpen: false })} - onSave={handleSaveOrUpdateQuery} - isSaving={isSavingQuery} - initialData={saveDialogState.data!} + isOpen={saveDialogState.isOpen} + onClose={() => setSaveDialogState({ isOpen: false })} + onSave={handleSaveOrUpdateQuery} + isSaving={isSavingQuery} + initialData={saveDialogState.data!} />, document.body ) : null; const shareQueryDialog = shareDialogState.isOpen && shareDialogState.query ? createPortal( setShareDialogState({ isOpen: false })} - onSave={handleUpdateSharing} - query={shareDialogState.query} + isOpen={shareDialogState.isOpen} + onClose={() => setShareDialogState({ isOpen: false })} + onSave={handleUpdateSharing} + query={shareDialogState.query} />, document.body ) : null; const shortcutCheatsheet = createPortal( - setIsShortcutCheatsheetOpen(false)} - />, - document.body + setIsShortcutCheatsheetOpen(false)} + />, + document.body ); return (
- - setIsTutorialActive(true)} - onShowSavedQueries={() => setIsSavedQueriesPanelOpen(true)} - onShowShortcuts={() => setIsShortcutCheatsheetOpen(true)} - onNavigateToExplorer={handleNavigateToExplorer} - isExplorerNavEnabled={!!connectedResource} - isUserMenuForcedOpen={isUserMenuOpenForTutorial} + + setIsTutorialActive(true)} + onShowSavedQueries={() => setIsSavedQueriesPanelOpen(true)} + onShowShortcuts={() => setIsShortcutCheatsheetOpen(true)} + isUserMenuForcedOpen={isUserMenuOpenForTutorial} />
@@ -1094,13 +1219,23 @@ const QueryGeneratorPage: React.FC = ({ name, email, on Connected to: {accountNameForRender} / {dbInfoForRender.name}

- +
+ + +
@@ -1112,95 +1247,186 @@ const QueryGeneratorPage: React.FC = ({ name, email, on

{dbInfoForRender.size ?? 'N/A'}

-

Collections

-
- {dbInfoForRender.collections.map(col => ( - - ))} -
+ {col.name} + + ))} +
- {/* Collection Action Panel */} - {showCollectionPanel && ( -
- {isFetchingCollectionInfo && !isDemoModeForCollectionStep &&
Fetching collection details...
} - {collectionInfoError && !isDemoModeForCollectionStep && ( -
- {collectionInfoError} -
- )} - {collectionInfoForRender && selectedCollectionForRender === collectionInfoForRender.name && ( - { setSelectedCollection(null); setCollectionInfo(null); }} - /> - )} + + {/* Multi-Collection Analysis Panel */} + {selectedCollectionsForRender.length > 1 && ( +
+
+

+ 🔗 Schema Connections +

+
+ ✨ AI Inferred +
- )} + +

+ Note: These connections are inferred by AI based on the schema and may not reflect strict foreign key constraints. +

+ + {(isAnalyzingRelationships || !relationships) && !relationshipError && ( +
+
+ Searching for connections... +
+ )} + + {relationshipError && ( +
+ {relationshipError} +
+ )} + + {relationships && !isAnalyzingRelationships && ( +
+ +
+ )} +
+ )} + + {/* Collection Action Panel (Single or Stacked) */} + {selectedCollectionsForRender.length > 0 && ( +
+ {/* Error Display */} + {collectionInfoError && ( +
+ {collectionInfoError} +
+ )} + + {selectedCollectionsForRender.map(colName => { + const info = collectionDetailsMapForRender[colName]; + const isLoading = loadingCollections[colName]; + const isExpanded = expandedCollectionSchemas[colName] || (selectedCollectionsForRender.length === 1); // Default expanded if single + + return ( +
+ + + {isExpanded && ( +
+ {isLoading ? ( +
Loading details...
+ ) : info ? ( + { + // Deselect this collection + setSelectedCollections(prev => prev.filter(c => c !== colName)); + }} + /> + ) : ( +
Failed to load details.
+ )} +
+ )} +
+ ); + })} +
+ )}
) : (
-

Select a Database to Connect

- {dbError && !isLoadingAccounts && !isLoadingDatabases && ( -

{dbError}

- )} +

Select a Database to Connect

+ {dbError && !isLoadingAccounts && !isLoadingDatabases && ( +

{dbError}

+ )} {isLoadingAccounts ? ( -
Loading your Azure accounts...
+
Loading your Azure accounts...
) : !dbError && azureAccounts.length === 0 ? ( -
- No accessible Cosmos DB accounts found. -
- Ensure your account has Reader permissions on the resources. -
+
+ No accessible Cosmos DB accounts found. +
+ Ensure your account has Reader permissions on the resources. +
) : ( -
- {azureAccounts.map(account => ( -
- - {selectedAccountId === account.id && ( -
- {isLoadingDatabases ? ( -
Loading databases...
- ): dbError ? ( -
- {dbError} -
- ) : accountDatabases.length > 0 ? ( -
- {accountDatabases.map(db => ( - - ))} -
- ) : ( -

No databases found in this account.

- )} -
- )} -
- ))} -
+
+ {azureAccounts.map(account => ( +
+
+ + +
+ {selectedAccountId === account.id && ( +
+ {isLoadingDatabases ? ( +
Loading databases...
+ ) : dbError ? ( +
+ {dbError} +
+ ) : accountDatabases.length > 0 ? ( +
+ {accountDatabases.map(db => ( + + ))} +
+ ) : ( +

No databases found in this account.

+ )} +
+ )} +
+ ))} +
)}
)} @@ -1209,25 +1435,25 @@ const QueryGeneratorPage: React.FC = ({ name, email, on {/* Query Generator */}
{showContextBanner && contextForRender && ( -
-
-
- -
-

Query Context Active

-

- Using results from {contextForRender.source} ({Array.isArray(contextForRender.data) ? contextForRender.data.length : 1} items) as context for the next query. -

- -
-
- +
+
+
+ +
+

Query Context Active

+

+ Using results from {contextForRender.source} ({Array.isArray(contextForRender.data) ? contextForRender.data.length : 1} items) as context for the next query. +

+
+
+
+
)} - +
-
-

- Query Output -

- -
+
+

+ Query Output +

+ +
{isLoading && !isDemoModeForDebugStep && !isDemoModeForResultsStep && !isDemoModeForRunStep && } - + {error && !isDemoModeForDebugStep && !isDemoModeForResultsStep && !isDemoModeForRunStep && ( -
- Error: - {error} -
+
+ Error: + {error} +
)} {/* Tutorial Demo for Run, Edit & Save Steps */} @@ -1288,13 +1514,13 @@ const QueryGeneratorPage: React.FC = ({ name, email, on
{}} - onRunQuery={() => {}} - onSaveQuery={() => {}} + onCodeChange={() => { }} + onRunQuery={() => { }} + onSaveQuery={() => { }} isExecuting={false} historyCount={1} historyIndex={0} - onNavigateHistory={() => {}} + onNavigateHistory={() => { }} />
)} @@ -1304,26 +1530,26 @@ const QueryGeneratorPage: React.FC = ({ name, email, on
{}} - onRunQuery={() => {}} - onSaveQuery={() => {}} + onCodeChange={() => { }} + onRunQuery={() => { }} + onSaveQuery={() => { }} isExecuting={false} historyCount={1} historyIndex={0} - onNavigateHistory={() => {}} + onNavigateHistory={() => { }} /> {}} + onDebug={() => { }} isDebugging={false} debuggingResult={null} debugError={null} sourceCollection={'users'} - onSetIntermediateContext={() => {}} + onSetIntermediateContext={() => { }} intermediateContext={null} - onAnalyze={() => {}} + onAnalyze={() => { }} isAnalyzing={false} analysisResult={null} analysisError={null} @@ -1332,32 +1558,32 @@ const QueryGeneratorPage: React.FC = ({ name, email, on />
)} - + {/* Tutorial Demo for Debug View */} {isDemoModeForDebugStep && (
{}} - onRunQuery={() => {}} - onSaveQuery={() => {}} + onCodeChange={() => { }} + onRunQuery={() => { }} + onSaveQuery={() => { }} isExecuting={false} historyCount={1} historyIndex={0} - onNavigateHistory={() => {}} + onNavigateHistory={() => { }} /> {}} + onDebug={() => { }} isDebugging={false} debuggingResult={null} debugError={null} sourceCollection={'users'} - onSetIntermediateContext={() => {}} + onSetIntermediateContext={() => { }} intermediateContext={null} - onAnalyze={() => {}} + onAnalyze={() => { }} isAnalyzing={false} analysisResult={null} analysisError={null} @@ -1370,46 +1596,46 @@ const QueryGeneratorPage: React.FC = ({ name, email, on {/* Real results */} {(!isLoading && !error && !isDemoModeForResultsStep && !isDemoModeForDebugStep && !isDemoModeForContextActiveStep && !isDemoModeForRunStep && !isDemoModeForSaveStep) && (
- {editableCode ? ( - <> - - - - ) : ( -
-

{isQuerySectionDisabled ? 'Connect to a database to generate queries.' : 'Your generated query will appear here.'}

-
- )} + {editableCode ? ( + <> + + + + ) : ( +
+

{isQuerySectionDisabled ? 'Connect to a database to generate queries.' : 'Your generated query will appear here.'}

+
+ )}
)}
- +

Powered by Microsoft Azure and Google Gemini. For internal use only.

@@ -1417,15 +1643,15 @@ const QueryGeneratorPage: React.FC = ({ name, email, on

- + { - setIsTutorialActive(false); - localStorage.setItem('hasSeenTutorial', 'true'); + setIsTutorialActive(false); + localStorage.setItem('hasSeenTutorial', 'true'); }} - /> + /> {contextViewerDrawer} {notebookPanelDrawer} @@ -1434,7 +1660,7 @@ const QueryGeneratorPage: React.FC = ({ name, email, on {shareQueryDialog} {shortcutCheatsheet} -