From 56743e5b56a42ee30cdcaca2c96e9d77de38fe5c Mon Sep 17 00:00:00 2001 From: Patrick Stewart Date: Sun, 15 Dec 2024 14:34:50 -0700 Subject: [PATCH] add: adding pagination package --- packages/pagination/.analysis-options | 2 + packages/pagination/.gitignore | 58 ++++++ packages/pagination/AUTHORS.md | 12 ++ packages/pagination/CHANGELOG.md | 49 +++++ packages/pagination/LICENSE | 29 +++ packages/pagination/README.md | 59 ++++++ packages/pagination/analysis_options.yaml | 1 + packages/pagination/example/main.dart | 15 ++ .../pagination/lib/platform_pagination.dart | 194 ++++++++++++++++++ packages/pagination/pubspec.yaml | 37 ++++ packages/pagination/test/all_test.dart | 8 + packages/pagination/test/bounds_test.dart | 57 +++++ packages/pagination/test/paginate_test.dart | 127 ++++++++++++ 13 files changed, 648 insertions(+) create mode 100644 packages/pagination/.analysis-options create mode 100644 packages/pagination/.gitignore create mode 100644 packages/pagination/AUTHORS.md create mode 100644 packages/pagination/CHANGELOG.md create mode 100644 packages/pagination/LICENSE create mode 100644 packages/pagination/README.md create mode 100644 packages/pagination/analysis_options.yaml create mode 100644 packages/pagination/example/main.dart create mode 100644 packages/pagination/lib/platform_pagination.dart create mode 100644 packages/pagination/pubspec.yaml create mode 100644 packages/pagination/test/all_test.dart create mode 100644 packages/pagination/test/bounds_test.dart create mode 100644 packages/pagination/test/paginate_test.dart diff --git a/packages/pagination/.analysis-options b/packages/pagination/.analysis-options new file mode 100644 index 0000000..518eb90 --- /dev/null +++ b/packages/pagination/.analysis-options @@ -0,0 +1,2 @@ +analyzer: + strong-mode: true \ No newline at end of file diff --git a/packages/pagination/.gitignore b/packages/pagination/.gitignore new file mode 100644 index 0000000..fe4c4cf --- /dev/null +++ b/packages/pagination/.gitignore @@ -0,0 +1,58 @@ +# See https://www.dartlang.org/tools/private-files.html + +# Files and directories created by pub +.packages +.pub/ +build/ +# If you're building an application, you may want to check-in your pubspec.lock +pubspec.lock + +# Directory created by dartdoc +# If you don't generate documentation locally you can remove this line. +doc/api/ +### JetBrains template +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff: +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/dictionaries + +# Sensitive or high-churn files: +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.xml +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml + +# Gradle: +.idea/**/gradle.xml +.idea/**/libraries + +# Mongo Explorer plugin: +.idea/**/mongoSettings.xml + +## File-based project format: +*.iws + +## Plugin-specific files: + +# IntelliJ +/out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +.dart_tool \ No newline at end of file diff --git a/packages/pagination/AUTHORS.md b/packages/pagination/AUTHORS.md new file mode 100644 index 0000000..ac95ab5 --- /dev/null +++ b/packages/pagination/AUTHORS.md @@ -0,0 +1,12 @@ +Primary Authors +=============== + +* __[Thomas Hii](dukefirehawk.apps@gmail.com)__ + + Thomas is the current maintainer of the code base. He has refactored and migrated the + code base to support NNBD. + +* __[Tobe O](thosakwe@gmail.com)__ + + Tobe has written much of the original code prior to NNBD migration. He has moved on and + is no longer involved with the project. diff --git a/packages/pagination/CHANGELOG.md b/packages/pagination/CHANGELOG.md new file mode 100644 index 0000000..4fec76b --- /dev/null +++ b/packages/pagination/CHANGELOG.md @@ -0,0 +1,49 @@ +# Change Log + +## 8.2.0 + +* Require Dart >= 3.3 +* Updated `lints` to 4.0.0 + +## 8.1.0 + +* Updated repository link +* Updated `lints` to 3.0.0 +* Fixed linter warnings + +## 8.0.0 + +* Require Dart >= 3.0 + +## 7.0.0 + +* Require Dart >= 2.17 + +## 6.0.0 + +* Require Dart >= 2.16 + +## 5.0.0 + +* Skipped release + +## 4.0.0 + +* Skipped release + +## 3.1.0 + +* Updated linter to `package:lints` + +## 3.0.1 + +* Updated README + +## 3.0.0 + +* Migrated to support Dart >= 2.12 NNBD + +## 2.0.0 + +* Dart2 + Angel2 update. + \ No newline at end of file diff --git a/packages/pagination/LICENSE b/packages/pagination/LICENSE new file mode 100644 index 0000000..df5e063 --- /dev/null +++ b/packages/pagination/LICENSE @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) 2021, dukefirehawk.com +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/packages/pagination/README.md b/packages/pagination/README.md new file mode 100644 index 0000000..89d1bb3 --- /dev/null +++ b/packages/pagination/README.md @@ -0,0 +1,59 @@ +# Angel3 Paginate + +![Pub Version (including pre-releases)](https://img.shields.io/pub/v/platform_pagination?include_prereleases) +[![Null Safety](https://img.shields.io/badge/null-safety-brightgreen)](https://dart.dev/null-safety) +[![Discord](https://img.shields.io/discord/1060322353214660698)](https://discord.gg/3X6bxTUdCM) +[![License](https://img.shields.io/github/license/dart-backend/angel)](https://github.com/dart-backend/angel/tree/master/packages/paginate/LICENSE) + +Platform-agnostic pagination library, with custom support for the [Angel3](https://angel3-framework.web.app/). + +## Installation + +In your `pubspec.yaml` file: + +```yaml +dependencies: + platform_pagination: ^6.0.0 +``` + +## Usage + +This library exports a `Paginator`, which can be used to efficiently produce instances of `PaginationResult`. Pagination results, when serialized to JSON, look like this: + +```json +{ + "total" : 75, + "items_per_page" : 10, + "previous_page" : 3, + "current_page" : 4, + "next_page" : 5, + "start_index" : 30, + "end_index" : 39, + "data" : [""] +} +``` + +Results can be parsed from Maps using the `PaginationResult.fromMap` constructor, and serialized via their `toJson()` method. + +To create a paginator: + +```dart +import 'package:platform_pagination/platform_pagination.dart'; + +void main() { + var p = new Paginator(iterable); + + // Get the current page (default: page 1) + var page = p.current; + print(page.total); + print(page.startIndex); + print(page.data); // The actual items on this page. + p.next(); // Advance a page + p.back(); // Back one page + p.goToPage(10); // Go to page number (1-based, not a 0-based index) +} +``` + +The entire Paginator API is documented, so check out the DartDocs. + +Paginators by default cache paginations, to improve performance as you shift through pages. This can be especially helpful in a client-side application where your UX involves a fast response time, i.e. a search page. diff --git a/packages/pagination/analysis_options.yaml b/packages/pagination/analysis_options.yaml new file mode 100644 index 0000000..ea2c9e9 --- /dev/null +++ b/packages/pagination/analysis_options.yaml @@ -0,0 +1 @@ +include: package:lints/recommended.yaml \ No newline at end of file diff --git a/packages/pagination/example/main.dart b/packages/pagination/example/main.dart new file mode 100644 index 0000000..54bcbc0 --- /dev/null +++ b/packages/pagination/example/main.dart @@ -0,0 +1,15 @@ +import 'package:platform_pagination/platform_pagination.dart'; + +void main() { + var iterable = [1, 2, 3, 4]; + var p = Paginator(iterable); + + // Get the current page (default: page 1) + var page = p.current!; + print(page.total); + print(page.startIndex); + print(page.data); // The actual items on this page. + p.next(); // Advance a page + p.back(); // Back one page + p.goToPage(10); // Go to page number (1-based, not a 0-based index) +} diff --git a/packages/pagination/lib/platform_pagination.dart b/packages/pagination/lib/platform_pagination.dart new file mode 100644 index 0000000..64f41b9 --- /dev/null +++ b/packages/pagination/lib/platform_pagination.dart @@ -0,0 +1,194 @@ +/// Efficiently paginates collections of items in an object-oriented manner. +class Paginator { + final Map> _cache = {}; + PaginationResult? _current; + int _page = 0; + + /// The collection of items to be paginated. + final Iterable _items; + + /// The maximum number of items to be shown per page. + final int itemsPerPage; + + /// If `true` (default), then the results of paginations will be saved by page number. + /// + /// For example, you would only have to paginate page 1 once. Future calls would return a cached version. + final bool useCache; + + Paginator(this._items, {this.itemsPerPage = 5, this.useCache = true}); + + /// Returns `true` if there are more items at lesser page indices than the current one. + bool get canGoBack => _page > 0; + + /// Returns `true` if there are more items at greater page indices than the current one. + bool get canGoForward => _page < _lastPage(); + + /// The current page index. + int get index => _page; + + /// Returns the greatest possible page number for this collection, given the number of [itemsPerPage]. + int get lastPageNumber => _lastPage(); + + /// The current page number. This is not the same as [index]. + /// + /// This getter will return user-friendly numbers. The lowest value it will ever return is `1`. + int get pageNumber => _page < 1 ? 1 : (_page + 1); + + /// Fetches the current page. This will be cached until [back] or [next] is called. + /// + /// If [useCache] is `true` (default), then computations will be cached even after the page changes. + PaginationResult? get current { + if (_current != null) { + return _current; + } else { + return _current = _getPage(); + } + } + + PaginationResult _computePage() { + var len = _items.length; + //var it = _items.skip(_page * (itemsPerPage ?? 5)); + var it = _items.skip(_page * (itemsPerPage)); + var offset = len - it.length; + it = it.take(itemsPerPage); + var last = _lastPage(); + // print('cur: $_page, last: $last'); + return _PaginationResultImpl(it, + currentPage: _page + 1, + previousPage: _page <= 0 ? -1 : _page, + nextPage: _page >= last - 1 ? -1 : _page + 2, + startIndex: it.isEmpty ? -1 : offset, + endIndex: offset + it.length - 1, + itemsPerPage: + itemsPerPage < _items.length ? itemsPerPage : _items.length, + total: len); + } + + PaginationResult _getPage() { + if (useCache != false) { + return _cache.putIfAbsent(_page, () => _computePage()); + } else { + return _computePage(); + } + } + + int _lastPage() { + var n = (_items.length / itemsPerPage).round(); + // print('items: ${_items.length}'); + // print('per page: $itemsPerPage'); + // print('quotient: $n'); + var remainder = _items.length - (n * itemsPerPage); + // print('remainder: $remainder'); + return (remainder <= 0) ? n : n + 1; + } + + /// Attempts to go the specified page. If it fails, then it will remain on the current page. + /// + /// Keep in mind - this just not be a zero-based index, but a one-based page number. The lowest + /// allowed value is `1`. + void goToPage(int page) { + if (page > 0 && page <= _lastPage()) { + _page = page - 1; + _current = null; + } + } + + /// Moves the paginator back one page, if possible. + void back() { + if (_page > 0) { + _page--; + _current = null; + } + } + + /// Advances the paginator one page, if possible. + void next() { + if (_page < _lastPage()) { + _page++; + _current = null; + } + } +} + +/// Stores the result of a pagination. +abstract class PaginationResult { + factory PaginationResult.fromMap(Map map) => + _PaginationResultImpl((map['data'] as List).cast(), + currentPage: map['current_page'] as int?, + endIndex: map['end_index'] as int?, + itemsPerPage: map['items_per_page'] as int?, + nextPage: map['next_page'] as int?, + previousPage: map['previous_page'] as int?, + startIndex: map['start_index'] as int?, + total: map['total'] as int?); + + Iterable get data; + + int? get currentPage; + + int? get previousPage; + + int? get nextPage; + + int? get itemsPerPage; + + int? get total; + + int? get startIndex; + + int? get endIndex; + + Map toJson(); +} + +class _PaginationResultImpl implements PaginationResult { + final Iterable _data; + Iterable? _cachedData; + + @override + final int? currentPage; + + _PaginationResultImpl(this._data, + {this.currentPage, + this.endIndex, + this.itemsPerPage, + this.nextPage, + this.previousPage, + this.startIndex, + this.total}); + + @override + Iterable get data => _cachedData ?? (_cachedData = List.from(_data)); + + @override + final int? endIndex; + + @override + final int? itemsPerPage; + + @override + final int? nextPage; + + @override + final int? previousPage; + + @override + final int? startIndex; + + @override + final int? total; + + @override + Map toJson() { + return { + 'total': total, + 'items_per_page': itemsPerPage, + 'previous_page': previousPage, + 'current_page': currentPage, + 'next_page': nextPage, + 'start_index': startIndex, + 'end_index': endIndex, + 'data': data + }; + } +} diff --git a/packages/pagination/pubspec.yaml b/packages/pagination/pubspec.yaml new file mode 100644 index 0000000..a96984a --- /dev/null +++ b/packages/pagination/pubspec.yaml @@ -0,0 +1,37 @@ +name: platform_pagination +version: 8.2.0 +description: Platform-agnostic pagination library, with custom support for the Angel3 framework. +homepage: https://angel3-framework.web.app/ +repository: https://github.com/dart-backend/angel/tree/master/packages/paginate +environment: + sdk: '>=3.3.0 <4.0.0' +dependencies: + platform_foundation: ^8.0.0 +dev_dependencies: + platform_testing: ^8.0.0 + logging: ^1.2.0 + test: ^1.24.0 + lints: ^4.0.0 +# dependency_overrides: +# angel3_framework: +# path: ../framework +# angel3_test: +# path: ../test +# angel3_container: +# path: ../container/angel_container +# angel3_http_exception: +# path: ../http_exception +# angel3_model: +# path: ../model +# angel3_route: +# path: ../route +# angel3_mock_request: +# path: ../mock_request +# angel3_websocket: +# path: ../websocket +# angel3_client: +# path: ../client +# angel3_auth: +# path: ../auth +# angel3_validate: +# path: ../validate \ No newline at end of file diff --git a/packages/pagination/test/all_test.dart b/packages/pagination/test/all_test.dart new file mode 100644 index 0000000..d96efa4 --- /dev/null +++ b/packages/pagination/test/all_test.dart @@ -0,0 +1,8 @@ +import 'package:test/test.dart'; +import 'bounds_test.dart' as bounds; +import 'paginate_test.dart' as paginate; + +void main() { + group('bounds', bounds.main); + group('paginate', paginate.main); +} diff --git a/packages/pagination/test/bounds_test.dart b/packages/pagination/test/bounds_test.dart new file mode 100644 index 0000000..9babd58 --- /dev/null +++ b/packages/pagination/test/bounds_test.dart @@ -0,0 +1,57 @@ +import 'dart:convert'; +import 'package:platform_foundation/core.dart'; +import 'package:platform_pagination/platform_pagination.dart'; +import 'package:platform_testing/testing.dart'; +import 'package:logging/logging.dart'; +import 'package:test/test.dart'; + +final List> mjAlbums = [ + {'billie': 'jean'}, + {'off': 'the_wall'}, + {'michael': 'jackson'} +]; + +void main() { + late TestClient client; + + setUp(() async { + var app = Application(); + + app.get('/api/songs', (req, res) { + var p = Paginator(mjAlbums, itemsPerPage: mjAlbums.length); + p.goToPage(int.parse(req.queryParameters['page'] as String? ?? '1')); + return p.current; + }); + + client = await connectTo(app); + + app.logger = Logger('angel_paginate') + ..onRecord.listen((rec) { + print(rec); + if (rec.error != null) print(rec.error); + if (rec.stackTrace != null) print(rec.stackTrace); + }); + }); + + tearDown(() => client.close()); + + test('limit exceeds size of collection', () async { + var response = await client.get(Uri( + path: '/api/songs', + queryParameters: {r'$limit': (mjAlbums.length + 1).toString()})); + + var page = PaginationResult>.fromMap( + json.decode(response.body) as Map); + + print('page: ${page.toJson()}'); + + expect(page.total, mjAlbums.length); + expect(page.itemsPerPage, mjAlbums.length); + expect(page.previousPage, -1); + expect(page.currentPage, 1); + expect(page.nextPage, -1); + expect(page.startIndex, 0); + expect(page.endIndex, mjAlbums.length - 1); + expect(page.data, mjAlbums); + }); +} diff --git a/packages/pagination/test/paginate_test.dart b/packages/pagination/test/paginate_test.dart new file mode 100644 index 0000000..c9ba20a --- /dev/null +++ b/packages/pagination/test/paginate_test.dart @@ -0,0 +1,127 @@ +import 'package:platform_pagination/platform_pagination.dart'; +import 'package:test/test.dart'; + +// Count-down from 100, then 101 at the end... +final List data = List.generate(100, (i) => 100 - i)..add(101); + +void main() { + group('cache', () { + var cached = Paginator(data), + uncached = Paginator(data, useCache: false); + + test('always cache current', () { + expect(cached.current, cached.current); + expect(uncached.current, uncached.current); + }); + + test('only cache prev/next if useCache != false', () { + var cached1 = cached.current; + cached.goToPage(4); + var cached4 = cached.current; + cached.goToPage(1); + expect(cached.current, cached1); + cached.goToPage(4); + expect(cached.current, cached4); + + var uncached1 = uncached.current; + uncached.goToPage(4); + var uncached4 = uncached.current; + uncached.goToPage(1); + expect(uncached.current, isNot(uncached1)); + uncached.goToPage(4); + expect(uncached.current, isNot(uncached4)); + }); + }); + + test('default state', () { + var paginator = Paginator(data); + expect(paginator.index, 0); + expect(paginator.pageNumber, 1); + expect(paginator.itemsPerPage, 5); + expect(paginator.useCache, true); + expect(paginator.canGoBack, false); + expect(paginator.canGoForward, true); + expect(paginator.lastPageNumber, 21); + + // Going back should do nothing + var p = paginator.pageNumber; + paginator.back(); + expect(paginator.pageNumber, p); + }); + + group('paginate', () { + test('first page', () { + var paginator = Paginator(data); + expect(paginator.pageNumber, 1); + var r = paginator.current!; + print(r.toJson()); + expect(r.total, data.length); + expect(r.itemsPerPage, 5); + expect(r.previousPage, -1); + expect(r.currentPage, 1); + expect(r.nextPage, 2); + expect(r.startIndex, 0); + expect(r.endIndex, 4); + expect(r.data, data.skip(r.startIndex!).take(r.itemsPerPage!).toList()); + }); + }); + + test('third page', () { + var paginator = Paginator(data)..goToPage(3); + expect(paginator.pageNumber, 3); + var r = paginator.current!; + print(r.toJson()); + expect(r.total, data.length); + expect(r.itemsPerPage, 5); + expect(r.previousPage, 2); + expect(r.currentPage, 3); + expect(r.nextPage, 4); + expect(r.startIndex, 10); + expect(r.endIndex, 14); + expect(r.data, data.skip(r.startIndex!).take(r.itemsPerPage!).toList()); + + paginator.back(); + expect(paginator.pageNumber, 2); + }); + + test('last page', () { + var paginator = Paginator(data); + paginator.goToPage(paginator.lastPageNumber); + var r = paginator.current!; + expect(r.total, data.length); + expect(r.itemsPerPage, 5); + expect(r.previousPage, paginator.lastPageNumber - 1); + expect(r.currentPage, paginator.lastPageNumber); + expect(r.nextPage, -1); + expect(r.startIndex, (paginator.lastPageNumber - 1) * 5); + expect(r.endIndex, r.startIndex); + expect(r.data, [data.last]); + expect(r.data, data.skip(r.startIndex!).take(r.itemsPerPage!).toList()); + }); + + test('dump pages', () { + var paginator = Paginator(data); + print('${paginator.lastPageNumber} page(s) of data:'); + + do { + print(' * Page #${paginator.pageNumber}: ${paginator.current!.data}'); + paginator.next(); + } while (paginator.canGoForward); + }); + + test('empty collection', () { + var paginator = Paginator([]); + var page = paginator.current!; + print(page.toJson()); + + expect(page.total, 0); + expect(page.previousPage, -1); + expect(page.nextPage, -1); + expect(page.currentPage, 1); + expect(page.startIndex, -1); + expect(page.endIndex, -1); + expect(page.data, isEmpty); + expect(paginator.canGoBack, isFalse); + expect(paginator.canGoForward, isFalse); + }); +}