Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 95 additions & 22 deletions lib/features/mongodb/mongo_documents_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,40 @@ class MongoDocumentsView extends material.StatefulWidget {
_MongoDocumentsViewState();
}

/// Total document count implied by one page alone, or null when it is unknown.
///
/// A short non-empty page (or an empty first page) ends the result set, so the
/// total is `skip + pageLength` without a separate count round trip.
int? mongoExactTotalFromPage({
required int skip,
required int limit,
required int pageLength,
}) {
if (pageLength >= limit) return null;
if (pageLength == 0 && skip > 0) return null;
return skip + pageLength;
}

/// Whether a next page may exist: from the [total] when known, otherwise
/// whenever the current page is full.
bool mongoHasNextPage({
required int skip,
required int limit,
required int pageLength,
int? total,
}) {
if (total != null) return skip + limit < total;
return pageLength >= limit;
}

class _MongoDocumentsViewState extends material.State<MongoDocumentsView> {
List<Map<String, dynamic>> _documents = [];
int _totalCount = 0;
/// Exact match count, or null while the background count is still running
/// (the first page is painted from `find` alone).
int? _totalCount;

/// Bumped per load so a slow older find / count cannot overwrite a newer one.
int _loadSeq = 0;
int _skip = 0;
final int _limit = _defaultLimit;
bool _loading = true;
Expand Down Expand Up @@ -70,19 +101,18 @@ class _MongoDocumentsViewState extends material.State<MongoDocumentsView> {
super.dispose();
}

Future<void> _load() async {
/// Loads the current page. Page navigation passes [keepTotal] so the known
/// total is reused instead of recounting on every page.
Future<void> _load({bool keepTotal = false}) async {
if (!mounted) return;
final seq = ++_loadSeq;
final knownTotal = keepTotal ? _totalCount : null;
setState(() {
_loading = true;
_error = null;
_totalCount = knownTotal;
});
try {
final count = await MongoService.instance.countDocuments(
widget.connection,
widget.database,
widget.collection,
filter: _activeFilter,
);
final docs = await MongoService.instance.find(
widget.connection,
widget.database,
Expand All @@ -91,9 +121,17 @@ class _MongoDocumentsViewState extends material.State<MongoDocumentsView> {
limit: _limit,
skip: _skip,
);
if (!mounted) return;
if (!mounted || seq != _loadSeq) return;
// A short, non-empty page (or an empty first page) pins the total
// without a count round trip.
final exactTotal = knownTotal ??
mongoExactTotalFromPage(
skip: _skip,
limit: _limit,
pageLength: docs.length,
);
setState(() {
_totalCount = count;
_totalCount = exactTotal;
_documents = docs;
_loading = false;
_emptyFilterHint = docs.isEmpty &&
Expand All @@ -102,8 +140,9 @@ class _MongoDocumentsViewState extends material.State<MongoDocumentsView> {
? kMongoFilterIdStringHint
: null;
});
if (exactTotal == null) unawaited(_loadCount(seq));
} catch (e) {
if (mounted) {
if (mounted && seq == _loadSeq) {
setState(() {
_error = e.toString();
_loading = false;
Expand All @@ -112,6 +151,33 @@ class _MongoDocumentsViewState extends material.State<MongoDocumentsView> {
}
}

/// Exact count after the first page is on screen. Failures leave the total
/// unknown ("N+ documents") instead of failing the list.
Future<void> _loadCount(int seq) async {
try {
final count = await MongoService.instance.countDocuments(
widget.connection,
widget.database,
widget.collection,
filter: _activeFilter,
);
if (!mounted || seq != _loadSeq) return;
setState(() => _totalCount = count);
} catch (_) {
// Keep the "maybe more" pagination.
}
}

/// True when a next page may exist: known total, else "page is full".
bool get _hasNextPage {
return mongoHasNextPage(
skip: _skip,
limit: _limit,
pageLength: _documents.length,
total: _totalCount,
);
}

void _applyFilter() {
final text = _filterController.text.trim();
if (text.isEmpty) {
Expand Down Expand Up @@ -140,16 +206,16 @@ class _MongoDocumentsViewState extends material.State<MongoDocumentsView> {
}

void _goNextPage() {
if (_skip + _limit < _totalCount) {
if (_hasNextPage) {
_skip += _limit;
_load();
_load(keepTotal: true);
}
}

void _goPrevPage() {
if (_skip > 0) {
_skip = (_skip - _limit).clamp(0, _totalCount);
_load();
_skip = (_skip - _limit).clamp(0, _skip);
_load(keepTotal: true);
}
}

Expand Down Expand Up @@ -378,9 +444,12 @@ class _MongoDocumentsViewState extends material.State<MongoDocumentsView> {
Widget _buildPaginationBar(ColorScheme cs) {
final shadcnCs = shadcn.Theme.of(context).colorScheme;
final currentPage = (_skip / _limit).floor() + 1;
final totalPages = (_totalCount / _limit).ceil();
final from = _totalCount == 0 ? 0 : _skip + 1;
final to = (_skip + _limit).clamp(0, _totalCount);
final total = _totalCount;
final shown = _skip + _documents.length;
final totalPages = total == null ? null : (total / _limit).ceil();
final from = shown == _skip ? 0 : _skip + 1;
final to = shown;
final hasNext = _hasNextPage;

return material.Container(
constraints: const material.BoxConstraints(minHeight: 44),
Expand All @@ -394,7 +463,11 @@ class _MongoDocumentsViewState extends material.State<MongoDocumentsView> {
),
child: Row(
children: [
Text('$_totalCount documents').muted().small(),
Text(total != null
? '$total documents'
: '$shown${hasNext ? '+' : ''} documents')
.muted()
.small(),
const Spacer(),
Text('$from – $to').muted().small(),
const Gap(16),
Expand All @@ -411,16 +484,16 @@ class _MongoDocumentsViewState extends material.State<MongoDocumentsView> {
),
),
const Gap(8),
Text('$currentPage / $totalPages').small(),
Text('$currentPage / ${totalPages ?? '?'}').small(),
const Gap(8),
material.InkWell(
onTap: _skip + _limit < _totalCount ? _goNextPage : null,
onTap: hasNext ? _goNextPage : null,
child: material.Padding(
padding: const material.EdgeInsets.all(4),
child: material.Icon(
material.Icons.chevron_right_rounded,
size: 20,
color: _skip + _limit < _totalCount
color: hasNext
? shadcnCs.foreground
: shadcnCs.mutedForeground,
),
Expand Down
49 changes: 49 additions & 0 deletions test/features/mongodb/mongo_documents_paging_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:querya_desktop/features/mongodb/mongo_documents_view.dart';

void main() {
group('mongoExactTotalFromPage', () {
test('a short first page pins the total without a count', () {
expect(mongoExactTotalFromPage(skip: 0, limit: 25, pageLength: 7), 7);
expect(mongoExactTotalFromPage(skip: 0, limit: 25, pageLength: 0), 0);
});

test('a short later page pins skip + length', () {
expect(mongoExactTotalFromPage(skip: 50, limit: 25, pageLength: 3), 53);
});

test('a full page leaves the total unknown', () {
expect(mongoExactTotalFromPage(skip: 0, limit: 25, pageLength: 25),
isNull);
expect(mongoExactTotalFromPage(skip: 25, limit: 25, pageLength: 25),
isNull);
});

test('an empty page past the start leaves the total unknown', () {
expect(mongoExactTotalFromPage(skip: 75, limit: 25, pageLength: 0),
isNull);
});
});

group('mongoHasNextPage', () {
test('uses the known total', () {
expect(
mongoHasNextPage(skip: 0, limit: 25, pageLength: 25, total: 25),
isFalse,
);
expect(
mongoHasNextPage(skip: 0, limit: 25, pageLength: 25, total: 26),
isTrue,
);
expect(
mongoHasNextPage(skip: 25, limit: 25, pageLength: 1, total: 26),
isFalse,
);
});

test('assumes more while the count is pending and the page is full', () {
expect(mongoHasNextPage(skip: 0, limit: 25, pageLength: 25), isTrue);
expect(mongoHasNextPage(skip: 0, limit: 25, pageLength: 24), isFalse);
});
});
}
Loading