From 4d2ca8a28a4975fe9f9e4ced1387fb4eac9d2b6e Mon Sep 17 00:00:00 2001 From: Tobe O Date: Fri, 26 May 2017 09:21:47 -0400 Subject: [PATCH] 1.0.0 --- .analysis-options | 2 + .gitignore | 44 ++++++++++ .idea/vcs.xml | 6 ++ .travis.yml | 1 + README.md | 78 ++++++++++++++++- lib/angel_paginate.dart | 190 ++++++++++++++++++++++++++++++++++++++++ lib/server.dart | 49 +++++++++++ pubspec.yaml | 13 +++ test/paginate_test.dart | 105 ++++++++++++++++++++++ test/server_test.dart | 115 ++++++++++++++++++++++++ 10 files changed, 602 insertions(+), 1 deletion(-) create mode 100644 .analysis-options create mode 100644 .idea/vcs.xml create mode 100644 .travis.yml create mode 100644 lib/angel_paginate.dart create mode 100644 lib/server.dart create mode 100644 pubspec.yaml create mode 100644 test/paginate_test.dart create mode 100644 test/server_test.dart diff --git a/.analysis-options b/.analysis-options new file mode 100644 index 00000000..518eb901 --- /dev/null +++ b/.analysis-options @@ -0,0 +1,2 @@ +analyzer: + strong-mode: true \ No newline at end of file diff --git a/.gitignore b/.gitignore index 4d2a4d6d..99e7978e 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,47 @@ 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 diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 00000000..94a25f7f --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 00000000..de2210c9 --- /dev/null +++ b/.travis.yml @@ -0,0 +1 @@ +language: dart \ No newline at end of file diff --git a/README.md b/README.md index 7a52774a..47d1a526 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,78 @@ # paginate -Platform-agnostic pagination library, with custom support for the Angel framework. +[![version 1.0.0](https://img.shields.io/badge/pub-v1.0.0-brightgreen.svg)](https://pub.dartlang.org/packages/angel_paginate) +[![build status](https://travis-ci.org/angel-dart/paginate.svg)](https://travis-ci.org/angel-dart/paginate) + +Platform-agnostic pagination library, with custom support for the +[Angel framework](https://github.com/angel-dart/angel). + +# Installation +In your `pubspec.yaml` file: + +```yaml +dependencies: + angel_paginate: ^1.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:angel_paginate/angel_paginate.dart'; + +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.goBack(); // 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. + +## Use With Angel +Naturally, a library named `angel_paginate` has special provisions for the +[Angel framework](https://github.com/angel-dart/angel). + +In `package:angel_paginate/server.dart`, a function called `paginate` generates +pagination service hooks for you. If the result of a hooked service event is an `Iterable`, +it will be paginated. This is convenient because it works with any data store, whether it +be MongoDB, RethinkDB, an in-memory store, or something else entirely. + +```dart +configureServer(Angel app) { + var service = app.service('api/foo') as HookedService; + service.afterIndexed.listen(paginate(itemsPerPage: 10)); +} +``` + +See `test/server_test.dart` for examples of usage with Angel. \ No newline at end of file diff --git a/lib/angel_paginate.dart b/lib/angel_paginate.dart new file mode 100644 index 00000000..55d6e1ec --- /dev/null +++ b/lib/angel_paginate.dart @@ -0,0 +1,190 @@ +/// 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 offset = len - it.length; + it = it.take(itemsPerPage); + var last = _lastPage(); + // print('cur: $_page, last: $last'); + return new _PaginationResultImpl(it, + currentPage: _page + 1, + previousPage: _page <= 0 ? -1 : _page, + nextPage: _page >= last - 1 ? -1 : _page + 2, + startIndex: offset, + endIndex: offset + it.length - 1, + itemsPerPage: itemsPerPage, + 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) => + new _PaginationResultImpl(map['data'], + currentPage: map['current_page'], + endIndex: map['end_index'], + itemsPerPage: map['items_per_page'], + nextPage: map['next_page'], + previousPage: map['previous_page'], + startIndex: map['start_index'], + total: map['total']); + + List 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 + List get data => _cachedData ?? (_cachedData = new 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/lib/server.dart b/lib/server.dart new file mode 100644 index 00000000..8ca94321 --- /dev/null +++ b/lib/server.dart @@ -0,0 +1,49 @@ +import 'package:angel_framework/angel_framework.dart'; +import 'angel_paginate.dart'; +export 'angel_paginate.dart'; + +/// Paginates the results of service events. +/// +/// Users can add a `page` to the query to display a certain page, i.e. `http://foo.com/api/todos?page=5`. +/// +/// Users can also add a `$limit` to the query to display more or less items than specified in [itemsPerPage] (default: `5`). +/// If [maxItemsPerPage] is set, then even if the query contains a `$limit` parameter, it will be limited to the maximum. +HookedServiceEventListener paginate( + {int itemsPerPage, int maxItemsPerPage}) { + return (HookedServiceEvent e) { + if (e.isBefore) throw new UnsupportedError( + '`package:angel_paginate` can only be run as an after hook.'); + if (e.result is! Iterable) return; + + int page = 0, + nItems = itemsPerPage; + + if (e.params.containsKey('query') && e.params['query'] is Map) { + var query = e.params['query'] as Map; + + if (query.containsKey('page')) { + try { + page = int.parse(query['page']?.toString()); + } catch (e) { + // Fail silently... + } + } + + if (query.containsKey(r'$limit')) { + try { + var lim = int.parse(query[r'$limit']?.toString()); + if (lim > 0 && (maxItemsPerPage == null || lim <= maxItemsPerPage)) + nItems = lim; + } catch (e) { + // Fail silently... + } + } + } + + + var paginator = new Paginator( + e.result, itemsPerPage: nItems) + ..goToPage(page); + e.result = paginator.current; + }; +} \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 00000000..3338f1c6 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,13 @@ +name: angel_paginate +version: 1.0.0 +description: Platform-agnostic pagination library, with custom support for the Angel framework. +author: Tobe O +homepage: https://github.com/angel-dart/paginate +environment: + sdk: ">=1.19.0" +dependencies: + angel_framework: ^1.0.0 +dev_dependencies: + angel_diagnostics: ^1.0.0 + angel_test: ^1.0.0 + test: ^0.12.15 \ No newline at end of file diff --git a/test/paginate_test.dart b/test/paginate_test.dart new file mode 100644 index 00000000..6c94dc93 --- /dev/null +++ b/test/paginate_test.dart @@ -0,0 +1,105 @@ +import 'package:angel_paginate/angel_paginate.dart'; +import 'package:test/test.dart'; + +// Count-down from 100, then 101 at the end... +final List DATA = new List.generate(100, (i) => 100 - i) + ..add(101); + +main() { + group('cache', () { + var cached = new Paginator(DATA), + uncached = new 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 = new 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); + }); + + group('paginate', () { + test('first page', () { + var paginator = new 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 = new 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()); + }); + + test('last page', () { + var paginator = new 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 = new Paginator(DATA); + print('${paginator.lastPageNumber} page(s) of data:'); + + do { + print(' * Page #${paginator.pageNumber}: ${paginator.current.data}'); + paginator.next(); + } while(paginator.canGoForward); + }); +} diff --git a/test/server_test.dart b/test/server_test.dart new file mode 100644 index 00000000..ca714e7c --- /dev/null +++ b/test/server_test.dart @@ -0,0 +1,115 @@ +import 'package:angel_client/angel_client.dart' as c; +import 'package:angel_diagnostics/angel_diagnostics.dart'; +import 'package:angel_framework/angel_framework.dart'; +import 'package:angel_paginate/server.dart'; +import 'package:angel_test/angel_test.dart'; +import 'package:test/test.dart'; + +final List DATA = new List.filled(50, {'foo': 'bar'}, growable: true) + ..addAll(new List.filled(25, {'bar': 'baz'})); + +main() { + group('no max', () { + Angel app; + TestClient client; + c.Service dataService; + + setUp(() async { + app = new Angel() + ..use('/data', new AnonymousService(index: ([p]) async => DATA)); + await app.configure(logRequests()); + var service = app.service('data') as HookedService; + service.afterIndexed.listen(paginate(itemsPerPage: 10)); + + client = await connectTo(app); + dataService = client.service('data'); + }); + + tearDown(() => client.close()); + + test('first page', () async { + var response = await dataService + .index() + .then((m) => new PaginationResult.fromMap(m)); + print(response.toJson()); + + expect(response.total, DATA.length); + expect(response.itemsPerPage, 10); + expect(response.startIndex, 0); + expect(response.endIndex, 9); + expect(response.previousPage, -1); + expect(response.currentPage, 1); + expect(response.nextPage, 2); + expect(response.data, DATA.take(10).toList()); + }); + + test('third page', () async { + var response = await dataService.index({ + 'query': {'page': 3} + }).then((m) => new PaginationResult.fromMap(m)); + print(response.toJson()); + + expect(response.total, DATA.length); + expect(response.itemsPerPage, 10); + expect(response.startIndex, 20); + expect(response.endIndex, 29); + expect(response.previousPage, 2); + expect(response.currentPage, 3); + expect(response.nextPage, 4); + expect(response.data, DATA.skip(20).take(10).toList()); + }); + + test('custom limit', () async { + var response = await dataService.index({ + 'query': {'page': 4, r'$limit': 5} + }).then((m) => new PaginationResult.fromMap(m)); + print(response.toJson()); + + expect(response.total, DATA.length); + expect(response.itemsPerPage, 5); + expect(response.startIndex, 15); + expect(response.endIndex, 19); + expect(response.previousPage, 3); + expect(response.currentPage, 4); + expect(response.nextPage, 5); + expect(response.data, DATA.skip(15).take(5).toList()); + }); + }); + + group('max 15', () { + Angel app; + TestClient client; + c.Service dataService; + + setUp(() async { + app = new Angel() + ..use('/data', new AnonymousService(index: ([p]) async => DATA)); + await app.configure(logRequests()); + var service = app.service('data') as HookedService; + service.afterIndexed.listen( + paginate(itemsPerPage: 10, maxItemsPerPage: 15)); + + client = await connectTo(app); + dataService = client.service('data'); + }); + + tearDown(() => client.close()); + + test('exceed max', () async { + var response = await dataService.index({ + 'query': {'page': 4, r'$limit': 30} + }).then((m) => new PaginationResult.fromMap(m)); + print(response.toJson()); + + // Should default to 10 items per page :) + expect(response.total, DATA.length); + expect(response.itemsPerPage, 10); + expect(response.startIndex, 30); + expect(response.endIndex, 39); + expect(response.previousPage, 3); + expect(response.currentPage, 4); + expect(response.nextPage, 5); + expect(response.data, DATA.skip(30).take(10).toList()); + }); + }); +}