diff --git a/lib/core/database/mongodb_service.dart b/lib/core/database/mongodb_service.dart index 055ae83d..fa282dcb 100644 --- a/lib/core/database/mongodb_service.dart +++ b/lib/core/database/mongodb_service.dart @@ -327,6 +327,88 @@ class MongoService { }); } + /// Returns index information merged with disk sizes from collStats. + Future>> getIndexesWithStats( + MongoConnection connection, + String database, + String collection, + ) async { + return _withDb(connection, database, (db) async { + final coll = db.collection(collection); + final indexes = await coll.getIndexes(); + final list = + indexes.map((idx) => Map.from(idx)).toList(); + + try { + final stats = + await db.runCommand({'collStats': collection}); + final indexSizes = stats['indexSizes'] as Map?; + if (indexSizes != null) { + for (final idx in list) { + final name = idx['name']?.toString(); + if (name != null && indexSizes.containsKey(name)) { + idx['size'] = indexSizes[name]; + } + } + } + } catch (_) { + // collStats may not be supported on some restricted roles/DBs; indexes list still returned + } + + return list; + }); + } + + /// Creates a new index on [collection]. + Future> createIndex( + MongoConnection connection, + String database, + String collection, { + required Map keys, + String? name, + bool unique = false, + bool sparse = false, + int? expireAfterSeconds, + }) async { + if (keys.isEmpty) { + throw ArgumentError('Index keys cannot be empty'); + } + return _withDb(connection, database, (db) async { + final generatedName = name ?? + keys.entries.map((e) => '${e.key}_${e.value}').join('_'); + final indexSpec = { + 'key': keys, + 'name': generatedName, + if (unique) 'unique': true, + if (sparse) 'sparse': true, + if (expireAfterSeconds != null) + 'expireAfterSeconds': expireAfterSeconds, + }; + + final cmd = { + 'createIndexes': collection, + 'indexes': [indexSpec], + }; + return db.runCommand(cmd); + }); + } + + /// Drops an index by [indexName]. Prevents dropping the primary index `_id_`. + Future> dropIndex( + MongoConnection connection, + String database, + String collection, + String indexName, + ) async { + if (indexName == '_id_' || indexName == '_id') { + throw ArgumentError('Cannot drop the primary index _id_'); + } + return _withDb(connection, database, (db) async { + final coll = db.collection(collection); + return coll.dropIndexes(indexName); + }); + } + /// Creates a new collection. Future createCollection( MongoConnection connection, diff --git a/lib/features/mongodb/mongo_create_index_dialog.dart b/lib/features/mongodb/mongo_create_index_dialog.dart new file mode 100644 index 00000000..f7d60452 --- /dev/null +++ b/lib/features/mongodb/mongo_create_index_dialog.dart @@ -0,0 +1,388 @@ +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/database/mongodb_connection.dart'; +import 'package:querya_desktop/core/database/mongodb_service.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +class _IndexFieldDraft { + _IndexFieldDraft({String field = '', dynamic direction = 1}) + : controller = material.TextEditingController(text: field), + direction = direction; + + final material.TextEditingController controller; + dynamic direction; + + void dispose() { + controller.dispose(); + } +} + +/// Dialog for creating single, compound, unique, sparse, and TTL MongoDB indexes. +class MongoCreateIndexDialog extends material.StatefulWidget { + const MongoCreateIndexDialog({ + super.key, + required this.connection, + required this.database, + required this.collection, + this.onCreateIndex, + }); + + final MongoConnection connection; + final String database; + final String collection; + final Future Function({ + required Map keys, + String? name, + bool unique, + bool sparse, + int? expireAfterSeconds, + })? onCreateIndex; + + static Future show({ + required material.BuildContext context, + required MongoConnection connection, + required String database, + required String collection, + Future Function({ + required Map keys, + String? name, + bool unique, + bool sparse, + int? expireAfterSeconds, + })? onCreateIndex, + }) { + return showAppDialog( + context: context, + builder: (ctx) => MongoCreateIndexDialog( + connection: connection, + database: database, + collection: collection, + onCreateIndex: onCreateIndex, + ), + ); + } + + @override + material.State createState() => + _MongoCreateIndexDialogState(); +} + +class _MongoCreateIndexDialogState + extends material.State { + final List<_IndexFieldDraft> _fields = [ + _IndexFieldDraft(), + ]; + + bool _isUnique = false; + bool _isSparse = false; + final _ttlController = material.TextEditingController(); + final _nameController = material.TextEditingController(); + + bool _submitting = false; + String? _error; + + @override + void dispose() { + for (final f in _fields) { + f.dispose(); + } + _ttlController.dispose(); + _nameController.dispose(); + super.dispose(); + } + + void _addField() { + setState(() { + _fields.add(_IndexFieldDraft()); + }); + } + + void _removeField(int index) { + if (_fields.length <= 1) return; + setState(() { + final item = _fields.removeAt(index); + item.dispose(); + }); + } + + String _computedIndexName() { + final custom = _nameController.text.trim(); + if (custom.isNotEmpty) return custom; + final parts = _fields + .where((f) => f.controller.text.trim().isNotEmpty) + .map((f) => '${f.controller.text.trim()}_${f.direction}') + .toList(); + return parts.isEmpty ? 'new_index' : parts.join('_'); + } + + Future _submit() async { + final keys = {}; + for (final f in _fields) { + final name = f.controller.text.trim(); + if (name.isEmpty) { + setState(() => _error = 'All field names must be specified'); + return; + } + keys[name] = f.direction; + } + + if (keys.isEmpty) { + setState(() => _error = 'At least one index field is required'); + return; + } + + int? expireAfterSeconds; + final ttlText = _ttlController.text.trim(); + if (ttlText.isNotEmpty) { + final parsed = int.tryParse(ttlText); + if (parsed == null || parsed < 0) { + setState(() => _error = 'TTL must be a positive integer in seconds'); + return; + } + expireAfterSeconds = parsed; + } + + setState(() { + _submitting = true; + _error = null; + }); + + try { + final customName = _nameController.text.trim(); + final indexName = customName.isNotEmpty ? customName : null; + if (widget.onCreateIndex != null) { + await widget.onCreateIndex!( + keys: keys, + name: indexName, + unique: _isUnique, + sparse: _isSparse, + expireAfterSeconds: expireAfterSeconds, + ); + } else { + await MongoService.instance.createIndex( + widget.connection, + widget.database, + widget.collection, + keys: keys, + name: indexName, + unique: _isUnique, + sparse: _isSparse, + expireAfterSeconds: expireAfterSeconds, + ); + } + + if (!mounted) return; + showAppToast( + context: context, + message: 'Index created successfully', + variant: AppToastVariant.success, + ); + material.Navigator.of(context).pop(true); + } catch (e) { + if (!mounted) return; + setState(() { + _submitting = false; + _error = e.toString(); + }); + } + } + + @override + material.Widget build(material.BuildContext context) { + final cs = Theme.of(context).colorScheme; + + return QueryaModalDialog( + title: const Text('Create Index'), + description: Text('Collection: ${widget.collection}'), + constraints: const material.BoxConstraints(maxWidth: 540), + content: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + if (_error != null) ...[ + material.Container( + padding: const material.EdgeInsets.all(10), + decoration: material.BoxDecoration( + color: cs.destructive.withValues(alpha: 0.1), + borderRadius: material.BorderRadius.circular(6), + border: material.Border.all( + color: cs.destructive.withValues(alpha: 0.3)), + ), + child: material.Text( + _error!, + style: material.TextStyle( + color: cs.destructive, + fontSize: 12.5, + ), + ), + ), + const Gap(12), + ], + // Index fields + const Text('Index Keys').semiBold().small(), + const Gap(6), + for (int i = 0; i < _fields.length; i++) ...[ + material.Row( + children: [ + material.Expanded( + flex: 3, + child: material.TextField( + controller: _fields[i].controller, + decoration: const material.InputDecoration( + hintText: 'Field name (e.g. email)', + isDense: true, + border: material.OutlineInputBorder(), + contentPadding: material.EdgeInsets.symmetric( + horizontal: 10, + vertical: 8, + ), + ), + style: const material.TextStyle(fontSize: 13), + onChanged: (_) => setState(() {}), + ), + ), + const Gap(8), + material.SizedBox( + width: 150, + child: QueryaDropdown( + value: _fields[i].direction, + expandToParent: true, + items: const [ + QueryaDropdownItem(value: 1, label: '1 (Asc)'), + QueryaDropdownItem(value: -1, label: '-1 (Desc)'), + QueryaDropdownItem(value: 'text', label: 'text'), + QueryaDropdownItem(value: '2dsphere', label: '2dsphere'), + ], + onSelected: (val) { + if (val != null) { + setState(() { + _fields[i].direction = val; + }); + } + }, + ), + ), + if (_fields.length > 1) ...[ + const Gap(6), + QueryaIconButton( + icon: const material.Icon( + material.Icons.remove_circle_outline_rounded, + ), + tooltip: 'Remove Field', + density: QueryaIconButtonDensity.dense, + color: cs.destructive, + onPressed: () => _removeField(i), + ), + ], + ], + ), + const Gap(8), + ], + material.Align( + alignment: material.Alignment.centerLeft, + child: GhostButton( + onPressed: _addField, + size: ButtonSize.small, + leading: const material.Icon(material.Icons.add_rounded, size: 14), + child: const Text('Add Field (Compound)'), + ), + ), + const Gap(16), + const Divider(height: 1), + const Gap(12), + // Options + const Text('Index Options').semiBold().small(), + const Gap(8), + material.Row( + children: [ + material.InkWell( + onTap: () => setState(() => _isUnique = !_isUnique), + borderRadius: material.BorderRadius.circular(4), + child: material.Padding( + padding: const material.EdgeInsets.symmetric(vertical: 4, horizontal: 4), + child: material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Checkbox( + value: _isUnique, + onChanged: (val) => + setState(() => _isUnique = val == true), + ), + const Gap(4), + const Text('Unique').small(), + ], + ), + ), + ), + const Gap(16), + material.InkWell( + onTap: () => setState(() => _isSparse = !_isSparse), + borderRadius: material.BorderRadius.circular(4), + child: material.Padding( + padding: const material.EdgeInsets.symmetric(vertical: 4, horizontal: 4), + child: material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Checkbox( + value: _isSparse, + onChanged: (val) => + setState(() => _isSparse = val == true), + ), + const Gap(4), + const Text('Sparse').small(), + ], + ), + ), + ), + ], + ), + const Gap(8), + // TTL + material.TextField( + controller: _ttlController, + keyboardType: material.TextInputType.number, + decoration: const material.InputDecoration( + labelText: 'TTL: Expire After Seconds (optional)', + hintText: 'e.g. 86400 (24 hours)', + isDense: true, + border: material.OutlineInputBorder(), + contentPadding: material.EdgeInsets.symmetric( + horizontal: 10, + vertical: 8, + ), + ), + style: const material.TextStyle(fontSize: 13), + ), + const Gap(12), + // Index Name + material.TextField( + controller: _nameController, + decoration: material.InputDecoration( + labelText: 'Index Name (optional)', + hintText: _computedIndexName(), + isDense: true, + border: const material.OutlineInputBorder(), + contentPadding: const material.EdgeInsets.symmetric( + horizontal: 10, + vertical: 8, + ), + ), + style: const material.TextStyle(fontSize: 13), + ), + ], + ), + actions: [ + OutlineButton( + onPressed: () => material.Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + PrimaryButton( + onPressed: _submitting ? null : _submit, + leading: _submitting + ? const QueryaSpinner(size: QueryaSpinnerSize.sm) + : const material.Icon(material.Icons.check_rounded, size: 16), + child: Text(_submitting ? 'Creating...' : 'Create Index'), + ), + ], + ); + } +} diff --git a/lib/features/mongodb/mongo_documents_view.dart b/lib/features/mongodb/mongo_documents_view.dart index d69ca456..f269384b 100644 --- a/lib/features/mongodb/mongo_documents_view.dart +++ b/lib/features/mongodb/mongo_documents_view.dart @@ -46,6 +46,7 @@ class MongoDocumentsView extends material.StatefulWidget { required this.collection, this.onDocumentTap, this.onOpenAggregation, + this.onOpenIndexes, this.refreshToken = 0, this.initialViewMode, }); @@ -55,6 +56,7 @@ class MongoDocumentsView extends material.StatefulWidget { final String collection; final ValueChanged>? onDocumentTap; final material.VoidCallback? onOpenAggregation; + final material.VoidCallback? onOpenIndexes; /// Incremented by the parent when the user requests a refresh (toolbar). final int refreshToken; @@ -538,6 +540,18 @@ class _MongoDocumentsViewState extends material.State { child: const Text('Aggregation'), ), ], + if (widget.onOpenIndexes != null) ...[ + const Gap(8), + OutlineButton( + onPressed: widget.onOpenIndexes, + size: ButtonSize.small, + leading: const material.Icon( + material.Icons.layers_outlined, + size: 14, + ), + child: const Text('Indexes'), + ), + ], 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 1728364c..f9b1a362 100644 --- a/lib/features/mongodb/mongo_explorer_view.dart +++ b/lib/features/mongodb/mongo_explorer_view.dart @@ -14,6 +14,7 @@ import 'mongo_collections_view.dart'; import 'mongo_databases_view.dart'; import 'mongo_documents_view.dart'; import 'mongo_document_editor.dart'; +import 'mongo_indexes_view.dart'; import 'mongo_stats_view.dart'; // ─── Navigation path model ────────────────────────────────────────────────── @@ -25,7 +26,15 @@ class _Crumb { final _Level level; } -enum _Level { databases, collections, documents, document, stats, aggregation } +enum _Level { + databases, + collections, + documents, + document, + stats, + aggregation, + indexes, +} // ─── Main explorer widget ─────────────────────────────────────────────────── @@ -54,6 +63,7 @@ class _MongoExplorerViewState extends material.State { // View mode bool _showStats = false; bool _showAggregation = false; + bool _showIndexes = false; /// Bumped when the user taps Refresh in the breadcrumb bar (reload active view). int _refreshToken = 0; @@ -158,6 +168,7 @@ class _MongoExplorerViewState extends material.State { _selectedCollection = null; _selectedDocument = null; _showAggregation = false; + _showIndexes = false; }); } @@ -167,6 +178,7 @@ class _MongoExplorerViewState extends material.State { _lastSelectedCollection = collName; _selectedDocument = null; _showAggregation = false; + _showIndexes = false; }); } @@ -174,6 +186,7 @@ class _MongoExplorerViewState extends material.State { setState(() { _selectedDocument = doc; _showAggregation = false; + _showIndexes = false; }); } @@ -183,6 +196,7 @@ class _MongoExplorerViewState extends material.State { _selectedCollection = null; _selectedDocument = null; _showAggregation = false; + _showIndexes = false; }); } @@ -191,6 +205,7 @@ class _MongoExplorerViewState extends material.State { _selectedCollection = null; _selectedDocument = null; _showAggregation = false; + _showIndexes = false; }); } @@ -198,6 +213,7 @@ class _MongoExplorerViewState extends material.State { setState(() { _selectedDocument = null; _showAggregation = false; + _showIndexes = false; }); } @@ -217,7 +233,9 @@ class _MongoExplorerViewState extends material.State { final id = _selectedDocument!['_id']?.toString() ?? 'Document'; list.add(_Crumb(id, _Level.document)); } - if (_showAggregation) { + if (_showIndexes) { + list.add(const _Crumb('Indexes', _Level.indexes)); + } else if (_showAggregation) { list.add(const _Crumb('Aggregation', _Level.aggregation)); } else if (_showStats) { list.add(const _Crumb('Statistics', _Level.stats)); @@ -229,7 +247,8 @@ class _MongoExplorerViewState extends material.State { final leavesDocument = _selectedDocument != null && crumb.level != _Level.document && crumb.level != _Level.stats && - crumb.level != _Level.aggregation; + crumb.level != _Level.aggregation && + crumb.level != _Level.indexes; if (leavesDocument) { if (!await confirmDiscardUnsavedWorkIfNeeded(context)) return; if (!mounted) return; @@ -240,6 +259,9 @@ class _MongoExplorerViewState extends material.State { if (_showAggregation && crumb.level != _Level.aggregation) { setState(() => _showAggregation = false); } + if (_showIndexes && crumb.level != _Level.indexes) { + setState(() => _showIndexes = false); + } switch (crumb.level) { case _Level.databases: _navigateToDatabases(); @@ -253,6 +275,8 @@ class _MongoExplorerViewState extends material.State { break; case _Level.aggregation: break; + case _Level.indexes: + break; } } @@ -390,6 +414,20 @@ class _MongoExplorerViewState extends material.State { ); } + // Indexes view + if (_showIndexes && + _selectedDatabase != null && + _selectedCollection != null) { + return MongoIndexesView( + key: ValueKey('idx_${_selectedDatabase}_$_selectedCollection'), + connection: conn, + database: _selectedDatabase!, + collection: _selectedCollection!, + refreshToken: _refreshToken, + onBack: () => setState(() => _showIndexes = false), + ); + } + // Documents list if (_selectedCollection != null && _selectedDatabase != null) { return MongoDocumentsView( @@ -400,6 +438,7 @@ class _MongoExplorerViewState extends material.State { refreshToken: _refreshToken + _documentsListInvalidation, onDocumentTap: _navigateToDocument, onOpenAggregation: () => setState(() => _showAggregation = true), + onOpenIndexes: () => setState(() => _showIndexes = true), ); } diff --git a/lib/features/mongodb/mongo_index_info.dart b/lib/features/mongodb/mongo_index_info.dart new file mode 100644 index 00000000..281fc9b5 --- /dev/null +++ b/lib/features/mongodb/mongo_index_info.dart @@ -0,0 +1,109 @@ +/// Data model for a MongoDB collection index with metadata and size metrics. +class MongoIndexInfo { + const MongoIndexInfo({ + required this.name, + required this.keys, + this.sizeBytes, + this.isUnique = false, + this.isSparse = false, + this.expireAfterSeconds, + this.version, + }); + + final String name; + final Map keys; + final int? sizeBytes; + final bool isUnique; + final bool isSparse; + final int? expireAfterSeconds; + final int? version; + + /// Whether this is the built-in primary index `_id_`, which cannot be deleted. + bool get isPrimary => + name == '_id_' || (keys.length == 1 && keys.containsKey('_id')); + + /// Whether this index expires documents after a specified time. + bool get isTtl => expireAfterSeconds != null; + + /// Categorized display label for the index type. + String get indexType { + if (isPrimary) return 'Primary'; + if (isTtl) return 'TTL'; + if (keys.values.any((v) => v.toString().toLowerCase() == 'text')) { + return 'Text'; + } + if (keys.values.any((v) => + v.toString().toLowerCase() == '2dsphere' || + v.toString().toLowerCase() == '2d')) { + return 'Geospatial'; + } + if (keys.length > 1) return 'Compound'; + return 'Single Field'; + } + + /// Formatted string of keys, e.g. `status: 1, createdAt: -1`. + String get keysFormatted => + keys.entries.map((e) => '${e.key}: ${e.value}').join(', '); + + /// Human-readable representation of index size on disk. + String get sizeFormatted { + if (sizeBytes == null) return '—'; + final bytes = sizeBytes!; + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) { + return '${(bytes / 1024).toStringAsFixed(1)} KB'; + } + if (bytes < 1024 * 1024 * 1024) { + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + } + return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB'; + } + + factory MongoIndexInfo.fromMap(Map map) { + final rawKey = map['key']; + final keys = {}; + if (rawKey is Map) { + for (final entry in rawKey.entries) { + keys[entry.key.toString()] = entry.value; + } + } + + final rawSize = map['size']; + int? sizeBytes; + if (rawSize is num) { + sizeBytes = rawSize.toInt(); + } + + return MongoIndexInfo( + name: map['name']?.toString() ?? 'unnamed_index', + keys: keys, + sizeBytes: sizeBytes, + isUnique: map['unique'] == true, + isSparse: map['sparse'] == true, + expireAfterSeconds: (map['expireAfterSeconds'] as num?)?.toInt(), + version: (map['v'] as num?)?.toInt(), + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is MongoIndexInfo && + runtimeType == other.runtimeType && + name == other.name && + sizeBytes == other.sizeBytes && + isUnique == other.isUnique && + isSparse == other.isSparse && + expireAfterSeconds == other.expireAfterSeconds && + version == other.version; + + @override + int get hashCode => Object.hash( + name, + sizeBytes, + isUnique, + isSparse, + expireAfterSeconds, + version, + ); +} diff --git a/lib/features/mongodb/mongo_indexes_view.dart b/lib/features/mongodb/mongo_indexes_view.dart new file mode 100644 index 00000000..dbdda5f8 --- /dev/null +++ b/lib/features/mongodb/mongo_indexes_view.dart @@ -0,0 +1,444 @@ +import 'dart:async' show unawaited; + +import 'package:flutter/material.dart' as material; +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_create_index_dialog.dart'; +import 'package:querya_desktop/features/mongodb/mongo_index_info.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +/// Interactive view for managing, inspecting, creating, and dropping +/// MongoDB collection indexes with disk size metrics. +class MongoIndexesView extends material.StatefulWidget { + const MongoIndexesView({ + super.key, + required this.connection, + required this.database, + required this.collection, + this.onBack, + this.refreshToken = 0, + this.initialIndexes, + this.onFetchIndexes, + this.onDropIndex, + this.onCreateIndex, + }); + + final MongoConnection connection; + final String database; + final String collection; + final material.VoidCallback? onBack; + final int refreshToken; + final List? initialIndexes; + final Future>> Function()? onFetchIndexes; + final Future Function(String name)? onDropIndex; + final Future Function({ + required Map keys, + String? name, + bool unique, + bool sparse, + int? expireAfterSeconds, + })? onCreateIndex; + + @override + material.State createState() => _MongoIndexesViewState(); +} + +class _MongoIndexesViewState extends material.State { + List _indexes = []; + bool _loading = true; + String? _error; + + @override + void initState() { + super.initState(); + if (widget.initialIndexes != null) { + _indexes = List.from(widget.initialIndexes!); + _loading = false; + } else { + _load(); + } + } + + @override + void didUpdateWidget(covariant MongoIndexesView oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.refreshToken != widget.refreshToken || + oldWidget.database != widget.database || + oldWidget.collection != widget.collection) { + _load(); + } + } + + Future _load() async { + if (!mounted) return; + setState(() { + _loading = true; + _error = null; + }); + + try { + final rawList = await MongoService.instance.getIndexesWithStats( + widget.connection, + widget.database, + widget.collection, + ); + + if (!mounted) return; + setState(() { + _indexes = rawList.map(MongoIndexInfo.fromMap).toList(); + _loading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _loading = false; + _error = e.toString(); + }); + } + } + + int get _totalSizeBytes { + int total = 0; + for (final idx in _indexes) { + if (idx.sizeBytes != null) { + total += idx.sizeBytes!; + } + } + return total; + } + + String get _totalSizeFormatted { + final bytes = _totalSizeBytes; + if (bytes == 0) return '—'; + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) { + return '${(bytes / 1024).toStringAsFixed(1)} KB'; + } + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + } + + Future _openCreateIndexDialog() async { + final created = await MongoCreateIndexDialog.show( + context: context, + connection: widget.connection, + database: widget.database, + collection: widget.collection, + onCreateIndex: widget.onCreateIndex, + ); + + if (created == true) { + unawaited(_load()); + } + } + + Future _dropIndex(MongoIndexInfo index) async { + if (index.isPrimary) { + showAppToast( + context: context, + message: 'Primary index _id_ cannot be dropped', + variant: AppToastVariant.error, + ); + return; + } + + final confirmed = await QueryaConfirmDialog.show( + context: context, + title: 'Drop Index', + message: + 'Are you sure you want to drop index "${index.name}" from collection "${widget.collection}"?', + confirmLabel: 'Drop Index', + isDestructive: true, + icon: material.Icons.delete_forever_rounded, + ); + + if (confirmed != true || !mounted) return; + + try { + if (widget.onDropIndex != null) { + await widget.onDropIndex!(index.name); + } else { + await MongoService.instance.dropIndex( + widget.connection, + widget.database, + widget.collection, + index.name, + ); + } + + if (!mounted) return; + showAppToast( + context: context, + message: 'Index "${index.name}" dropped successfully', + variant: AppToastVariant.success, + ); + unawaited(_load()); + } catch (e) { + if (!mounted) return; + showAppToast( + context: context, + message: 'Failed to drop index: $e', + variant: AppToastVariant.error, + ); + } + } + + // ─── 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: [ + // Header action bar + _buildHeader(cs), + const Divider(height: 1), + // Error banner + if (_error != null) + material.Container( + padding: const material.EdgeInsets.all(12), + color: cs.destructive.withValues(alpha: 0.1), + child: material.Row( + children: [ + material.Icon(material.Icons.error_outline_rounded, + size: 16, color: cs.destructive), + const Gap(8), + material.Expanded( + child: material.SelectableText( + _error!, + style: material.TextStyle( + color: cs.destructive, + fontSize: 13, + ), + ), + ), + OutlineButton( + onPressed: _load, + size: ButtonSize.small, + child: const Text('Retry'), + ), + ], + ), + ), + // Content + material.Expanded( + child: _loading + ? const material.Center( + child: QueryaSpinner( + size: QueryaSpinnerSize.lg, + label: 'Loading collection indexes...', + ), + ) + : _indexes.isEmpty + ? QueryaEmptyState( + icon: const material.Icon( + material.Icons.layers_clear_rounded, + size: 40, + ), + title: 'No Indexes Found', + description: + 'This collection does not have any index definitions.', + actionLabel: 'Create Index', + onAction: _openCreateIndexDialog, + ) + : material.ListView.separated( + padding: const material.EdgeInsets.all(16), + itemCount: _indexes.length, + separatorBuilder: (_, __) => const Gap(10), + itemBuilder: (context, index) { + return _IndexCard( + index: _indexes[index], + onDrop: () => _dropIndex(_indexes[index]), + ); + }, + ), + ), + ], + ), + ); + } + + material.Widget _buildHeader(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.layers_outlined, + size: 18, + color: cs.primary, + ), + const Gap(8), + Text('Indexes: ${widget.collection}') + .semiBold() + .medium(), + const Gap(8), + QueryaBadge.status( + '${_indexes.length} indexes', + status: QueryaBadgeStatus.neutral, + ), + if (_totalSizeBytes > 0) ...[ + const Gap(8), + QueryaBadge.status( + '$_totalSizeFormatted on disk', + status: QueryaBadgeStatus.info, + ), + ], + const Gap(16), + QueryaIconButton( + icon: const material.Icon(material.Icons.refresh_rounded), + tooltip: 'Refresh Indexes', + density: QueryaIconButtonDensity.dense, + onPressed: _load, + ), + const Gap(8), + PrimaryButton( + onPressed: _openCreateIndexDialog, + size: ButtonSize.small, + leading: + const material.Icon(material.Icons.add_rounded, size: 14), + child: const Text('Create Index'), + ), + ], + ), + ), + ); + } +} + +// ─── Index Card ─────────────────────────────────────────────────────────────── + +class _IndexCard extends material.StatelessWidget { + const _IndexCard({ + required this.index, + required this.onDrop, + }); + + final MongoIndexInfo index; + final material.VoidCallback onDrop; + + @override + material.Widget build(material.BuildContext context) { + final cs = Theme.of(context).colorScheme; + + return Card( + child: material.Padding( + padding: const material.EdgeInsets.all(14), + child: material.Row( + children: [ + // Leading icon/badge + material.Container( + padding: const material.EdgeInsets.all(8), + decoration: material.BoxDecoration( + color: index.isPrimary + ? cs.primary.withValues(alpha: 0.12) + : cs.muted.withValues(alpha: 0.15), + borderRadius: material.BorderRadius.circular(8), + ), + child: material.Icon( + index.isPrimary + ? material.Icons.vpn_key_rounded + : material.Icons.layers_rounded, + size: 20, + color: index.isPrimary ? cs.primary : cs.mutedForeground, + ), + ), + const Gap(14), + // Info column + material.Expanded( + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.Row( + children: [ + Text(index.name).semiBold(), + const Gap(8), + if (index.isPrimary) + const QueryaBadge.primaryKey(label: 'PRIMARY') + else + QueryaBadge.status( + index.indexType, + status: QueryaBadgeStatus.neutral, + ), + if (index.isUnique && !index.isPrimary) ...[ + const Gap(6), + const QueryaBadge.status( + 'UNIQUE', + status: QueryaBadgeStatus.info, + ), + ], + if (index.isSparse) ...[ + const Gap(6), + const QueryaBadge.status( + 'SPARSE', + status: QueryaBadgeStatus.neutral, + ), + ], + if (index.isTtl) ...[ + const Gap(6), + QueryaBadge.status( + 'TTL: ${index.expireAfterSeconds}s', + status: QueryaBadgeStatus.warning, + ), + ], + ], + ), + const Gap(4), + material.Row( + children: [ + const Text('Keys: ').muted().small(), + material.SelectableText( + index.keysFormatted, + style: const material.TextStyle( + fontFamily: 'monospace', + fontSize: 12, + fontWeight: material.FontWeight.w500, + ), + ), + const Gap(16), + const Text('Size: ').muted().small(), + Text(index.sizeFormatted).small(), + ], + ), + ], + ), + ), + const Gap(12), + // Actions + if (index.isPrimary) + const QueryaIconButton( + icon: material.Icon(material.Icons.lock_outline_rounded), + tooltip: 'Primary index _id_ cannot be deleted', + density: QueryaIconButtonDensity.standard, + ) + else + QueryaIconButton( + icon: const material.Icon(material.Icons.delete_outline_rounded), + tooltip: 'Drop Index', + density: QueryaIconButtonDensity.standard, + isDestructive: true, + onPressed: onDrop, + ), + ], + ), + ), + ); + } +} diff --git a/test/features/mongodb/mongo_index_info_test.dart b/test/features/mongodb/mongo_index_info_test.dart new file mode 100644 index 00000000..18819c09 --- /dev/null +++ b/test/features/mongodb/mongo_index_info_test.dart @@ -0,0 +1,74 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/features/mongodb/mongo_index_info.dart'; + +void main() { + group('MongoIndexInfo tests', () { + test('identifies primary index correctly and forbids deletion', () { + final primary = MongoIndexInfo.fromMap({ + 'name': '_id_', + 'key': {'_id': 1}, + 'size': 16384, + }); + + expect(primary.isPrimary, isTrue); + expect(primary.indexType, equals('Primary')); + expect(primary.sizeFormatted, equals('16.0 KB')); + }); + + test('categorizes compound and single field indexes', () { + final single = MongoIndexInfo.fromMap({ + 'name': 'status_1', + 'key': {'status': 1}, + 'size': 8192, + }); + expect(single.isPrimary, isFalse); + expect(single.indexType, equals('Single Field')); + expect(single.keysFormatted, equals('status: 1')); + + final compound = MongoIndexInfo.fromMap({ + 'name': 'user_1_created_-1', + 'key': {'userId': 1, 'createdAt': -1}, + 'size': 32768, + 'unique': true, + }); + expect(compound.isPrimary, isFalse); + expect(compound.indexType, equals('Compound')); + expect(compound.isUnique, isTrue); + expect(compound.keysFormatted, equals('userId: 1, createdAt: -1')); + expect(compound.sizeFormatted, equals('32.0 KB')); + }); + + test('categorizes TTL index when expireAfterSeconds is set', () { + final ttl = MongoIndexInfo.fromMap({ + 'name': 'session_expire', + 'key': {'createdAt': 1}, + 'expireAfterSeconds': 3600, + 'size': 4096, + }); + expect(ttl.isTtl, isTrue); + expect(ttl.indexType, equals('TTL')); + expect(ttl.expireAfterSeconds, equals(3600)); + }); + + test('categorizes text and geospatial indexes', () { + final textIdx = MongoIndexInfo.fromMap({ + 'name': 'title_text', + 'key': {'title': 'text'}, + }); + expect(textIdx.indexType, equals('Text')); + + final geoIdx = MongoIndexInfo.fromMap({ + 'name': 'loc_2dsphere', + 'key': {'location': '2dsphere'}, + }); + expect(geoIdx.indexType, equals('Geospatial')); + }); + + test('size formatting formats bytes, KB, MB, and GB', () { + expect(const MongoIndexInfo(name: 'a', keys: {}).sizeFormatted, equals('—')); + expect(const MongoIndexInfo(name: 'a', keys: {}, sizeBytes: 512).sizeFormatted, equals('512 B')); + expect(const MongoIndexInfo(name: 'a', keys: {}, sizeBytes: 2048).sizeFormatted, equals('2.0 KB')); + expect(const MongoIndexInfo(name: 'a', keys: {}, sizeBytes: 5242880).sizeFormatted, equals('5.0 MB')); + }); + }); +} diff --git a/test/features/mongodb/mongo_indexes_view_test.dart b/test/features/mongodb/mongo_indexes_view_test.dart new file mode 100644 index 00000000..ddf7421d --- /dev/null +++ b/test/features/mongodb/mongo_indexes_view_test.dart @@ -0,0 +1,283 @@ +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/features/mongodb/mongo_create_index_dialog.dart'; +import 'package:querya_desktop/features/mongodb/mongo_index_info.dart'; +import 'package:querya_desktop/features/mongodb/mongo_indexes_view.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +void main() { + final testConn = MongoConnection(id: 1, name: 'test', host: 'localhost'); + + final testIndexes = [ + const MongoIndexInfo( + name: '_id_', + keys: {'_id': 1}, + sizeBytes: 16384, + ), + const MongoIndexInfo( + name: 'email_1', + keys: {'email': 1}, + isUnique: true, + sizeBytes: 8192, + ), + const MongoIndexInfo( + name: 'createdAt_1', + keys: {'createdAt': 1}, + expireAfterSeconds: 3600, + sizeBytes: 4096, + ), + ]; + + group('MongoIndexesView', () { + testWidgets('renders index list, metadata badges, keys, and disk sizes', + (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: MongoIndexesView( + connection: testConn, + database: 'testDb', + collection: 'users', + initialIndexes: testIndexes, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Indexes: users'), findsOneWidget); + expect(find.text('3 indexes'), findsOneWidget); + expect(find.text('_id_'), findsOneWidget); + expect(find.text('email_1'), findsOneWidget); + expect(find.text('createdAt_1'), findsOneWidget); + + // Badges + expect(find.text('PRIMARY'), findsOneWidget); + expect(find.text('UNIQUE'), findsOneWidget); + expect(find.text('TTL: 3600s'), findsOneWidget); + + // Sizes + expect(find.text('16.0 KB'), findsOneWidget); + expect(find.text('8.0 KB'), findsOneWidget); + expect(find.text('4.0 KB'), findsOneWidget); + }); + + testWidgets('primary index delete button is disabled with tooltip', + (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: MongoIndexesView( + connection: testConn, + database: 'testDb', + collection: 'users', + initialIndexes: testIndexes, + ), + ), + ); + await tester.pumpAndSettle(); + + final tooltipFinder = find.byWidgetPredicate( + (widget) => + widget is material.Tooltip && + widget.message == 'Primary index _id_ cannot be deleted', + ); + expect(tooltipFinder, findsOneWidget); + }); + + testWidgets('calls onBack when back button is tapped', (tester) async { + var backCalled = false; + await tester.pumpWidget( + queryaThemeTestShell( + child: MongoIndexesView( + connection: testConn, + database: 'testDb', + collection: 'users', + initialIndexes: testIndexes, + onBack: () => backCalled = true, + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Back to Documents')); + await tester.pumpAndSettle(); + + expect(backCalled, isTrue); + }); + + testWidgets('dropping a custom index shows confirm dialog and drops', + (tester) async { + String? droppedIndex; + await tester.pumpWidget( + queryaThemeTestShell( + child: MongoIndexesView( + connection: testConn, + database: 'testDb', + collection: 'users', + initialIndexes: testIndexes, + onDropIndex: (name) async { + droppedIndex = name; + }, + ), + ), + ); + await tester.pumpAndSettle(); + + // Find drop buttons for custom indexes (email_1 and createdAt_1) + final dropButtons = find.byIcon(material.Icons.delete_outline_rounded); + expect(dropButtons, findsNWidgets(2)); + + await tester.tap(dropButtons.first); + await tester.pumpAndSettle(); + + // Verify QueryaConfirmDialog is displayed + expect(find.byType(QueryaConfirmDialog), findsOneWidget); + expect(find.text('Drop Index'), findsWidgets); + expect( + find.textContaining('Are you sure you want to drop index "email_1"'), + findsOneWidget, + ); + + // Confirm drop + await tester.tap(find.text('Drop Index').last); + await tester.pumpAndSettle(); + await tester.pump(const Duration(seconds: 5)); + + expect(droppedIndex, equals('email_1')); + }); + }); + + group('MongoCreateIndexDialog', () { + testWidgets('validates required field name and submits draft', + (tester) async { + Map? createdKeys; + bool? isUniqueCreated; + bool? isSparseCreated; + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () async { + await MongoCreateIndexDialog.show( + context: context, + connection: testConn, + database: 'shop', + collection: 'orders', + onCreateIndex: ({ + required keys, + name, + unique = false, + sparse = false, + expireAfterSeconds, + }) async { + createdKeys = keys; + isUniqueCreated = unique; + isSparseCreated = sparse; + }, + ); + }, + child: const material.Text('Open Dialog'), + ), + ), + ), + ); + + await tester.tap(find.text('Open Dialog')); + await tester.pumpAndSettle(); + + expect(find.text('Create Index'), findsWidgets); + expect(find.text('Collection: orders'), findsOneWidget); + + // Initially field name is empty, tap "Create Index" should trigger validation error + await tester.tap(find.widgetWithText(PrimaryButton, 'Create Index')); + await tester.pumpAndSettle(); + + expect(find.text('All field names must be specified'), findsOneWidget); + expect(createdKeys, isNull); + + // Enter field name + final fieldNameInputs = find.byType(material.TextField); + await tester.enterText(fieldNameInputs.first, 'status'); + await tester.pumpAndSettle(); + + // Check unique + await tester.tap(find.text('Unique')); + await tester.pumpAndSettle(); + + // Submit + await tester.tap(find.widgetWithText(PrimaryButton, 'Create Index')); + await tester.pumpAndSettle(); + await tester.pump(const Duration(seconds: 5)); + + expect(createdKeys, equals({'status': 1})); + expect(isUniqueCreated, isTrue); + expect(isSparseCreated, isFalse); + }); + + testWidgets('allows adding composite fields and specifying directions', + (tester) async { + Map? createdKeys; + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () async { + await MongoCreateIndexDialog.show( + context: context, + connection: testConn, + database: 'shop', + collection: 'orders', + onCreateIndex: ({ + required keys, + name, + unique = false, + sparse = false, + expireAfterSeconds, + }) async { + createdKeys = keys; + }, + ); + }, + child: const material.Text('Open Dialog'), + ), + ), + ), + ); + + await tester.tap(find.text('Open Dialog')); + await tester.pumpAndSettle(); + + // First field: category -> 1 + final textFields = find.byType(material.TextField); + await tester.enterText(textFields.first, 'category'); + await tester.pumpAndSettle(); + + // Add field + await tester.tap(find.text('Add Field (Compound)')); + await tester.pumpAndSettle(); + + // Second field: price + final updatedTextFields = find.byType(material.TextField); + await tester.enterText(updatedTextFields.at(1), 'price'); + await tester.pumpAndSettle(); + + // Change direction of second field to Descending (-1) + final dropdowns = find.byType(QueryaDropdown); + expect(dropdowns, findsNWidgets(2)); + await tester.tap(dropdowns.at(1)); + await tester.pumpAndSettle(); + await tester.tap(find.text('-1 (Desc)').last); + await tester.pumpAndSettle(); + + // Submit + await tester.tap(find.widgetWithText(PrimaryButton, 'Create Index')); + await tester.pumpAndSettle(); + await tester.pump(const Duration(seconds: 5)); + + expect(createdKeys, equals({'category': 1, 'price': -1})); + }); + }); +}