From 104bf7d3ed55802841dc43b2d902ce1c7933c5fa Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 5 Oct 2026 14:51:50 +0300 Subject: [PATCH] feat(mongodb): MQL Query & Shell Console (#1030) --- .../mongodb/mongo_collections_view.dart | 14 + .../mongodb/mongo_documents_view.dart | 14 + lib/features/mongodb/mongo_explorer_view.dart | 34 +- .../mongodb/mongo_query_executor.dart | 314 ++++++++ .../mongodb/mongo_query_workspace.dart | 545 ++++++++++++++ lib/features/mongodb/mql_parser.dart | 681 ++++++++++++++++++ .../mongodb/mongo_query_workspace_test.dart | 226 ++++++ test/features/mongodb/mql_parser_test.dart | 123 ++++ 8 files changed, 1949 insertions(+), 2 deletions(-) create mode 100644 lib/features/mongodb/mongo_query_executor.dart create mode 100644 lib/features/mongodb/mongo_query_workspace.dart create mode 100644 lib/features/mongodb/mql_parser.dart create mode 100644 test/features/mongodb/mongo_query_workspace_test.dart create mode 100644 test/features/mongodb/mql_parser_test.dart diff --git a/lib/features/mongodb/mongo_collections_view.dart b/lib/features/mongodb/mongo_collections_view.dart index 8f768703..0c4078d2 100644 --- a/lib/features/mongodb/mongo_collections_view.dart +++ b/lib/features/mongodb/mongo_collections_view.dart @@ -17,12 +17,14 @@ class MongoCollectionsView extends material.StatefulWidget { required this.connection, required this.database, this.onCollectionTap, + this.onOpenQueryConsole, this.refreshToken = 0, }); final MongoConnection connection; final String database; final ValueChanged? onCollectionTap; + final material.VoidCallback? onOpenQueryConsole; /// Incremented by the parent when the user requests a refresh (toolbar). final int refreshToken; @@ -302,6 +304,18 @@ class _MongoCollectionsViewState extends material.State { ], ), ), + if (widget.onOpenQueryConsole != null) ...[ + OutlineButton( + onPressed: widget.onOpenQueryConsole, + size: ButtonSize.small, + leading: const material.Icon( + material.Icons.terminal_rounded, + size: 14, + ), + child: const Text('MQL Console'), + ), + const Gap(8), + ], material.SizedBox( width: 180, child: TextField( diff --git a/lib/features/mongodb/mongo_documents_view.dart b/lib/features/mongodb/mongo_documents_view.dart index f269384b..e8c3ca91 100644 --- a/lib/features/mongodb/mongo_documents_view.dart +++ b/lib/features/mongodb/mongo_documents_view.dart @@ -47,6 +47,7 @@ class MongoDocumentsView extends material.StatefulWidget { this.onDocumentTap, this.onOpenAggregation, this.onOpenIndexes, + this.onOpenQueryConsole, this.refreshToken = 0, this.initialViewMode, }); @@ -57,6 +58,7 @@ class MongoDocumentsView extends material.StatefulWidget { final ValueChanged>? onDocumentTap; final material.VoidCallback? onOpenAggregation; final material.VoidCallback? onOpenIndexes; + final material.VoidCallback? onOpenQueryConsole; /// Incremented by the parent when the user requests a refresh (toolbar). final int refreshToken; @@ -552,6 +554,18 @@ class _MongoDocumentsViewState extends material.State { child: const Text('Indexes'), ), ], + if (widget.onOpenQueryConsole != null) ...[ + const Gap(8), + OutlineButton( + onPressed: widget.onOpenQueryConsole, + size: ButtonSize.small, + leading: const material.Icon( + material.Icons.terminal_rounded, + size: 14, + ), + child: const Text('Console'), + ), + ], const Gap(12), PrimaryButton( onPressed: _addDocument, diff --git a/lib/features/mongodb/mongo_explorer_view.dart b/lib/features/mongodb/mongo_explorer_view.dart index f9b1a362..0a82e695 100644 --- a/lib/features/mongodb/mongo_explorer_view.dart +++ b/lib/features/mongodb/mongo_explorer_view.dart @@ -15,6 +15,7 @@ import 'mongo_databases_view.dart'; import 'mongo_documents_view.dart'; import 'mongo_document_editor.dart'; import 'mongo_indexes_view.dart'; +import 'mongo_query_workspace.dart'; import 'mongo_stats_view.dart'; // ─── Navigation path model ────────────────────────────────────────────────── @@ -34,6 +35,7 @@ enum _Level { stats, aggregation, indexes, + queryConsole, } // ─── Main explorer widget ─────────────────────────────────────────────────── @@ -64,6 +66,7 @@ class _MongoExplorerViewState extends material.State { bool _showStats = false; bool _showAggregation = false; bool _showIndexes = false; + bool _showQueryConsole = false; /// Bumped when the user taps Refresh in the breadcrumb bar (reload active view). int _refreshToken = 0; @@ -169,6 +172,7 @@ class _MongoExplorerViewState extends material.State { _selectedDocument = null; _showAggregation = false; _showIndexes = false; + _showQueryConsole = false; }); } @@ -179,6 +183,7 @@ class _MongoExplorerViewState extends material.State { _selectedDocument = null; _showAggregation = false; _showIndexes = false; + _showQueryConsole = false; }); } @@ -187,6 +192,7 @@ class _MongoExplorerViewState extends material.State { _selectedDocument = doc; _showAggregation = false; _showIndexes = false; + _showQueryConsole = false; }); } @@ -197,6 +203,7 @@ class _MongoExplorerViewState extends material.State { _selectedDocument = null; _showAggregation = false; _showIndexes = false; + _showQueryConsole = false; }); } @@ -206,6 +213,7 @@ class _MongoExplorerViewState extends material.State { _selectedDocument = null; _showAggregation = false; _showIndexes = false; + _showQueryConsole = false; }); } @@ -214,6 +222,7 @@ class _MongoExplorerViewState extends material.State { _selectedDocument = null; _showAggregation = false; _showIndexes = false; + _showQueryConsole = false; }); } @@ -233,7 +242,9 @@ class _MongoExplorerViewState extends material.State { final id = _selectedDocument!['_id']?.toString() ?? 'Document'; list.add(_Crumb(id, _Level.document)); } - if (_showIndexes) { + if (_showQueryConsole) { + list.add(const _Crumb('MQL Console', _Level.queryConsole)); + } else if (_showIndexes) { list.add(const _Crumb('Indexes', _Level.indexes)); } else if (_showAggregation) { list.add(const _Crumb('Aggregation', _Level.aggregation)); @@ -248,7 +259,8 @@ class _MongoExplorerViewState extends material.State { crumb.level != _Level.document && crumb.level != _Level.stats && crumb.level != _Level.aggregation && - crumb.level != _Level.indexes; + crumb.level != _Level.indexes && + crumb.level != _Level.queryConsole; if (leavesDocument) { if (!await confirmDiscardUnsavedWorkIfNeeded(context)) return; if (!mounted) return; @@ -262,6 +274,9 @@ class _MongoExplorerViewState extends material.State { if (_showIndexes && crumb.level != _Level.indexes) { setState(() => _showIndexes = false); } + if (_showQueryConsole && crumb.level != _Level.queryConsole) { + setState(() => _showQueryConsole = false); + } switch (crumb.level) { case _Level.databases: _navigateToDatabases(); @@ -277,6 +292,8 @@ class _MongoExplorerViewState extends material.State { break; case _Level.indexes: break; + case _Level.queryConsole: + break; } } @@ -428,6 +445,17 @@ class _MongoExplorerViewState extends material.State { ); } + // Query Console workspace + if (_showQueryConsole && _selectedDatabase != null) { + return MongoQueryWorkspace( + key: ValueKey('mql_${_selectedDatabase}_$_selectedCollection'), + connection: conn, + database: _selectedDatabase!, + initialCollection: _selectedCollection, + onBack: () => setState(() => _showQueryConsole = false), + ); + } + // Documents list if (_selectedCollection != null && _selectedDatabase != null) { return MongoDocumentsView( @@ -439,6 +467,7 @@ class _MongoExplorerViewState extends material.State { onDocumentTap: _navigateToDocument, onOpenAggregation: () => setState(() => _showAggregation = true), onOpenIndexes: () => setState(() => _showIndexes = true), + onOpenQueryConsole: () => setState(() => _showQueryConsole = true), ); } @@ -450,6 +479,7 @@ class _MongoExplorerViewState extends material.State { database: _selectedDatabase!, refreshToken: _refreshToken, onCollectionTap: _navigateToCollection, + onOpenQueryConsole: () => setState(() => _showQueryConsole = true), ); } diff --git a/lib/features/mongodb/mongo_query_executor.dart b/lib/features/mongodb/mongo_query_executor.dart new file mode 100644 index 00000000..1c1a2b3f --- /dev/null +++ b/lib/features/mongodb/mongo_query_executor.dart @@ -0,0 +1,314 @@ +import 'package:querya_desktop/core/database/mongodb_connection.dart'; +import 'package:querya_desktop/core/database/mongodb_service.dart'; +import 'package:querya_desktop/features/mongodb/mql_parser.dart'; + +/// Result of executing an MQL statement. +class MongoQueryResult { + const MongoQueryResult({ + required this.documents, + required this.elapsed, + required this.summary, + this.isMutation = false, + }); + + final List> documents; + final Duration elapsed; + final String summary; + final bool isMutation; + + int get count => documents.length; +} + +/// Abstract contract for executing parsed [MqlCommand]s against MongoDB. +abstract class MongoQueryExecutor { + Future execute({ + required MongoConnection connection, + required String database, + required MqlCommand command, + String? defaultCollection, + }); +} + +/// Production implementation of [MongoQueryExecutor] delegating to [MongoService]. +class DefaultMongoQueryExecutor implements MongoQueryExecutor { + const DefaultMongoQueryExecutor({MongoService? service}) + : _service = service; + + final MongoService? _service; + + MongoService get _effectiveService => _service ?? MongoService.instance; + + @override + Future execute({ + required MongoConnection connection, + required String database, + required MqlCommand command, + String? defaultCollection, + }) async { + final coll = command.collection ?? defaultCollection; + final stopwatch = Stopwatch()..start(); + + switch (command.method) { + case MqlMethod.find: + if (coll == null || coll.isEmpty) { + throw const FormatException('find() requires a target collection'); + } + final docs = await _effectiveService.find( + connection, + database, + coll, + filter: command.filter, + sort: command.sort, + limit: command.limit ?? 100, + skip: command.skip, + ); + stopwatch.stop(); + return MongoQueryResult( + documents: docs, + elapsed: stopwatch.elapsed, + summary: 'Fetched ${docs.length} documents', + ); + + case MqlMethod.findOne: + if (coll == null || coll.isEmpty) { + throw const FormatException('findOne() requires a target collection'); + } + final docs = await _effectiveService.find( + connection, + database, + coll, + filter: command.filter, + limit: 1, + ); + stopwatch.stop(); + return MongoQueryResult( + documents: docs, + elapsed: stopwatch.elapsed, + summary: docs.isEmpty ? 'No document found' : 'Fetched 1 document', + ); + + case MqlMethod.count: + case MqlMethod.countDocuments: + if (coll == null || coll.isEmpty) { + throw const FormatException('count() requires a target collection'); + } + final count = await _effectiveService.countDocuments( + connection, + database, + coll, + filter: command.filter, + ); + stopwatch.stop(); + return MongoQueryResult( + documents: [{'count': count}], + elapsed: stopwatch.elapsed, + summary: 'Count: $count', + ); + + case MqlMethod.aggregate: + if (coll == null || coll.isEmpty) { + throw const FormatException('aggregate() requires a target collection'); + } + final docs = await _effectiveService.aggregate( + connection, + database, + coll, + command.pipeline ?? [], + ); + stopwatch.stop(); + return MongoQueryResult( + documents: docs, + elapsed: stopwatch.elapsed, + summary: 'Pipeline returned ${docs.length} documents', + ); + + case MqlMethod.insertOne: + if (coll == null || coll.isEmpty) { + throw const FormatException('insertOne() requires a target collection'); + } + final inserted = await _effectiveService.insertDocument( + connection, + database, + coll, + command.document ?? {}, + ); + stopwatch.stop(); + return MongoQueryResult( + documents: [inserted], + elapsed: stopwatch.elapsed, + summary: 'Inserted 1 document', + isMutation: true, + ); + + case MqlMethod.insertMany: + if (coll == null || coll.isEmpty) { + throw const FormatException('insertMany() requires a target collection'); + } + final list = command.documents ?? []; + final results = >[]; + for (final doc in list) { + final res = await _effectiveService.insertDocument( + connection, + database, + coll, + doc, + ); + results.add(res); + } + stopwatch.stop(); + return MongoQueryResult( + documents: results, + elapsed: stopwatch.elapsed, + summary: 'Inserted ${results.length} documents', + isMutation: true, + ); + + case MqlMethod.updateOne: + if (coll == null || coll.isEmpty) { + throw const FormatException('updateOne() requires a target collection'); + } + await _effectiveService.updateDocument( + connection, + database, + coll, + command.filter ?? {}, + command.update ?? {}, + ); + stopwatch.stop(); + return MongoQueryResult( + documents: [{'acknowledged': true, 'ok': 1}], + elapsed: stopwatch.elapsed, + summary: 'Updated successfully', + isMutation: true, + ); + + case MqlMethod.updateMany: + if (coll == null || coll.isEmpty) { + throw const FormatException('updateMany() requires a target collection'); + } + final res = await _effectiveService.executeCommand( + connection, + database, + { + 'update': coll, + 'updates': [ + { + 'q': command.filter ?? {}, + 'u': command.update ?? {}, + 'multi': true, + 'upsert': command.upsert, + }, + ], + }, + ); + stopwatch.stop(); + final n = res['nModified'] ?? res['n'] ?? 0; + return MongoQueryResult( + documents: [res], + elapsed: stopwatch.elapsed, + summary: 'Updated $n documents', + isMutation: true, + ); + + case MqlMethod.deleteOne: + if (coll == null || coll.isEmpty) { + throw const FormatException('deleteOne() requires a target collection'); + } + await _effectiveService.deleteDocument( + connection, + database, + coll, + command.filter ?? {}, + ); + stopwatch.stop(); + return MongoQueryResult( + documents: [{'acknowledged': true, 'deletedCount': 1}], + elapsed: stopwatch.elapsed, + summary: 'Deleted 1 document', + isMutation: true, + ); + + case MqlMethod.deleteMany: + if (coll == null || coll.isEmpty) { + throw const FormatException('deleteMany() requires a target collection'); + } + final res = await _effectiveService.executeCommand( + connection, + database, + { + 'delete': coll, + 'deletes': [ + {'q': command.filter ?? {}, 'limit': 0}, + ], + }, + ); + stopwatch.stop(); + final n = res['n'] ?? 0; + return MongoQueryResult( + documents: [res], + elapsed: stopwatch.elapsed, + summary: 'Deleted $n documents', + isMutation: true, + ); + + case MqlMethod.drop: + if (coll == null || coll.isEmpty) { + throw const FormatException('drop() requires a target collection'); + } + await _effectiveService.dropCollection( + connection, + database, + coll, + ); + stopwatch.stop(); + return MongoQueryResult( + documents: [{'dropped': coll, 'ok': 1}], + elapsed: stopwatch.elapsed, + summary: 'Collection "$coll" dropped', + isMutation: true, + ); + + case MqlMethod.runCommand: + final res = await _effectiveService.executeCommand( + connection, + database, + command.commandMap ?? {}, + ); + stopwatch.stop(); + return MongoQueryResult( + documents: [res], + elapsed: stopwatch.elapsed, + summary: 'Command executed in ${stopwatch.elapsedMilliseconds}ms', + ); + + case MqlMethod.getCollectionNames: + final names = await connection.listCollections(database); + stopwatch.stop(); + final docs = names.map((n) => {'name': n}).toList(); + return MongoQueryResult( + documents: docs, + elapsed: stopwatch.elapsed, + summary: 'Found ${docs.length} collections', + ); + + case MqlMethod.stats: + if (coll == null || coll.isEmpty) { + throw const FormatException('stats() requires a target collection'); + } + final stats = await _effectiveService.getCollectionStats( + connection, + database, + coll, + ); + stopwatch.stop(); + return MongoQueryResult( + documents: [stats], + elapsed: stopwatch.elapsed, + summary: 'Collection stats retrieved', + ); + + case MqlMethod.unknown: + throw const FormatException('Unknown or unsupported MQL method'); + } + } +} diff --git a/lib/features/mongodb/mongo_query_workspace.dart b/lib/features/mongodb/mongo_query_workspace.dart new file mode 100644 index 00000000..c57ef9c4 --- /dev/null +++ b/lib/features/mongodb/mongo_query_workspace.dart @@ -0,0 +1,545 @@ +import 'dart:async' show unawaited; + +import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart'; +import 'package:querya_desktop/core/database/mongodb_connection.dart'; +import 'package:querya_desktop/core/editor/querya_code_editor.dart'; +import 'package:querya_desktop/core/editor/querya_code_language.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/mongodb/mongo_documents_json_view.dart'; +import 'package:querya_desktop/features/mongodb/mongo_documents_table_view.dart'; +import 'package:querya_desktop/features/mongodb/mongo_query_executor.dart'; +import 'package:querya_desktop/features/mongodb/mql_parser.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +enum _ResultViewMode { + grid, + json, +} + +class _QueryTabSession { + _QueryTabSession({ + required this.id, + required this.title, + String initialQuery = '', + }) : controller = material.TextEditingController(text: initialQuery), + focusNode = material.FocusNode(); + + final String id; + String title; + final material.TextEditingController controller; + final material.FocusNode focusNode; + + List> results = []; + Duration? elapsed; + String? summary; + String? error; + bool isLoading = false; + _ResultViewMode viewMode = _ResultViewMode.grid; + + void dispose() { + controller.dispose(); + focusNode.dispose(); + } +} + +/// Interactive multi-tab MQL (MongoDB Query Language) and Shell Console workspace. +class MongoQueryWorkspace extends material.StatefulWidget { + const MongoQueryWorkspace({ + super.key, + required this.connection, + required this.database, + this.initialCollection, + this.onBack, + this.executor, + this.initialQueries, + }); + + final MongoConnection connection; + final String database; + final String? initialCollection; + final material.VoidCallback? onBack; + final MongoQueryExecutor? executor; + final List? initialQueries; + + @override + material.State createState() => + _MongoQueryWorkspaceState(); +} + +class _MongoQueryWorkspaceState extends material.State { + final List<_QueryTabSession> _tabs = []; + int _activeTabIndex = 0; + int _tabCounter = 1; + late final MongoQueryExecutor _executor; + + _QueryTabSession get _activeTab => _tabs[_activeTabIndex]; + + @override + void initState() { + super.initState(); + _executor = widget.executor ?? const DefaultMongoQueryExecutor(); + + if (widget.initialQueries != null && widget.initialQueries!.isNotEmpty) { + for (final q in widget.initialQueries!) { + _tabs.add(_QueryTabSession( + id: 'tab_${_tabCounter++}', + title: 'Query ${_tabs.length + 1}', + initialQuery: q, + )); + } + } else { + final defaultQuery = widget.initialCollection != null + ? 'db.${widget.initialCollection}.find({})' + : 'db.runCommand({ ping: 1 })'; + _tabs.add(_QueryTabSession( + id: 'tab_${_tabCounter++}', + title: 'Query 1', + initialQuery: defaultQuery, + )); + } + } + + @override + void dispose() { + for (final tab in _tabs) { + tab.dispose(); + } + super.dispose(); + } + + void _addNewTab() { + setState(() { + final num = ++_tabCounter; + final defaultQuery = widget.initialCollection != null + ? 'db.${widget.initialCollection}.find({})' + : 'db.runCommand({ ping: 1 })'; + _tabs.add(_QueryTabSession( + id: 'tab_$num', + title: 'Query ${_tabs.length + 1}', + initialQuery: defaultQuery, + )); + _activeTabIndex = _tabs.length - 1; + }); + } + + void _closeTab(int index) { + if (_tabs.length <= 1) return; + setState(() { + final removed = _tabs.removeAt(index); + removed.dispose(); + if (_activeTabIndex >= _tabs.length) { + _activeTabIndex = _tabs.length - 1; + } + }); + } + + Future _executeActiveTab() async { + final tab = _activeTab; + if (tab.isLoading) return; + + final query = tab.controller.text.trim(); + if (query.isEmpty) { + setState(() { + tab.error = 'Query cannot be empty'; + tab.results = []; + tab.summary = null; + }); + return; + } + + setState(() { + tab.isLoading = true; + tab.error = null; + }); + + try { + final parsedCommand = MqlParser.parse( + query, + defaultCollection: widget.initialCollection, + ); + + final result = await _executor.execute( + connection: widget.connection, + database: widget.database, + command: parsedCommand, + defaultCollection: widget.initialCollection, + ); + + if (!mounted) return; + + setState(() { + tab.isLoading = false; + tab.results = result.documents; + tab.elapsed = result.elapsed; + tab.summary = result.summary; + tab.error = null; + }); + + // Persist to query history + unawaited(_saveHistory(query)); + } catch (e) { + if (!mounted) return; + setState(() { + tab.isLoading = false; + tab.error = e.toString(); + tab.results = []; + tab.summary = null; + }); + } + } + + Future _saveHistory(String query) async { + final connId = widget.connection.id; + try { + await LocalDb.instance.recordSqlQueryHistory( + connectionId: connId, + databaseName: widget.database, + sqlText: query, + ); + } catch (_) { + // Best-effort history logging + } + } + + Future _showHistory() async { + final connId = widget.connection.id; + + final history = await LocalDb.instance.listSqlQueryHistory( + connectionId: connId, + databaseName: widget.database, + limit: 30, + ); + + if (!mounted) return; + + await showAppDialog( + context: context, + builder: (ctx) { + final cs = Theme.of(ctx).colorScheme; + return QueryaModalDialog( + title: const Text('MQL Query History'), + description: Text('${widget.database} — recent queries'), + constraints: const material.BoxConstraints(maxWidth: 600, maxHeight: 500), + content: history.isEmpty + ? const material.Padding( + padding: material.EdgeInsets.all(24), + child: material.Center( + child: Text('No query history recorded yet'), + ), + ) + : material.ListView.separated( + shrinkWrap: true, + itemCount: history.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (context, i) { + final item = history[i]; + return material.ListTile( + dense: true, + title: material.Text( + item.sqlText, + maxLines: 2, + overflow: material.TextOverflow.ellipsis, + style: const material.TextStyle( + fontFamily: 'monospace', + fontSize: 12, + ), + ), + subtitle: material.Text( + item.recordedAt, + style: material.TextStyle( + fontSize: 11, + color: cs.mutedForeground, + ), + ), + trailing: const material.Icon( + material.Icons.arrow_forward_ios_rounded, + size: 12, + ), + onTap: () { + setState(() { + _activeTab.controller.text = item.sqlText; + }); + material.Navigator.of(ctx).pop(); + }, + ); + }, + ), + actions: [ + OutlineButton( + onPressed: () => material.Navigator.of(ctx).pop(), + child: const Text('Close'), + ), + ], + ); + }, + ); + } + + // ─── Build ───────────────────────────────────────────────────────────────── + + @override + material.Widget build(material.BuildContext context) { + final theme = Theme.of(context); + final cs = theme.colorScheme; + final tab = _activeTab; + + return material.CallbackShortcuts( + bindings: { + const material.SingleActivator( + LogicalKeyboardKey.enter, + control: true, + ): _executeActiveTab, + const material.SingleActivator( + LogicalKeyboardKey.enter, + meta: true, + ): _executeActiveTab, + }, + child: material.Container( + color: cs.background, + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + // Top action bar + _buildTopBar(cs), + const Divider(height: 1), + // Tab strip + _buildTabStrip(cs), + const Divider(height: 1), + // Main content split + material.Expanded( + child: material.Column( + children: [ + // Code editor panel (top half) + material.Expanded( + flex: 4, + child: material.Container( + color: cs.card, + child: QueryaCodeEditor( + key: ValueKey('editor_${tab.id}'), + controller: tab.controller, + language: QueryaCodeLanguage.json, + variant: QueryaCodeEditorVariant.material, + focusNode: tab.focusNode, + ), + ), + ), + const Divider(height: 1), + // Result bar & timing stats + _buildResultBar(cs, tab), + const Divider(height: 1), + // Results view (bottom half) + material.Expanded( + flex: 6, + child: _buildResultsView(cs, tab), + ), + ], + ), + ), + ], + ), + ), + ); + } + + material.Widget _buildTopBar(ColorScheme cs) { + return material.Container( + padding: const material.EdgeInsets.symmetric(horizontal: 16, vertical: 8), + color: cs.card, + child: material.SingleChildScrollView( + scrollDirection: material.Axis.horizontal, + child: material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + if (widget.onBack != null) ...[ + OutlineButton( + onPressed: widget.onBack, + size: ButtonSize.small, + leading: const material.Icon( + material.Icons.arrow_back_rounded, + size: 14, + ), + child: const Text('Back to Documents'), + ), + const Gap(12), + ], + material.Icon( + material.Icons.terminal_rounded, + size: 18, + color: cs.primary, + ), + const Gap(8), + Text('MQL Console — ${widget.database}') + .semiBold() + .medium(), + const Gap(16), + // History button + QueryaIconButton( + icon: const material.Icon(material.Icons.history_rounded), + tooltip: 'Query History', + density: QueryaIconButtonDensity.dense, + onPressed: _showHistory, + ), + const Gap(8), + // Run Query button + PrimaryButton( + onPressed: _activeTab.isLoading ? null : _executeActiveTab, + size: ButtonSize.small, + leading: _activeTab.isLoading + ? const QueryaSpinner(size: QueryaSpinnerSize.sm) + : const material.Icon(material.Icons.play_arrow_rounded, size: 16), + child: const Text('Run Query (Ctrl+Enter)'), + ), + ], + ), + ), + ); + } + + material.Widget _buildTabStrip(ColorScheme cs) { + return material.Container( + height: 38, + padding: const material.EdgeInsets.symmetric(horizontal: 8), + color: cs.card, + child: QueryaTabStrip( + labels: _tabs.map((t) => t.title).toList(), + selectedIndex: _activeTabIndex, + onSelected: (i) => setState(() => _activeTabIndex = i), + onClose: _closeTab, + onAdd: _addNewTab, + canClose: _tabs.length > 1 ? (_) => true : (_) => false, + ), + ); + } + + material.Widget _buildResultBar(ColorScheme cs, _QueryTabSession tab) { + return material.Container( + padding: const material.EdgeInsets.symmetric(horizontal: 16, vertical: 6), + color: cs.card, + child: material.SingleChildScrollView( + scrollDirection: material.Axis.horizontal, + child: material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Icon( + material.Icons.table_chart_outlined, + size: 16, + color: cs.mutedForeground, + ), + const Gap(8), + if (tab.summary != null) ...[ + Text(tab.summary!).semiBold().small(), + const Gap(8), + ], + if (tab.elapsed != null) ...[ + QueryaBadge.status( + '${tab.elapsed!.inMilliseconds} ms', + status: QueryaBadgeStatus.info, + ), + const Gap(8), + ], + if (tab.results.isNotEmpty) ...[ + QueryaBadge.status( + '${tab.results.length} rows', + status: QueryaBadgeStatus.neutral, + ), + const Gap(8), + ], + const Gap(16), + // Mode switchers: Grid vs JSON + material.SegmentedButton<_ResultViewMode>( + segments: const [ + material.ButtonSegment( + value: _ResultViewMode.grid, + icon: material.Icon(material.Icons.grid_on_rounded, size: 14), + label: material.Text('Grid', style: material.TextStyle(fontSize: 12)), + ), + material.ButtonSegment( + value: _ResultViewMode.json, + icon: material.Icon(material.Icons.data_object_rounded, size: 14), + label: material.Text('JSON', style: material.TextStyle(fontSize: 12)), + ), + ], + selected: {tab.viewMode}, + onSelectionChanged: (selected) { + if (selected.isNotEmpty) { + setState(() => tab.viewMode = selected.first); + } + }, + style: material.SegmentedButton.styleFrom( + visualDensity: material.VisualDensity.compact, + ), + ), + ], + ), + ), + ); + } + + material.Widget _buildResultsView(ColorScheme cs, _QueryTabSession tab) { + if (tab.isLoading) { + return const material.Center( + child: QueryaSpinner( + size: QueryaSpinnerSize.lg, + label: 'Executing MQL query...', + ), + ); + } + + if (tab.error != null) { + return material.Container( + margin: const material.EdgeInsets.all(16), + padding: const material.EdgeInsets.all(16), + decoration: material.BoxDecoration( + color: cs.destructive.withValues(alpha: 0.08), + borderRadius: material.BorderRadius.circular(8), + border: material.Border.all(color: cs.destructive.withValues(alpha: 0.3)), + ), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Row( + children: [ + material.Icon( + material.Icons.error_outline_rounded, + color: cs.destructive, + size: 18, + ), + const Gap(8), + const Text('Query Error').semiBold(), + ], + ), + const Gap(8), + material.SelectableText( + tab.error!, + style: material.TextStyle( + color: cs.destructive, + fontFamily: 'monospace', + fontSize: 12.5, + ), + ), + ], + ), + ); + } + + if (tab.results.isEmpty) { + return QueryaEmptyState( + icon: const material.Icon( + material.Icons.terminal_rounded, + size: 40, + ), + title: 'No Documents to Display', + description: tab.summary ?? + 'Run an MQL query (e.g. db.collection.find({})) or execute a command to view results.', + ); + } + + if (tab.viewMode == _ResultViewMode.json) { + return MongoDocumentsJsonView(documents: tab.results); + } + + return MongoDocumentsTableView(documents: tab.results); + } +} diff --git a/lib/features/mongodb/mql_parser.dart b/lib/features/mongodb/mql_parser.dart new file mode 100644 index 00000000..116d4bf1 --- /dev/null +++ b/lib/features/mongodb/mql_parser.dart @@ -0,0 +1,681 @@ +import 'dart:convert'; +import 'package:mongo_dart/mongo_dart.dart' show ObjectId; + +/// Recognized MQL method types that can be invoked via the query console. +enum MqlMethod { + find, + findOne, + count, + countDocuments, + aggregate, + insertOne, + insertMany, + updateOne, + updateMany, + deleteOne, + deleteMany, + drop, + runCommand, + getCollectionNames, + stats, + unknown, +} + +/// Parsed representation of an MQL query or shell statement. +class MqlCommand { + const MqlCommand({ + required this.method, + this.collection, + this.filter, + this.projection, + this.sort, + this.limit, + this.skip, + this.pipeline, + this.document, + this.documents, + this.update, + this.upsert = false, + this.commandMap, + this.rawText = '', + }); + + final MqlMethod method; + final String? collection; + final Map? filter; + final Map? projection; + final Map? sort; + final int? limit; + final int? skip; + final List>? pipeline; + final Map? document; + final List>? documents; + final Map? update; + final bool upsert; + final Map? commandMap; + final String rawText; + + @override + String toString() { + return 'MqlCommand(method: $method, collection: $collection, filter: $filter, ' + 'sort: $sort, limit: $limit, skip: $skip, pipeline: $pipeline)'; + } +} + +/// Parser for MongoDB Query Language (MQL) and shell statements. +/// +/// Supports: +/// - `db..find({ ... })` with `.sort()`, `.limit()`, `.skip()`, `.projection()` +/// - `db..findOne({ ... })` +/// - `db..countDocuments({ ... })` or `.count()` +/// - `db..aggregate([ ... ])` +/// - `db..insertOne({ ... })` +/// - `db..insertMany([ ... ])` +/// - `db..updateOne({ ... }, { ... })` +/// - `db..updateMany({ ... }, { ... })` +/// - `db..deleteOne({ ... })` +/// - `db..deleteMany({ ... })` +/// - `db..drop()` +/// - `db..stats()` +/// - `db.runCommand({ ... })` +/// - `db.getCollectionNames()` +/// - Direct JSON/EJSON object `{ "find": "...", ... }` +/// - Shorthand `.find(...)` without `db.` +class MqlParser { + const MqlParser(); + + /// Parses a raw MQL query string into an [MqlCommand]. + static MqlCommand parse(String input, {String? defaultCollection}) { + var text = input.trim(); + if (text.endsWith(';')) { + text = text.substring(0, text.length - 1).trim(); + } + + if (text.isEmpty) { + throw const FormatException('Query cannot be empty'); + } + + // Direct JSON object (e.g. `{ "ping": 1 }` or `{ "find": "coll" }`) + if (text.startsWith('{')) { + final decoded = _parseRelaxedJson(text); + if (decoded is Map) { + if (decoded.containsKey('find') && decoded['find'] is String) { + final coll = decoded['find'] as String; + final filter = decoded['filter'] is Map + ? Map.from(decoded['filter'] as Map) + : null; + final sort = decoded['sort'] is Map + ? Map.from(decoded['sort'] as Map) + : null; + final limit = decoded['limit'] is num + ? (decoded['limit'] as num).toInt() + : null; + final skip = decoded['skip'] is num + ? (decoded['skip'] as num).toInt() + : null; + return MqlCommand( + method: MqlMethod.find, + collection: coll, + filter: filter, + sort: sort, + limit: limit, + skip: skip, + rawText: input, + ); + } + return MqlCommand( + method: MqlMethod.runCommand, + commandMap: decoded, + rawText: input, + ); + } + } + + // Direct aggregation pipeline array: `[ { "$match": ... } ]` + if (text.startsWith('[')) { + final decoded = _parseRelaxedJson(text); + if (decoded is List) { + final pipeline = decoded + .whereType() + .map((m) => Map.from(m)) + .toList(); + return MqlCommand( + method: MqlMethod.aggregate, + collection: defaultCollection, + pipeline: pipeline, + rawText: input, + ); + } + } + + // Remove leading `db.` if present + if (text.startsWith('db.')) { + text = text.substring(3).trim(); + } + + // Check for `runCommand(...)` + if (text.startsWith('runCommand(')) { + final argText = _extractFirstCallArguments(text, 'runCommand'); + final arg = _parseRelaxedJson(argText); + if (arg is! Map) { + throw const FormatException('runCommand argument must be a document'); + } + return MqlCommand( + method: MqlMethod.runCommand, + commandMap: Map.from(arg), + rawText: input, + ); + } + + // Check for `getCollectionNames()` + if (text == 'getCollectionNames()' || text == 'getCollectionNames') { + return MqlCommand( + method: MqlMethod.getCollectionNames, + rawText: input, + ); + } + + // Collection-level call: `.(...)` + final dotIndex = text.indexOf('.'); + if (dotIndex == -1) { + throw FormatException('Invalid query format: $input'); + } + + final collectionName = text.substring(0, dotIndex).trim(); + final remainingCalls = text.substring(dotIndex + 1).trim(); + + return _parseCollectionChain( + collectionName: collectionName, + chainText: remainingCalls, + rawText: input, + ); + } + + static MqlCommand _parseCollectionChain({ + required String collectionName, + required String chainText, + required String rawText, + }) { + // Parse chain of method calls: e.g. find(...).sort(...).limit(...) + final calls = _splitMethodChain(chainText); + if (calls.isEmpty) { + throw FormatException('No method called on collection "$collectionName"'); + } + + final primaryCall = calls.first; + final methodName = primaryCall.name; + final rawArgs = primaryCall.argumentsText; + + MqlMethod method; + switch (methodName) { + case 'find': + method = MqlMethod.find; + break; + case 'findOne': + method = MqlMethod.findOne; + break; + case 'count': + method = MqlMethod.count; + break; + case 'countDocuments': + method = MqlMethod.countDocuments; + break; + case 'aggregate': + method = MqlMethod.aggregate; + break; + case 'insertOne': + method = MqlMethod.insertOne; + break; + case 'insertMany': + method = MqlMethod.insertMany; + break; + case 'updateOne': + method = MqlMethod.updateOne; + break; + case 'updateMany': + method = MqlMethod.updateMany; + break; + case 'deleteOne': + method = MqlMethod.deleteOne; + break; + case 'deleteMany': + method = MqlMethod.deleteMany; + break; + case 'drop': + method = MqlMethod.drop; + break; + case 'stats': + method = MqlMethod.stats; + break; + default: + throw FormatException('Unsupported collection method: $methodName'); + } + + final parsedArgs = _parseArgumentsList(rawArgs); + + Map? filter; + Map? projection; + Map? sort; + int? limit; + int? skip; + List>? pipeline; + Map? document; + List>? documents; + Map? update; + + switch (method) { + case MqlMethod.find: + if (parsedArgs.isNotEmpty && parsedArgs[0] is Map) { + filter = Map.from(parsedArgs[0] as Map); + } + if (parsedArgs.length > 1 && parsedArgs[1] is Map) { + projection = Map.from(parsedArgs[1] as Map); + } + break; + + case MqlMethod.findOne: + if (parsedArgs.isNotEmpty && parsedArgs[0] is Map) { + filter = Map.from(parsedArgs[0] as Map); + } + if (parsedArgs.length > 1 && parsedArgs[1] is Map) { + projection = Map.from(parsedArgs[1] as Map); + } + limit = 1; + break; + + case MqlMethod.count: + case MqlMethod.countDocuments: + if (parsedArgs.isNotEmpty && parsedArgs[0] is Map) { + filter = Map.from(parsedArgs[0] as Map); + } + break; + + case MqlMethod.aggregate: + if (parsedArgs.isNotEmpty && parsedArgs[0] is List) { + pipeline = (parsedArgs[0] as List) + .whereType() + .map((m) => Map.from(m)) + .toList(); + } else { + pipeline = []; + } + break; + + case MqlMethod.insertOne: + if (parsedArgs.isNotEmpty && parsedArgs[0] is Map) { + document = Map.from(parsedArgs[0] as Map); + } else { + throw const FormatException('insertOne requires a document argument'); + } + break; + + case MqlMethod.insertMany: + if (parsedArgs.isNotEmpty && parsedArgs[0] is List) { + documents = (parsedArgs[0] as List) + .whereType() + .map((m) => Map.from(m)) + .toList(); + } else { + throw const FormatException( + 'insertMany requires an array of documents'); + } + break; + + case MqlMethod.updateOne: + case MqlMethod.updateMany: + if (parsedArgs.length >= 2 && + parsedArgs[0] is Map && + parsedArgs[1] is Map) { + filter = Map.from(parsedArgs[0] as Map); + update = Map.from(parsedArgs[1] as Map); + } else { + throw FormatException( + '$methodName requires filter and update documents'); + } + break; + + case MqlMethod.deleteOne: + case MqlMethod.deleteMany: + if (parsedArgs.isNotEmpty && parsedArgs[0] is Map) { + filter = Map.from(parsedArgs[0] as Map); + } else { + throw FormatException( + '$methodName requires a filter document argument'); + } + break; + + default: + break; + } + + // Process secondary chained methods: .sort(), .limit(), .skip(), .count(), .project() + for (var i = 1; i < calls.length; i++) { + final call = calls[i]; + final cArgs = _parseArgumentsList(call.argumentsText); + + switch (call.name) { + case 'sort': + if (cArgs.isNotEmpty && cArgs[0] is Map) { + sort = Map.from(cArgs[0] as Map); + } + break; + case 'limit': + if (cArgs.isNotEmpty && cArgs[0] is num) { + limit = (cArgs[0] as num).toInt(); + } + break; + case 'skip': + if (cArgs.isNotEmpty && cArgs[0] is num) { + skip = (cArgs[0] as num).toInt(); + } + break; + case 'project': + case 'projection': + if (cArgs.isNotEmpty && cArgs[0] is Map) { + projection = Map.from(cArgs[0] as Map); + } + break; + case 'count': + method = MqlMethod.count; + break; + } + } + + return MqlCommand( + method: method, + collection: collectionName, + filter: filter, + projection: projection, + sort: sort, + limit: limit, + skip: skip, + pipeline: pipeline, + document: document, + documents: documents, + update: update, + rawText: rawText, + ); + } + + // ─── Method Chain Splitting ────────────────────────────────────────────────── + + static List<_CallInfo> _splitMethodChain(String input) { + final result = <_CallInfo>[]; + var index = 0; + + while (index < input.length) { + if (input[index] == '.') { + index++; + } + while (index < input.length && input[index].trim().isEmpty) { + index++; + } + if (index >= input.length) break; + + final openParen = input.indexOf('(', index); + if (openParen == -1) break; + + final methodName = input.substring(index, openParen).trim(); + final closeParen = _findMatchingParen(input, openParen); + if (closeParen == -1) { + throw FormatException('Unclosed parenthesis in: $input'); + } + + final argsContent = input.substring(openParen + 1, closeParen).trim(); + result.add(_CallInfo(name: methodName, argumentsText: argsContent)); + index = closeParen + 1; + } + + return result; + } + + static int _findMatchingParen(String text, int openIndex) { + var depth = 0; + var inSingle = false; + var inDouble = false; + var escaped = false; + + for (var i = openIndex; i < text.length; i++) { + final char = text[i]; + if (escaped) { + escaped = false; + continue; + } + if (char == r'\') { + escaped = true; + continue; + } + if (char == "'" && !inDouble) { + inSingle = !inSingle; + continue; + } + if (char == '"' && !inSingle) { + inDouble = !inDouble; + continue; + } + if (inSingle || inDouble) continue; + + if (char == '(') { + depth++; + } else if (char == ')') { + depth--; + if (depth == 0) return i; + } + } + return -1; + } + + static String _extractFirstCallArguments(String text, String methodName) { + final startIdx = text.indexOf('$methodName('); + if (startIdx == -1) return ''; + final openParen = startIdx + methodName.length; + final closeParen = _findMatchingParen(text, openParen); + if (closeParen == -1) return ''; + return text.substring(openParen + 1, closeParen).trim(); + } + + // ─── Argument Splitting & Parsing ──────────────────────────────────────────── + + static List _parseArgumentsList(String argsText) { + final trimmed = argsText.trim(); + if (trimmed.isEmpty) return []; + + final rawArgs = _splitTopLevelArguments(trimmed); + return rawArgs.map((a) => _parseRelaxedJson(a.trim())).toList(); + } + + static List _splitTopLevelArguments(String text) { + final result = []; + final buffer = StringBuffer(); + var depth = 0; + var inSingle = false; + var inDouble = false; + var escaped = false; + + for (var i = 0; i < text.length; i++) { + final char = text[i]; + if (escaped) { + buffer.write(char); + escaped = false; + continue; + } + if (char == r'\') { + buffer.write(char); + escaped = true; + continue; + } + if (char == "'" && !inDouble) { + inSingle = !inSingle; + buffer.write(char); + continue; + } + if (char == '"' && !inSingle) { + inDouble = !inDouble; + buffer.write(char); + continue; + } + + if (!inSingle && !inDouble) { + if (char == '{' || char == '[' || char == '(') { + depth++; + } else if (char == '}' || char == ']' || char == ')') { + depth--; + } else if (char == ',' && depth == 0) { + result.add(buffer.toString()); + buffer.clear(); + continue; + } + } + + buffer.write(char); + } + + if (buffer.isNotEmpty) { + result.add(buffer.toString()); + } + + return result; + } + + // ─── Relaxed JSON / JS Object Literal Parser ──────────────────────────────── + + static dynamic _parseRelaxedJson(String text) { + final trimmed = text.trim(); + if (trimmed.isEmpty) return null; + + // Check for constructor functions e.g. ObjectId("..."), ISODate("...") + if (trimmed.startsWith('ObjectId(') && trimmed.endsWith(')')) { + final hex = trimmed + .substring(9, trimmed.length - 1) + .replaceAll(RegExp(r'''['"]'''), ''); + return ObjectId.fromHexString(hex); + } + if (trimmed.startsWith('ISODate(') && trimmed.endsWith(')')) { + final iso = trimmed + .substring(8, trimmed.length - 1) + .replaceAll(RegExp(r'''['"]'''), ''); + return DateTime.parse(iso); + } + if ((trimmed.startsWith('NumberLong(') || trimmed.startsWith('NumberInt(')) && + trimmed.endsWith(')')) { + final numStr = trimmed + .substring(trimmed.indexOf('(') + 1, trimmed.length - 1) + .replaceAll(RegExp(r'''['"]'''), ''); + return int.parse(numStr); + } + if (trimmed.startsWith('RegExp(') && trimmed.endsWith(')')) { + final args = trimmed.substring(7, trimmed.length - 1).split(','); + final pattern = args[0].trim().replaceAll(RegExp(r'''['"]'''), ''); + return RegExp(pattern); + } + + // Try standard JSON decode first + try { + return json.decode(trimmed); + } catch (_) { + // Fallback to relaxed JS object parsing + } + + final normalized = _normalizeJsObject(trimmed); + return json.decode(normalized); + } + + /// Normalizes JavaScript object literal syntax into valid JSON: + /// - Quote unquoted keys: `{ foo: "bar" }` -> `{ "foo": "bar" }` + /// - Single quotes to double quotes: `'foo'` -> `"foo"` + /// - Constructor function handling inside objects/arrays + static String _normalizeJsObject(String input) { + var s = input; + + // Replace ObjectId("hex") with {"$oid": "hex"} + s = s.replaceAllMapped( + RegExp(r'ObjectId\s*\(\s*["\x27]([0-9a-fA-F]{24})["\x27]\s*\)'), + (m) => '{"\$oid":"${m[1]}"}', + ); + + // Replace ISODate("...") with {"$date": "..."} + s = s.replaceAllMapped( + RegExp(r'ISODate\s*\(\s*["\x27]([^"\x27]+)["\x27]\s*\)'), + (m) => '{"\$date":"${m[1]}"}', + ); + + // Replace NumberLong(...) / NumberInt(...) with numeric literal + s = s.replaceAllMapped( + RegExp(r'(?:NumberLong|NumberInt)\s*\(\s*["\x27]?(\d+)["\x27]?\s*\)'), + (m) => '${m[1]}', + ); + + // Tokenize strings to preserve quotes and contents + final strings = []; + final tokenized = StringBuffer(); + var inSingle = false; + var inDouble = false; + var escaped = false; + var currentStr = StringBuffer(); + + for (var i = 0; i < s.length; i++) { + final char = s[i]; + if (escaped) { + currentStr.write(char); + escaped = false; + continue; + } + if (char == r'\') { + currentStr.write(char); + escaped = true; + continue; + } + if (char == "'" && !inDouble) { + if (inSingle) { + inSingle = false; + final placeholder = '@@@STR_${strings.length}@@@'; + strings.add(json.encode(currentStr.toString())); + tokenized.write(placeholder); + currentStr.clear(); + } else { + inSingle = true; + } + continue; + } + if (char == '"' && !inSingle) { + if (inDouble) { + inDouble = false; + final placeholder = '@@@STR_${strings.length}@@@'; + strings.add(json.encode(currentStr.toString())); + tokenized.write(placeholder); + currentStr.clear(); + } else { + inDouble = true; + } + continue; + } + + if (inSingle || inDouble) { + currentStr.write(char); + } else { + tokenized.write(char); + } + } + + var processed = tokenized.toString(); + + // Quote unquoted object keys: e.g. `{ key: ` or `, key: ` + processed = processed.replaceAllMapped( + RegExp(r'([{\[,]\s*)([a-zA-Z0-9_\$]+)\s*:'), + (m) => '${m[1]}"${m[2]}":', + ); + + // Restore strings + for (var i = 0; i < strings.length; i++) { + processed = processed.replaceAll('@@@STR_$i@@@', strings[i]); + } + + return processed; + } +} + +class _CallInfo { + const _CallInfo({required this.name, required this.argumentsText}); + final String name; + final String argumentsText; +} diff --git a/test/features/mongodb/mongo_query_workspace_test.dart b/test/features/mongodb/mongo_query_workspace_test.dart new file mode 100644 index 00000000..1db26704 --- /dev/null +++ b/test/features/mongodb/mongo_query_workspace_test.dart @@ -0,0 +1,226 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/mongodb_connection.dart'; +import 'package:querya_desktop/core/editor/querya_code_editor.dart'; +import 'package:querya_desktop/features/mongodb/mongo_documents_json_view.dart'; +import 'package:querya_desktop/features/mongodb/mongo_documents_table_view.dart'; +import 'package:querya_desktop/features/mongodb/mongo_query_executor.dart'; +import 'package:querya_desktop/features/mongodb/mongo_query_workspace.dart'; +import 'package:querya_desktop/features/mongodb/mql_parser.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +class _FakeQueryExecutor implements MongoQueryExecutor { + _FakeQueryExecutor({this.shouldFail = false}); + + List>? mockResults; + bool shouldFail; + MqlCommand? lastExecutedCommand; + + @override + Future execute({ + required MongoConnection connection, + required String database, + required MqlCommand command, + String? defaultCollection, + }) async { + lastExecutedCommand = command; + if (shouldFail) { + throw const FormatException('Database error: collection not found'); + } + final docs = mockResults ?? + [ + {'_id': '1', 'name': 'Alice', 'role': 'admin'}, + {'_id': '2', 'name': 'Bob', 'role': 'user'}, + ]; + return MongoQueryResult( + documents: docs, + elapsed: const Duration(milliseconds: 45), + summary: 'Fetched ${docs.length} documents', + ); + } +} + +void main() { + final testConn = MongoConnection(id: 1, name: 'test', host: 'localhost'); + + group('MongoQueryWorkspace', () { + testWidgets('renders top bar, initial tab, code editor, and run button', + (tester) async { + final fakeExecutor = _FakeQueryExecutor(); + + await tester.pumpWidget( + queryaThemeTestShell( + child: MongoQueryWorkspace( + connection: testConn, + database: 'testDb', + initialCollection: 'users', + executor: fakeExecutor, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('MQL Console — testDb'), findsOneWidget); + expect(find.text('Query 1'), findsOneWidget); + expect(find.text('Run Query (Ctrl+Enter)'), findsOneWidget); + expect(find.text('No Documents to Display'), findsOneWidget); + }); + + testWidgets('adds and closes tabs via QueryaTabStrip', (tester) async { + final fakeExecutor = _FakeQueryExecutor(); + + await tester.pumpWidget( + queryaThemeTestShell( + child: MongoQueryWorkspace( + connection: testConn, + database: 'testDb', + executor: fakeExecutor, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Query 1'), findsOneWidget); + expect(find.text('Query 2'), findsNothing); + + // Add a new tab + final addTabButton = find.byKey(const material.ValueKey('querya_tab_add_button')); + expect(addTabButton, findsOneWidget); + await tester.tap(addTabButton); + await tester.pumpAndSettle(); + + expect(find.text('Query 1'), findsOneWidget); + expect(find.text('Query 2'), findsOneWidget); + + // Close the second tab + final closeButtons = find.byIcon(material.Icons.close_rounded); + expect(closeButtons, findsWidgets); + await tester.tap(closeButtons.last); + await tester.pumpAndSettle(); + + expect(find.text('Query 2'), findsNothing); + expect(find.text('Query 1'), findsOneWidget); + }); + + testWidgets('executes MQL query, displays timing and switches Grid / JSON view', + (tester) async { + final fakeExecutor = _FakeQueryExecutor(); + + await tester.pumpWidget( + queryaThemeTestShell( + child: MongoQueryWorkspace( + connection: testConn, + database: 'testDb', + initialCollection: 'users', + initialQueries: const ['db.users.find({ role: "admin" })'], + executor: fakeExecutor, + ), + ), + ); + await tester.pumpAndSettle(); + + // Click "Run Query" + await tester.tap(find.widgetWithText(PrimaryButton, 'Run Query (Ctrl+Enter)')); + await tester.pumpAndSettle(); + + expect(fakeExecutor.lastExecutedCommand, isNotNull); + expect(fakeExecutor.lastExecutedCommand!.method, equals(MqlMethod.find)); + expect(fakeExecutor.lastExecutedCommand!.collection, equals('users')); + + // Check results bar + expect(find.text('Fetched 2 documents'), findsOneWidget); + expect(find.text('45 ms'), findsOneWidget); + expect(find.text('2 rows'), findsOneWidget); + + // Default view is Grid (Table) + expect(find.byType(MongoDocumentsTableView), findsOneWidget); + expect(find.byType(MongoDocumentsJsonView), findsNothing); + + // Switch to JSON mode + await tester.tap(find.text('JSON')); + await tester.pumpAndSettle(); + + expect(find.byType(MongoDocumentsJsonView), findsOneWidget); + expect(find.byType(MongoDocumentsTableView), findsNothing); + }); + + testWidgets('displays error container when query fails', (tester) async { + final fakeExecutor = _FakeQueryExecutor(shouldFail: true); + + await tester.pumpWidget( + queryaThemeTestShell( + child: MongoQueryWorkspace( + connection: testConn, + database: 'testDb', + initialCollection: 'users', + executor: fakeExecutor, + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(PrimaryButton, 'Run Query (Ctrl+Enter)')); + await tester.pumpAndSettle(); + + expect(find.text('Query Error'), findsOneWidget); + expect(find.textContaining('Database error: collection not found'), findsOneWidget); + }); + + testWidgets('triggers query execution via Ctrl+Enter shortcut', + (tester) async { + final fakeExecutor = _FakeQueryExecutor(); + + await tester.pumpWidget( + queryaThemeTestShell( + child: MongoQueryWorkspace( + connection: testConn, + database: 'testDb', + initialCollection: 'users', + initialQueries: const ['db.users.find({ active: true })'], + executor: fakeExecutor, + ), + ), + ); + await tester.pumpAndSettle(); + + // Focus the code editor + await tester.tap(find.byType(QueryaCodeEditor)); + await tester.pumpAndSettle(); + + // Trigger Ctrl+Enter shortcut + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pumpAndSettle(); + + expect(fakeExecutor.lastExecutedCommand, isNotNull); + expect(fakeExecutor.lastExecutedCommand!.method, equals(MqlMethod.find)); + expect(fakeExecutor.lastExecutedCommand!.collection, equals('users')); + expect(find.text('Fetched 2 documents'), findsOneWidget); + }); + + testWidgets('invokes onBack when Back to Documents is pressed', + (tester) async { + var backPressed = false; + + await tester.pumpWidget( + queryaThemeTestShell( + child: MongoQueryWorkspace( + connection: testConn, + database: 'testDb', + onBack: () => backPressed = true, + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Back to Documents')); + await tester.pumpAndSettle(); + + expect(backPressed, isTrue); + }); + }); +} diff --git a/test/features/mongodb/mql_parser_test.dart b/test/features/mongodb/mql_parser_test.dart new file mode 100644 index 00000000..4791fea0 --- /dev/null +++ b/test/features/mongodb/mql_parser_test.dart @@ -0,0 +1,123 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/features/mongodb/mql_parser.dart'; + +void main() { + group('MqlParser', () { + test('parses simple find with unquoted keys and string values', () { + final cmd = MqlParser.parse('db.users.find({ status: "active" })'); + expect(cmd.method, equals(MqlMethod.find)); + expect(cmd.collection, equals('users')); + expect(cmd.filter, equals({'status': 'active'})); + }); + + test('parses find without db prefix', () { + final cmd = MqlParser.parse('users.find({ age: { \$gt: 21 } })'); + expect(cmd.method, equals(MqlMethod.find)); + expect(cmd.collection, equals('users')); + expect(cmd.filter, equals({'age': {'\$gt': 21}})); + }); + + test('parses chained sort, limit, and skip', () { + final cmd = MqlParser.parse( + 'db.orders.find({ status: "completed" }).sort({ createdAt: -1 }).skip(20).limit(50);', + ); + expect(cmd.method, equals(MqlMethod.find)); + expect(cmd.collection, equals('orders')); + expect(cmd.filter, equals({'status': 'completed'})); + expect(cmd.sort, equals({'createdAt': -1})); + expect(cmd.skip, equals(20)); + expect(cmd.limit, equals(50)); + }); + + test('parses findOne with projection', () { + final cmd = MqlParser.parse('db.users.findOne({ _id: "123" }, { name: 1, email: 1 })'); + expect(cmd.method, equals(MqlMethod.findOne)); + expect(cmd.collection, equals('users')); + expect(cmd.filter, equals({'_id': '123'})); + expect(cmd.projection, equals({'name': 1, 'email': 1})); + expect(cmd.limit, equals(1)); + }); + + test('parses countDocuments and count', () { + final cmd1 = MqlParser.parse('db.logs.countDocuments({ level: "error" })'); + expect(cmd1.method, equals(MqlMethod.countDocuments)); + expect(cmd1.collection, equals('logs')); + expect(cmd1.filter, equals({'level': 'error'})); + + final cmd2 = MqlParser.parse('db.logs.find().count()'); + expect(cmd2.method, equals(MqlMethod.count)); + expect(cmd2.collection, equals('logs')); + }); + + test('parses aggregate pipeline', () { + final cmd = MqlParser.parse( + 'db.orders.aggregate([ { \$match: { status: "A" } }, { \$group: { _id: "\$cust_id", total: { \$sum: "\$amount" } } } ])', + ); + expect(cmd.method, equals(MqlMethod.aggregate)); + expect(cmd.collection, equals('orders')); + expect(cmd.pipeline?.length, equals(2)); + expect(cmd.pipeline?[0]['\$match'], equals({'status': 'A'})); + }); + + test('parses insertOne and insertMany', () { + final cmd1 = MqlParser.parse('db.products.insertOne({ title: "Laptop", price: 999.99 })'); + expect(cmd1.method, equals(MqlMethod.insertOne)); + expect(cmd1.collection, equals('products')); + expect(cmd1.document?['title'], equals('Laptop')); + + final cmd2 = MqlParser.parse('db.products.insertMany([ { title: "Item 1" }, { title: "Item 2" } ])'); + expect(cmd2.method, equals(MqlMethod.insertMany)); + expect(cmd2.collection, equals('products')); + expect(cmd2.documents?.length, equals(2)); + }); + + test('parses updateOne and updateMany', () { + final cmd1 = MqlParser.parse('db.users.updateOne({ name: "Bob" }, { \$set: { age: 30 } })'); + expect(cmd1.method, equals(MqlMethod.updateOne)); + expect(cmd1.collection, equals('users')); + expect(cmd1.filter, equals({'name': 'Bob'})); + expect(cmd1.update, equals({'\$set': {'age': 30}})); + }); + + test('parses deleteOne and deleteMany', () { + final cmd1 = MqlParser.parse('db.sessions.deleteOne({ expired: true })'); + expect(cmd1.method, equals(MqlMethod.deleteOne)); + expect(cmd1.collection, equals('sessions')); + expect(cmd1.filter, equals({'expired': true})); + }); + + test('parses runCommand and getCollectionNames', () { + final cmd1 = MqlParser.parse('db.runCommand({ ping: 1 })'); + expect(cmd1.method, equals(MqlMethod.runCommand)); + expect(cmd1.commandMap, equals({'ping': 1})); + + final cmd2 = MqlParser.parse('db.getCollectionNames()'); + expect(cmd2.method, equals(MqlMethod.getCollectionNames)); + }); + + test('parses direct JSON object as command or find', () { + final cmd1 = MqlParser.parse('{ "collStats": "users" }'); + expect(cmd1.method, equals(MqlMethod.runCommand)); + expect(cmd1.commandMap, equals({'collStats': 'users'})); + + final cmd2 = MqlParser.parse('{ "find": "users", "filter": { "active": true }, "limit": 10 }'); + expect(cmd2.method, equals(MqlMethod.find)); + expect(cmd2.collection, equals('users')); + expect(cmd2.filter, equals({'active': true})); + expect(cmd2.limit, equals(10)); + }); + + test('handles single-quoted strings and nested objects', () { + final cmd = MqlParser.parse("db.users.find({'profile.city': 'New York', 'tags': ['vip', 'admin']})"); + expect(cmd.method, equals(MqlMethod.find)); + expect(cmd.collection, equals('users')); + expect(cmd.filter?['profile.city'], equals('New York')); + expect(cmd.filter?['tags'], equals(['vip', 'admin'])); + }); + + test('handles empty or whitespace query with FormatException', () { + expect(() => MqlParser.parse(''), throwsA(isA())); + expect(() => MqlParser.parse(' ; '), throwsA(isA())); + }); + }); +}