diff --git a/README.md b/README.md index f0546eeb..9ab1face 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ This mono repo is split into several sub-projects, each with its own detailed documentation and examples: * `angel_graphql` - Support for handling GraphQL via HTTP and WebSockets in the [Angel](https://angel-dart.dev) framework. Also serves as the `package:graphql_server` reference implementation. +* `data_loader` - A Dart port of [`graphql/data_loader`](https://github.com/graphql/dataloader). * `example_star_wars`: An example GraphQL API built using `package:angel_graphql`. * `graphql_generator`: Generates `package:graphql_schema` object types from concrete Dart classes. diff --git a/data_loader/.gitignore b/data_loader/.gitignore new file mode 100644 index 00000000..bf127bac --- /dev/null +++ b/data_loader/.gitignore @@ -0,0 +1,3 @@ +.packages +pubspec.lock +.dart_tool \ No newline at end of file diff --git a/data_loader/CHANGELOG.md b/data_loader/CHANGELOG.md new file mode 100644 index 00000000..92642921 --- /dev/null +++ b/data_loader/CHANGELOG.md @@ -0,0 +1,2 @@ +# 1.0.0 +* Initial version. \ No newline at end of file diff --git a/data_loader/LICENSE b/data_loader/LICENSE new file mode 100644 index 00000000..89074fd3 --- /dev/null +++ b/data_loader/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 The Angel Framework + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/data_loader/README.md b/data_loader/README.md new file mode 100644 index 00000000..af37dcab --- /dev/null +++ b/data_loader/README.md @@ -0,0 +1,22 @@ +# data_loader +Batch and cache database lookups. Works well with GraphQL. +Ported from the original JS version: +https://github.com/graphql/dataloader + +## Installation +In your pubspec.yaml: + +```yaml +dependencies: + data_loader: ^1.0.0 +``` + +## Usage +Complete example: +https://github.com/angel-dart/graphql/blob/master/data_loader/example/main.dart + +```dart +var userLoader = new DataLoader((key) => myBatchGetUsers(keys)); +var invitedBy = await userLoader.load(1)then(user => userLoader.load(user.invitedByID)) +print('User 1 was invited by $invitedBy')); +``` \ No newline at end of file diff --git a/data_loader/analysis_options.yaml b/data_loader/analysis_options.yaml new file mode 100644 index 00000000..c230cee7 --- /dev/null +++ b/data_loader/analysis_options.yaml @@ -0,0 +1,4 @@ +include: package:pedantic/analysis_options.yaml +analyzer: + strong-mode: + implicit-casts: false \ No newline at end of file diff --git a/data_loader/example/main.dart b/data_loader/example/main.dart new file mode 100644 index 00000000..2121bb23 --- /dev/null +++ b/data_loader/example/main.dart @@ -0,0 +1,44 @@ +import 'dart:async'; +import 'package:data_loader/data_loader.dart'; +import 'package:graphql_schema/graphql_schema.dart'; + +external Future> fetchTodos(Iterable ids); + +main() async { + // Create a DataLoader. By default, it caches lookups. + var todoLoader = DataLoader(fetchTodos); // DataLoader + + // type Todo { id: Int, text: String, is_complete: Boolean } + var todoType = objectType( + 'Todo', + fields: [ + field('id', graphQLInt), + field('text', graphQLString), + field('is_complete', graphQLBoolean), + ], + ); + + // type Query { todo($id: Int!) Todo } + // ignore: unused_local_variable + var schema = graphQLSchema( + queryType: objectType( + 'Query', + fields: [ + field( + 'todo', + listOf(todoType), + inputs: [GraphQLFieldInput('id', graphQLInt.nonNullable())], + resolve: (_, args) => todoLoader.load(args['id'] as int), + ), + ], + ), + ); + + // Do something with your schema... +} + +abstract class Todo { + int get id; + String get text; + bool get isComplete; +} diff --git a/data_loader/lib/data_loader.dart b/data_loader/lib/data_loader.dart new file mode 100644 index 00000000..4b013609 --- /dev/null +++ b/data_loader/lib/data_loader.dart @@ -0,0 +1,85 @@ +import 'dart:async'; +import 'dart:collection'; + +/// A utility for batching multiple requests together, to improve application performance. +/// +/// Enqueues batches of requests until the next tick, when they are processed in bulk. +/// +/// Port of Facebook's `DataLoader`: +/// https://github.com/graphql/dataloader +class DataLoader { + /// Invoked to fetch a batch of keys simultaneously. + final FutureOr> Function(Iterable) loadMany; + + /// Whether to use a memoization cache to store the results of past lookups. + final bool cache; + + var _cache = {}; + var _queue = Queue<_QueueItem>(); + bool _started = false; + + DataLoader(this.loadMany, {this.cache = true}); + + Future _onTick() async { + if (_queue.isNotEmpty) { + var current = _queue.toList(); + _queue.clear(); + + var data = await loadMany(current.map((i) => i.id)); + + for (int i = 0; i < current.length; i++) { + var item = current[i]; + var value = data.elementAt(i); + if (cache) _cache[item.id] = value; + item.completer.complete(value); + } + } + + _started = false; + // if (!_closed) scheduleMicrotask(_onTick); + } + + /// Clears the value at [key], if it exists. + void clear(Id key) => _cache.remove(key); + + /// Clears the entire cache. + void clearAll() => _cache.clear(); + + /// Primes the cache with the provided key and value. If the key already exists, no change is made. + /// + /// To forcefully prime the cache, clear the key first with + /// `loader..clear(key)..prime(key, value)`. + void prime(Id key, Data value) => _cache.putIfAbsent(key, () => value); + + /// Closes this [DataLoader], cancelling all pending requests. + void close() { + while (_queue.isNotEmpty) { + _queue.removeFirst().completer.completeError( + StateError('The DataLoader was closed before the item was loaded.')); + } + + _queue.clear(); + } + + /// Returns a [Future] that completes when the next batch of requests completes. + Future load(Id id) { + if (cache && _cache.containsKey(id)) { + return Future.value(_cache[id]); + } else { + var item = _QueueItem(id); + _queue.add(item); + if (!_started) { + _started = true; + scheduleMicrotask(_onTick); + } + return item.completer.future; + } + } +} + +class _QueueItem { + final Id id; + final Completer completer = Completer(); + + _QueueItem(this.id); +} diff --git a/data_loader/mono_pkg.yaml b/data_loader/mono_pkg.yaml new file mode 100644 index 00000000..e69de29b diff --git a/data_loader/pubspec.yaml b/data_loader/pubspec.yaml new file mode 100644 index 00000000..6b05bbba --- /dev/null +++ b/data_loader/pubspec.yaml @@ -0,0 +1,11 @@ +name: data_loader +version: 1.0.0 +author: Tobe O +description: Batch and cache database lookups. Works well with GraphQL. Ported from JS. +homepage: https://github.com/angel-dart/graphql +environment: + sdk: ">=2.0.0-dev <3.0.0" +dev_dependencies: + graphql_schema: ^1.0.0 + pedantic: ^1.0.0 + test: ">=0.12.0 <2.0.0" \ No newline at end of file diff --git a/data_loader/test/all_test.dart b/data_loader/test/all_test.dart new file mode 100644 index 00000000..5da29500 --- /dev/null +++ b/data_loader/test/all_test.dart @@ -0,0 +1,75 @@ +import 'dart:async'; +import 'package:data_loader/data_loader.dart'; +import 'package:test/test.dart'; + +void main() { + var numbers = List.generate(10, (i) => i.toStringAsFixed(2)); + var numberLoader = DataLoader((ids) { + print('ID batch: $ids'); + return ids.map((i) => numbers[i]); + }); + + test('batch', () async { + var zero = numberLoader.load(0); + var one = numberLoader.load(1); + var two = numberLoader.load(2); + var batch = await Future.wait([zero, one, two]); + print('Fetched result: $batch'); + expect(batch, ['0.00', '1.00', '2.00']); + }); + + group('cache', () { + DataLoader uniqueLoader, noCache; + + setUp(() { + uniqueLoader = DataLoader((ids) async { + var numbers = await numberLoader.loadMany(ids); + return numbers.map((s) => _Unique(s)); + }); + noCache = DataLoader(uniqueLoader.loadMany, cache: false); + }); + + tearDown(() { + uniqueLoader.close(); + noCache.close(); + }); + + test('only lookup once', () async { + var a = await uniqueLoader.load(3); + var b = await uniqueLoader.load(3); + expect(a, b); + }); + + test('can be disabled', () async { + var a = await noCache.load(3); + var b = await noCache.load(3); + expect(a, isNot(b)); + }); + + test('clear', () async { + var a = await uniqueLoader.load(3); + uniqueLoader.clear(3); + var b = await uniqueLoader.load(3); + expect(a, isNot(b)); + }); + + test('clearAll', () async { + var a = await uniqueLoader.load(3); + uniqueLoader.clearAll(); + var b = await uniqueLoader.load(3); + expect(a, isNot(b)); + }); + + test('prime', () async { + uniqueLoader.prime(3, _Unique('hey')); + var a = await uniqueLoader.load(3); + expect(a.value, 'hey'); + }); + }); +} + +class _Unique { + final String value; + + _Unique(this.value); +}