diff --git a/lib/core/database/mongodb_service.dart b/lib/core/database/mongodb_service.dart index d534f32b..055ae83d 100644 --- a/lib/core/database/mongodb_service.dart +++ b/lib/core/database/mongodb_service.dart @@ -181,9 +181,22 @@ class MongoService { final pipe = pipeline .map((stage) => stage.map((k, v) => MapEntry(k, v as Object))) .toList(); - final result = await coll.aggregate(pipe); - // aggregate returns a Map, wrap it in a List - return [Map.from(result)]; + try { + final docs = await coll.aggregateToStream(pipe).toList(); + return docs.map((d) => Map.from(d)).toList(); + } catch (_) { + final result = await coll.aggregate(pipe); + if (result['cursor'] is Map && + (result['cursor'] as Map)['firstBatch'] is List) { + final batch = (result['cursor'] as Map)['firstBatch'] as List; + return batch.map((d) => Map.from(d as Map)).toList(); + } + if (result['result'] is List) { + final res = result['result'] as List; + return res.map((d) => Map.from(d as Map)).toList(); + } + return [Map.from(result)]; + } }); } diff --git a/lib/features/mongodb/mongo_aggregation_stage.dart b/lib/features/mongodb/mongo_aggregation_stage.dart new file mode 100644 index 00000000..a740c2a7 --- /dev/null +++ b/lib/features/mongodb/mongo_aggregation_stage.dart @@ -0,0 +1,373 @@ +import 'dart:convert'; + +import 'package:mongo_dart/mongo_dart.dart' show EJsonCodec; + +/// Standard MongoDB aggregation stage operators with descriptions. +class MongoStageOperatorInfo { + const MongoStageOperatorInfo(this.op, this.summary, this.template); + + final String op; + final String summary; + final String template; +} + +const List kMongoStageOperators = [ + MongoStageOperatorInfo( + r'$match', + 'Filters documents to pass only those matching criteria', + '{\n "status": "active"\n}', + ), + MongoStageOperatorInfo( + r'$group', + 'Groups documents by a specified identifier expression', + '{\n "_id": r"\$category",\n "count": {\n r"\$sum": 1\n }\n}', + ), + MongoStageOperatorInfo( + r'$project', + 'Passes along documents with specified requested fields', + '{\n "_id": 1,\n "name": 1\n}', + ), + MongoStageOperatorInfo( + r'$sort', + 'Reorders the document stream by specified sort keys', + '{\n "createdAt": -1\n}', + ), + MongoStageOperatorInfo( + r'$limit', + 'Passes the first n documents unmodified', + '20', + ), + MongoStageOperatorInfo( + r'$skip', + 'Passes along documents after skipping n documents', + '10', + ), + MongoStageOperatorInfo( + r'$lookup', + 'Performs a left outer join to an unsharded collection', + '{\n "from": "users",\n "localField": "userId",\n "foreignField": "_id",\n "as": "user"\n}', + ), + MongoStageOperatorInfo( + r'$unwind', + 'Deconstructs an array field from input documents', + '{\n "path": r"\$items",\n "preserveNullAndEmptyArrays": true\n}', + ), + MongoStageOperatorInfo( + r'$addFields', + 'Adds new computed fields to documents', + '{\n "totalWithTax": {\n r"\$multiply": [r"\$price", 1.2]\n }\n}', + ), + MongoStageOperatorInfo( + r'$count', + 'Returns a count of documents arriving at this stage', + '"total_records"', + ), + MongoStageOperatorInfo( + r'$facet', + 'Processes multiple aggregation pipelines within a single stage', + '{\n "categorizedByTags": [\n {\n r"\$unwind": r"\$tags"\n },\n {\n r"\$count": "count"\n }\n ]\n}', + ), + MongoStageOperatorInfo( + r'$replaceRoot', + 'Replaces the input document with the specified document', + '{\n "newRoot": r"\$nested"\n}', + ), + MongoStageOperatorInfo( + r'$set', + r'Alias for $addFields: adds or updates fields', + '{\n "updated": true\n}', + ), + MongoStageOperatorInfo( + r'$unset', + 'Excludes fields from documents', + '["temporaryField", "extraData"]', + ), +]; + +/// Returns the default template JSON for a given stage operator. +String templateForOperator(String op) { + switch (op) { + case r'$match': + return '{\n "status": "active"\n}'; + case r'$group': + return '{\n "_id": "\$category",\n "count": {\n "\$sum": 1\n }\n}'; + case r'$project': + return '{\n "_id": 1,\n "name": 1\n}'; + case r'$sort': + return '{\n "createdAt": -1\n}'; + case r'$limit': + return '20'; + case r'$skip': + return '10'; + case r'$lookup': + return '{\n "from": "users",\n "localField": "userId",\n "foreignField": "_id",\n "as": "user"\n}'; + case r'$unwind': + return '{\n "path": "\$items",\n "preserveNullAndEmptyArrays": true\n}'; + case r'$addFields': + return '{\n "totalWithTax": {\n "\$multiply": ["\$price", 1.2]\n }\n}'; + case r'$count': + return '"total_records"'; + case r'$facet': + return '{\n "categorizedByTags": [\n {\n "\$unwind": "\$tags"\n },\n {\n "\$count": "count"\n }\n ]\n}'; + case r'$replaceRoot': + return '{\n "newRoot": "\$nested"\n}'; + case r'$set': + return '{\n "updated": true\n}'; + case r'$unset': + return '["temporaryField", "extraData"]'; + default: + return '{\n}'; + } +} + +/// Model representing a single stage in an aggregation pipeline. +class MongoAggregationStage { + const MongoAggregationStage({ + required this.id, + required this.operator, + required this.queryText, + this.isEnabled = true, + this.executionDurationMs, + this.outputCount, + this.error, + }); + + final String id; + final String operator; + final String queryText; + final bool isEnabled; + final int? executionDurationMs; + final int? outputCount; + final String? error; + + MongoAggregationStage copyWith({ + String? id, + String? operator, + String? queryText, + bool? isEnabled, + int? executionDurationMs, + int? outputCount, + String? error, + bool clearError = false, + bool clearMetrics = false, + }) { + return MongoAggregationStage( + id: id ?? this.id, + operator: operator ?? this.operator, + queryText: queryText ?? this.queryText, + isEnabled: isEnabled ?? this.isEnabled, + executionDurationMs: clearMetrics + ? null + : (executionDurationMs ?? this.executionDurationMs), + outputCount: + clearMetrics ? null : (outputCount ?? this.outputCount), + error: clearError ? null : (error ?? this.error), + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is MongoAggregationStage && + runtimeType == other.runtimeType && + id == other.id && + operator == other.operator && + queryText == other.queryText && + isEnabled == other.isEnabled && + executionDurationMs == other.executionDurationMs && + outputCount == other.outputCount && + error == other.error; + + @override + int get hashCode => Object.hash( + id, + operator, + queryText, + isEnabled, + executionDurationMs, + outputCount, + error, + ); +} + +/// Validates whether the stage query body contains valid JSON / EJSON. +/// Returns null if valid, or a descriptive error message. +String? validateStage(MongoAggregationStage stage) { + final trimmed = stage.queryText.trim(); + if (trimmed.isEmpty) { + return 'Stage body cannot be empty'; + } + try { + json.decode(trimmed); + return null; + } on FormatException catch (e) { + return 'Invalid JSON: ${e.message}'; + } catch (e) { + return 'Syntax error: $e'; + } +} + +/// Parses a stage into a MongoDB aggregation stage dictionary `{ "$operator": value }`. +Map parseStage(MongoAggregationStage stage) { + final trimmed = stage.queryText.trim(); + if (trimmed.isEmpty) { + throw const FormatException('Stage body cannot be empty'); + } + + final decoded = json.decode(trimmed); + + // If the user pasted the full stage wrapper e.g. `{ "$match": { ... } }` + if (decoded is Map) { + final rawMap = Map.from(decoded); + if (rawMap.length == 1 && rawMap.containsKey(stage.operator)) { + return _convertEjsonMap(rawMap); + } + return { + stage.operator: _convertEjsonValue(rawMap), + }; + } + + // Scalar, string, or array value (e.g. `$limit: 20`, `$unwind: "$field"`, `$unset: ["a", "b"]`) + return { + stage.operator: _convertEjsonValue(decoded), + }; +} + +dynamic _convertEjsonValue(dynamic value) { + if (value is Map) { + return _convertEjsonMap(Map.from(value)); + } else if (value is List) { + return value.map(_convertEjsonValue).toList(); + } + return value; +} + +Map _convertEjsonMap(Map map) { + try { + return EJsonCodec.eJson2Doc(map); + } catch (_) { + return map; + } +} + +/// Builds an executable pipeline list up to [upToStageIndex] (inclusive). +/// Disabled stages are filtered out. +List> buildPipeline( + List stages, { + int? upToStageIndex, +}) { + final effectiveStages = upToStageIndex == null + ? stages + : stages.sublist(0, (upToStageIndex + 1).clamp(0, stages.length)); + + final pipeline = >[]; + for (int i = 0; i < effectiveStages.length; i++) { + final stage = effectiveStages[i]; + if (!stage.isEnabled) continue; + final stageMap = parseStage(stage); + pipeline.add(stageMap); + } + return pipeline; +} + +/// Code generation helpers for MongoDB aggregation pipelines. +class MongoAggregationExporter { + const MongoAggregationExporter._(); + + /// Formats the pipeline into JSON array string. + static String formatPipelineJson(List stages) { + final enabled = stages.where((s) => s.isEnabled).toList(); + final list = >[]; + for (final stage in enabled) { + try { + final decoded = json.decode(stage.queryText.trim()); + if (decoded is Map && + decoded.length == 1 && + decoded.containsKey(stage.operator)) { + list.add(Map.from(decoded)); + } else { + list.add({stage.operator: decoded}); + } + } catch (_) { + list.add({stage.operator: stage.queryText.trim()}); + } + } + return const JsonEncoder.withIndent(' ').convert(list); + } + + /// Exports to `mongosh` / MongoDB Shell script. + static String toMongosh({ + required String database, + required String collection, + required List stages, + }) { + final pipelineJson = formatPipelineJson(stages); + return '''// mongosh script +use('$database'); + +db.getCollection('$collection').aggregate($pipelineJson); +'''; + } + + /// Exports to Node.js (Official MongoDB Node Driver) code. + static String toNodeJs({ + required String database, + required String collection, + required List stages, + }) { + final pipelineJson = formatPipelineJson(stages); + return '''// Node.js (mongodb driver) +const { MongoClient } = require('mongodb'); + +async function runPipeline() { + const uri = process.env.MONGODB_URI || 'mongodb://localhost:27017'; + const client = new MongoClient(uri); + + try { + await client.connect(); + const db = client.db('$database'); + const collection = db.collection('$collection'); + + const pipeline = $pipelineJson; + + const results = await collection.aggregate(pipeline).toArray(); + console.log('Results count:', results.length); + console.dir(results, { depth: null }); + } finally { + await client.close(); + } +} + +runPipeline().catch(console.error); +'''; + } + + /// Exports to Python (PyMongo) script. + static String toPython({ + required String database, + required String collection, + required List stages, + }) { + final pipelineJson = formatPipelineJson(stages); + return '''# Python (pymongo) +import os +from pymongo import MongoClient + +uri = os.environ.get('MONGODB_URI', 'mongodb://localhost:27017') +client = MongoClient(uri) + +try: + db = client['$database'] + collection = db['$collection'] + + pipeline = $pipelineJson + + results = list(collection.aggregate(pipeline)) + print(f"Results count: {len(results)}") + for doc in results: + print(doc) +finally: + client.close() +'''; + } +} diff --git a/lib/features/mongodb/mongo_aggregation_workspace.dart b/lib/features/mongodb/mongo_aggregation_workspace.dart new file mode 100644 index 00000000..d869a85b --- /dev/null +++ b/lib/features/mongodb/mongo_aggregation_workspace.dart @@ -0,0 +1,855 @@ +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/database/mongodb_service.dart'; +import 'package:querya_desktop/features/mongodb/mongo_aggregation_stage.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/shared/widgets/widgets.dart'; + +/// Workspace for visually and interactively building, running, and inspecting +/// MongoDB Aggregation Pipelines step-by-step. +class MongoAggregationWorkspace extends material.StatefulWidget { + const MongoAggregationWorkspace({ + super.key, + required this.connection, + required this.database, + required this.collection, + this.onBack, + this.initialStages, + }); + + final MongoConnection connection; + final String database; + final String collection; + final material.VoidCallback? onBack; + final List? initialStages; + + @override + material.State createState() => + _MongoAggregationWorkspaceState(); +} + +class _MongoAggregationWorkspaceState + extends material.State { + late List _stages; + List> _results = []; + bool _executing = false; + String? _executionError; + int? _lastExecutionDurationMs; + int? _executedUpToStageIndex; + bool _jsonViewMode = false; + + @override + void initState() { + super.initState(); + if (widget.initialStages != null && widget.initialStages!.isNotEmpty) { + _stages = List.from(widget.initialStages!); + } else { + _stages = [ + MongoAggregationStage( + id: 'stage_1', + operator: r'$match', + queryText: templateForOperator(r'$match'), + isEnabled: true, + ), + ]; + } + } + + // ─── Stage management ────────────────────────────────────────────────────── + + void _addStage([String operator = r'$match']) { + setState(() { + final newIndex = _stages.length + 1; + _stages.add( + MongoAggregationStage( + id: 'stage_${DateTime.now().microsecondsSinceEpoch}_$newIndex', + operator: operator, + queryText: templateForOperator(operator), + isEnabled: true, + ), + ); + }); + } + + void _removeStage(int index) { + if (_stages.length <= 1) { + showAppToast( + context: context, + message: 'Pipeline must contain at least one stage', + variant: AppToastVariant.info, + ); + return; + } + setState(() { + _stages.removeAt(index); + }); + } + + void _moveStage(int from, int to) { + if (to < 0 || to >= _stages.length) return; + setState(() { + final stage = _stages.removeAt(from); + _stages.insert(to, stage); + }); + } + + void _toggleStageEnabled(int index, bool enabled) { + setState(() { + _stages[index] = _stages[index].copyWith(isEnabled: enabled); + }); + } + + void _updateStageOperator(int index, String newOp) { + setState(() { + final current = _stages[index]; + _stages[index] = current.copyWith( + operator: newOp, + queryText: templateForOperator(newOp), + clearError: true, + clearMetrics: true, + ); + }); + } + + void _updateStageQuery(int index, String newQuery) { + setState(() { + _stages[index] = _stages[index].copyWith( + queryText: newQuery, + error: validateStage(_stages[index].copyWith(queryText: newQuery)), + ); + }); + } + + // ─── Execution ───────────────────────────────────────────────────────────── + + Future _runPipeline({int? upToStageIndex}) async { + // Validate all participating stages first + final targetLimit = upToStageIndex ?? (_stages.length - 1); + for (int i = 0; i <= targetLimit; i++) { + final stage = _stages[i]; + if (!stage.isEnabled) continue; + final err = validateStage(stage); + if (err != null) { + setState(() { + _stages[i] = stage.copyWith(error: err); + _executionError = + 'Stage #${i + 1} (${stage.operator}) has a syntax error: $err'; + }); + return; + } + } + + setState(() { + _executing = true; + _executionError = null; + _executedUpToStageIndex = upToStageIndex; + }); + + final stopwatch = Stopwatch()..start(); + try { + final pipeline = buildPipeline(_stages, upToStageIndex: upToStageIndex); + final docs = await MongoService.instance.aggregate( + widget.connection, + widget.database, + widget.collection, + pipeline, + ); + stopwatch.stop(); + + if (!mounted) return; + setState(() { + _results = docs; + _lastExecutionDurationMs = stopwatch.elapsedMilliseconds; + _executing = false; + + // Record metrics on target stage if applicable + if (upToStageIndex != null && upToStageIndex < _stages.length) { + _stages[upToStageIndex] = _stages[upToStageIndex].copyWith( + executionDurationMs: stopwatch.elapsedMilliseconds, + outputCount: docs.length, + clearError: true, + ); + } else if (_stages.isNotEmpty) { + _stages.last = _stages.last.copyWith( + executionDurationMs: stopwatch.elapsedMilliseconds, + outputCount: docs.length, + clearError: true, + ); + } + }); + } catch (e) { + stopwatch.stop(); + if (!mounted) return; + setState(() { + _executing = false; + _executionError = e.toString(); + }); + } + } + + void _showExportDialog() { + material.showDialog( + context: context, + builder: (dialogCtx) => _ExportDialog( + database: widget.database, + collection: widget.collection, + stages: _stages, + ), + ); + } + + // ─── Build ───────────────────────────────────────────────────────────────── + + @override + material.Widget build(material.BuildContext context) { + final cs = Theme.of(context).colorScheme; + + return material.Container( + color: cs.background, + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + // Top action bar + _buildActionBar(cs), + const Divider(height: 1), + // Main content: responsive layout + material.Expanded( + child: material.LayoutBuilder( + builder: (context, constraints) { + if (constraints.maxWidth >= 900) { + return material.Row( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + // Left pane: Stages builder + material.SizedBox( + width: (constraints.maxWidth * 0.45).clamp(380.0, 560.0), + child: _buildStagesPane(cs), + ), + const VerticalDivider(width: 1), + // Right pane: Results + material.Expanded( + child: _buildResultsPane(cs), + ), + ], + ); + } else { + // Narrow viewport: vertical split + return material.Column( + children: [ + material.Expanded( + flex: 5, + child: _buildStagesPane(cs), + ), + const Divider(height: 1), + material.Expanded( + flex: 5, + child: _buildResultsPane(cs), + ), + ], + ); + } + }, + ), + ), + ], + ), + ); + } + + material.Widget _buildActionBar(ColorScheme cs) { + final enabledCount = _stages.where((s) => s.isEnabled).length; + + 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.auto_awesome_motion_rounded, + size: 18, + color: cs.primary, + ), + const Gap(8), + Text('Aggregation Pipeline: ${widget.collection}') + .semiBold() + .medium(), + const Gap(8), + QueryaBadge.status( + '$enabledCount / ${_stages.length} active', + status: QueryaBadgeStatus.neutral, + ), + if (_lastExecutionDurationMs != null) ...[ + const Gap(8), + QueryaBadge.status( + '${_results.length} docs in ${_lastExecutionDurationMs}ms', + status: QueryaBadgeStatus.success, + ), + ], + const Gap(16), + OutlineButton( + onPressed: () => _addStage(), + size: ButtonSize.small, + leading: + const material.Icon(material.Icons.add_rounded, size: 14), + child: const Text('Add Stage'), + ), + const Gap(8), + OutlineButton( + onPressed: _showExportDialog, + size: ButtonSize.small, + leading: + const material.Icon(material.Icons.code_rounded, size: 14), + child: const Text('Export Code'), + ), + const Gap(8), + PrimaryButton( + onPressed: _executing ? null : () => _runPipeline(), + size: ButtonSize.small, + leading: _executing + ? const QueryaSpinner(size: QueryaSpinnerSize.sm) + : const material.Icon(material.Icons.play_arrow_rounded, + size: 16), + child: Text(_executing ? 'Running...' : 'Run Pipeline'), + ), + ], + ), + ), + ); + } + + material.Widget _buildStagesPane(ColorScheme cs) { + return material.Container( + color: cs.card.withValues(alpha: 0.35), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Padding( + padding: const material.EdgeInsets.fromLTRB(16, 12, 16, 8), + child: material.Row( + children: [ + const Text('Pipeline Stages').semiBold(), + const material.Spacer(), + Text('${_stages.length} stages').muted().small(), + ], + ), + ), + const Divider(height: 1), + material.Expanded( + child: material.ListView.separated( + padding: const material.EdgeInsets.all(12), + itemCount: _stages.length + 1, + separatorBuilder: (_, __) => const Gap(12), + itemBuilder: (context, index) { + if (index == _stages.length) { + return material.Center( + child: OutlineButton( + onPressed: () => _addStage(), + size: ButtonSize.small, + leading: const material.Icon( + material.Icons.add_circle_outline_rounded, + size: 14, + ), + child: const Text('Add Next Stage'), + ), + ); + } + + final stage = _stages[index]; + return _StageCard( + key: ValueKey(stage.id), + stage: stage, + index: index, + isFirst: index == 0, + isLast: index == _stages.length - 1, + onToggleEnabled: (val) => _toggleStageEnabled(index, val), + onOperatorChanged: (op) => _updateStageOperator(index, op), + onQueryChanged: (query) => _updateStageQuery(index, query), + onMoveUp: () => _moveStage(index, index - 1), + onMoveDown: () => _moveStage(index, index + 1), + onDelete: () => _removeStage(index), + onRunUpToHere: () => _runPipeline(upToStageIndex: index), + ); + }, + ), + ), + ], + ), + ); + } + + material.Widget _buildResultsPane(ColorScheme cs) { + final title = _executedUpToStageIndex == null + ? 'Pipeline Results' + : 'Results after Stage #${_executedUpToStageIndex! + 1} (${_stages[_executedUpToStageIndex!].operator})'; + + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + // Results header + material.Container( + padding: const material.EdgeInsets.symmetric(horizontal: 16, vertical: 8), + color: cs.card, + child: material.Row( + children: [ + Text(title).semiBold(), + const material.Spacer(), + if (_results.isNotEmpty) ...[ + Text('${_results.length} documents').muted().small(), + const Gap(12), + ], + material.SizedBox( + height: 28, + child: material.SegmentedButton( + segments: const [ + material.ButtonSegment( + value: false, + label: material.Text('Table'), + icon: material.Icon( + material.Icons.table_chart_rounded, + size: 14, + ), + ), + material.ButtonSegment( + value: true, + label: material.Text('JSON'), + icon: material.Icon( + material.Icons.code_rounded, + size: 14, + ), + ), + ], + selected: {_jsonViewMode}, + onSelectionChanged: (selected) { + setState(() => _jsonViewMode = selected.first); + }, + showSelectedIcon: false, + style: material.SegmentedButton.styleFrom( + padding: const material.EdgeInsets.symmetric(horizontal: 8), + visualDensity: material.VisualDensity.compact, + ), + ), + ), + ], + ), + ), + const Divider(height: 1), + // Error banner if any + if (_executionError != null) + material.Container( + padding: const material.EdgeInsets.all(12), + color: cs.destructive.withValues(alpha: 0.1), + child: material.Row( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.Icon( + material.Icons.error_outline_rounded, + size: 16, + color: cs.destructive, + ), + const Gap(8), + material.Expanded( + child: material.SelectableText( + _executionError!, + style: material.TextStyle( + color: cs.destructive, + fontSize: 13, + ), + ), + ), + material.InkWell( + onTap: () => setState(() => _executionError = null), + child: material.Icon( + material.Icons.close_rounded, + size: 16, + color: cs.destructive, + ), + ), + ], + ), + ), + // Results content + material.Expanded( + child: _executing + ? const material.Center( + child: QueryaSpinner( + size: QueryaSpinnerSize.lg, + label: 'Executing aggregation pipeline...', + ), + ) + : _results.isEmpty + ? const QueryaEmptyState( + icon: material.Icon( + material.Icons.auto_awesome_motion_rounded, + size: 40, + ), + title: 'No Documents Yet', + description: + 'Add stages, configure query filters/aggregations, and click "Run Pipeline" or "Run up to here" on any stage.', + ) + : _jsonViewMode + ? MongoDocumentsJsonView(documents: _results) + : MongoDocumentsTableView(documents: _results), + ), + ], + ); + } +} + +// ─── Stage card widget ──────────────────────────────────────────────────────── + +class _StageCard extends material.StatefulWidget { + const _StageCard({ + super.key, + required this.stage, + required this.index, + required this.isFirst, + required this.isLast, + required this.onToggleEnabled, + required this.onOperatorChanged, + required this.onQueryChanged, + required this.onMoveUp, + required this.onMoveDown, + required this.onDelete, + required this.onRunUpToHere, + }); + + final MongoAggregationStage stage; + final int index; + final bool isFirst; + final bool isLast; + final ValueChanged onToggleEnabled; + final ValueChanged onOperatorChanged; + final ValueChanged onQueryChanged; + final material.VoidCallback onMoveUp; + final material.VoidCallback onMoveDown; + final material.VoidCallback onDelete; + final material.VoidCallback onRunUpToHere; + + @override + material.State<_StageCard> createState() => _StageCardState(); +} + +class _StageCardState extends material.State<_StageCard> { + late material.TextEditingController _textController; + + @override + void initState() { + super.initState(); + _textController = material.TextEditingController(text: widget.stage.queryText); + } + + @override + void didUpdateWidget(covariant _StageCard oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.stage.queryText != widget.stage.queryText && + _textController.text != widget.stage.queryText) { + _textController.text = widget.stage.queryText; + } + } + + @override + void dispose() { + _textController.dispose(); + super.dispose(); + } + + @override + material.Widget build(material.BuildContext context) { + final cs = Theme.of(context).colorScheme; + final stage = widget.stage; + final syntaxError = validateStage(stage); + + return Card( + child: material.Padding( + padding: const material.EdgeInsets.all(12), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + // Stage header + material.SingleChildScrollView( + scrollDirection: material.Axis.horizontal, + child: material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + QueryaBadge( + label: '#${widget.index + 1}', + ), + const Gap(8), + // Operator selector via QueryaDropdown + material.SizedBox( + width: 140, + child: QueryaDropdown( + value: stage.operator, + items: [ + for (final opInfo in kMongoStageOperators) + QueryaDropdownItem( + value: opInfo.op, + label: opInfo.op, + ), + ], + onSelected: (val) { + if (val != null) widget.onOperatorChanged(val); + }, + ), + ), + const Gap(8), + Switch( + value: stage.isEnabled, + onChanged: widget.onToggleEnabled, + ), + if (!stage.isEnabled) + const Text('Disabled').muted().small(), + const Gap(8), + if (stage.executionDurationMs != null) ...[ + QueryaBadge.status( + '${stage.outputCount ?? 0} docs (${stage.executionDurationMs}ms)', + status: QueryaBadgeStatus.success, + ), + const Gap(6), + ], + QueryaIconButton( + icon: const material.Icon(material.Icons.arrow_upward_rounded), + tooltip: 'Move Up', + density: QueryaIconButtonDensity.dense, + onPressed: widget.isFirst ? null : widget.onMoveUp, + ), + QueryaIconButton( + icon: const material.Icon(material.Icons.arrow_downward_rounded), + tooltip: 'Move Down', + density: QueryaIconButtonDensity.dense, + onPressed: widget.isLast ? null : widget.onMoveDown, + ), + QueryaIconButton( + icon: const material.Icon(material.Icons.delete_outline_rounded), + tooltip: 'Delete Stage', + density: QueryaIconButtonDensity.dense, + isDestructive: true, + onPressed: widget.onDelete, + ), + ], + ), + ), + const Gap(8), + // Code input body + material.DecoratedBox( + decoration: material.BoxDecoration( + border: material.Border.all( + color: syntaxError != null ? cs.destructive : cs.border, + ), + borderRadius: material.BorderRadius.circular(6), + color: cs.background, + ), + child: material.TextField( + controller: _textController, + maxLines: null, + minLines: 3, + style: const material.TextStyle( + fontFamily: 'monospace', + fontSize: 12.5, + height: 1.4, + ), + decoration: const material.InputDecoration( + isDense: true, + border: material.InputBorder.none, + contentPadding: material.EdgeInsets.all(10), + ), + onChanged: widget.onQueryChanged, + ), + ), + if (syntaxError != null) ...[ + const Gap(4), + Text( + syntaxError, + style: material.TextStyle( + color: cs.destructive, + fontSize: 11.5, + ), + ), + ], + const Gap(8), + // Footer actions: run up to here + material.Row( + children: [ + GhostButton( + onPressed: stage.isEnabled ? widget.onRunUpToHere : null, + size: ButtonSize.small, + leading: const material.Icon( + material.Icons.play_circle_outline_rounded, + size: 14, + ), + child: const Text('Run up to here'), + ), + const Gap(8), + material.Expanded( + child: material.Text( + _operatorSummary(stage.operator), + textAlign: material.TextAlign.end, + overflow: material.TextOverflow.ellipsis, + style: material.TextStyle( + fontSize: 11, + color: cs.mutedForeground, + ), + ), + ), + ], + ), + ], + ), + ), + ); + } + + String _operatorSummary(String op) { + for (final info in kMongoStageOperators) { + if (info.op == op) return info.summary; + } + return ''; + } +} + +// ─── Export Code Dialog ─────────────────────────────────────────────────────── + +class _ExportDialog extends material.StatefulWidget { + const _ExportDialog({ + required this.database, + required this.collection, + required this.stages, + }); + + final String database; + final String collection; + final List stages; + + @override + material.State<_ExportDialog> createState() => _ExportDialogState(); +} + +class _ExportDialogState extends material.State<_ExportDialog> { + int _selectedTab = 0; + + String _generateCode() { + switch (_selectedTab) { + case 0: + return MongoAggregationExporter.toMongosh( + database: widget.database, + collection: widget.collection, + stages: widget.stages, + ); + case 1: + return MongoAggregationExporter.toNodeJs( + database: widget.database, + collection: widget.collection, + stages: widget.stages, + ); + case 2: + return MongoAggregationExporter.toPython( + database: widget.database, + collection: widget.collection, + stages: widget.stages, + ); + default: + return ''; + } + } + + @override + material.Widget build(material.BuildContext context) { + final cs = Theme.of(context).colorScheme; + final code = _generateCode(); + + return QueryaModalDialog( + title: const Text('Export Aggregation Pipeline'), + description: const Text('Copy ready-to-run driver code for your pipeline'), + constraints: const material.BoxConstraints(maxWidth: 680), + content: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.SizedBox( + height: 32, + child: material.SegmentedButton( + segments: const [ + material.ButtonSegment( + value: 0, + label: material.Text('mongosh (Shell)'), + ), + material.ButtonSegment( + value: 1, + label: material.Text('Node.js'), + ), + material.ButtonSegment( + value: 2, + label: material.Text('Python'), + ), + ], + selected: {_selectedTab}, + onSelectionChanged: (selected) { + setState(() => _selectedTab = selected.first); + }, + showSelectedIcon: false, + style: material.SegmentedButton.styleFrom( + visualDensity: material.VisualDensity.compact, + ), + ), + ), + const Gap(12), + material.Container( + height: 280, + padding: const material.EdgeInsets.all(12), + decoration: material.BoxDecoration( + color: cs.card, + border: material.Border.all(color: cs.border), + borderRadius: material.BorderRadius.circular(6), + ), + child: material.SingleChildScrollView( + child: material.SelectableText( + code, + style: const material.TextStyle( + fontFamily: 'monospace', + fontSize: 12.5, + height: 1.45, + ), + ), + ), + ), + ], + ), + actions: [ + OutlineButton( + onPressed: () => material.Navigator.of(context).pop(), + child: const Text('Close'), + ), + PrimaryButton( + onPressed: () async { + await Clipboard.setData(ClipboardData(text: code)); + if (context.mounted) { + showAppToast( + context: context, + message: 'Pipeline code copied to clipboard', + variant: AppToastVariant.success, + ); + material.Navigator.of(context).pop(); + } + }, + leading: const material.Icon(material.Icons.copy_rounded, size: 16), + child: const Text('Copy to Clipboard'), + ), + ], + ); + } +} diff --git a/lib/features/mongodb/mongo_documents_view.dart b/lib/features/mongodb/mongo_documents_view.dart index a44690b1..d69ca456 100644 --- a/lib/features/mongodb/mongo_documents_view.dart +++ b/lib/features/mongodb/mongo_documents_view.dart @@ -45,6 +45,7 @@ class MongoDocumentsView extends material.StatefulWidget { required this.database, required this.collection, this.onDocumentTap, + this.onOpenAggregation, this.refreshToken = 0, this.initialViewMode, }); @@ -53,6 +54,7 @@ class MongoDocumentsView extends material.StatefulWidget { final String database; final String collection; final ValueChanged>? onDocumentTap; + final material.VoidCallback? onOpenAggregation; /// Incremented by the parent when the user requests a refresh (toolbar). final int refreshToken; @@ -524,6 +526,18 @@ class _MongoDocumentsViewState extends material.State { ), ), ), + if (widget.onOpenAggregation != null) ...[ + const Gap(8), + OutlineButton( + onPressed: widget.onOpenAggregation, + size: ButtonSize.small, + leading: const material.Icon( + material.Icons.auto_awesome_motion_rounded, + size: 14, + ), + child: const Text('Aggregation'), + ), + ], 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 6685cd40..1728364c 100644 --- a/lib/features/mongodb/mongo_explorer_view.dart +++ b/lib/features/mongodb/mongo_explorer_view.dart @@ -9,6 +9,7 @@ import 'package:querya_desktop/core/unsaved_work_guard.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn; +import 'mongo_aggregation_workspace.dart'; import 'mongo_collections_view.dart'; import 'mongo_databases_view.dart'; import 'mongo_documents_view.dart'; @@ -24,7 +25,7 @@ class _Crumb { final _Level level; } -enum _Level { databases, collections, documents, document, stats } +enum _Level { databases, collections, documents, document, stats, aggregation } // ─── Main explorer widget ─────────────────────────────────────────────────── @@ -52,6 +53,7 @@ class _MongoExplorerViewState extends material.State { // View mode bool _showStats = false; + bool _showAggregation = false; /// Bumped when the user taps Refresh in the breadcrumb bar (reload active view). int _refreshToken = 0; @@ -155,6 +157,7 @@ class _MongoExplorerViewState extends material.State { _selectedDatabase = dbName; _selectedCollection = null; _selectedDocument = null; + _showAggregation = false; }); } @@ -163,12 +166,14 @@ class _MongoExplorerViewState extends material.State { _selectedCollection = collName; _lastSelectedCollection = collName; _selectedDocument = null; + _showAggregation = false; }); } void _navigateToDocument(Map doc) { setState(() { _selectedDocument = doc; + _showAggregation = false; }); } @@ -177,6 +182,7 @@ class _MongoExplorerViewState extends material.State { _selectedDatabase = null; _selectedCollection = null; _selectedDocument = null; + _showAggregation = false; }); } @@ -184,12 +190,14 @@ class _MongoExplorerViewState extends material.State { setState(() { _selectedCollection = null; _selectedDocument = null; + _showAggregation = false; }); } void _navigateToDocuments() { setState(() { _selectedDocument = null; + _showAggregation = false; }); } @@ -209,7 +217,9 @@ class _MongoExplorerViewState extends material.State { final id = _selectedDocument!['_id']?.toString() ?? 'Document'; list.add(_Crumb(id, _Level.document)); } - if (_showStats) { + if (_showAggregation) { + list.add(const _Crumb('Aggregation', _Level.aggregation)); + } else if (_showStats) { list.add(const _Crumb('Statistics', _Level.stats)); } return list; @@ -218,7 +228,8 @@ class _MongoExplorerViewState extends material.State { Future _onCrumbTap(_Crumb crumb) async { final leavesDocument = _selectedDocument != null && crumb.level != _Level.document && - crumb.level != _Level.stats; + crumb.level != _Level.stats && + crumb.level != _Level.aggregation; if (leavesDocument) { if (!await confirmDiscardUnsavedWorkIfNeeded(context)) return; if (!mounted) return; @@ -226,6 +237,9 @@ class _MongoExplorerViewState extends material.State { if (_showStats && crumb.level != _Level.stats) { setState(() => _showStats = false); } + if (_showAggregation && crumb.level != _Level.aggregation) { + setState(() => _showAggregation = false); + } switch (crumb.level) { case _Level.databases: _navigateToDatabases(); @@ -237,6 +251,8 @@ class _MongoExplorerViewState extends material.State { break; // Already on the document case _Level.stats: break; + case _Level.aggregation: + break; } } @@ -342,6 +358,19 @@ class _MongoExplorerViewState extends material.State { } material.Widget _buildContent(MongoConnection conn) { + // Aggregation workspace + if (_showAggregation && + _selectedDatabase != null && + _selectedCollection != null) { + return MongoAggregationWorkspace( + key: ValueKey('agg_${_selectedDatabase}_$_selectedCollection'), + connection: conn, + database: _selectedDatabase!, + collection: _selectedCollection!, + onBack: () => setState(() => _showAggregation = false), + ); + } + // Document editor if (_selectedDocument != null && _selectedDatabase != null && @@ -370,6 +399,7 @@ class _MongoExplorerViewState extends material.State { collection: _selectedCollection!, refreshToken: _refreshToken + _documentsListInvalidation, onDocumentTap: _navigateToDocument, + onOpenAggregation: () => setState(() => _showAggregation = true), ); } diff --git a/test/features/mongodb/mongo_aggregation_stage_test.dart b/test/features/mongodb/mongo_aggregation_stage_test.dart new file mode 100644 index 00000000..bfc796c9 --- /dev/null +++ b/test/features/mongodb/mongo_aggregation_stage_test.dart @@ -0,0 +1,164 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/features/mongodb/mongo_aggregation_stage.dart'; + +void main() { + group('MongoAggregationStage and pipeline helpers', () { + test('validateStage returns null on valid JSON and error string on invalid JSON', () { + const validStage = MongoAggregationStage( + id: 's1', + operator: r'$match', + queryText: '{"status": "active"}', + ); + expect(validateStage(validStage), isNull); + + const emptyStage = MongoAggregationStage( + id: 's2', + operator: r'$match', + queryText: ' ', + ); + expect(validateStage(emptyStage), contains('cannot be empty')); + + const invalidJsonStage = MongoAggregationStage( + id: 's3', + operator: r'$match', + queryText: '{"status": active}', + ); + expect(validateStage(invalidJsonStage), contains('Invalid JSON')); + }); + + test('parseStage correctly parses map stage bodies', () { + const stage = MongoAggregationStage( + id: 's1', + operator: r'$match', + queryText: '{"status": "active", "count": {"\$gt": 5}}', + ); + final parsed = parseStage(stage); + expect(parsed, contains(r'$match')); + final body = parsed[r'$match'] as Map; + expect(body['status'], equals('active')); + expect(body['count'], equals({r'$gt': 5})); + }); + + test('parseStage unwraps redundant outer operator if user provided it', () { + const stage = MongoAggregationStage( + id: 's1', + operator: r'$match', + queryText: '{"\$match": {"status": "active"}}', + ); + final parsed = parseStage(stage); + expect(parsed, equals({ + r'$match': {'status': 'active'}, + })); + }); + + test('parseStage handles scalar values for limit/skip/count', () { + const limitStage = MongoAggregationStage( + id: 's1', + operator: r'$limit', + queryText: '25', + ); + expect(parseStage(limitStage), equals({r'$limit': 25})); + + const countStage = MongoAggregationStage( + id: 's2', + operator: r'$count', + queryText: '"total_records"', + ); + expect(parseStage(countStage), equals({r'$count': 'total_records'})); + + const unsetStage = MongoAggregationStage( + id: 's3', + operator: r'$unset', + queryText: '["fieldA", "fieldB"]', + ); + expect(parseStage(unsetStage), equals({ + r'$unset': ['fieldA', 'fieldB'], + })); + }); + + test('buildPipeline filters disabled stages and supports upToStageIndex', () { + final stages = [ + const MongoAggregationStage( + id: 's1', + operator: r'$match', + queryText: '{"active": true}', + isEnabled: true, + ), + const MongoAggregationStage( + id: 's2', + operator: r'$group', + queryText: '{"_id": "\$role", "total": {"\$sum": 1}}', + isEnabled: false, + ), + const MongoAggregationStage( + id: 's3', + operator: r'$sort', + queryText: '{"total": -1}', + isEnabled: true, + ), + const MongoAggregationStage( + id: 's4', + operator: r'$limit', + queryText: '5', + isEnabled: true, + ), + ]; + + // Full pipeline skips s2 (disabled) + final fullPipeline = buildPipeline(stages); + expect(fullPipeline.length, equals(3)); + expect(fullPipeline[0], contains(r'$match')); + expect(fullPipeline[1], contains(r'$sort')); + expect(fullPipeline[2], contains(r'$limit')); + + // Up to stage index 1 (s1, s2): only s1 is enabled + final partialPipeline = buildPipeline(stages, upToStageIndex: 1); + expect(partialPipeline.length, equals(1)); + expect(partialPipeline.first, contains(r'$match')); + }); + + test('MongoAggregationExporter formats valid snippets for mongosh, node and python', () { + final stages = [ + const MongoAggregationStage( + id: 's1', + operator: r'$match', + queryText: '{"status": "published"}', + isEnabled: true, + ), + const MongoAggregationStage( + id: 's2', + operator: r'$limit', + queryText: '10', + isEnabled: true, + ), + ]; + + final mongoshCode = MongoAggregationExporter.toMongosh( + database: 'my_db', + collection: 'articles', + stages: stages, + ); + expect(mongoshCode, contains("use('my_db');")); + expect(mongoshCode, contains("db.getCollection('articles').aggregate(")); + expect(mongoshCode, contains(r'$match')); + + final nodeCode = MongoAggregationExporter.toNodeJs( + database: 'my_db', + collection: 'articles', + stages: stages, + ); + expect(nodeCode, contains("client.db('my_db')")); + expect(nodeCode, contains("db.collection('articles')")); + expect(nodeCode, contains("collection.aggregate(pipeline).toArray()")); + + final pythonCode = MongoAggregationExporter.toPython( + database: 'my_db', + collection: 'articles', + stages: stages, + ); + expect(pythonCode, contains("client['my_db']")); + expect(pythonCode, contains("db['articles']")); + expect(pythonCode, contains("list(collection.aggregate(pipeline))")); + }); + }); +} diff --git a/test/features/mongodb/mongo_aggregation_workspace_test.dart b/test/features/mongodb/mongo_aggregation_workspace_test.dart new file mode 100644 index 00000000..88706a7f --- /dev/null +++ b/test/features/mongodb/mongo_aggregation_workspace_test.dart @@ -0,0 +1,173 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/mongodb_connection.dart'; +import 'package:querya_desktop/core/theme/querya_theme.dart'; +import 'package:querya_desktop/features/mongodb/mongo_aggregation_stage.dart'; +import 'package:querya_desktop/features/mongodb/mongo_aggregation_workspace.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + final connection = MongoConnection(id: 1, name: 'test', host: 'localhost'); + + Future pumpWorkspace( + WidgetTester tester, { + List? initialStages, + material.VoidCallback? onBack, + }) async { + tester.view.physicalSize = const material.Size(1440, 900); + tester.view.devicePixelRatio = 1.0; + addTearDown(() => tester.view.resetPhysicalSize()); + + await tester.pumpWidget( + queryaThemeTestShell( + data: QueryaTheme.darkDefault, + child: material.SizedBox( + width: 1400, + height: 800, + child: MongoAggregationWorkspace( + connection: connection, + database: 'ecommerce', + collection: 'orders', + initialStages: initialStages, + onBack: onBack, + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + group('MongoAggregationWorkspace widget tests', () { + testWidgets('renders title, action bar, and default initial stage', (tester) async { + await pumpWorkspace(tester); + + expect(find.text('Aggregation Pipeline: orders'), findsOneWidget); + expect(find.text('Pipeline Stages'), findsOneWidget); + expect(find.text('#1'), findsOneWidget); + expect(find.text('Add Stage'), findsOneWidget); + expect(find.text('Export Code'), findsOneWidget); + expect(find.text('Run Pipeline'), findsOneWidget); + expect(find.text('No Documents Yet'), findsOneWidget); + }); + + testWidgets('can add a new stage', (tester) async { + await pumpWorkspace(tester); + + expect(find.text('#1'), findsOneWidget); + expect(find.text('#2'), findsNothing); + + // Tap Add Stage button + await tester.tap(find.text('Add Stage')); + await tester.pumpAndSettle(); + + expect(find.text('#1'), findsOneWidget); + expect(find.text('#2'), findsOneWidget); + expect(find.text('2 stages'), findsOneWidget); + }); + + testWidgets('can reorder and delete stages', (tester) async { + final stages = [ + const MongoAggregationStage( + id: 'stage_1', + operator: r'$match', + queryText: '{"status": "A"}', + ), + const MongoAggregationStage( + id: 'stage_2', + operator: r'$limit', + queryText: '10', + ), + ]; + + await pumpWorkspace(tester, initialStages: stages); + + expect(find.text('#1'), findsOneWidget); + expect(find.text('#2'), findsOneWidget); + + // Delete stage 2 + final deleteButtons = find.byTooltip('Delete Stage'); + expect(deleteButtons, findsNWidgets(2)); + await tester.tap(deleteButtons.last); + await tester.pumpAndSettle(); + + expect(find.text('#1'), findsOneWidget); + expect(find.text('#2'), findsNothing); + expect(find.text('1 stages'), findsOneWidget); + }); + + testWidgets('shows validation error on invalid JSON stage body', (tester) async { + await pumpWorkspace(tester); + + // Enter invalid JSON in the stage textfield + final textField = find.byType(material.TextField).first; + await tester.enterText(textField, '{ invalid json'); + await tester.pumpAndSettle(); + + expect(find.textContaining('Invalid JSON'), findsOneWidget); + + // Attempting to run pipeline shows error banner + final runButton = find.text('Run Pipeline'); + await tester.ensureVisible(runButton); + await tester.tap(runButton); + await tester.pumpAndSettle(); + + expect(find.textContaining('syntax error'), findsOneWidget); + }); + + testWidgets('opens export code dialog and displays code templates', (tester) async { + await pumpWorkspace(tester); + + await tester.tap(find.text('Export Code')); + await tester.pumpAndSettle(); + + expect(find.text('Export Aggregation Pipeline'), findsOneWidget); + expect(find.text('mongosh (Shell)'), findsOneWidget); + expect(find.text('Node.js'), findsOneWidget); + expect(find.text('Python'), findsOneWidget); + expect(find.text('Copy to Clipboard'), findsOneWidget); + + // Switch to Python tab + await tester.tap(find.text('Python')); + await tester.pumpAndSettle(); + + expect(find.textContaining('pymongo'), findsOneWidget); + + // Close dialog + await tester.tap(find.text('Close')); + await tester.pumpAndSettle(); + + expect(find.text('Export Aggregation Pipeline'), findsNothing); + }); + + testWidgets('calls onBack callback when Back to Documents is pressed', (tester) async { + bool backTapped = false; + await pumpWorkspace(tester, onBack: () => backTapped = true); + + final backButton = find.text('Back to Documents'); + expect(backButton, findsOneWidget); + + await tester.tap(backButton); + await tester.pumpAndSettle(); + + expect(backTapped, isTrue); + }); + + testWidgets('toggle switch disables stage', (tester) async { + await pumpWorkspace(tester); + + expect(find.text('Disabled'), findsNothing); + + // Tap Switch + final switchWidget = find.byType(Switch).first; + await tester.tap(switchWidget); + await tester.pumpAndSettle(); + + expect(find.text('Disabled'), findsOneWidget); + expect(find.text('0 / 1 active'), findsOneWidget); + }); + }); +}