commit c7169b04c72bc0c17268b1648b7b0f81ae548a44 Author: Kingkor Roy Tirtho Date: Mon Feb 7 00:11:15 2022 +0600 initial commit with cache support & example diff --git a/packages/fl_query/.gitignore b/packages/fl_query/.gitignore new file mode 100644 index 0000000..9be145f --- /dev/null +++ b/packages/fl_query/.gitignore @@ -0,0 +1,29 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +/pubspec.lock +**/doc/api/ +.dart_tool/ +.packages +build/ diff --git a/packages/fl_query/.metadata b/packages/fl_query/.metadata new file mode 100644 index 0000000..af84dae --- /dev/null +++ b/packages/fl_query/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: 77d935af4db863f6abd0b9c31c7e6df2a13de57b + channel: stable + +project_type: package diff --git a/packages/fl_query/CHANGELOG.md b/packages/fl_query/CHANGELOG.md new file mode 100644 index 0000000..41cc7d8 --- /dev/null +++ b/packages/fl_query/CHANGELOG.md @@ -0,0 +1,3 @@ +## 0.0.1 + +* TODO: Describe initial release. diff --git a/packages/fl_query/LICENSE b/packages/fl_query/LICENSE new file mode 100644 index 0000000..ba75c69 --- /dev/null +++ b/packages/fl_query/LICENSE @@ -0,0 +1 @@ +TODO: Add your license here. diff --git a/packages/fl_query/README.md b/packages/fl_query/README.md new file mode 100644 index 0000000..8b55e73 --- /dev/null +++ b/packages/fl_query/README.md @@ -0,0 +1,39 @@ + + +TODO: Put a short description of the package here that helps potential users +know whether this package might be useful for them. + +## Features + +TODO: List what your package can do. Maybe include images, gifs, or videos. + +## Getting started + +TODO: List prerequisites and provide or point to information on how to +start using the package. + +## Usage + +TODO: Include short and useful examples for package users. Add longer examples +to `/example` folder. + +```dart +const like = 'sample'; +``` + +## Additional information + +TODO: Tell users more about the package: where to find more information, how to +contribute to the package, how to file issues, what response they can expect +from the package authors, and more. diff --git a/packages/fl_query/analysis_options.yaml b/packages/fl_query/analysis_options.yaml new file mode 100644 index 0000000..a5744c1 --- /dev/null +++ b/packages/fl_query/analysis_options.yaml @@ -0,0 +1,4 @@ +include: package:flutter_lints/flutter.yaml + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/packages/fl_query/example/.gitignore b/packages/fl_query/example/.gitignore new file mode 100644 index 0000000..3c8a157 --- /dev/null +++ b/packages/fl_query/example/.gitignore @@ -0,0 +1,6 @@ +# Files and directories created by pub. +.dart_tool/ +.packages + +# Conventional directory for build output. +build/ diff --git a/packages/fl_query/example/CHANGELOG.md b/packages/fl_query/example/CHANGELOG.md new file mode 100644 index 0000000..effe43c --- /dev/null +++ b/packages/fl_query/example/CHANGELOG.md @@ -0,0 +1,3 @@ +## 1.0.0 + +- Initial version. diff --git a/packages/fl_query/example/README.md b/packages/fl_query/example/README.md new file mode 100644 index 0000000..a307539 --- /dev/null +++ b/packages/fl_query/example/README.md @@ -0,0 +1 @@ +A simple command-line application. diff --git a/packages/fl_query/example/analysis_options.yaml b/packages/fl_query/example/analysis_options.yaml new file mode 100644 index 0000000..dee8927 --- /dev/null +++ b/packages/fl_query/example/analysis_options.yaml @@ -0,0 +1,30 @@ +# This file configures the static analysis results for your project (errors, +# warnings, and lints). +# +# This enables the 'recommended' set of lints from `package:lints`. +# This set helps identify many issues that may lead to problems when running +# or consuming Dart code, and enforces writing Dart using a single, idiomatic +# style and format. +# +# If you want a smaller set of lints you can change this to specify +# 'package:lints/core.yaml'. These are just the most critical lints +# (the recommended set includes the core lints). +# The core lints are also what is used by pub.dev for scoring packages. + +include: package:lints/recommended.yaml + +# Uncomment the following section to specify additional rules. + +# linter: +# rules: +# - camel_case_types + +# analyzer: +# exclude: +# - path/to/excluded/files/** + +# For more information about the core and recommended set of lints, see +# https://dart.dev/go/core-lints + +# For additional information about configuring this file, see +# https://dart.dev/guides/language/analysis-options diff --git a/packages/fl_query/example/bin/example.dart b/packages/fl_query/example/bin/example.dart new file mode 100644 index 0000000..04860f2 --- /dev/null +++ b/packages/fl_query/example/bin/example.dart @@ -0,0 +1,146 @@ +import 'package:fl_query/fl_query.dart'; + +var todos = [ + {"userId": 1, "id": 1, "title": "delectus aut autem", "completed": false}, + { + "userId": 1, + "id": 2, + "title": "quis ut nam facilis et officia qui", + "completed": false + }, + {"userId": 1, "id": 3, "title": "fugiat veniam minus", "completed": false}, + {"userId": 1, "id": 4, "title": "et porro tempora", "completed": true}, + { + "userId": 1, + "id": 5, + "title": "laboriosam mollitia et enim quasi adipisci quia provident illum", + "completed": false + }, + { + "userId": 1, + "id": 6, + "title": "qui ullam ratione quibusdam voluptatem quia omnis", + "completed": false + }, + { + "userId": 1, + "id": 7, + "title": "illo expedita consequatur quia in", + "completed": false + }, + { + "userId": 1, + "id": 8, + "title": "quo adipisci enim quam ut ab", + "completed": true + }, + { + "userId": 1, + "id": 9, + "title": "molestiae perspiciatis ipsa", + "completed": false + }, + { + "userId": 1, + "id": 10, + "title": "illo est ratione doloremque quia maiores aut", + "completed": true + }, + { + "userId": 1, + "id": 11, + "title": "vero rerum temporibus dolor", + "completed": true + }, + { + "userId": 1, + "id": 12, + "title": "ipsa repellendus fugit nisi", + "completed": true + }, + {"userId": 1, "id": 13, "title": "et doloremque nulla", "completed": false}, + { + "userId": 1, + "id": 14, + "title": "repellendus sunt dolores architecto voluptatum", + "completed": true + }, + { + "userId": 1, + "id": 15, + "title": "ab voluptatum amet voluptas", + "completed": true + }, + { + "userId": 1, + "id": 16, + "title": "accusamus eos facilis sint et aut voluptatem", + "completed": true + }, + { + "userId": 1, + "id": 17, + "title": "quo laboriosam deleniti aut qui", + "completed": true + }, + { + "userId": 1, + "id": 18, + "title": "dolorum est consequatur ea mollitia in culpa", + "completed": false + }, + { + "userId": 1, + "id": 19, + "title": "molestiae ipsa aut voluptatibus pariatur dolor nihil", + "completed": true + }, + { + "userId": 1, + "id": 20, + "title": "ullam nobis libero sapiente ad optio sint", + "completed": true + }, + { + "userId": 2, + "id": 21, + "title": "suscipit repellat esse quibusdam voluptatem incidunt", + "completed": false + }, + { + "userId": 2, + "id": 22, + "title": "distinctio vitae autem nihil ut molestias quo", + "completed": true + }, + { + "userId": 2, + "id": 23, + "title": "et itaque necessitatibus maxime molestiae qui quas velit", + "completed": false + }, + { + "userId": 2, + "id": 24, + "title": "adipisci non ad dicta qui amet quaerat doloribus ea", + "completed": false + }, +]; + +void main(List arguments) { + QueryCache cache = QueryCache(); + for (var todo in todos.asMap().entries) { + cache.writeQuery(QueryKey.fromList(["KEY", todo.key.toString()]), data: { + "data": todo.value, + "meta": { + "created": DateTime.now(), + "expiration": DateTime.now().add(Duration(hours: 2)) + } + }); + } + + for (var i = 0; i < todos.length; i++) { + var result = cache.readQuery(QueryKey.fromList(["KEY", i.toString()])); + print(result); + } +} diff --git a/packages/fl_query/example/pubspec.lock b/packages/fl_query/example/pubspec.lock new file mode 100644 index 0000000..69f71b9 --- /dev/null +++ b/packages/fl_query/example/pubspec.lock @@ -0,0 +1,163 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + async: + dependency: transitive + description: + name: async + url: "https://pub.dartlang.org" + source: hosted + version: "2.8.2" + characters: + dependency: transitive + description: + name: characters + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.0" + charcode: + dependency: transitive + description: + name: charcode + url: "https://pub.dartlang.org" + source: hosted + version: "1.3.1" + collection: + dependency: transitive + description: + name: collection + url: "https://pub.dartlang.org" + source: hosted + version: "1.15.0" + crypto: + dependency: transitive + description: + name: crypto + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.1" + fl_query: + dependency: "direct main" + description: + path: ".." + relative: true + source: path + version: "0.0.1" + flutter: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + hive: + dependency: transitive + description: + name: hive + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.5" + http: + dependency: transitive + description: + name: http + url: "https://pub.dartlang.org" + source: hosted + version: "0.13.4" + http_parser: + dependency: transitive + description: + name: http_parser + url: "https://pub.dartlang.org" + source: hosted + version: "4.0.0" + lints: + dependency: "direct dev" + description: + name: lints + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.1" + meta: + dependency: transitive + description: + name: meta + url: "https://pub.dartlang.org" + source: hosted + version: "1.7.0" + path: + dependency: transitive + description: + name: path + url: "https://pub.dartlang.org" + source: hosted + version: "1.8.1" + rxdart: + dependency: transitive + description: + name: rxdart + url: "https://pub.dartlang.org" + source: hosted + version: "0.27.3" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.99" + source_span: + dependency: transitive + description: + name: source_span + url: "https://pub.dartlang.org" + source: hosted + version: "1.8.2" + stream_channel: + dependency: transitive + description: + name: stream_channel + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" + string_scanner: + dependency: transitive + description: + name: string_scanner + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.0" + term_glyph: + dependency: transitive + description: + name: term_glyph + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.0" + typed_data: + dependency: transitive + description: + name: typed_data + url: "https://pub.dartlang.org" + source: hosted + version: "1.3.0" + uuid: + dependency: transitive + description: + name: uuid + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.5" + vector_math: + dependency: transitive + description: + name: vector_math + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" +sdks: + dart: ">=2.15.1 <3.0.0" + flutter: ">=1.17.0" diff --git a/packages/fl_query/example/pubspec.yaml b/packages/fl_query/example/pubspec.yaml new file mode 100644 index 0000000..2fcdf31 --- /dev/null +++ b/packages/fl_query/example/pubspec.yaml @@ -0,0 +1,16 @@ +name: example +description: A simple command-line application. +version: 1.0.0 +# homepage: https://www.example.com +publish_to: none + +environment: + sdk: '>=2.15.1 <3.0.0' + + +dependencies: + fl_query: + path: ../ + +dev_dependencies: + lints: ^1.0.0 diff --git a/packages/fl_query/lib/fl_query.dart b/packages/fl_query/lib/fl_query.dart new file mode 100644 index 0000000..5ce961d --- /dev/null +++ b/packages/fl_query/lib/fl_query.dart @@ -0,0 +1,13 @@ +library fl_query; + +export 'package:fl_query/src/cache/cache.dart'; +// export 'package:fl_query/src/core/core.dart'; +// export 'package:fl_query/src/core/query_result.dart'; +// export 'package:fl_query/src/core/policies.dart'; +// export 'package:fl_query/src/exceptions.dart'; +// export 'package:fl_query/src/graphql_client.dart'; +export 'package:fl_query/src/core/query_key.dart'; + +// export 'package:fl_query/src/links/links.dart'; + +// export 'package:fl_query/src/utilities/helpers.dart' show gql; diff --git a/packages/fl_query/lib/src/cache/_normalizing_data_proxy.dart b/packages/fl_query/lib/src/cache/_normalizing_data_proxy.dart new file mode 100644 index 0000000..2becddf --- /dev/null +++ b/packages/fl_query/lib/src/cache/_normalizing_data_proxy.dart @@ -0,0 +1,51 @@ +import 'package:fl_query/src/core/query_key.dart'; +import "package:meta/meta.dart"; +import 'package:fl_query/src/cache/data_proxy.dart'; + +typedef DataIdResolver = String? Function(Map object); + +/// Implements the core (de)normalization api leveraged by the cache and proxy, +/// +/// [readNormalized] and [writeNormalized] must still be supplied by the implementing class +abstract class NormalizingDataProxy extends JSONDataProxy { + /// Flag used to request a (re)broadcast from the [QueryManager]. + /// + /// This is set on every [writeQuery] and [writeFragment] by default. + @protected + @visibleForTesting + bool broadcastRequested = false; + + /// Read normaized data from the cache + /// + /// Called from [readQuery] and [readFragment], which handle denormalization. + /// + /// The key differentiating factor for an implementing `cache` or `proxy` + /// is usually how they handle [optimistic] reads. + @protected + dynamic readNormalized(String rootId, {bool? optimistic}); + + /// Write normalized data into the cache. + /// + /// Called from [writeQuery] and [writeFragment]. + /// Implementors are expected to handle deep merging results themselves + @protected + void writeNormalized(String dataId, dynamic value); + + Map? readQuery( + QueryKey queryKey, { + bool? optimistic = true, + }) { + return readNormalized(queryKey.key, optimistic: optimistic); + } + + void writeQuery( + QueryKey queryKey, { + required Map data, + bool? broadcast = true, + }) { + writeNormalized(queryKey.key, data); + if (broadcast ?? true) { + broadcastRequested = true; + } + } +} diff --git a/packages/fl_query/lib/src/cache/_optimistic_transactions.dart b/packages/fl_query/lib/src/cache/_optimistic_transactions.dart new file mode 100644 index 0000000..4d62be5 --- /dev/null +++ b/packages/fl_query/lib/src/cache/_optimistic_transactions.dart @@ -0,0 +1,63 @@ +/// Optimistic proxying and patching classes and typedefs used by `./cache.dart` +import 'dart:collection'; + +import 'package:fl_query/src/utilities/helpers.dart'; +import 'package:meta/meta.dart'; + +import 'package:fl_query/src/cache/_normalizing_data_proxy.dart'; +import 'package:fl_query/src/cache/data_proxy.dart'; + +import 'package:fl_query/src/cache/cache.dart' show QueryCache; + +/// API for users to provide cache updates through +typedef CacheTransaction = JSONDataProxy Function(JSONDataProxy proxy); + +/// An optimistic update recorded with [QueryCache.recordOptimisticTransaction], +/// identifiable through it's [id]. +@immutable +class OptimisticPatch { + const OptimisticPatch(this.id, this.data); + final String id; + final HashMap data; +} + +/// Proxy by which users record [_OptimisticPatch]s though +/// [QueryCache.recordOptimisticTransaction]. +/// +/// Implements, and is exposed as, a [JSONDataProxy]. +/// It's `optimistic` parameters default to `true`, +/// but the user can override them to read directly from the `store`. +class OptimisticProxy extends NormalizingDataProxy { + OptimisticProxy(this.cache); + + QueryCache cache; + + HashMap data = HashMap(); + + @override + dynamic readNormalized(String rootId, {bool? optimistic = true}) { + if (!optimistic!) { + return cache.readNormalized(rootId, optimistic: false); + } + // the cache calls `patch.data.containsKey(rootId)`, + // so this is not an infinite loop + return data[rootId] ?? cache.readNormalized(rootId, optimistic: true); + } + + // TODO consider using store for optimistic patches + /// Write normalized data into the patch, + /// deeply merging maps with existing values + /// + /// Called from [writeQuery] and [writeFragment]. + void writeNormalized(String dataId, dynamic value) { + if (value is Map) { + final existing = data[dataId]; + data[dataId] = + existing != null ? deeplyMergeLeft([existing, value]) : value; + } else { + data[dataId] = value; + } + } + + OptimisticPatch asPatch(String id) => OptimisticPatch(id, data); +} diff --git a/packages/fl_query/lib/src/cache/cache.dart b/packages/fl_query/lib/src/cache/cache.dart new file mode 100644 index 0000000..33c5d98 --- /dev/null +++ b/packages/fl_query/lib/src/cache/cache.dart @@ -0,0 +1,173 @@ +import 'package:collection/collection.dart' show IterableExtension; +import 'package:fl_query/src/cache/_normalizing_data_proxy.dart'; +import 'package:meta/meta.dart'; + +import 'package:fl_query/src/utilities/helpers.dart'; +import 'package:fl_query/src/cache/store.dart'; + +import 'package:fl_query/src/cache/_optimistic_transactions.dart'; + +export 'package:fl_query/src/cache/data_proxy.dart'; +export 'package:fl_query/src/cache/store.dart'; +export 'package:fl_query/src/cache/hive_store.dart'; + +typedef VariableEncoder = Object Function(Object t); + +/// Optimistic JSON data cache with configurable [store]. +/// +/// **NOTE**: The default [InMemoryStore] does _not_ persist to disk. +/// The recommended store for persistent environments is the [HiveStore]. +/// +/// [dataIdFromObject] and [typePolicies] are passed down to [normalize] operations, which say: +/// > IDs are determined by the following: +/// > +/// > 1. If a `TypePolicy` is provided for the given type, it's `TypePolicy.keyFields` are used. +/// > 2. If a `dataIdFromObject` funciton is provided, the result is used. +/// > 3. The `id` or `_id` field (respectively) are used. +class QueryCache extends NormalizingDataProxy { + QueryCache({ + Store? store, + }) : store = store ?? InMemoryStore(); + + /// Stores the underlying normalized data. Defaults to an [InMemoryStore] + /// + /// **WARNING**: Directly editing the contents of the store will not automatically + /// rebroadcast operations. + final Store store; + + /// Tracks the number of ongoing transactions (cache updates) + /// to prevent rebroadcasts until they are completed. + /// + /// **NOTE**: Does not track network calls + @protected + int inflightOptimisticTransactions = 0; + + /// Whether a cache operation has requested a broadcast and it is safe to do. + /// + /// The caller must [claimExectution] to clear the [broadcastRequested] flag. + /// + /// This is not meant to be called outside of the [QueryManager] + bool shouldBroadcast({bool claimExecution = false}) { + if (inflightOptimisticTransactions == 0 && broadcastRequested) { + if (claimExecution) { + broadcastRequested = false; + } + return true; + } + return false; + } + + /// List of patches recorded through [recordOptimisticTransaction] + /// + /// They are applied in ascending order, + /// thus data in `last` will overwrite that in `first` + /// if there is a conflict + @protected + @visibleForTesting + List optimisticPatches = []; + + /// Reads dereferences an entity from the first valid optimistic layer, + /// defaulting to the base internal HashMap. + @override + Object? readNormalized(String rootId, {bool? optimistic = true}) { + Object? value = store.get(rootId); + + if (!optimistic!) { + return value; + } + + for (final patch in optimisticPatches) { + if (patch.data.containsKey(rootId)) { + final Object? patchData = patch.data[rootId]; + if (value is Map && patchData is Map) { + value = deeplyMergeLeft([ + value, + patchData, + ]); + } else { + // Overwrite if not mergable + value = patchData; + } + } + } + + return value; + } + + /// Write normalized data into the cache, + /// deeply merging maps with existing values + /// + /// Called from [witeQuery] and [writeFragment]. + @override + void writeNormalized(String dataId, dynamic value) { + if (value is Map) { + final existing = store.get(dataId); + store.put( + dataId, + existing != null ? deeplyMergeLeft([existing, value]) : value, + ); + } else { + store.put(dataId, value); + } + } + + String? _parentPatchId(String id) { + final List parts = id.split('.'); + if (parts.length > 1) { + return parts.first; + } + return null; + } + + bool _patchExistsFor(String id) => + optimisticPatches.firstWhereOrNull( + (patch) => patch.id == id, + ) != + null; + + /// avoid race conditions from slow updates + /// + /// if a server result is returned before an optimistic update is finished, + /// that update is discarded + bool _safeToAdd(String id) { + final String? parentId = _parentPatchId(id); + return parentId == null || _patchExistsFor(parentId); + } + + // TODO does patch hierachy still makes sense + /// Record the given [transaction] into a patch with the id [addId] + /// + /// 1 level of hierarchical optimism is supported: + /// * if a patch has the id `$queryId.child`, it will be removed with `$queryId` + /// * if the update somehow fails to complete before the root response is removed, + /// It will still be called, but the result will not be added. + /// + /// This allows for multiple optimistic treatments of a query, + /// without having to tightly couple optimistic changes + void recordOptimisticTransaction( + CacheTransaction transaction, + String addId, + ) { + inflightOptimisticTransactions += 1; + final _proxy = transaction(OptimisticProxy(this)) as OptimisticProxy; + if (_safeToAdd(addId)) { + optimisticPatches.add(_proxy.asPatch(addId)); + broadcastRequested = broadcastRequested || _proxy.broadcastRequested; + } + inflightOptimisticTransactions -= 1; + } + + /// Remove a given patch from the list + /// + /// This will also remove all "nested" patches, such as `$queryId.update` + /// (see [recordOptimisticTransaction]) + /// + /// This allows for hierarchical optimism that is automatically cleaned up + /// without having to tightly couple optimistic changes + void removeOptimisticPatch(String removeId) { + optimisticPatches.removeWhere( + (patch) => patch.id == removeId || _parentPatchId(patch.id) == removeId, + ); + broadcastRequested = true; + } +} diff --git a/packages/fl_query/lib/src/cache/data_proxy.dart b/packages/fl_query/lib/src/cache/data_proxy.dart new file mode 100644 index 0000000..707d557 --- /dev/null +++ b/packages/fl_query/lib/src/cache/data_proxy.dart @@ -0,0 +1,29 @@ +import 'package:fl_query/src/core/query_key.dart'; +import 'package:fl_query/src/exceptions/exceptions_next.dart'; + +/// The DataProxy class that can be inherited/implemented for reading +/// or writing queries in a query pool/store with queryKey +abstract class JSONDataProxy { + /// Reads a JSON query from the root query id. + Map? readQuery(QueryKey queryKey, {bool? optimistic}); + + /// Writes (saves) a JSON data to the root query id, + /// then [broadcast] changes to watchers unless `broadcast: false` + /// + /// [normalize] the given [data] into a valid JSON format. It get rids + /// of Dart native objects + /// Conceptually, this can be thought of as providing a manual execution result + /// in the form of [data] + /// + /// For complex `normalize` type policies that involve custom reads, + /// `optimistic` will be the default. + /// + /// Will throw a [PartialDataException] if the [data] structure + /// doesn't match that of the [queryKey] `operation.document`, + /// or a [CacheMisconfigurationException] if the write fails for some other reason. + void writeQuery( + QueryKey queryKey, { + required Map data, + bool? broadcast, + }); +} diff --git a/packages/fl_query/lib/src/cache/hive_store.dart b/packages/fl_query/lib/src/cache/hive_store.dart new file mode 100644 index 0000000..5928243 --- /dev/null +++ b/packages/fl_query/lib/src/cache/hive_store.dart @@ -0,0 +1,68 @@ +import 'dart:async'; +import 'package:meta/meta.dart'; + +import 'package:hive/hive.dart'; + +import './store.dart'; + +@immutable +class HiveStore extends Store { + /// Default box name for the `graphql/client.dart` cache store (`graphqlClientStore`) + static const defaultBoxName = 'graphqlClientStore'; + + /// Opens a box. Convenience pass through to [Hive.openBox]. + /// + /// If the box is already open, the instance is returned and all provided parameters are being ignored. + static final openBox = Hive.openBox; + + /// Convenience factory for `HiveStore(await openBox(boxName ?? 'JSONCacheStore', path: path))` + /// + /// [boxName] defaults to [defaultBoxName], [path] is optional. + /// For full configuration of a [Box] use [HiveStore()] in tandem with [openBox] / [Hive.openBox] + static Future open({ + String boxName = defaultBoxName, + String? path, + }) async => + HiveStore(await openBox(boxName, path: path)); + + /// Direct access to the underlying [Box]. + /// + /// **WARNING**: Directly editing the contents of the store will not automatically + /// rebroadcast operations. + final Box box; + + /// Creates a HiveStore inititalized with the given [box], defaulting to `Hive.box(defaultBoxName)` + /// + /// **N.B.**: [box] must already be [opened] with either [openBox], [open], or `initHiveForFlutter` from `graphql_flutter`. + /// This lets us decouple the async initialization logic, making store usage elsewhere much more straightforward. + /// + /// [opened]: https://docs.hivedb.dev/#/README?id=open-a-box + HiveStore([Box? box]) : this.box = box ?? Hive.box(defaultBoxName); + + @override + Map? get(String dataId) { + final result = box.get(dataId); + if (result == null) return null; + return Map.from(result); + } + + @override + void put(String dataId, Map? value) { + box.put(dataId, value); + } + + @override + void putAll(Map> data) { + box.putAll(data); + } + + @override + void delete(String dataId) { + box.delete(dataId); + } + + @override + Map> toMap() => Map.unmodifiable(box.toMap()); + + Future reset() => box.clear(); +} diff --git a/packages/fl_query/lib/src/cache/store.dart b/packages/fl_query/lib/src/cache/store.dart new file mode 100644 index 0000000..58a630b --- /dev/null +++ b/packages/fl_query/lib/src/cache/store.dart @@ -0,0 +1,66 @@ +import 'dart:collection'; + +import 'package:meta/meta.dart'; + +// TODO decide if [Store] should have save, etc +// TODO figure out how to reference non-imported symbols +/// Raw key-value datastore API leveraged by the [Cache] +@immutable +abstract class Store { + Map? get(String dataId); + + /// Write [value] into this store under the key [dataId] + void put(String dataId, Map? value); + + /// [put] all entries from [data] into the store + /// + /// Functionally equivalent to `data.map(put);` + void putAll(Map> data); + + /// Delete the value of the [dataId] from the store, if preset + void delete(String dataId); + + /// Empty the store + void reset(); + + /// Return the entire contents of the cache as [Map]. + /// + /// NOTE: some [Store]s might return mutable objects + /// referenced by the store itself. + Map> toMap(); +} + +/// Simplest possible [Map]-backed store +@immutable +class InMemoryStore extends Store { + /// Normalized map that backs the store. + /// Defaults to an empty [HashMap] + @protected + @visibleForTesting + final Map data; + + /// Creates an InMemoryStore inititalized with [data], + /// which defaults to an empty [HashMap] + InMemoryStore([ + Map? data, + ]) : data = data ?? HashMap(); + + @override + Map? get(String dataId) => data[dataId]; + + @override + void put(String dataId, Map? value) => data[dataId] = value; + + @override + void putAll(Map> entries) => + data.addAll(entries); + + @override + void delete(String dataId) => data.remove(dataId); + + /// Return the underlying [data] as an unmodifiable [Map]. + @override + Map> toMap() => Map.unmodifiable(data); + + void reset() => data.clear(); +} diff --git a/packages/fl_query/lib/src/core/_base_options.dart b/packages/fl_query/lib/src/core/_base_options.dart new file mode 100644 index 0000000..2928f9e --- /dev/null +++ b/packages/fl_query/lib/src/core/_base_options.dart @@ -0,0 +1,95 @@ +import 'package:fl_query/src/core/_data_class.dart'; +import 'package:fl_query/fl_query.dart'; +import 'package:fl_query/src/core/result_parser.dart'; + +/// TODO refactor into [Request] container +/// Base options. +abstract class BaseOptions extends MutableDataClass { + BaseOptions({ + required this.document, + this.variables = const {}, + this.operationName, + ResultParserFn? parserFn, + Context? context, + FetchPolicy? fetchPolicy, + ErrorPolicy? errorPolicy, + CacheRereadPolicy? cacheRereadPolicy, + this.optimisticResult, + }) : policies = Policies( + fetch: fetchPolicy, + error: errorPolicy, + cacheReread: cacheRereadPolicy, + ), + context = context ?? Context(), + parserFn = parserFn ?? + ((d) => throw UnimplementedError( + "Please provide a parser function to support result parsing.", + )); + + /// Document containing at least one [OperationDefinitionNode] + DocumentNode document; + + /// Name of the executable definition + /// + /// Must be specified if [document] contains more than one [OperationDefinitionNode] + String? operationName; + + /// A map going from variable name to variable value, where the variables are used + /// within the GraphQL query. + Map variables; + + /// An optimistic result to eagerly add to the operation stream + Object? optimisticResult; + + /// Specifies the [Policies] to be used during execution. + Policies policies; + + FetchPolicy? get fetchPolicy => policies.fetch; + + ErrorPolicy? get errorPolicy => policies.error; + + CacheRereadPolicy? get cacheRereadPolicy => policies.cacheReread; + + /// Context to be passed to link execution chain. + Context context; + + ResultParserFn parserFn; + + // TODO consider inverting this relationship + /// Resolve these options into a request + Request get asRequest => Request( + operation: Operation( + document: document, + operationName: operationName, + ), + variables: variables, + context: context, + ); + + @override + List get properties => [ + document, + operationName, + variables, + optimisticResult, + policies, + context, + ]; + + OperationType get type { + final definitions = + document.definitions.whereType().toList(); + if (operationName != null) { + definitions.removeWhere( + (node) => node.name!.value != operationName, + ); + } + // TODO differentiate error types, add exception + assert(definitions.length == 1); + return definitions.first.type; + } + + bool get isQuery => type == OperationType.query; + bool get isMutation => type == OperationType.mutation; + bool get isSubscription => type == OperationType.subscription; +} diff --git a/packages/fl_query/lib/src/core/_data_class.dart b/packages/fl_query/lib/src/core/_data_class.dart new file mode 100644 index 0000000..511f7dd --- /dev/null +++ b/packages/fl_query/lib/src/core/_data_class.dart @@ -0,0 +1,29 @@ +import 'package:meta/meta.dart'; +import "package:collection/collection.dart"; + +/// Helper for making mutable data classes with +/// a [properties] based [equal] helper +/// +/// NOTE: I (@micimize) settled on this helper instead of truly immutable classes +/// because I didn't want to deal with the issue of `copyWith(field: null)`, +/// but also didn't want to commit to adding a true dataclass generator +/// like `freezed` or `built_value` yet. I consider this a stopgap, +/// and think we should eventually have a truly immutable API +abstract class MutableDataClass { + const MutableDataClass(); + + /// identifying properties for the inheriting class + @protected + List get properties; + + /// [properties] based deep equality check + bool equal(MutableDataClass other) => + identical(this, other) || + (runtimeType == other.runtimeType && + const ListEquality( + DeepCollectionEquality(), + ).equals( + other.properties, + properties, + )); +} diff --git a/packages/fl_query/lib/src/core/_query_write_handling.dart b/packages/fl_query/lib/src/core/_query_write_handling.dart new file mode 100644 index 0000000..f0d183d --- /dev/null +++ b/packages/fl_query/lib/src/core/_query_write_handling.dart @@ -0,0 +1,103 @@ +import 'package:fl_query/fl_query.dart'; +import 'package:fl_query/src/core/query_key.dart'; + +/// Internal writeQuery wrapper +typedef _IntWriteQuery = void Function( + QueryKey queryKey, Map? data); + +extension InternalQueryWriteHandling on QueryManager { + /// Merges exceptions into `queryResult` and + /// returns `true` on success. + /// + /// This is named `*OrSetExceptionOnQueryResult` because it is very imperative, + /// and edits the [queryResult] inplace. + bool _writeQueryOrSetExceptionOnQueryResult( + QueryKey queryKey, + Map? data, + QueryResult? queryResult, { + required _IntWriteQuery writeQuery, + }) { + try { + writeQuery(queryKey, data); + return true; + } on CacheMisconfigurationException catch (failure) { + queryResult!.exception = coalesceErrors( + exception: queryResult.exception, + linkException: failure, + ); + } + return false; + } + + /// Part of [InternalQueryWriteHandling], and not exposed outside the + /// library. + /// + /// Returns `true` if a reread should be attempted to incorporate potential optimistic data. + /// + /// If we have no data, we skip caching, thus taking [ErrorPolicy.none] + /// into account. + /// + /// networked wrapper for [_writeQueryOrSetExceptionOnQueryResult] + /// NOTE: mapFetchResultToQueryResult must be called beforehand + bool attemptCacheWriteFromResponse( + Policies policies, + Request request, + Response response, + QueryResult? queryResult, + ) => + (policies.fetch == FetchPolicy.noCache || queryResult!.data == null) + ? false + : _writeQueryOrSetExceptionOnQueryResult( + request, + response.data, + queryResult, + writeQuery: (req, data) => cache.writeQuery(req, data: data!), + onPartial: (failure) => UnexpectedResponseStructureException( + failure, + queryKey: request, + parsedResponse: response, + ), + ) && + policies.mergeOptimisticData; + + /// Part of [InternalQueryWriteHandling], and not exposed outside the + /// library. + /// + /// client-side wrapper for [_writeQueryOrSetExceptionOnQueryResult] + bool attemptCacheWriteFromClient( + Request request, + Map? data, + QueryResult queryResult, { + required _IntWriteQuery writeQuery, + }) => + _writeQueryOrSetExceptionOnQueryResult( + request, + data, + queryResult, + writeQuery: writeQuery, + onPartial: (failure) => MismatchedDataStructureException( + failure, + queryKey: request, + data: data, + ), + ); + + /// Reread the request into the result from the cache, + /// adding a [CacheMissException] if it fails to do so + void attempCacheRereadIntoResult(Request request, QueryResult? queryResult) { + // normalize results if previously written + final rereadData = cache.readQuery(request); + if (rereadData == null) { + queryResult!.exception = coalesceErrors( + exception: queryResult.exception, + linkException: CacheMissException( + 'Round trip cache re-read failed: cache.readQuery(request) returned null', + request, + expectedData: queryResult.data, + ), + ); + } else { + queryResult!.data = rereadData; + } + } +} diff --git a/packages/fl_query/lib/src/core/core.dart b/packages/fl_query/lib/src/core/core.dart new file mode 100644 index 0000000..d8e1b00 --- /dev/null +++ b/packages/fl_query/lib/src/core/core.dart @@ -0,0 +1,6 @@ +export 'package:fl_query/src/core/observable_query.dart'; +export 'package:fl_query/src/core/query_manager.dart'; +export 'package:fl_query/src/core/query_options.dart'; +export 'package:fl_query/src/core/mutation_options.dart'; +export 'package:fl_query/src/core/query_result.dart'; +export 'package:fl_query/src/core/policies.dart'; diff --git a/packages/fl_query/lib/src/core/fetch_more.dart b/packages/fl_query/lib/src/core/fetch_more.dart new file mode 100644 index 0000000..956aedc --- /dev/null +++ b/packages/fl_query/lib/src/core/fetch_more.dart @@ -0,0 +1,84 @@ +import 'dart:async'; + +import 'package:fl_query/fl_query.dart'; + +import 'package:fl_query/src/core/_query_write_handling.dart'; + +/// Fetch more results and then merge them with [previousResult] +/// according to [FetchMoreOptions.updateQuery] +/// +/// Will add results if [ObservableQuery.queryId] is supplied, +/// and broadcast any cache changes +/// +/// This is the **Internal Implementation**, +/// used by [ObservableQuery] and [GraphQLCLient.fetchMore] +Future> fetchMoreImplementation( + FetchMoreOptions fetchMoreOptions, { + required QueryOptions originalOptions, + required QueryManager queryManager, + required QueryResult previousResult, + String? queryId, +}) async { + // fetch more and update + + final document = (fetchMoreOptions.document ?? originalOptions.document); + final request = originalOptions.asRequest; + + final combinedOptions = QueryOptions( + fetchPolicy: FetchPolicy.noCache, + errorPolicy: originalOptions.errorPolicy, + document: document, + variables: { + ...originalOptions.variables, + ...fetchMoreOptions.variables, + }, + ); + + QueryResult fetchMoreResult = + await queryManager.query(combinedOptions); + + try { + // combine the query with the new query, using the function provided by the user + final data = fetchMoreOptions.updateQuery( + previousResult.data, + fetchMoreResult.data, + )!; + + fetchMoreResult.data = data; + + if (originalOptions.fetchPolicy != FetchPolicy.noCache) { + queryManager.attemptCacheWriteFromClient( + request, + data, + fetchMoreResult, + writeQuery: (req, data) => queryManager.cache.writeQuery( + req, + data: data!, + ), + ); + } + + // will add to a stream with `queryId` and rebroadcast if appropriate + queryManager.addQueryResult( + request, + queryId, + fetchMoreResult, + ); + } catch (error) { + if (fetchMoreResult.hasException) { + // because the updateQuery failure might have been because of these errors, + // we just add them to the old errors + previousResult.exception = coalesceErrors( + exception: previousResult.exception, + graphqlErrors: fetchMoreResult.exception!.graphqlErrors, + linkException: fetchMoreResult.exception!.linkException, + ); + return previousResult; + } else { + // TODO merge results OperationException + rethrow; + } + } + + return fetchMoreResult; +} diff --git a/packages/fl_query/lib/src/core/mutation_options.dart b/packages/fl_query/lib/src/core/mutation_options.dart new file mode 100644 index 0000000..ef1af69 --- /dev/null +++ b/packages/fl_query/lib/src/core/mutation_options.dart @@ -0,0 +1,141 @@ +// ignore_for_file: deprecated_member_use_from_same_package +import 'dart:async'; +import 'package:fl_query/src/cache/cache.dart'; +import 'package:fl_query/src/core/_base_options.dart'; +import 'package:fl_query/src/core/observable_query.dart'; + +import 'package:fl_query/src/core/result_parser.dart'; + +import 'package:fl_query/src/exceptions.dart'; +import 'package:fl_query/src/core/query_result.dart'; +import 'package:fl_query/src/utilities/helpers.dart'; +import 'package:fl_query/src/core/policies.dart'; + +typedef OnMutationCompleted = FutureOr Function(dynamic data); +typedef OnMutationUpdate = FutureOr Function( + JSONDataProxy cache, + QueryResult? result, +); +typedef OnError = FutureOr Function(OperationException? error); + +class MutationOptions extends BaseOptions { + MutationOptions({ + required DocumentNode document, + String? operationName, + Map variables = const {}, + FetchPolicy? fetchPolicy, + ErrorPolicy? errorPolicy, + CacheRereadPolicy? cacheRereadPolicy, + Context? context, + Object? optimisticResult, + this.onCompleted, + this.update, + this.onError, + ResultParserFn? parserFn, + }) : super( + fetchPolicy: fetchPolicy, + errorPolicy: errorPolicy, + cacheRereadPolicy: cacheRereadPolicy, + document: document, + operationName: operationName, + variables: variables, + context: context, + optimisticResult: optimisticResult, + parserFn: parserFn, + ); + + final OnMutationCompleted? onCompleted; + final OnMutationUpdate? update; + final OnError? onError; + + @override + List get properties => + [...super.properties, onCompleted, update, onError]; +} + +/// Handles execution of mutation `update`, `onCompleted`, and `onError` callbacks +class MutationCallbackHandler { + final MutationOptions options; + final QueryCache cache; + final String queryId; + + MutationCallbackHandler({ + required this.options, + required this.cache, + required this.queryId, + }); + + // callbacks will be called against each result in the stream, + // which should then rebroadcast queries with the appropriate optimism + Iterable get callbacks => + [onCompleted, update, onError].where(notNull).cast(); + + // Todo: probably move this to its own class + OnData? get onCompleted { + if (options.onCompleted != null) { + return (QueryResult? result) { + if (!result!.isLoading && !result.isOptimistic) { + return options.onCompleted!(result.data); + } + }; + } + return null; + } + + OnData? get onError { + if (options.onError != null) { + return (QueryResult? result) { + if (!result!.isLoading && + result.hasException && + options.errorPolicy != ErrorPolicy.ignore) { + return options.onError!(result.exception); + } + }; + } + + return null; + } + + /// The optimistic cache layer id `update` will write to + /// is a "child patch" of the default optimistic patch + /// created by the query manager + String get _patchId => '${queryId}.update'; + + /// apply the user's patch + void _optimisticUpdate(QueryResult? result) { + final String patchId = _patchId; + // this is also done in query_manager, but better safe than sorry + cache.recordOptimisticTransaction( + (JSONDataProxy cache) { + options.update!(cache, result); + return cache; + }, + patchId, + ); + } + + // optimistic patches will be cleaned up by the query_manager + // cleanup is handled by heirarchical optimism - + // as in, because our patch id is prefixed with '${observableQuery.queryId}.', + // it will be discarded along with the observableQuery.queryId patch + // TODO this results in an implicit coupling with the patch id system + OnData? get update { + if (options.update != null) { + // dereference all variables that might be needed if the widget is disposed + final OnMutationUpdate? widgetUpdate = options.update; + final OnData optimisticUpdate = _optimisticUpdate; + + // wrap update logic to handle optimism + FutureOr updateOnData(QueryResult? result) { + if (result!.isOptimistic) { + return optimisticUpdate(result); + } else { + return widgetUpdate!(cache, result); + } + } + + return updateOnData; + } + return null; + } +} diff --git a/packages/fl_query/lib/src/core/observable_query.dart b/packages/fl_query/lib/src/core/observable_query.dart new file mode 100644 index 0000000..96e1d91 --- /dev/null +++ b/packages/fl_query/lib/src/core/observable_query.dart @@ -0,0 +1,386 @@ +import 'dart:async'; +import 'package:fl_query/fl_query.dart'; +import 'package:meta/meta.dart'; + +import 'package:fl_query/src/core/fetch_more.dart'; +import 'package:fl_query/src/scheduler/scheduler.dart'; + +/// Side effect to register for execution when data is received +typedef OnData = FutureOr Function(QueryResult? result); + +/// Lifecycle states for [ObservableQuery.lifecycle] +enum QueryLifecycle { + /// No results have been requested or fetched + unexecuted, + + /// Results are being fetched, and will be side-effect free + pending, + + /// Polling for results periodically + polling, + + /// Was polling but [ObservableQuery.stopPolling()] was called + pollingStopped, + + /// Results are being fetched, and will trigger + /// the callbacks registered with [ObservableQuery.onData] + sideEffectsPending, + + /// Pending side effects are preventing [ObservableQuery.close], + /// and the [ObservableQuery] will be discarded after fetch completes + /// and side effects are resolved. + sideEffectsBlocking, + + /// The operation was executed and is not [polling] + completed, + + /// [ObservableQuery.close] was called and all activity + /// from this [ObservableQuery] has ceased. + closed +} + +/// An Observable/Stream-based API for both queries and mutations. +/// +/// Returned from [GraphQLClient.watchQuery] for use in reactive programming, +/// for instance in `graphql_flutter` widgets. +/// It is modelled closely after [Apollo's ObservableQuery][apollo_oq] +/// +/// [ObservableQuery]'s core api/usage is to [fetchResults], then listen to the [stream]. +/// [fetchResults] will be called on instantiation if [options.eagerlyFetchResults] is set, +/// which in turn defaults to [options.fetchResults]. +/// +/// Beyond that, [ObservableQuery] is a bit of a kitchen sink: +/// * There are [refetch] and [fetchMore] methods for fetching more results +/// * An [onData] method for registering callbacks (namely for mutations) +/// * [lifecycle] for tracking polling, side effect, an inflight execution state +/// * [latestResult] – the most recent result from this operation +/// +/// And a handful of internally leveraged methods. +/// +/// [apollo_oq]: https://www.apollographql.com/docs/react/v3.0-beta/api/core/ObservableQuery/ +class ObservableQuery { + ObservableQuery({ + required this.queryManager, + required this.options, + }) : queryId = queryManager.generateQueryId().toString() { + if (options.eagerlyFetchResults) { + _latestWasEagerlyFetched = true; + fetchResults(); + } + controller = StreamController>.broadcast( + onListen: onListen, + ); + } + + // set to true when eagerly fetched to prevent back-to-back queries + bool _latestWasEagerlyFetched = false; + + /// The identity of this query within the [QueryManager] + final String queryId; + + @protected + final QueryManager queryManager; + + @protected + QueryScheduler? get scheduler => queryManager.scheduler; + + /// callbacks registered with [onData] + List _onDataCallbacks = []; + + /// call [queryManager.maybeRebroadcastQueries] after all other [_onDataCallbacks] + /// + /// Automatically appended as an [OnData] + FutureOr _maybeRebroadcast(QueryResult? result) => + queryManager.maybeRebroadcastQueries(exclude: this); + + /// The most recently seen result from this operation's stream + QueryResult? latestResult; + + QueryLifecycle lifecycle = QueryLifecycle.unexecuted; + + WatchQueryOptions options; + + late StreamController> controller; + + Stream> get stream => controller.stream; + bool get isCurrentlyPolling => lifecycle == QueryLifecycle.polling; + + bool get isRefetchSafe { + if (!options.isQuery) { + return false; + } + switch (lifecycle) { + case QueryLifecycle.completed: + case QueryLifecycle.polling: + case QueryLifecycle.pollingStopped: + return true; + + case QueryLifecycle.pending: + case QueryLifecycle.closed: + case QueryLifecycle.unexecuted: + case QueryLifecycle.sideEffectsPending: + case QueryLifecycle.sideEffectsBlocking: + return false; + } + } + + /// Attempts to refetch _on the network_, throwing error if not refetch safe + /// + /// **NOTE:** overrides any present non-network-only [FetchPolicy], + /// as refetching from the `cache` does not make sense. + Future?> refetch() { + if (isRefetchSafe) { + addResult(QueryResult.loading( + data: latestResult?.data, + parserFn: options.parserFn, + )); + return queryManager.refetchQuery(queryId); + } + throw Exception('Query is not refetch safe'); + } + + /// Whether it is safe to rebroadcast results due to cache + /// changes based on policies and [lifecycle]. + /// + /// Called internally by the [QueryManager] + bool get isRebroadcastSafe { + if (!options.policies.allowsRebroadcasting) { + return false; + } + switch (lifecycle) { + case QueryLifecycle.pending: + case QueryLifecycle.completed: + case QueryLifecycle.polling: + case QueryLifecycle.pollingStopped: + return true; + + case QueryLifecycle.unexecuted: // this might be ok + case QueryLifecycle.closed: + case QueryLifecycle.sideEffectsPending: + case QueryLifecycle.sideEffectsBlocking: + return false; + } + } + + void onListen() { + if (_latestWasEagerlyFetched) { + _latestWasEagerlyFetched = false; + + // eager results are resolved synchronously, + // so we have to add them manually now that + // the stream is available + if (!controller.isClosed && latestResult != null) { + controller.add(latestResult!); + } + return; + } + if (options.fetchResults) { + fetchResults(); + } + } + + /// Fetch results based on [options.fetchPolicy] + /// + /// Will [startPolling] if [options.pollInterval] is set + MultiSourceResult fetchResults() { + final MultiSourceResult allResults = + queryManager.fetchQueryAsMultiSourceResult(queryId, options); + latestResult ??= allResults.eagerResult; + + if (allResults.networkResult == null) { + // This path is only possible for cacheFirst and cacheOnly fetch policies. + lifecycle = QueryLifecycle.completed; + } else { + // if onData callbacks have been registered, + // they are waited on by default + lifecycle = _onDataCallbacks.isNotEmpty + ? QueryLifecycle.sideEffectsPending + : QueryLifecycle.pending; + } + + if (options.pollInterval != null && options.pollInterval! > Duration.zero) { + startPolling(options.pollInterval); + } + + return allResults; + } + + /// fetch more results and then merge them with the [latestResult] + /// according to [FetchMoreOptions.updateQuery]. + /// + /// The results will then be added to to stream for listeners to react to, + /// such as for triggering `grahphql_flutter` widget rebuilds + /// + /// **NOTE**: with the addition of strict data structure checking in v4, + /// it is easy to make mistakes in writing [updateQuery]. + /// + /// To mitigate this, [FetchMoreOptions.partial] has been provided. + Future> fetchMore( + FetchMoreOptions fetchMoreOptions) async { + addResult(QueryResult.loading( + data: latestResult?.data, + parserFn: options.parserFn, + )); + + return fetchMoreImplementation( + fetchMoreOptions, + originalOptions: options, + queryManager: queryManager, + previousResult: latestResult!, + queryId: queryId, + ); + } + + /// Add a [result] to the [stream] unless it was created + /// before [lasestResult]. + /// + /// Copies the [QueryResult.source] from the [latestResult] + /// if it is set to `null`. + /// + /// Called internally by the [QueryManager] + void addResult(QueryResult result, {bool fromRebroadcast = false}) { + // don't overwrite results due to some async/optimism issue + if (latestResult != null && + latestResult!.timestamp.isAfter(result.timestamp)) { + return; + } + + if (options.carryForwardDataOnException && result.hasException) { + result.data ??= latestResult?.data; + } + + if (lifecycle == QueryLifecycle.pending && result.isConcrete) { + lifecycle = QueryLifecycle.completed; + } + + latestResult = result; + + // TODO should callbacks be applied before or after streaming + if (!controller.isClosed) { + controller.add(result); + } + + if (result.isNotLoading) { + _applyCallbacks(result, fromRebroadcast: fromRebroadcast); + } + } + + // most mutation behavior happens here + /// Register [callbacks] to trigger when [stream] has new results + /// where [QueryResult.isNotLoading] + /// + /// Will deregister [callbacks] after calling them on the first + /// result that [QueryResult.isConcrete], + /// handling the resolution of [lifecycle] from + /// [QueryLifecycle.sideEffectsBlocking] to [QueryLifecycle.completed] + /// as appropriate + void onData(Iterable callbacks) => _onDataCallbacks.addAll(callbacks); + + /// Applies [onData] callbacks at the end of [addResult] + /// + /// [fromRebroadcast] is used to avoid the super-edge case of infinite rebroadcasts + /// (not sure if it's even possible) + void _applyCallbacks( + QueryResult? result, { + bool fromRebroadcast = false, + }) async { + final callbacks = [ + ..._onDataCallbacks, + if (!fromRebroadcast) _maybeRebroadcast + ]; + for (final callback in callbacks) { + await callback(result); + } + + if (lifecycle == QueryLifecycle.closed) { + // .close(force: true) was called + return; + } + + if (result!.isConcrete) { + // avoid removing new callbacks + _onDataCallbacks.removeWhere((cb) => callbacks.contains(cb)); + + // if there are new callbacks, there is maybe another inflight mutation + if (_onDataCallbacks.isEmpty) { + if (lifecycle == QueryLifecycle.sideEffectsBlocking) { + lifecycle = QueryLifecycle.completed; + close(); + } + // the mutation has been completed, but disposal has not been requested + if (lifecycle == QueryLifecycle.sideEffectsPending) { + lifecycle = QueryLifecycle.completed; + } + } + } + } + + /// Poll the server periodically for results. + /// + /// Will be called by [fetchResults] automatically if [options.pollInterval] is set + void startPolling(Duration? pollInterval) { + if (options.fetchPolicy == FetchPolicy.cacheFirst || + options.fetchPolicy == FetchPolicy.cacheOnly) { + throw Exception( + 'Queries that specify the cacheFirst and cacheOnly fetch policies cannot also be polling queries.', + ); + } + + if (isCurrentlyPolling) { + scheduler!.stopPollingQuery(queryId); + } + + options.pollInterval = pollInterval; + lifecycle = QueryLifecycle.polling; + scheduler!.startPollingQuery(options, queryId); + } + + void stopPolling() { + if (isCurrentlyPolling) { + scheduler!.stopPollingQuery(queryId); + options.pollInterval = null; + lifecycle = QueryLifecycle.pollingStopped; + } + } + + set variables(Map variables) => + options.variables = variables; + + /// [onData] callbacks have het to be run + /// + /// inlcudes `lifecycle == QueryLifecycle.sideEffectsBlocking` + bool get sideEffectsArePending => + (lifecycle == QueryLifecycle.sideEffectsPending || + lifecycle == QueryLifecycle.sideEffectsBlocking); + + /// Closes the query or mutation, or else queues it for closing. + /// + /// To preserve Mutation side effects, [close] checks the [lifecycle], + /// queuing the stream for closing if [sideEffectsArePending]. + /// You can override this check with `force: true`. + /// + /// Returns a [FutureOr] of the resultant lifecycle, either + /// [QueryLifecycle.sideEffectsBlocking] or [QueryLifecycle.closed] + FutureOr close({ + bool force = false, + bool fromManager = false, + }) async { + if (lifecycle == QueryLifecycle.sideEffectsPending && !force) { + lifecycle = QueryLifecycle.sideEffectsBlocking; + // stop closing because we're waiting on something + return lifecycle; + } + + // `fromManager` is used by the query manager when it wants to close a query to avoid infinite loops + if (!fromManager) { + queryManager.closeQuery(this, fromQuery: true); + } + + stopPolling(); + + await controller.close(); + + lifecycle = QueryLifecycle.closed; + return QueryLifecycle.closed; + } +} diff --git a/packages/fl_query/lib/src/core/policies.dart b/packages/fl_query/lib/src/core/policies.dart new file mode 100644 index 0000000..88a9bcb --- /dev/null +++ b/packages/fl_query/lib/src/core/policies.dart @@ -0,0 +1,323 @@ +import 'package:fl_query/fl_query.dart'; +import 'package:meta/meta.dart'; +import "package:collection/collection.dart"; + +/// [FetchPolicy] determines where the client may return a result from. +/// +/// * [cacheFirst]: return result from cache. Only fetch from network if cached result is not available. +/// * [cacheAndNetwork]: return result from cache first (if it exists), then return network result once it's available. +/// * [cacheOnly]: return result from cache if available, fail otherwise. +/// * [noCache]: return result from network, fail if network call doesn't succeed, don't save to cache. +/// * [networkOnly]: return result from network, fail if network call doesn't succeed, save to cache. +/// +/// The default `fetchPolicy` for each method are: +/// * `watchQuery`: [cacheAndNetwork] +/// * `watchMutation`: [cacheAndNetwork] +/// * `query`: [cacheFirst] +/// * `mutation`: [networkOnly] +/// * `subscribe`: [networkOnly] +/// +/// These can be overriden at client construction time by passing +/// a [DefaultPolicies] instance to `defaultPolicies`. +enum FetchPolicy { + /// Return result from cache. Only fetch from network if cached result is not available. + cacheFirst, + + /// Return result from cache first (if it exists), then return network result once it's available. + cacheAndNetwork, + + /// Return result from cache if available, fail otherwise. + cacheOnly, + + /// Return result from network, fail if network call doesn't succeed, don't save to cache. + noCache, + + /// Return result from network, fail if network call doesn't succeed, save to cache. + networkOnly, +} + +// TODO investigate the relationship between optimistic results +// and policy in flutter +bool shouldRespondEagerlyFromCache(FetchPolicy? fetchPolicy) => + fetchPolicy == FetchPolicy.cacheFirst || + fetchPolicy == FetchPolicy.cacheAndNetwork || + fetchPolicy == FetchPolicy.cacheOnly; + +bool shouldStopAtCache(FetchPolicy? fetchPolicy) => + fetchPolicy == FetchPolicy.cacheFirst || + fetchPolicy == FetchPolicy.cacheOnly; + +bool willAlwaysExecuteOnNetwork(FetchPolicy? policy) { + switch (policy) { + case FetchPolicy.noCache: + case FetchPolicy.networkOnly: + return true; + case FetchPolicy.cacheFirst: + case FetchPolicy.cacheAndNetwork: + case FetchPolicy.cacheOnly: + case null: + return false; + } +} + +/// [ErrorPolicy] determines the level of events for GraphQL Errors in the execution result. The options are: +/// +/// While the default for all client methods is [none], +/// [all] is recommended for notifying your users of potential issues. +/// +/// * [none] (default): Any GraphQL Errors are treated the same as network errors and any data is ignored from the response. +/// * [ignore]: Ignore allows you to read any data that is returned alongside GraphQL Errors, +/// but doesn't save the errors or report them to your UI. +/// * [all]: Saves both data and errors into the `cache` so your UI can use them. +/// It is recommended for notifying your users of potential issues, +/// while still showing as much data as possible from your server. +/// +/// **NOTE**: [ErrorPolicy] only effects **GraphQL Errors**. +/// Client side and network exceptions are added to a [QueryResult] as they occur, +/// and can co-exist alongside data. +enum ErrorPolicy { + /// Any GraphQL Errors are treated the same as network errors and any data is ignored from the response. (default) + none, + + /// Ignore allows you to read any data that is returned alongside GraphQL Errors, + /// but doesn't save the errors or report them to your UI. + ignore, + + /// Saves both data and errors into the `cache` so your UI can use them. + /// + /// It is recommended for notifying your users of potential issues, + /// while still showing as much data as possible from your server. + all, +} + +/// [CacheRereadPolicy] determines whether and how cache data will be merged into +/// the final [QueryResult] `data` before it is returned. +/// +/// It _does not_ effect `optimisticResults` added to [QueryOptions], etc. +/// +/// * [mergeOptimistic]: Merge relevant optimistic data from the cache before returning. +/// * [ignoreOptimistic]: Ignore optimistic data, but still allow for non-optimistic cache rebroadcasts +/// **if applicable**. +/// * [ignoreAll]: Ignore all cache data besides the result, and never rebroadcast the result, +/// even if the underlying cache data changes. +/// +/// The default `cacheRereadPolicy` for each method are: +/// * `watchQuery`: [mergeOptimistic] +/// * `watchMutation`: [ignoreAll] +/// * `query`: [mergeOptimistic] +/// * `mutation`: [ignoreAll] +/// * `subscribe`: [mergeOptimistic] +enum CacheRereadPolicy { + /// Merge relevant optimistic data from the cache before returning. + mergeOptimistic, + + /// Ignore optimistic data, but still allow for non-optimistic cache rebroadcasts + /// **if applicable**. + ignoreOptimisitic, + + /// Ignore all cache data besides the result, and never rebroadcast the result, + /// even if the underlying cache data changes. + ignoreAll, +} + +/// Container for supplying [fetch], [error], and [cacheReread] policies. +/// +/// If any are `null`, the appropriate policy will be selected from [DefaultPolicies] +@immutable +class Policies { + /// Specifies the [FetchPolicy] to be used. + final FetchPolicy? fetch; + + /// Specifies the [ErrorPolicy] to be used. + final ErrorPolicy? error; + + /// Specifies the [CacheRereadPolicy] to be used. + final CacheRereadPolicy? cacheReread; + + bool get mergeOptimisticData => + cacheReread == CacheRereadPolicy.mergeOptimistic; + + Policies({ + this.fetch, + this.error, + this.cacheReread, + }); + + Policies.safe( + FetchPolicy this.fetch, + ErrorPolicy this.error, + CacheRereadPolicy this.cacheReread, + ); + + Policies withOverrides([Policies? overrides]) => Policies.safe( + overrides?.fetch ?? fetch!, + overrides?.error ?? error!, + overrides?.cacheReread ?? cacheReread!, + ); + + Policies copyWith({FetchPolicy? fetch, ErrorPolicy? error}) => + Policies(fetch: fetch, error: error, cacheReread: cacheReread); + + operator ==(Object other) => + identical(this, other) || + (other is Policies && + fetch == other.fetch && + error == other.error && + cacheReread == other.cacheReread); + + @override + int get hashCode => const ListEquality( + DeepCollectionEquality(), + ).hash([fetch, error, cacheReread]); + + /// Returns `false` if either [fetch] or [cacheReread] policies have disabled rebroadcast. + bool get allowsRebroadcasting => !(fetch == FetchPolicy.noCache || + cacheReread == CacheRereadPolicy.ignoreAll); + + @override + String toString() => + 'Policies(fetch: $fetch, error: $error, cacheReread: $cacheReread)'; +} + +/// The default [Policies] to set for each client action. +@immutable +class DefaultPolicies { + /// The default [Policies] for watchQuery. + /// Defaults to + /// ``` + /// Policies( + /// FetchPolicy.cacheAndNetwork, + /// ErrorPolicy.none, + /// CacheRereadPolicy.mergeOptimistic, + /// ) + /// ``` + final Policies watchQuery; + + /// The default [Policies] for watchMutation. + /// Defaults to + /// ``` + /// Policies( + /// FetchPolicy.networkOnly, + /// ErrorPolicy.none, + /// CacheRereadPolicy.ignoreAll, + /// ) + /// ``` + final Policies watchMutation; + + /// The default [Policies] for query. + /// Defaults to + /// ``` + /// Policies( + /// FetchPolicy.cacheFirst, + /// ErrorPolicy.none, + /// CacheRereadPolicy.mergeOptimistic, + /// ) + /// ``` + final Policies query; + + /// The default [Policies] for mutate. + /// Defaults to + /// ``` + /// Policies( + /// FetchPolicy.networkOnly, + /// ErrorPolicy.none, + /// CacheRereadPolicy.ignore, + /// ) + /// ``` + final Policies mutate; + + /// The default [Policies] for subscribe. + /// Defaults to + /// ``` + /// Policies( + /// FetchPolicy.networkOnly, + /// ErrorPolicy.none, + /// CacheRereadPolicy.mergeOptimistic, + /// ) + /// ``` + /// + /// The subscription spec is very flexible, so we default to `FetchPolicy.networkOnly` + /// to avoid breaking some use-cases by default. + /// + /// `FetchPolicy.cacheOnly` is invalid for subscriptions. This is because `FetchPolicy` changes do + /// little to change subscription behavior, only determining + /// whether an eager result is first read from the cache. + final Policies subscribe; + + DefaultPolicies({ + Policies? watchQuery, + Policies? watchMutation, + Policies? query, + Policies? mutate, + Policies? subscribe, + }) : watchQuery = _watchQueryDefaults.withOverrides(watchQuery), + watchMutation = _mutateDefaults.withOverrides(watchMutation), + query = _queryDefaults.withOverrides(query), + mutate = _mutateDefaults.withOverrides(mutate), + subscribe = _subscribeDefaults.withOverrides(subscribe); + + static final _watchQueryDefaults = Policies.safe( + FetchPolicy.cacheAndNetwork, + ErrorPolicy.none, + CacheRereadPolicy.mergeOptimistic, + ); + + static final _queryDefaults = Policies.safe( + FetchPolicy.cacheFirst, + ErrorPolicy.none, + CacheRereadPolicy.mergeOptimistic, + ); + + static final _mutateDefaults = Policies.safe( + FetchPolicy.networkOnly, + ErrorPolicy.none, + CacheRereadPolicy.ignoreAll, + ); + + static final _subscribeDefaults = Policies.safe( + FetchPolicy.networkOnly, + ErrorPolicy.none, + CacheRereadPolicy.mergeOptimistic, + ); + + DefaultPolicies copyWith({ + Policies? watchQuery, + Policies? query, + Policies? watchMutation, + Policies? mutate, + Policies? subscribe, + }) => + DefaultPolicies( + watchQuery: watchQuery, + query: query, + watchMutation: watchMutation, + mutate: mutate, + subscribe: subscribe, + ); + + List _getChildren() => [ + watchQuery, + query, + watchMutation, + mutate, + subscribe, + ]; + + @override + bool operator ==(Object o) => + identical(this, o) || + (o is DefaultPolicies && + const ListEquality( + DeepCollectionEquality(), + ).equals( + o._getChildren(), + _getChildren(), + )); + + @override + int get hashCode => const ListEquality( + DeepCollectionEquality(), + ).hash( + _getChildren(), + ); +} diff --git a/packages/fl_query/lib/src/core/query_key.dart b/packages/fl_query/lib/src/core/query_key.dart new file mode 100644 index 0000000..ac0d7cc --- /dev/null +++ b/packages/fl_query/lib/src/core/query_key.dart @@ -0,0 +1,17 @@ +/// Used for defining a unique identifier for a specific query +/// that can be used to read/modify/delete the query from the +/// store +class QueryKey { + List _key; + QueryKey(String key) : _key = [key]; + + QueryKey.fromList(List key) : _key = key; + QueryKey.parse(String keyStr) : _key = keyStr.split("."); + + String get key => _key.map((k) => k.replaceAll(".", "")).join("."); + + @override + String toString() { + return 'QueryKey("$key")'; + } +} diff --git a/packages/fl_query/lib/src/core/query_manager.dart b/packages/fl_query/lib/src/core/query_manager.dart new file mode 100644 index 0000000..2084bb4 --- /dev/null +++ b/packages/fl_query/lib/src/core/query_manager.dart @@ -0,0 +1,547 @@ +import 'dart:async'; + +import 'package:meta/meta.dart'; +import 'package:collection/collection.dart'; + +import 'package:fl_query/src/core/result_parser.dart'; +import 'package:fl_query/src/cache/cache.dart'; +import 'package:fl_query/src/core/observable_query.dart'; +import 'package:fl_query/src/core/_base_options.dart'; +import 'package:fl_query/src/core/mutation_options.dart'; +import 'package:fl_query/src/core/query_options.dart'; +import 'package:fl_query/src/core/query_result.dart'; +import 'package:fl_query/src/core/policies.dart'; +import 'package:fl_query/src/exceptions.dart'; +import 'package:fl_query/src/scheduler/scheduler.dart'; +import 'package:fl_query/src/core/_query_write_handling.dart'; + +bool Function(dynamic a, dynamic b) _deepEquals = + const DeepCollectionEquality().equals; + +class QueryManager { + QueryManager({ + required this.link, + required this.cache, + this.alwaysRebroadcast = false, + }) { + scheduler = QueryScheduler( + queryManager: this, + ); + } + + final Link link; + final QueryCache cache; + + /// Whether to skip deep equality checks in [maybeRebroadcastQueries] + final bool alwaysRebroadcast; + + QueryScheduler? scheduler; + static final _oneOffOpId = '0'; + int idCounter = 1; + + /// [ObservableQuery] registry + Map queries = {}; + + /// prevents rebroadcasting for some intensive bulk operation like [refetchSafeQueries] + bool rebroadcastLocked = false; + + ObservableQuery watchQuery( + WatchQueryOptions options) { + final ObservableQuery observableQuery = ObservableQuery( + queryManager: this, + options: options, + ); + + setQuery(observableQuery); + + return observableQuery; + } + + Stream> subscribe( + SubscriptionOptions options) async* { + assert( + options.fetchPolicy != FetchPolicy.cacheOnly, + "Cannot subscribe with FetchPolicy.cacheOnly: $options", + ); + final request = options.asRequest; + + // Add optimistic or cache-based result to the stream if any + if (options.optimisticResult != null) { + // TODO optimisticResults for streams just skip the cache for now + yield QueryResult.optimistic( + data: options.optimisticResult as Map?, + parserFn: options.parserFn, + ); + } else if (shouldRespondEagerlyFromCache(options.fetchPolicy)) { + final cacheResult = cache.readQuery( + request, + optimistic: options.policies.mergeOptimisticData, + ); + if (cacheResult != null) { + yield QueryResult( + source: QueryResultSource.cache, + data: cacheResult, + parserFn: options.parserFn, + ); + } + } + + try { + yield* link.queryKey(request).map((response) { + QueryResult? queryResult; + bool rereadFromCache = false; + try { + queryResult = mapFetchResultToQueryResult( + response, + options, + source: QueryResultSource.network, + ); + + rereadFromCache = attemptCacheWriteFromResponse( + options.policies, + request, + response, + queryResult, + ); + } catch (failure, trace) { + // we set the source to indicate where the source of failure + queryResult ??= QueryResult( + source: QueryResultSource.network, + parserFn: options.parserFn, + ); + + queryResult.exception = coalesceErrors( + exception: queryResult.exception, + linkException: translateFailure(failure, trace), + ); + } + + if (rereadFromCache) { + // normalize results if previously written + attempCacheRereadIntoResult(request, queryResult); + } + + return queryResult; + }).transform(StreamTransformer.fromHandlers( + handleError: (err, trace, sink) => sink.add(_wrapFailure( + err, + trace, + options.parserFn, + )), + )); + } catch (ex, trace) { + yield* Stream.fromIterable([ + _wrapFailure( + ex, + trace, + options.parserFn, + ) + ]); + } + } + + Future> query( + QueryOptions options) async { + final result = await fetchQuery(_oneOffOpId, options); + maybeRebroadcastQueries(); + + return result; + } + + Future> mutate( + MutationOptions options) async { + final result = await fetchQuery(_oneOffOpId, options); + // once the mutation has been process successfully, execute callbacks + // before returning the results + final mutationCallbacks = MutationCallbackHandler( + cache: cache, + options: options, + queryId: _oneOffOpId, + ); + + final callbacks = mutationCallbacks.callbacks; + + for (final callback in callbacks) { + await callback(result); + } + + /// wait until callbacks complete to rebroadcast + maybeRebroadcastQueries(); + + return result; + } + + Future> fetchQuery( + String queryId, + BaseOptions options, + ) async { + final MultiSourceResult allResults = + fetchQueryAsMultiSourceResult(queryId, options); + return allResults.networkResult ?? allResults.eagerResult; + } + + /// Wrap both the `eagerResult` and `networkResult` future in a `MultiSourceResult` + /// if the cache policy precludes a network request, `networkResult` will be `null` + MultiSourceResult fetchQueryAsMultiSourceResult( + String queryId, + BaseOptions options, + ) { + // create a new request to execute + final request = options.asRequest; + + final QueryResult eagerResult = _resolveQueryEagerly( + request, + queryId, + options, + ); + + // _resolveQueryEagerly handles cacheOnly, + // so if we're loading + cacheFirst we continue to network + return MultiSourceResult( + parserFn: options.parserFn, + eagerResult: eagerResult, + networkResult: + (shouldStopAtCache(options.fetchPolicy) && !eagerResult.isLoading) + ? null + : _resolveQueryOnNetwork(request, queryId, options), + ); + } + + /// Resolve the query on the network, + /// negotiating any necessary cache edits / optimistic cleanup + Future> _resolveQueryOnNetwork( + Request request, + String queryId, + BaseOptions options, + ) async { + Response response; + QueryResult? queryResult; + + bool rereadFromCache = false; + + try { + // execute the request through the provided link(s) + response = await link.queryKey(request).first; + + queryResult = mapFetchResultToQueryResult( + response, + options, + source: QueryResultSource.network, + ); + + rereadFromCache = attemptCacheWriteFromResponse( + options.policies, + request, + response, + queryResult, + ); + } catch (failure, trace) { + // we set the source to indicate where the source of failure + queryResult ??= QueryResult( + source: QueryResultSource.network, + parserFn: options.parserFn, + ); + + queryResult.exception = coalesceErrors( + exception: queryResult.exception, + linkException: translateFailure(failure, trace), + ); + } + + // cleanup optimistic results + cache.removeOptimisticPatch(queryId); + + if (rereadFromCache) { + // normalize results if previously written + attempCacheRereadIntoResult(request, queryResult); + } + + // one off operations do not have an ObservableQuery to add to + if (queryId != _oneOffOpId) { + addQueryResult(request, queryId, queryResult); + } + + return queryResult; + } + + /// Add an eager cache response to the stream if possible, + /// based on `fetchPolicy` and `optimisticResults` + QueryResult _resolveQueryEagerly( + Request request, + String queryId, + BaseOptions options, + ) { + QueryResult queryResult = QueryResult.loading( + parserFn: options.parserFn, + ); + + try { + if (options.optimisticResult != null) { + queryResult = _getOptimisticQueryResult( + request, + queryId: queryId, + optimisticResult: options.optimisticResult, + options: options, + ); + } + + // if we haven't already resolved results optimistically, + // we attempt to resolve the from the cache + if (shouldRespondEagerlyFromCache(options.fetchPolicy) && + !queryResult.isOptimistic) { + final dynamic data = cache.readQuery(request, optimistic: false); + // we only push an eager query with data + if (data != null) { + queryResult = QueryResult( + data: data, + source: QueryResultSource.cache, + parserFn: options.parserFn, + ); + } + + if (options.fetchPolicy == FetchPolicy.cacheOnly && + queryResult.isLoading) { + queryResult = QueryResult( + source: QueryResultSource.cache, + parserFn: options.parserFn, + exception: OperationException( + linkException: CacheMissException( + 'Could not resolve the given request against the cache. (FetchPolicy.cacheOnly)', + request, + ), + ), + ); + } + } + } catch (failure, trace) { + queryResult.exception = coalesceErrors( + exception: queryResult.exception, + linkException: translateFailure(failure, trace), + ); + } + + // If not a regular eager cache resolution, + // will either be loading, or optimistic. + // + // if there's an optimistic result, we add it regardless of fetchPolicy. + // This is undefined-ish behavior/edge case, but still better than just + // ignoring a provided optimisticResult. + // Would probably be better to add it ignoring the cache in such cases + // + // one off operations do not have an ObservableQuery to add to + if (queryId != _oneOffOpId) { + addQueryResult(request, queryId, queryResult); + } + + return queryResult; + } + + /// Refetch the [ObservableQuery] referenced by [queryId], + /// overriding any present non-network-only [FetchPolicy]. + Future?> refetchQuery(String queryId) { + final WatchQueryOptions options = + queries[queryId]!.options.copy() as WatchQueryOptions; + if (!willAlwaysExecuteOnNetwork(options.fetchPolicy)) { + options.policies = options.policies.copyWith( + fetch: FetchPolicy.networkOnly, + ); + } + + // create a new request to execute + final request = options.asRequest; + + return _resolveQueryOnNetwork(request, queryId, options); + } + + @experimental + Future> refetchSafeQueries() async { + rebroadcastLocked = true; + final results = await Future.wait( + queries.values.where((q) => q.isRefetchSafe).map((q) => q.refetch()), + ); + rebroadcastLocked = false; + maybeRebroadcastQueries(); + return results; + } + + ObservableQuery? getQuery(String? queryId) { + if (queries.containsKey(queryId)) { + return queries[queryId!]; + } + + return null; + } + + /// Add a result to the [ObservableQuery] specified by `queryId`, if it exists. + /// + /// Will [maybeRebroadcastQueries] from [ObservableQuery.addResult] if the [cache] has flagged the need to. + /// + /// Queries are registered via [setQuery] and [watchQuery] + void addQueryResult( + Request request, + String? queryId, + QueryResult queryResult, + ) { + final ObservableQuery? observableQuery = + getQuery(queryId) as ObservableQuery?; + + if (observableQuery != null && !observableQuery.controller.isClosed) { + observableQuery.addResult(queryResult); + } + } + + /// Create an optimstic result for the query specified by `queryId`, if it exists + QueryResult _getOptimisticQueryResult( + Request request, { + required String queryId, + required Object? optimisticResult, + required BaseOptions options, + }) { + QueryResult queryResult = QueryResult( + source: QueryResultSource.optimisticResult, + parserFn: options.parserFn, + ); + + attemptCacheWriteFromClient( + request, + optimisticResult as Map?, + queryResult, + writeQuery: (req, data) => cache.recordOptimisticTransaction( + (proxy) => proxy..writeQuery(req, data: data!), + queryId, + ), + ); + + if (!queryResult.hasException) { + queryResult.data = cache.readQuery( + request, + optimistic: true, + ); + } + + return queryResult; + } + + /// Rebroadcast cached queries with changed underlying data if [cache.broadcastRequested] or [force]. + /// + /// Push changed data from cache to query streams. + /// [exclude] is used to skip a query if it was recently executed + /// (normally the query that caused the rebroadcast) + /// + /// Returns whether a broadcast was executed, which depends on the state of the cache. + /// If there are multiple in-flight cache updates, we wait until they all complete + /// + /// **Note on internal implementation details**: + /// There is sometimes confusion on when this is called, but rebroadcasts are requested + /// from every [addQueryResult] where `result.isNotLoading` as an [OnData] callback from [ObservableQuery]. + bool maybeRebroadcastQueries({ObservableQuery? exclude, bool force = false}) { + if (rebroadcastLocked && !force) { + return false; + } + + final shouldBroadast = cache.shouldBroadcast(claimExecution: true); + + if (!shouldBroadast && !force) { + return false; + } + + for (ObservableQuery query in queries.values) { + if (query != exclude && query.isRebroadcastSafe) { + final cachedData = cache.readQuery( + query.options.asRequest, + optimistic: query.options.policies.mergeOptimisticData, + ); + if (_cachedDataHasChangedFor(query, cachedData)) { + query.addResult( + mapFetchResultToQueryResult( + Response(data: cachedData), + query.options, + source: QueryResultSource.cache, + ), + fromRebroadcast: true, + ); + } + } + } + return true; + } + + bool _cachedDataHasChangedFor( + ObservableQuery query, + Map? cachedData, + ) => + cachedData != null && + (alwaysRebroadcast || !_deepEquals(query.latestResult!.data, cachedData)); + + void setQuery(ObservableQuery observableQuery) { + queries[observableQuery.queryId] = observableQuery; + } + + void closeQuery(ObservableQuery observableQuery, {bool fromQuery = false}) { + if (!fromQuery) { + observableQuery.close(fromManager: true); + } + queries.remove(observableQuery.queryId); + } + + int generateQueryId() { + final int requestId = idCounter; + + idCounter++; + + return requestId; + } + + QueryResult mapFetchResultToQueryResult( + Response response, + BaseOptions options, { + required QueryResultSource source, + }) { + List? errors; + dynamic data; + + // check if there are errors and apply the error policy if so + // in a nutshell: `ignore` swallows errors, `none` swallows data + if (response.errors != null && response.errors!.isNotEmpty) { + switch (options.errorPolicy) { + case ErrorPolicy.all: + // handle both errors and data + errors = response.errors; + data = response.data; + break; + case ErrorPolicy.ignore: + // ignore errors + data = response.data; + break; + case ErrorPolicy.none: + default: + // TODO not actually sure if apollo even casts graphql errors in `none` mode, + // it's also kind of legacy + errors = response.errors; + break; + } + } else { + data = response.data; + } + + return QueryResult( + data: data, + context: response.context, + source: source, + exception: coalesceErrors(graphqlErrors: errors), + parserFn: options.parserFn, + ); + } +} + +QueryResult _wrapFailure( + dynamic ex, + trace, + ResultParserFn parserFn, +) => + QueryResult( + // we set the source to indicate where the source of failure + source: QueryResultSource.network, + exception: coalesceErrors(linkException: translateFailure(ex, trace)), + parserFn: parserFn, + ); diff --git a/packages/fl_query/lib/src/core/query_options.dart b/packages/fl_query/lib/src/core/query_options.dart new file mode 100644 index 0000000..b5efb52 --- /dev/null +++ b/packages/fl_query/lib/src/core/query_options.dart @@ -0,0 +1,191 @@ +// ignore_for_file: deprecated_member_use_from_same_package +import 'package:fl_query/src/core/_base_options.dart'; +import 'package:fl_query/src/core/result_parser.dart'; +import 'package:fl_query/src/utilities/helpers.dart'; + +import 'package:fl_query/fl_query.dart'; + +/// Query options. +class QueryOptions extends BaseOptions { + QueryOptions({ + required DocumentNode document, + String? operationName, + Map variables = const {}, + FetchPolicy? fetchPolicy, + ErrorPolicy? errorPolicy, + CacheRereadPolicy? cacheRereadPolicy, + Object? optimisticResult, + this.pollInterval, + Context? context, + ResultParserFn? parserFn, + }) : super( + fetchPolicy: fetchPolicy, + errorPolicy: errorPolicy, + cacheRereadPolicy: cacheRereadPolicy, + document: document, + operationName: operationName, + variables: variables, + context: context, + optimisticResult: optimisticResult, + parserFn: parserFn, + ); + + /// The time interval on which this query should be re-fetched from the server. + Duration? pollInterval; + + @override + List get properties => [...super.properties, pollInterval]; + + WatchQueryOptions asWatchQueryOptions({bool fetchResults = true}) => + WatchQueryOptions( + document: document, + operationName: operationName, + variables: variables, + fetchPolicy: fetchPolicy, + errorPolicy: errorPolicy, + cacheRereadPolicy: cacheRereadPolicy, + pollInterval: pollInterval, + fetchResults: fetchResults, + context: context, + optimisticResult: optimisticResult, + parserFn: this.parserFn, + ); +} + +class SubscriptionOptions extends BaseOptions { + SubscriptionOptions({ + required DocumentNode document, + String? operationName, + Map variables = const {}, + FetchPolicy? fetchPolicy, + ErrorPolicy? errorPolicy, + CacheRereadPolicy? cacheRereadPolicy, + Object? optimisticResult, + Context? context, + ResultParserFn? parserFn, + }) : super( + fetchPolicy: fetchPolicy, + errorPolicy: errorPolicy, + cacheRereadPolicy: cacheRereadPolicy, + document: document, + operationName: operationName, + variables: variables, + context: context, + optimisticResult: optimisticResult, + parserFn: parserFn, + ); + + /// An optimistic first result to eagerly add to the subscription stream + Object? optimisticResult; +} + +class WatchQueryOptions extends QueryOptions { + WatchQueryOptions({ + required DocumentNode document, + String? operationName, + Map variables = const {}, + FetchPolicy? fetchPolicy, + ErrorPolicy? errorPolicy, + CacheRereadPolicy? cacheRereadPolicy, + Object? optimisticResult, + Duration? pollInterval, + this.fetchResults = false, + this.carryForwardDataOnException = true, + bool? eagerlyFetchResults, + Context? context, + ResultParserFn? parserFn, + }) : eagerlyFetchResults = eagerlyFetchResults ?? fetchResults, + super( + document: document, + operationName: operationName, + variables: variables, + fetchPolicy: fetchPolicy, + errorPolicy: errorPolicy, + cacheRereadPolicy: cacheRereadPolicy, + pollInterval: pollInterval, + context: context, + optimisticResult: optimisticResult, + parserFn: parserFn, + ); + + /// Whether or not to fetch results + bool fetchResults; + + /// Whether to [fetchResults] immediately on instantiation. + /// Defaults to [fetchResults]. + bool eagerlyFetchResults; + + /// carry forward previous data in the result of errors and no data. + /// defaults to `true`. + bool carryForwardDataOnException; + + @override + List get properties => + [...super.properties, fetchResults, eagerlyFetchResults]; + + WatchQueryOptions copy() => WatchQueryOptions( + document: document, + operationName: operationName, + variables: variables, + fetchPolicy: fetchPolicy, + errorPolicy: errorPolicy, + cacheRereadPolicy: cacheRereadPolicy, + optimisticResult: optimisticResult, + pollInterval: pollInterval, + fetchResults: fetchResults, + eagerlyFetchResults: eagerlyFetchResults, + carryForwardDataOnException: carryForwardDataOnException, + context: context, + parserFn: parserFn, + ); +} + +/// options for fetchMore operations +/// +/// **NOTE**: with the addition of strict data structure checking in v4, +/// it is easy to make mistakes in writing [updateQuery]. +/// +/// To mitigate this, [FetchMoreOptions.partial] has been provided. +class FetchMoreOptions { + FetchMoreOptions({ + this.document, + this.variables = const {}, + required this.updateQuery, + }); + + /// Automatically merge the results of [updateQuery] into `previousResultData`. + /// + /// This is useful if you only want to, say, extract some list data + /// from the newly fetched result, and don't want to worry about + /// structural inconsistencies while merging. + static FetchMoreOptions partial({ + DocumentNode? document, + Map variables = const {}, + required UpdateQuery updateQuery, + }) => + FetchMoreOptions( + document: document, + variables: variables, + updateQuery: partialUpdater(updateQuery), + ); + + DocumentNode? document; + + Map variables; + + /// Strategy for merging the fetchMore result data + /// with the result data already in the cache + UpdateQuery updateQuery; + + /// Wrap an [UpdateQuery] in a [deeplyMergeLeft] of the `previousResultData`. + static UpdateQuery partialUpdater(UpdateQuery update) => + (previous, fetched) => deeplyMergeLeft( + [previous, update(previous, fetched)], + ); +} + +/// merge fetchMore result data with earlier result data +typedef Map? UpdateQuery( + Map? previousResultData, + Map? fetchMoreResultData, +); diff --git a/packages/fl_query/lib/src/core/query_result.dart b/packages/fl_query/lib/src/core/query_result.dart new file mode 100644 index 0000000..bc45215 --- /dev/null +++ b/packages/fl_query/lib/src/core/query_result.dart @@ -0,0 +1,155 @@ +import 'dart:async' show FutureOr; +import 'package:fl_query/fl_query.dart'; +import 'package:fl_query/src/core/result_parser.dart'; + +/// The source of the result data contained +/// +/// * [loading]: No data has been specified from any source +/// for the _most recent_ operation +/// * [cache]: A result has been eagerly resolved from the cache +/// * [optimisticResult]: An optimistic result has been specified +/// May include eager results from the cache. +/// * [network]: The query has been resolved on the network +/// +/// Both [optimisticResult] and [cache] sources are considered "Eager" results. +enum QueryResultSource { + /// No data has been specified from any source for the _most recent_ operation + loading, + + /// A result has been eagerly resolved from the cache + cache, + + /// An optimistic result has been specified. + /// May include eager results from the cache + optimisticResult, + + /// The query has been resolved on the network + network, +} + +extension Getters on QueryResultSource { + /// Whether this result source is considered "eager" (is [cache] or [optimisticResult]) + bool get isEager => _eagerSources.contains(this); +} + +final _eagerSources = { + QueryResultSource.cache, + QueryResultSource.optimisticResult +}; + +/// A single operation result +class QueryResult { + QueryResult({ + this.data, + this.exception, + this.context = const Context(), + required this.parserFn, + required this.source, + }) : timestamp = DateTime.now(); + + /// Unexecuted singleton, used as a placeholder for mutations, + /// etc. + static final unexecuted = QueryResult( + source: null, + parserFn: (d) => + throw UnimplementedError("Unexecuted query data can not be parsed."), + )..timestamp = DateTime.fromMillisecondsSinceEpoch(0); + + factory QueryResult.loading({ + Map? data, + required ResultParserFn parserFn, + }) => + QueryResult( + data: data, + source: QueryResultSource.loading, + parserFn: parserFn, + ); + + factory QueryResult.optimistic({ + Map? data, + required ResultParserFn parserFn, + }) => + QueryResult( + data: data, + source: QueryResultSource.optimisticResult, + parserFn: parserFn, + ); + + DateTime timestamp; + + /// The source of the result data. + /// + /// `null` when unexecuted. + /// Will be set when encountering an error during any execution attempt + QueryResultSource? source; + + /// Response data + Map? data; + + /// Response context. Defaults to an empty `Context()` + Context context; + + OperationException? exception; + + ResultParserFn parserFn; + + /// [data] has yet to be specified from any source + /// for the _most recent_ operation + /// (including [QueryResultSource.optimisticResult]) + /// + /// **NOTE:** query updating methods like `fetchMore` and `refetch` will send + /// an [isLoading], so it is best practice to check both `isLoading && data != null` + /// before assuming there is no data that should be displayed. + bool get isLoading => source == QueryResultSource.loading; + + /// [data] been specified (including [QueryResultSource.optimisticResult]) + bool get isNotLoading => !isLoading; + + /// [data] has been specified as an [QueryResultSource.optimisticResult] + /// + /// May include eager results from the cache. + bool get isOptimistic => source == QueryResultSource.optimisticResult; + + /// [data] has been specified and is **not** an [QueryResultSource.optimisticResult] + /// + /// shorthand for `!isLoading && !isOptimistic` + bool get isConcrete => !isLoading && !isOptimistic; + + /// Whether the response includes an [exception] + bool get hasException => (exception != null); + + /// If a parserFn is provided, this getter can be used to fetch the parsed data. + TParsed? get parsedData { + final data = this.data; + final parserFn = this.parserFn; + + if (data == null) { + return null; + } + return parserFn(data); + } + + @override + String toString() => 'QueryResult(' + 'source: $source, ' + 'data: $data, ' + 'context: $context, ' + 'exception: $exception, ' + 'timestamp: $timestamp' + ')'; +} + +class MultiSourceResult { + MultiSourceResult({ + QueryResult? eagerResult, + this.networkResult, + required ResultParserFn parserFn, + }) : eagerResult = eagerResult ?? QueryResult.loading(parserFn: parserFn), + assert( + eagerResult!.source != QueryResultSource.network, + 'An eager result cannot be gotten from the network', + ); + + QueryResult eagerResult; + FutureOr>? networkResult; +} diff --git a/packages/fl_query/lib/src/core/result_parser.dart b/packages/fl_query/lib/src/core/result_parser.dart new file mode 100644 index 0000000..9357b2c --- /dev/null +++ b/packages/fl_query/lib/src/core/result_parser.dart @@ -0,0 +1 @@ +typedef ResultParserFn = TResult Function(Map data); diff --git a/packages/fl_query/lib/src/exceptions.dart b/packages/fl_query/lib/src/exceptions.dart new file mode 100644 index 0000000..26392f1 --- /dev/null +++ b/packages/fl_query/lib/src/exceptions.dart @@ -0,0 +1,4 @@ +/// Once `gql_link` has robust http and socket exception handling, +/// this will be replaced with `./exceptions/exceptions_next.dart` +/// and the rest of `./exceptions/` will be deleted +export './exceptions/exceptions.dart'; diff --git a/packages/fl_query/lib/src/exceptions/exceptions.dart b/packages/fl_query/lib/src/exceptions/exceptions.dart new file mode 100644 index 0000000..620d142 --- /dev/null +++ b/packages/fl_query/lib/src/exceptions/exceptions.dart @@ -0,0 +1,18 @@ +import 'package:fl_query/src/exceptions/exceptions_next.dart' + show UnknownException; + +export 'package:fl_query/src/exceptions/exceptions_next.dart'; + +import 'package:fl_query/src/exceptions/network.dart' + if (dart.library.io) 'package:fl_query/src/exceptions/network_io.dart' + as network; + +export 'package:fl_query/src/exceptions/network.dart' + if (dart.library.io) 'package:fl_query/src/exceptions/network_io.dart'; + +LinkException translateFailure(dynamic failure, StackTrace trace) { + if (failure is LinkException) { + return failure; + } + return network.translateFailure(failure) ?? UnknownException(failure, trace); +} diff --git a/packages/fl_query/lib/src/exceptions/exceptions_next.dart b/packages/fl_query/lib/src/exceptions/exceptions_next.dart new file mode 100644 index 0000000..4188384 --- /dev/null +++ b/packages/fl_query/lib/src/exceptions/exceptions_next.dart @@ -0,0 +1,172 @@ +import 'package:fl_query/fl_query.dart'; + +/// Once `gql_link` has robust http and socket exception handling, +/// these should be the only exceptions we need +import 'package:meta/meta.dart'; + +/// A failure to find a response from the cache. +/// +/// Can occur when `cacheOnly=true`, or when the [queryKey] was just written +/// to the cache with [expectedData] +@immutable +class CacheMissException implements Exception { + CacheMissException(this.message, this.queryKey, {this.expectedData}) + : super(); + + final String message; + final QueryKey queryKey; + + /// The data just written to the cache under [queryKey], if any. + final Map? expectedData; + + @override + String toString() => [ + 'CacheMissException($message', + '$queryKey', + if (expectedData != null) 'expectedData: $expectedData)' + ].join(', '); +} + +/// A failure due to a data structure mismatch between the data and the expected +/// structure based on the [queryKey] `operation` `document`. +/// +/// If [validateStructure] passes, then the mismatch must be due to a cache misconfiguration, +/// [CacheMisconfigurationException]. +class MismatchedDataStructureException implements Exception { + const MismatchedDataStructureException({ + this.queryKey, + required this.data, + }) : super(); + + final Map? data; + final QueryKey? queryKey; + + @override + String toString() => 'MismatchedDataStructureException(' + 'queryKey: $queryKey, ' + 'data: $data, ' + ')'; +} + +/// Failure occurring when the structure of [data] +/// does not match that of the [queryKey] `operation` `document`. +/// +/// This is checked by leveraging `normalize` +@immutable +class CacheMisconfigurationException + implements MismatchedDataStructureException { + const CacheMisconfigurationException({ + this.queryKey, + required this.data, + }) : super(); + + final QueryKey? queryKey; + final Map data; + + @override + String toString() => [ + 'CacheMisconfigurationException(', + if (queryKey != null) 'queryKey: ${queryKey}', + 'data: ${data}, ', + ')', + ].join(''); +} + +// /// Failure occurring when the structure of the [parsedResponse] `data` +// /// does not match that of the [queryKey] `operation` `document`. +// /// +// /// This is checked by leveraging `normalize` +// @immutable +// class UnexpectedResponseStructureException extends ServerException +// implements MismatchedDataStructureException { +// const UnexpectedResponseStructureException( +// this.originalException, { +// required this.queryKey, +// required Response parsedResponse, +// }) : super( +// parsedResponse: parsedResponse, +// originalException: originalException); + +// @override +// final Request queryKey; + +// @override +// get data => parsedResponse!.data; + +// @override +// final PartialDataException originalException; + +// @override +// String toString() => 'UnexpectedResponseStructureException(' +// '$originalException, ' +// 'request: ${queryKey}, ' +// 'parsedResponse: ${parsedResponse}, ' +// ')'; +// } + +// /// Exception occurring when an unhandled, non-link exception +// /// is thrown during execution +// @immutable +// class UnknownException extends LinkException { +// String get message => 'Unhandled Client-Side Exception: $originalException'; + +// /// stacktrace of the [originalException]. +// final StackTrace originalStackTrace; + +// const UnknownException( +// dynamic originalException, +// this.originalStackTrace, +// ) : super(originalException); + +// @override +// String toString() => +// "UnknownException($originalException, stack:\n$originalStackTrace\n)"; +// } + +// /// Container for both [graphqlErrors] returned from the server +// /// and any [linkException] that caused a failure. +// class OperationException implements Exception { +// /// Any graphql errors returned from the operation +// List graphqlErrors = []; + +// // generalize to include cache error, etc +// /// Errors encountered during execution such as network or cache errors +// LinkException? linkException; + +// OperationException({ +// this.linkException, +// Iterable graphqlErrors = const [], +// }) : this.graphqlErrors = graphqlErrors.toList(); + +// void addError(GraphQLError error) => graphqlErrors.add(error); + +// @override +// String toString() => 'OperationException(' +// 'linkException: ${linkException}, ' +// 'graphqlErrors: ${graphqlErrors}' +// ')'; +// } + +// /// `(graphqlErrors?, exception?) => exception?` +// /// +// /// merges both optional graphqlErrors and an optional container +// /// into a single optional container +// /// NOTE: NULL returns expected +// OperationException? coalesceErrors({ +// List? graphqlErrors, +// LinkException? linkException, +// OperationException? exception, +// }) { +// if (exception != null || +// linkException != null || +// (graphqlErrors != null && graphqlErrors.isNotEmpty)) { +// return OperationException( +// linkException: linkException ?? exception?.linkException, +// graphqlErrors: [ +// if (graphqlErrors != null) ...graphqlErrors, +// if (exception?.graphqlErrors != null) ...exception!.graphqlErrors +// ], +// ); +// } +// return null; +// } diff --git a/packages/fl_query/lib/src/exceptions/network.dart b/packages/fl_query/lib/src/exceptions/network.dart new file mode 100644 index 0000000..09d5653 --- /dev/null +++ b/packages/fl_query/lib/src/exceptions/network.dart @@ -0,0 +1,32 @@ +import 'package:http/http.dart' as http show ClientException; + +/// Exception occurring when there is a network-level error +class NetworkException extends LinkException { + NetworkException({ + dynamic originalException, + this.message, + required this.uri, + }) : super(originalException); + + final String? message; + final Uri? uri; + + String toString() => + 'Failed to connect to $uri: ${message ?? originalException}'; +} + +/// We wrap [base.translateFailure] to handle io-specific network errors. +/// +/// Once `gql_link` has robust http and socket exception handling, +/// this and `./network.dart` can be removed and `./exceptions_next.dart` +/// will be all that is necessary +NetworkException? translateFailure(dynamic failure) { + if (failure is http.ClientException) { + return NetworkException( + originalException: failure, + message: failure.message, + uri: failure.uri, + ); + } + return null; +} diff --git a/packages/fl_query/lib/src/exceptions/network_io.dart b/packages/fl_query/lib/src/exceptions/network_io.dart new file mode 100644 index 0000000..d3f1181 --- /dev/null +++ b/packages/fl_query/lib/src/exceptions/network_io.dart @@ -0,0 +1,24 @@ +import 'dart:io' as io show SocketException; + +import './network.dart' as base; +export './network.dart' show NetworkException; + +/// We wrap [base.translateFailure] to handle io-specific network errors. +/// +/// Once `gql_link` has robust http and socket exception handling, +/// this and `./unhandled.dart` can be removed and `./exceptions_next.dart` +/// will be all that is necessary +base.NetworkException? translateFailure(dynamic failure) { + if (failure is io.SocketException) { + return base.NetworkException( + originalException: failure, + message: failure.message, + uri: Uri( + scheme: 'http', + host: failure.address?.host, + port: failure.port, + ), + ); + } + return base.translateFailure(failure); +} diff --git a/packages/fl_query/lib/src/graphql_client.dart b/packages/fl_query/lib/src/graphql_client.dart new file mode 100644 index 0000000..e305673 --- /dev/null +++ b/packages/fl_query/lib/src/graphql_client.dart @@ -0,0 +1,273 @@ +import 'package:meta/meta.dart'; +import 'dart:async'; + +import 'package:fl_query/src/core/core.dart'; +import 'package:fl_query/src/cache/cache.dart'; + +import 'package:fl_query/src/core/fetch_more.dart'; + +/// Universal GraphQL Client with configurable caching and [link][] system. +/// modelled after the [`apollo-client`][ac]. +/// +/// The link is a [Link] over which GraphQL documents will be resolved into a [Response]. +/// The cache is the [QueryCache] to use for caching results and optimistic updates. +/// +/// The client automatically rebroadcasts watched queries when their underlying data +/// changes in the cache. To skip the data comparison check, `alwaysRebroadcast: true` can be passed. +/// **NOTE**: This flag was added ot accomodate the old default behavior. +/// It is marked `@experimental` because it may be deprecated in the future. +/// +/// [ac]: https://www.apollographql.com/docs/react/v3.0-beta/api/core/ApolloClient/ +/// [link]: https://github.com/gql-dart/gql/tree/master/links/gql_link +class GraphQLClient implements JSONDataProxy { + /// Constructs a [GraphQLClient] given a [Link] and a [Cache]. + GraphQLClient({ + required this.link, + required this.cache, + DefaultPolicies? defaultPolicies, + bool alwaysRebroadcast = false, + }) : defaultPolicies = defaultPolicies ?? DefaultPolicies(), + queryManager = QueryManager( + link: link, + cache: cache, + alwaysRebroadcast: alwaysRebroadcast, + ); + + /// The default [Policies] to set for each client action + late final DefaultPolicies defaultPolicies; + + /// The [Link] over which GraphQL documents will be resolved into a [Response]. + final Link link; + + /// The initial [Cache] to use in the data store. + final QueryCache cache; + + late final QueryManager queryManager; + + /// This registers a query in the [QueryManager] and returns an [ObservableQuery] + /// based on the provided [WatchQueryOptions]. + /// + /// {@tool snippet} + /// Basic usage + /// + /// ```dart + /// final observableQuery = client.watchQuery( + /// WatchQueryOptions( + /// document: gql( + /// r''' + /// query HeroForEpisode($ep: Episode!) { + /// hero(episode: $ep) { + /// name + /// } + /// } + /// ''', + /// ), + /// variables: {'ep': 'NEWHOPE'}, + /// ), + /// ); + /// + /// /// Listen to the stream of results. This will include: + /// /// * `options.optimisitcResult` if passed + /// /// * The result from the server (if `options.fetchPolicy` includes networking) + /// /// * rebroadcast results from edits to the cache + /// observableQuery.stream.listen((QueryResult result) { + /// if (!result.isLoading && result.data != null) { + /// if (result.hasException) { + /// print(result.exception); + /// return; + /// } + /// if (result.isLoading) { + /// print('loading'); + /// return; + /// } + /// doSomethingWithMyQueryResult(myCustomParser(result.data)); + /// } + /// }); + /// // ... cleanup: + /// observableQuery.close(); + /// ``` + /// {@end-tool} + ObservableQuery watchQuery( + WatchQueryOptions options) { + options.policies = + defaultPolicies.watchQuery.withOverrides(options.policies); + return queryManager.watchQuery(options); + } + + /// [watchMutation] is the same as [watchQuery], but with a different [defaultPolicies] that are more appropriate for mutations. + /// + /// This is a stop-gap solution to the problems created by the reliance of `graphql_flutter` on [ObservableQuery] for mutations. + /// + /// For more details, see https://github.com/zino-app/graphql-flutter/issues/774 + ObservableQuery watchMutation( + WatchQueryOptions options) { + options.policies = + defaultPolicies.watchMutation.withOverrides(options.policies); + return queryManager.watchQuery(options); + } + + /// This resolves a single query according to the [QueryOptions] specified and + /// returns a [Future] which resolves with the [QueryResult] or throws an [Exception]. + /// + /// {@tool snippet} + /// Basic usage + /// + /// ```dart + /// final QueryResult result = await client.query( + /// QueryOptions( + /// document: gql( + /// r''' + /// query ReadRepositories($nRepositories: Int!) { + /// viewer { + /// repositories(last: $nRepositories) { + /// nodes { + /// __typename + /// id + /// name + /// viewerHasStarred + /// } + /// } + /// } + /// } + /// ''', + /// ), + /// variables: { + /// 'nRepositories': 50, + /// }, + /// ), + /// ); + /// + /// if (result.hasException) { + /// print(result.exception.toString()); + /// } + /// + /// final List repositories = + /// result.data['viewer']['repositories']['nodes'] as List; + /// ``` + /// {@end-tool} + + Future> query( + QueryOptions options, + ) async { + options.policies = defaultPolicies.query.withOverrides(options.policies); + return await queryManager.query(options); + } + + /// This resolves a single mutation according to the [MutationOptions] specified and + /// returns a [Future] which resolves with the [QueryResult] or throws an [Exception]. + Future> mutate( + MutationOptions options) async { + options.policies = defaultPolicies.mutate.withOverrides(options.policies); + return await queryManager.mutate(options); + } + + /// This subscribes to a GraphQL subscription according to the options specified and returns a + /// [Stream] which either emits received data or an error. + /// + /// {@tool snippet} + /// Basic usage + /// + /// ```dart + /// subscription = client.subscribe( + /// SubscriptionOptions( + /// document: gql( + /// r''' + /// subscription reviewAdded { + /// reviewAdded { + /// stars, commentary, episode + /// } + /// } + /// ''', + /// ), + /// ), + /// ); + /// + /// subscription.listen((result) { + /// if (result.hasException) { + /// print(result.exception.toString()); + /// return; + /// } + /// + /// if (result.isLoading) { + /// print('awaiting results'); + /// return; + /// } + /// + /// print('New Review: ${result.data}'); + /// }); + /// ``` + /// {@end-tool} + Stream> subscribe( + SubscriptionOptions options) { + options.policies = defaultPolicies.subscribe.withOverrides( + options.policies, + ); + return queryManager.subscribe(options); + } + + /// Fetch more results and then merge them with the given [previousResult] + /// according to [FetchMoreOptions.updateQuery]. + /// + /// **NOTE**: with the addition of strict data structure checking in v4, + /// it is easy to make mistakes in writing [updateQuery]. + /// + /// To mitigate this, [FetchMoreOptions.partial] has been provided. + @experimental + Future> fetchMore( + FetchMoreOptions fetchMoreOptions, { + required QueryOptions originalOptions, + required QueryResult previousResult, + }) async { + return await fetchMoreImplementation( + fetchMoreOptions, + originalOptions: originalOptions, + previousResult: previousResult, + queryManager: queryManager, + ); + } + + /// pass through to [cache.readQuery] + readQuery(request, {optimistic = true}) => + cache.readQuery(request, optimistic: optimistic); + + /// pass through to [cache.readFragment] + readFragment( + fragmentRequest, { + optimistic = true, + }) => + cache.readFragment( + fragmentRequest, + optimistic: optimistic, + ); + + /// pass through to [cache.writeQuery] and then rebroadcast any changes. + void writeQuery(request, {required data, broadcast = true}) { + cache.writeQuery(request, data: data, broadcast: broadcast); + queryManager.maybeRebroadcastQueries(); + } + + /// pass through to [cache.writeFragment] and then rebroadcast any changes. + void writeFragment( + fragmentRequest, { + broadcast = true, + required data, + }) { + cache.writeFragment( + fragmentRequest, + broadcast: broadcast, + data: data, + ); + queryManager.maybeRebroadcastQueries(); + } + + /// Resets the contents of the store with [cache.store.reset()] + /// and then refetches of all queries unless [refetchQueries] is disabled + @experimental + Future>? resetStore({bool refetchQueries = true}) { + cache.store.reset(); + if (refetchQueries) { + return queryManager.refetchSafeQueries(); + } + return null; + } +} diff --git a/packages/fl_query/lib/src/links/auth_link.dart b/packages/fl_query/lib/src/links/auth_link.dart new file mode 100644 index 0000000..488e7f8 --- /dev/null +++ b/packages/fl_query/lib/src/links/auth_link.dart @@ -0,0 +1,64 @@ +import 'dart:async'; +import 'package:fl_query/fl_query.dart'; + +import "package:gql_transform_link/gql_transform_link.dart"; + +typedef _RequestTransformer = FutureOr Function(Request request); + +typedef OnException = FutureOr Function( + HttpLinkServerException exception, +); + +/// Simple header-based authentication link that adds [headerKey]: [getToken()] to every request. +/// +/// If a lazy or exception-based authentication link is needed for your use case, +/// implementing your own from the [gql reference auth link] or opening an issue. +/// +/// [gql reference auth link]: https://github.com/gql-dart/gql/blob/1884596904a411363165bcf3c7cfa9dcc2a61c26/examples/gql_example_http_auth_link/lib/http_auth_link.dart +class AuthLink extends _AsyncReqTransformLink { + AuthLink({ + required this.getToken, + this.headerKey = 'Authorization', + }) : super(requestTransformer: transform(headerKey, getToken)); + + /// Authentication callback. Note – must include prefixes, e.g. `'Bearer $token'` + final FutureOr Function() getToken; + + /// Header key to set to the result of [getToken] + final String headerKey; + + static _RequestTransformer transform( + String headerKey, + FutureOr Function() getToken, + ) => + (Request request) async { + final token = await getToken(); + return request.updateContextEntry( + (headers) => HttpLinkHeaders( + headers: { + ...headers?.headers ?? {}, + if (token != null) headerKey: token, + }, + ), + ); + }; +} + +/// Version of [TransformLink] that handles async transforms +class _AsyncReqTransformLink extends Link { + final _RequestTransformer requestTransformer; + + _AsyncReqTransformLink({ + required this.requestTransformer, + }); + + @override + Stream request( + Request request, [ + NextLink? forward, + ]) async* { + final req = await requestTransformer(request); + + yield* forward!(req); + } +} diff --git a/packages/fl_query/lib/src/links/gql_links.dart b/packages/fl_query/lib/src/links/gql_links.dart new file mode 100644 index 0000000..4a83fde --- /dev/null +++ b/packages/fl_query/lib/src/links/gql_links.dart @@ -0,0 +1,4 @@ +export 'package:gql_link/gql_link.dart'; +export 'package:gql_http_link/gql_http_link.dart'; +export 'package:gql_error_link/gql_error_link.dart'; +export 'package:gql_dedupe_link/gql_dedupe_link.dart'; diff --git a/packages/fl_query/lib/src/links/links.dart b/packages/fl_query/lib/src/links/links.dart new file mode 100644 index 0000000..acac049 --- /dev/null +++ b/packages/fl_query/lib/src/links/links.dart @@ -0,0 +1,4 @@ +// Reexport all gql_links +export 'package:fl_query/src/links/gql_links.dart'; +export 'package:fl_query/src/links/auth_link.dart'; +export 'package:fl_query/src/links/websocket_link/websocket_link.dart'; diff --git a/packages/fl_query/lib/src/links/websocket_link/websocket_client.dart b/packages/fl_query/lib/src/links/websocket_link/websocket_client.dart new file mode 100644 index 0000000..7ab2eda --- /dev/null +++ b/packages/fl_query/lib/src/links/websocket_link/websocket_client.dart @@ -0,0 +1,498 @@ +import 'dart:async'; +import 'dart:collection'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:fl_query/src/links/gql_links.dart'; +import 'package:fl_query/src/utilities/platform.dart'; +import 'package:meta/meta.dart'; + +import 'package:fl_query/src/core/query_options.dart' show WithType; + +import 'package:stream_channel/stream_channel.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; +import 'package:web_socket_channel/status.dart' as ws_status; + +import 'package:rxdart/rxdart.dart'; +import 'package:uuid/uuid.dart'; +import 'package:uuid/uuid_util.dart'; + +import './websocket_messages.dart'; + +typedef GetInitPayload = FutureOr Function(); + +/// A definition for functions that returns a connected [WebSocketChannel] +typedef WebSocketConnect = FutureOr Function( + Uri uri, + Iterable? protocols, +); + +// create uuid generator +final _uuid = Uuid(options: {'grng': UuidUtil.cryptoRNG}); + +class SubscriptionListener { + Function callback; + bool hasBeenTriggered = false; + + SubscriptionListener(this.callback, this.hasBeenTriggered); +} + +enum SocketConnectionState { notConnected, connecting, connected } + +class SocketClientConfig { + const SocketClientConfig({ + this.serializer = const RequestSerializer(), + this.parser = const ResponseParser(), + this.autoReconnect = true, + this.queryAndMutationTimeout = const Duration(seconds: 10), + this.inactivityTimeout = const Duration(seconds: 30), + this.delayBetweenReconnectionAttempts = const Duration(seconds: 5), + this.initialPayload, + this.headers, + this.connectFn, + }); + + /// Serializer used to serialize request + final RequestSerializer serializer; + + /// Response parser + final ResponseParser parser; + + /// Whether to reconnect to the server after detecting connection loss. + final bool autoReconnect; + + /// The duration after which the connection is considered unstable, because no keep alive message + /// was received from the server in the given time-frame. The connection to the server will be closed. + /// If [autoReconnect] is set to true, we try to reconnect to the server after the specified [delayBetweenReconnectionAttempts]. + /// + /// If null, the keep alive messages will be ignored. + final Duration? inactivityTimeout; + + /// The duration that needs to pass before trying to reconnect to the server after a connection loss. + /// This only takes effect when [autoReconnect] is set to true. + /// + /// If null, the reconnection will occur immediately, although not recommended. + final Duration? delayBetweenReconnectionAttempts; + + /// The duration after which a query or mutation should time out. + /// If null, no timeout is applied, although not recommended. + final Duration? queryAndMutationTimeout; + + /// Connect or reconnect to the websocket. + /// + /// Useful supplying custom headers to an IO client, registering custom listeners, + /// and extracting the socket for other non-graphql features. + /// + /// Warning: if you want to listen to the listen to the stream, + /// wrap your channel with our [GraphQLWebSocketChannel] using the `.forGraphQL()` helper: + /// ```dart + /// connectFn: (url, protocols) { + /// var channel = WebSocketChannel.connect(url, protocols: protocols) + /// // without this line, our client won't be able to listen to stream events, + /// // because you are already listening. + /// channel = channel.forGraphQL(); + /// channel.stream.listen(myListener) + /// return channel; + /// } + /// ``` + final WebSocketConnect? connectFn; + + /// Custom header to add inside the client + final Map? headers; + + /// Function to define another connection without call directly + /// the connection function + FutureOr connect( + {required Uri uri, + Iterable? protocols, + Map? headers}) { + if (connectFn != null) { + return connectFn!(uri, protocols); + } + return defaultConnectPlatform( + uri, + protocols, + headers: headers ?? this.headers, + ); + } + + /// Payload to be sent with the connection_init request. + /// + /// Can be a literal value, a callback, or an async callback. End value must be valid argument for `json.encode`. + /// + /// Internal usage is roughly: + /// ```dart + /// Future get initOperation async { + /// if (initialPayload is Function) { + /// final dynamic payload = await initialPayload(); + /// return InitOperation(payload); + /// } else { + /// return InitOperation(initialPayload); + /// } + /// } + /// ``` + final dynamic initialPayload; + + Future get initOperation async { + if (initialPayload is Function) { + final dynamic payload = await initialPayload(); + return InitOperation(payload); + } else { + return InitOperation(initialPayload); + } + } +} + +/// Wraps a standard web socket instance to marshal and un-marshal the server / +/// client payloads into dart object representation. +/// +/// This class also deals with reconnection, handles timeout and keep alive messages. +/// +/// It is meant to be instantiated once, and you can let this class handle all the heavy- +/// lifting of socket state management. Once you're done with the socket connection, make sure +/// you call the [dispose] method to release all allocated resources. +class SocketClient { + SocketClient( + this.url, { + this.protocols = const ['graphql-ws'], + this.config = const SocketClientConfig(), + @visibleForTesting this.randomBytesForUuid, + @visibleForTesting this.onMessage, + @visibleForTesting this.onStreamError = _defaultOnStreamError, + }) { + _connect(); + } + + Uint8List? randomBytesForUuid; + final String url; + final Iterable? protocols; + final SocketClientConfig config; + + final BehaviorSubject _connectionStateController = + BehaviorSubject(); + + final HashMap _subscriptionInitializers = + HashMap(); + + bool _connectionWasLost = false; + bool _wasDisposed = false; + + Timer? _reconnectTimer; + + @visibleForTesting + GraphQLWebSocketChannel? socketChannel; + + @visibleForTesting + void Function(GraphQLSocketMessage)? onMessage; + + @visibleForTesting + void Function(Object error, StackTrace stackTrace) onStreamError; + + Stream get _messages => socketChannel!.messages; + + StreamSubscription? _keepAliveSubscription; + StreamSubscription? _messageSubscription; + + Map Function(Request) get serialize => + config.serializer.serializeRequest; + + Response Function(Map) get parse => + config.parser.parseResponse; + + void _disconnectOnKeepAliveTimeout(Stream messages) { + _keepAliveSubscription = messages.whereType().timeout( + config.inactivityTimeout!, + onTimeout: (EventSink event) { + event.close(); + unawaited(_closeSocketChannel()); + }, + ).listen(null); + } + + Future _closeSocketChannel() async { + // avoid race condition in onCancel by setting socket connection + // state to notConnected prior to closing socket. This ensures we don't + // attempt to send a message over the channel that we're closing + // if we are forcefully closing the socket + if (!_connectionStateController.isClosed && + _connectionStateController.value != + SocketConnectionState.notConnected) { + _connectionStateController.add(SocketConnectionState.notConnected); + } + await socketChannel?.sink.close(ws_status.normalClosure); + } + + /// Connects to the server. + /// + /// If this instance is disposed, this method does nothing. + Future _connect() async { + final InitOperation initOperation = await config.initOperation; + + if (_connectionStateController.isClosed || _wasDisposed) { + return; + } + + _connectionStateController.add(SocketConnectionState.connecting); + + try { + // Even though config.connect is sync, we call async in order to make the + // SocketConnectionState.connected attribution not overload SocketConnectionState.connecting + var connection = + await config.connect(uri: Uri.parse(url), protocols: protocols); + socketChannel = connection.forGraphQL(); + _connectionStateController.add(SocketConnectionState.connected); + _write(initOperation); + + if (config.inactivityTimeout != null) { + _disconnectOnKeepAliveTimeout(_messages); + } + + _messageSubscription = _messages.listen( + onMessage, + onDone: onConnectionLost, + // onDone will not be triggered if the subscription is + // auto-cancelled on error; make sure to pass false + cancelOnError: false, + onError: onStreamError, + ); + + if (_connectionWasLost) { + for (final s in _subscriptionInitializers.values) { + s.callback(); + } + + _connectionWasLost = false; + } + } catch (e) { + onConnectionLost(e); + } + } + + void onConnectionLost([e]) async { + await _closeSocketChannel(); + if (e != null) { + print('There was an error causing connection lost: $e'); + } + print('Disconnected from websocket.'); + _reconnectTimer?.cancel(); + _keepAliveSubscription?.cancel(); + _messageSubscription?.cancel(); + + if (_connectionStateController.isClosed || _wasDisposed) { + return; + } + + _connectionWasLost = true; + _subscriptionInitializers.values.forEach((s) => s.hasBeenTriggered = false); + + if (config.autoReconnect && + !_connectionStateController.isClosed && + !_wasDisposed) { + if (config.delayBetweenReconnectionAttempts != null) { + _reconnectTimer = Timer( + config.delayBetweenReconnectionAttempts!, + () { + _connect(); + }, + ); + } else { + Timer.run(() => _connect()); + } + } + } + + /// Closes the underlying socket if connected, and stops reconnection attempts. + /// After calling this method, this [SocketClient] instance must be considered + /// unusable. Instead, create a new instance of this class. + /// + /// Use this method if you'd like to disconnect from the specified server permanently, + /// and you'd like to connect to another server instead of the current one. + Future dispose() async { + // Make sure we do not attempt to reconnect when we close the socket + // and onConnectionLost is called (as part of onDone) + _wasDisposed = true; + print('Disposing socket client..'); + _reconnectTimer?.cancel(); + _keepAliveSubscription?.cancel(); + + await Future.wait([ + _closeSocketChannel(), + _messageSubscription?.cancel(), + _connectionStateController.close(), + ].where((future) => future != null).cast>().toList()); + } + + void _write(final GraphQLSocketMessage message) { + if (_connectionStateController.value == SocketConnectionState.connected) { + socketChannel!.sink.add( + json.encode( + message, + toEncodable: (dynamic m) => m.toJson(), + ), + ); + } + } + + /// Sends a query, mutation or subscription request to the server, and returns a stream of the response. + /// + /// If the request is a query or mutation, a timeout will be applied to the request as specified by + /// [SocketClientConfig]'s [queryAndMutationTimeout] field. + /// + /// If the request is a subscription, obviously no timeout is applied. + /// + /// In case of socket disconnection, the returned stream will be closed. + Stream subscribe( + final Request payload, + final bool waitForConnection, + ) { + final String id = _uuid.v4( + options: { + 'random': randomBytesForUuid, + }, + ).toString(); + final StreamController response = StreamController(); + StreamSubscription? sub; + final bool addTimeout = + !payload.isSubscription && config.queryAndMutationTimeout != null; + + final onListen = () { + final Stream waitForConnectedStateWithoutTimeout = + (waitForConnection + ? _connectionStateController + : _connectionStateController + .startWith(SocketConnectionState.connected)) + .where((SocketConnectionState state) => + state == SocketConnectionState.connected) + .take(1); + + final Stream waitForConnectedState = addTimeout + ? waitForConnectedStateWithoutTimeout.timeout( + config.queryAndMutationTimeout!, + onTimeout: (EventSink event) { + print('Connection timed out.'); + response.addError(TimeoutException('Connection timed out.')); + event.close(); + response.close(); + }, + ) + : waitForConnectedStateWithoutTimeout; + + sub = waitForConnectedState.listen((_) { + final Stream dataErrorComplete = _messages.where( + (GraphQLSocketMessage message) { + if (message is SubscriptionData) { + return message.id == id; + } + + if (message is SubscriptionError) { + return message.id == id; + } + + if (message is SubscriptionComplete) { + return message.id == id; + } + + return false; + }, + ).takeWhile((_) => (!response.isClosed && !_wasDisposed)); + + final Stream subscriptionComplete = addTimeout + ? dataErrorComplete + .where((message) => message is SubscriptionComplete) + .take(1) + .timeout( + config.queryAndMutationTimeout!, + onTimeout: (EventSink event) { + response.addError(TimeoutException('Request timed out.')); + event.close(); + response.close(); + }, + ) + : dataErrorComplete + .where((message) => message is SubscriptionComplete) + .take(1); + + subscriptionComplete.listen((_) => response.close()); + + dataErrorComplete + .where((message) => message is SubscriptionData) + .cast() + .listen((message) => response.add( + parse(message.toJson()), + )); + + dataErrorComplete + .where((message) => message is SubscriptionError) + .cast() + .listen((message) => response.addError(message)); + + if (!_subscriptionInitializers[id]!.hasBeenTriggered) { + _write( + StartOperation( + id, + serialize(payload), + ), + ); + _subscriptionInitializers[id]!.hasBeenTriggered = true; + } + }); + }; + + response.onListen = onListen; + + response.onCancel = () { + _subscriptionInitializers.remove(id); + + sub?.cancel(); + if (_connectionStateController.value == SocketConnectionState.connected && + socketChannel != null) { + _write(StopOperation(id)); + } + }; + + _subscriptionInitializers[id] = SubscriptionListener(onListen, false); + + return response.stream; + } + + /// These streams will emit done events when the current socket is done. + /// A stream that emits the last value of the connection state upon subscription. + Stream get connectionState => + _connectionStateController.stream; +} + +void _defaultOnStreamError(Object error, StackTrace st) { + print('[SocketClient] message stream encountered error: $error\n' + 'stacktrace:\n${st.toString()}'); +} + +class GraphQLWebSocketChannel extends StreamChannelMixin + implements WebSocketChannel { + GraphQLWebSocketChannel(this._webSocket) + : stream = _webSocket.stream.asBroadcastStream(); + + WebSocketChannel _webSocket; + + Stream stream; + Stream? _messages; + + /// Stream of messages from the endpoint parsed as GraphQLSocketMessages + Stream get messages => _messages ??= + stream.map(GraphQLSocketMessage.parse); + + String? get protocol => _webSocket.protocol; + + int? get closeCode => _webSocket.closeCode; + + String? get closeReason => _webSocket.closeReason; + + @override + WebSocketSink get sink => _webSocket.sink; +} + +extension GraphQLGetter on WebSocketChannel { + /// Returns a wrapper that has safety and convenience features for graphql + GraphQLWebSocketChannel forGraphQL() => this is GraphQLWebSocketChannel + ? this as GraphQLWebSocketChannel + : GraphQLWebSocketChannel(this); +} diff --git a/packages/fl_query/lib/src/links/websocket_link/websocket_link.dart b/packages/fl_query/lib/src/links/websocket_link/websocket_link.dart new file mode 100644 index 0000000..8fa02a5 --- /dev/null +++ b/packages/fl_query/lib/src/links/websocket_link/websocket_link.dart @@ -0,0 +1,51 @@ +import 'package:gql_link/gql_link.dart'; +import 'package:gql_exec/gql_exec.dart'; + +import './websocket_client.dart'; + +export './websocket_client.dart'; +export './websocket_messages.dart'; + +/// A Universal Websocket [Link] implementation to support the websocket transport. +/// It supports subscriptions, query and mutation operations as well. +/// +/// NOTE: the actual socket connection will only get established after a [Request] is handled by this [WebSocketLink]. +/// If you'd like to connect to the socket server instantly, call the [connectOrReconnect] method after creating this [WebSocketLink] instance. +class WebSocketLink extends Link { + /// Creates a new [WebSocketLink] instance with the specified config. + WebSocketLink( + this.url, { + this.config = const SocketClientConfig(), + }); + + final String url; + final SocketClientConfig config; + + // cannot be final because we're changing the instance upon a header change. + SocketClient? _socketClient; + + @override + Stream request(Request request, [forward]) async* { + if (_socketClient == null) { + connectOrReconnect(); + } + + yield* _socketClient!.subscribe(request, true); + } + + /// Connects or reconnects to the server with the specified headers. + void connectOrReconnect() { + _socketClient?.dispose(); + _socketClient = SocketClient( + url, + config: config, + ); + } + + /// Disposes the underlying socket client explicitly. Only use this, if you want to disconnect from + /// the current server in favour of another one. If that's the case, create a new [WebSocketLink] instance. + Future dispose() async { + await _socketClient?.dispose(); + _socketClient = null; + } +} diff --git a/packages/fl_query/lib/src/links/websocket_link/websocket_messages.dart b/packages/fl_query/lib/src/links/websocket_link/websocket_messages.dart new file mode 100644 index 0000000..2205c16 --- /dev/null +++ b/packages/fl_query/lib/src/links/websocket_link/websocket_messages.dart @@ -0,0 +1,248 @@ +// Adapted to `gql` by @iscriptology + +import "dart:convert"; + +/// These messages represent the structures used for Client-server communication +/// in a GraphQL web-socket subscription. Each message is represented in a JSON +/// format where the data type is denoted by the `type` field. + +/// A list of constants used for identifying message types +class MessageTypes { + MessageTypes._(); + + // client connections + static const String connectionInit = "connection_init"; + static const String connectionTerminate = "connection_terminate"; + + // server connections + static const String connectionAck = "connection_ack"; + static const String connectionError = "connection_error"; + static const String connectionKeepAlive = "ka"; + + // client operations + static const String start = "start"; + static const String stop = "stop"; + + // server operations + static const String data = "data"; + static const String error = "error"; + static const String complete = "complete"; + + // default tag for use in identifying issues + static const String unknown = "unknown"; +} + +abstract class JsonSerializable { + Map toJson(); + + @override + String toString() => toJson().toString(); +} + +/// Base type for representing a server-client subscription message. +abstract class GraphQLSocketMessage extends JsonSerializable { + GraphQLSocketMessage(this.type); + + final String type; + + @override + Map toJson() => {"type": type}; + + static GraphQLSocketMessage parse(dynamic message) { + final Map map = + json.decode(message as String) as Map; + final String type = (map['type'] ?? 'unknown') as String; + final dynamic payload = map['payload'] ?? {}; + final String id = (map['id'] ?? 'none') as String; + + switch (type) { + // for completeness + case MessageTypes.connectionInit: + return InitOperation(payload); + case MessageTypes.connectionTerminate: + return TerminateOperation(); + + case MessageTypes.connectionAck: + return ConnectionAck(); + case MessageTypes.connectionError: + return ConnectionError(payload); + case MessageTypes.connectionKeepAlive: + return ConnectionKeepAlive(); + + // for completeness + case MessageTypes.start: + return StartOperation(id, payload); + case MessageTypes.stop: + return StopOperation(id); + + case MessageTypes.data: + return SubscriptionData(id, payload['data'], payload['errors']); + case MessageTypes.error: + return SubscriptionError(id, payload); + case MessageTypes.complete: + return SubscriptionComplete(id); + default: + return UnknownData(map); + } + } +} + +/// After establishing a connection with the server, the client will +/// send this message to tell the server that it is ready to begin sending +/// new subscription queries. +class InitOperation extends GraphQLSocketMessage { + InitOperation(this.payload) : super(MessageTypes.connectionInit); + + final dynamic payload; + + @override + toJson() => { + "type": type, + if (payload != null) "payload": payload, + }; +} + +/// The client sends this message to terminate the connection. +class TerminateOperation extends GraphQLSocketMessage { + TerminateOperation() : super(MessageTypes.connectionTerminate); +} + +/// Represent the payload used during a Start query operation. +/// The operationName should match one of the top level query definitions +/// defined in the query provided. Additional variables can be provided +/// and sent to the server for processing. +class QueryPayload extends JsonSerializable { + QueryPayload({ + this.operationName, + required this.query, + required this.variables, + }); + + final String? operationName; + final String query; + final Map variables; + + @override + toJson() => { + "operationName": operationName, + "query": query, + "variables": variables, + }; +} + +/// A message to tell the server to create a subscription. The contents of the +/// query will be defined by the payload request. The id provided will be used +/// to tag messages such that they can be identified for this subscription +/// instance. id values should be unique and not be re-used during the lifetime +/// of the server. +class StartOperation extends GraphQLSocketMessage { + StartOperation(this.id, this.payload) : super(MessageTypes.start); + + final String id; +// final QueryPayload payload; + final Map payload; + + @override + toJson() => { + "type": type, + "id": id, + "payload": payload, + }; +} + +/// Tell the server to stop sending subscription data for a particular +/// subscription instance. See [StartOperation]. +class StopOperation extends GraphQLSocketMessage { + StopOperation(this.id) : super(MessageTypes.stop); + + final String id; + + @override + toJson() => {"type": type, "id": id}; +} + +/// The server will send this acknowledgment message after receiving the init +/// command from the client if the init was successful. +class ConnectionAck extends GraphQLSocketMessage { + ConnectionAck() : super(MessageTypes.connectionAck); +} + +/// The server will send this error message after receiving the init command +/// from the client if the init was not successful. +class ConnectionError extends GraphQLSocketMessage { + ConnectionError(this.payload) : super(MessageTypes.connectionError); + + final dynamic payload; + + @override + toJson() => {"type": type, "payload": payload}; +} + +/// The server will send this message to keep the connection alive +class ConnectionKeepAlive extends GraphQLSocketMessage { + ConnectionKeepAlive() : super(MessageTypes.connectionKeepAlive); +} + +/// Data sent from the server to the client with subscription data or error +/// payload. The user should check the errors result before processing the +/// data value. These error are from the query resolvers. +class SubscriptionData extends GraphQLSocketMessage { + SubscriptionData(this.id, this.data, this.errors) : super(MessageTypes.data); + + final String id; + final dynamic data; + final dynamic errors; + + @override + toJson() => { + "type": type, + "data": data, + "errors": errors, + }; + + @override + int get hashCode => toJson().hashCode; + + @override + bool operator ==(dynamic other) => + other is SubscriptionData && jsonEncode(other) == jsonEncode(this); +} + +/// Errors sent from the server to the client if the subscription operation was +/// not successful, usually due to GraphQL validation errors. +class SubscriptionError extends GraphQLSocketMessage { + SubscriptionError(this.id, this.payload) : super(MessageTypes.error); + + final String id; + final dynamic payload; + + @override + toJson() => { + "type": type, + "id": id, + "payload": payload, + }; +} + +/// Server message to the client to indicate that no more data will be sent +/// for a particular subscription instance. +class SubscriptionComplete extends GraphQLSocketMessage { + SubscriptionComplete(this.id) : super(MessageTypes.complete); + + final String id; + + @override + toJson() => {"type": type, "id": id}; +} + +/// Not expected to be created. Indicates there are problems parsing the server +/// response, or that new unsupported types have been added to the subscription +/// implementation. +class UnknownData extends GraphQLSocketMessage { + UnknownData(this.payload) : super(MessageTypes.unknown); + + final dynamic payload; + + @override + toJson() => {"type": type, "payload": payload}; +} diff --git a/packages/fl_query/lib/src/scheduler/scheduler.dart b/packages/fl_query/lib/src/scheduler/scheduler.dart new file mode 100644 index 0000000..2406b1c --- /dev/null +++ b/packages/fl_query/lib/src/scheduler/scheduler.dart @@ -0,0 +1,92 @@ +import 'dart:async'; + +import 'package:fl_query/src/core/query_manager.dart'; +import 'package:fl_query/src/core/query_options.dart'; +import 'package:fl_query/src/core/observable_query.dart'; + +/// Handles scheduling polling results for each [ObservableQuery] with a `pollInterval` +class QueryScheduler { + QueryScheduler({ + this.queryManager, + }); + + QueryManager? queryManager; + + /// Map going from query ids to the [WatchQueryOptions] associated with those queries. + Map registeredQueries = + {}; + + /// Map going from poling interval to the query ids that fire on that interval. + /// These query ids are associated with a [ObservableQuery] in the registeredQueries. + Map> intervalQueries = >{}; + + /// Map going from polling interval durations to polling timers. + final Map _pollingTimers = {}; + + void fetchQueriesOnInterval( + Timer timer, + Duration? interval, + ) { + intervalQueries[interval]!.retainWhere( + (String queryId) { + // If ObservableQuery can't be found from registeredQueries or if it has a + // different interval, it means that this queryId is no longer registered + // and should be removed from the list of queries firing on this interval. + // + // We don't remove queries from intervalQueries immediately in + // stopPollingQuery so that we can keep the timer consistent when queries + // are removed and replaced, and to avoid quadratic behavior when stopping + // many queries. + if (registeredQueries[queryId] == null) { + return false; + } + + final Duration? pollInterval = registeredQueries[queryId]!.pollInterval; + + return registeredQueries.containsKey(queryId) && + pollInterval == interval; + }, + ); + + // if no queries on the interval clean up + if (intervalQueries[interval]!.isEmpty) { + intervalQueries.remove(interval); + _pollingTimers.remove(interval); + timer.cancel(); + return; + } + + // fetch each query on the interval + intervalQueries[interval]!.forEach(queryManager!.refetchQuery); + } + + void startPollingQuery( + WatchQueryOptions options, + String queryId, + ) { + assert( + options.pollInterval != null && options.pollInterval! > Duration.zero, + ); + + registeredQueries[queryId] = options; + + final interval = options.pollInterval; + + if (intervalQueries.containsKey(interval)) { + intervalQueries[interval]!.add(queryId); + } else { + intervalQueries[interval] = [queryId]; + + _pollingTimers[interval] = Timer.periodic( + interval!, + (Timer timer) => fetchQueriesOnInterval(timer, interval), + ); + } + } + + /// Removes the [ObservableQuery] from one of the registered queries. + /// The fetchQueriesOnInterval will then take care of not firing it anymore. + void stopPollingQuery(String queryId) { + registeredQueries.remove(queryId); + } +} diff --git a/packages/fl_query/lib/src/utilities/helpers.dart b/packages/fl_query/lib/src/utilities/helpers.dart new file mode 100644 index 0000000..a5ff237 --- /dev/null +++ b/packages/fl_query/lib/src/utilities/helpers.dart @@ -0,0 +1,45 @@ +bool notNull(Object? any) { + return any != null; +} + +Map? _recursivelyAddAll( + Map? target, + Map? source, +) { + target = Map.from(target ?? {}); + source?.forEach((String key, dynamic value) { + if (target!.containsKey(key) && + target[key] is Map && + value != null && + value is Map) { + target[key] = _recursivelyAddAll( + target[key] as Map, + value, + ); + } else { + // Lists and nulls overwrite target as if they were normal scalars + target[key] = value; + } + }); + return target; +} + +/// Deeply merges `maps` into a new map, merging nested maps recursively. +/// +/// Paths in the rightmost maps override those in the earlier ones, so: +/// ``` +/// print(deeplyMergeLeft([ +/// {'keyA': 'a1'}, +/// {'keyA': 'a2', 'keyB': 'b2'}, +/// {'keyB': 'b3'} +/// ])); +/// // { keyA: a2, keyB: b3 } +/// ``` +/// +/// Conflicting [List]s are overwritten like scalars +Map? deeplyMergeLeft( + Iterable?> maps, +) { + // prepend an empty literal for functional immutability + return (?>[{}]..addAll(maps)).reduce(_recursivelyAddAll); +} diff --git a/packages/fl_query/lib/src/utilities/platform.dart b/packages/fl_query/lib/src/utilities/platform.dart new file mode 100644 index 0000000..8abbc35 --- /dev/null +++ b/packages/fl_query/lib/src/utilities/platform.dart @@ -0,0 +1 @@ +export './platform_html.dart' if (dart.library.io) './platform_io.dart'; diff --git a/packages/fl_query/lib/src/utilities/platform_html.dart b/packages/fl_query/lib/src/utilities/platform_html.dart new file mode 100644 index 0000000..6caed97 --- /dev/null +++ b/packages/fl_query/lib/src/utilities/platform_html.dart @@ -0,0 +1,13 @@ +import 'package:fl_query/src/links/websocket_link/websocket_client.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; + +Future defaultConnectPlatform( + Uri uri, Iterable? protocols, + {Map? headers}) async { + if (headers != null) { + print("The headers on the web are not supported"); + } + final webSocketChannel = + await WebSocketChannel.connect(uri, protocols: protocols); + return webSocketChannel.forGraphQL(); +} diff --git a/packages/fl_query/lib/src/utilities/platform_io.dart b/packages/fl_query/lib/src/utilities/platform_io.dart new file mode 100644 index 0000000..d7d3eeb --- /dev/null +++ b/packages/fl_query/lib/src/utilities/platform_io.dart @@ -0,0 +1,13 @@ +import 'dart:io'; + +import 'package:fl_query/src/links/websocket_link/websocket_client.dart'; +import 'package:web_socket_channel/io.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; + +Future defaultConnectPlatform( + Uri uri, Iterable? protocols, + {Map? headers}) async { + final webSocket = await WebSocket.connect(uri.toString(), + protocols: protocols, headers: headers); + return IOWebSocketChannel(webSocket).forGraphQL(); +} diff --git a/packages/fl_query/pubspec.yaml b/packages/fl_query/pubspec.yaml new file mode 100644 index 0000000..cedd5e2 --- /dev/null +++ b/packages/fl_query/pubspec.yaml @@ -0,0 +1,40 @@ +name: fl_query +description: A new Flutter package project. +version: 0.0.1 +homepage: https://github.com/KRTirtho/fl-query + +environment: + sdk: ">=2.15.1 <3.0.0" + flutter: ">=1.17.0" + +dependencies: + flutter: + sdk: flutter + meta: ^1.3.0 + path: ^1.8.0 + # gql: ^0.13.0 + # gql_exec: 0.3.0 + # gql_link: 0.4.0 + # gql_http_link: 0.4.0 + # gql_transform_link: ^0.2.0 + # gql_error_link: ^0.2.0 + # gql_dedupe_link: ^2.0.0 + # normalize: ^0.6.0 + hive: ^2.0.0 + http: ^0.13.0 + collection: ^1.15.0 + web_socket_channel: ^2.0.0 + stream_channel: ^2.1.0 + rxdart: ^0.27.1 + uuid: ^3.0.1 + +dev_dependencies: + async: ^2.5.0 + mockito: ^5.0.0 + test: ^1.18.2 + coverage: ^1.0.3 + http_parser: ^4.0.0 + lints: ^1.0.1 + +# The following section is specific to Flutter. +flutter: diff --git a/packages/fl_query/test/anonymous_operations_test.dart b/packages/fl_query/test/anonymous_operations_test.dart new file mode 100644 index 0000000..918008c --- /dev/null +++ b/packages/fl_query/test/anonymous_operations_test.dart @@ -0,0 +1,171 @@ +import 'package:gql/language.dart'; +import 'package:test/test.dart'; +import 'package:mockito/mockito.dart'; + +import 'package:fl_query/fl_query.dart'; + +import './helpers.dart'; + +void main() { + const String readRepositories = r'''{ + viewer { + repositories(last: 42) { + nodes { + __typename + id + name + viewerHasStarred + } + } + } + } +'''; + + const String addStar = r'''mutation { + action: addStar(input: {starrableId: "some_repo"}) { + starrable { + viewerHasStarred + } + } + } +'''; + + late MockLink link; + late GraphQLClient graphQLClientClient; + + group('simple json', () { + setUp(() { + link = MockLink(); + + graphQLClientClient = GraphQLClient( + cache: getTestCache(), + link: link, + ); + }); + + group('query', () { + test('successful query', () async { + final WatchQueryOptions _options = WatchQueryOptions( + document: parseString(readRepositories), + variables: {}, + ); + + when( + link.request(any), + ).thenAnswer( + (_) => Stream.fromIterable( + [ + Response( + data: { + 'viewer': { + 'repositories': { + 'nodes': [ + { + '__typename': 'Repository', + 'id': 'MDEwOlJlcG9zaXRvcnkyNDgzOTQ3NA==', + 'name': 'pq', + 'viewerHasStarred': false, + }, + { + '__typename': 'Repository', + 'id': 'MDEwOlJlcG9zaXRvcnkzMjkyNDQ0Mw==', + 'name': 'go-evercookie', + 'viewerHasStarred': false, + }, + { + '__typename': 'Repository', + 'id': 'MDEwOlJlcG9zaXRvcnkzNTA0NjgyNA==', + 'name': 'watchbot', + 'viewerHasStarred': false, + }, + ], + }, + }, + }, + ), + ], + ), + ); + + final QueryResult r = await graphQLClientClient.query(_options); + + verify( + link.request( + Request( + operation: Operation( + document: parseString(readRepositories), + operationName: null, + ), + variables: {}, + context: Context(), + ), + ), + ); + + expect(r.exception, isNull); + expect(r.data, isNotNull); + final List> nodes = + (r.data!['viewer']['repositories']['nodes'] as List) + .cast>(); + expect(nodes, hasLength(3)); + expect(nodes[0]['id'], 'MDEwOlJlcG9zaXRvcnkyNDgzOTQ3NA=='); + expect(nodes[1]['name'], 'go-evercookie'); + expect(nodes[2]['viewerHasStarred'], false); + return; + }); +// test('failed query because of network', {}); +// test('failed query because of because of error response', {}); +// test('failed query because of because of invalid response', () { +// String responseBody = +// '{\"message\":\"Bad credentials\",\"documentation_url\":\"https://developer.github.com/v4\"}'; +// int responseCode = 401; +// }); +// test('partially success query with some errors', {}); + }); + group('mutation', () { + test('successful mutation', () async { + final MutationOptions _options = MutationOptions( + document: parseString(addStar), + ); + + when( + link.request(any), + ).thenAnswer( + (_) => Stream.fromIterable( + [ + Response( + data: { + 'action': { + 'starrable': { + 'viewerHasStarred': true, + }, + }, + }, + ), + ], + ), + ); + + final QueryResult response = await graphQLClientClient.mutate(_options); + + verify( + link.request( + Request( + operation: Operation( + document: parseString(addStar), + ), + variables: {}, + context: Context(), + ), + ), + ); + + expect(response.exception, isNull); + expect(response.data, isNotNull); + final bool? viewerHasStarred = + response.data!['action']['starrable']['viewerHasStarred'] as bool?; + expect(viewerHasStarred, true); + }); + }); + }); +} diff --git a/packages/fl_query/test/cache/cache_data.dart b/packages/fl_query/test/cache/cache_data.dart new file mode 100644 index 0000000..4c86a27 --- /dev/null +++ b/packages/fl_query/test/cache/cache_data.dart @@ -0,0 +1,362 @@ +import 'package:gql_exec/gql_exec.dart'; +import 'package:gql/language.dart'; +import 'package:fl_query/fl_query.dart' show Fragment; +import 'package:fl_query/src/utilities/helpers.dart'; +import 'package:http/http.dart' as http; +import 'package:http_parser/http_parser.dart'; + +const String rawOperationKey = 'rawOperationKey'; + +class TestCase { + TestCase({ + required this.data, + required String operation, + Map variables = const {}, + this.normalizedEntities, + }) : request = Request( + operation: Operation(document: parseString(operation)), + variables: variables, + context: Context(), + ); + + Request request; + + /// data to write to cache + Map data; + + /// entities to inspect the store for, if any + List>? normalizedEntities; +} + +final basicTest = TestCase( + operation: r'''{ + a { + __typename + id + # union + list { + __typename + value + ... on Item { id } + } + b { + __typename + id + c { + __typename + id, + cField + } + bField { field } + }, + d { + id, + dField {field} + } + aField { field } + } + }''', + data: { + 'a': { + '__typename': 'A', + 'id': 1, + 'list': [ + {'__typename': 'Num', 'value': 1}, + {'__typename': 'Num', 'value': 2}, + {'__typename': 'Num', 'value': 3}, + {'__typename': 'Item', 'id': 4, 'value': 4} + ], + 'b': { + '__typename': 'B', + 'id': 5, + 'c': { + '__typename': 'C', + 'id': 6, + 'cField': 'value', + }, + 'bField': {'field': true} + }, + 'd': { + 'id': 9, + 'dField': {'field': true} + }, + 'aField': {'field': false} + }, + }, +); + +/// https://github.com/gql-dart/gql/blob/master/links/gql_http_link/test/multipart_upload_test.dart +final fileVarsTest = TestCase( + data: { + "multipleUpload": [ + { + "id": "r1odc4PAz", + "filename": "sample_upload.jpg", + "mimetype": "image/jpeg", + "path": "./uploads/r1odc4PAz-sample_upload.jpg" + }, + { + "id": "5Ea18qlMur", + "filename": "sample_upload.txt", + "mimetype": "text/plain", + "path": "./uploads/5Ea18qlMur-sample_upload.txt" + } + ], + }, + operation: r""" + mutation($files: [Upload!]!) { + multipleUpload(files: $files) { + id + filename + mimetype + path + } + } + """, + variables: { + 'files': [ + http.MultipartFile.fromBytes( + "", + [0, 1, 254, 255], + filename: "sample_upload.jpg", + contentType: MediaType("image", "jpeg"), + ), + http.MultipartFile.fromString( + "", + "just plain text", + filename: "sample_upload.txt", + contentType: MediaType("text", "plain"), + ), + ], + }, +); + +final originalCValue = { + '__typename': 'C', + 'id': 6, + 'cField': 'value', +}; +final originalCFragment = Fragment( + document: parseString( + r''' + fragment partialC on C { + __typename + id + cField + } + ''', + ), +); + +final updatedCFragment = Fragment( + document: parseString( + r''' + fragment partialC on C { + __typename + id + new + cField + } + ''', + ), +); + +final updatedCValue = { + '__typename': 'C', + 'id': 6, + 'new': 'field', + 'cField': 'changed value', +}; + +final Map? updatedCBasicTestData = deeplyMergeLeft([ + basicTest.data, + { + 'a': { + 'b': { + 'c': { + '__typename': 'C', + 'id': 6, + 'cField': 'changed value', + }, + }, + }, + }, +]); + +final basicTestSubsetAValue = TestCase( + operation: r'''{ + a { + __typename + id + list { + __typename + value + ... on Item { id } + } + d { id } + } + }''', + data: { + 'a': { + '__typename': 'A', + 'id': 1, + 'list': [ + {'__typename': 'Num', 'value': 5}, + {'__typename': 'Num', 'value': 6}, + {'__typename': 'Num', 'value': 7}, + { + '__typename': 'Item', + 'id': 8, + 'value': 8, + } + ], + 'd': { + 'id': 10, + }, + }, + }, +); + +getUpdatedSubsetOperationData({withUpdatedC = false}) => { + 'a': { + '__typename': 'A', + 'id': 1, + 'list': basicTestSubsetAValue.data['a']['list'], + 'b': { + '__typename': 'B', + 'id': 5, + 'c': { + '__typename': 'C', + 'id': 6, + 'cField': '${withUpdatedC ? "changed " : ""}value', + }, + 'bField': {'field': true} + }, + 'd': { + 'id': 10, + 'dField': {'field': true} + }, + 'aField': {'field': false} + }, + }; + +final cyclicalTest = TestCase(operation: r'''{ + a { + __typename + id + b { + __typename + id + as { + __typename + id + } + } + } + }''', data: { + 'a': { + '__typename': 'A', + 'id': 1, + 'b': { + '__typename': 'B', + 'id': 5, + 'as': [ + { + '__typename': 'A', + 'id': 1, + }, + ] + }, + }, +}, normalizedEntities: [ + { + '__typename': 'A', + 'id': 1, + 'b': {r"$ref": 'B:5'} + }, + { + '__typename': 'B', + 'id': 5, + 'as': [ + {r"$ref": 'A:1'} + ], + }, +]); + +Map get cyclicalObjOperationData { + Map a; + Map b; + a = { + '__typename': 'A', + 'id': 1, + }; + b = { + '__typename': 'B', + 'id': 5, + 'as': [a] + }; + a['b'] = b; + return {'a': a}; +} + +final typelessTest = TestCase( + operation: r'''{ + a { + # union + list { + #__typename + value + #... on Item { id } + } + b { + id + c { + id, + cField + } + bField { field } + }, + d { + id, + dField {field} + } + aField { field } + } + }''', + data: { + 'a': { + 'list': [ + { + //'__typename': 'Num', + 'value': 1, + }, + { + //'__typename': 'Num', + 'value': 2, + }, + { + //'__typename': 'Num', + 'value': 3, + }, + { + //'__typename': 'Item', + //'id': 4, + 'value': 4, + } + ], + 'b': { + 'id': 5, + 'c': { + 'id': 6, + 'cField': 'value', + }, + 'bField': {'field': true} + }, + 'd': { + 'id': 9, + 'dField': {'field': true} + }, + 'aField': {'field': false} + }, + }, +); diff --git a/packages/fl_query/test/cache/graphql_cache_test.dart b/packages/fl_query/test/cache/graphql_cache_test.dart new file mode 100644 index 0000000..580ded1 --- /dev/null +++ b/packages/fl_query/test/cache/graphql_cache_test.dart @@ -0,0 +1,313 @@ +import 'package:fl_query/src/cache/_normalizing_data_proxy.dart'; +import 'package:normalize/normalize.dart' show PartialDataException; +import 'package:test/test.dart'; + +import 'package:fl_query/src/cache/cache.dart'; + +import '../helpers.dart'; +import './cache_data.dart'; + +typedef CacheTransaction = JSONDataProxy Function(JSONDataProxy proxy); + +void main() { + if (debuggingUnexpectedTestFailures) { + print( + 'DEBUGGING UNEXPECTED TEST FAILURES: $debuggingUnexpectedTestFailures.\n' + 'RUNNING TESTS WITH returnPartialData SET TO TRUE.\n', + ); + } + + group('Normalizes writes', () { + late QueryCache cache; + setUp(() { + cache = getTestCache(); + }); + test('.writeQuery .readQuery round trip', () { + cache.writeQuery(basicTest.request, data: basicTest.data); + expect( + cache.readQuery(basicTest.request), + equals(basicTest.data), + ); + }); + + test('typeless .writeQuery .readQuery round trip', () { + cache.writeQuery(typelessTest.request, data: typelessTest.data); + expect( + cache.readQuery(typelessTest.request), + equals(typelessTest.data), + ); + }); + + test('typeless custom dataIdFromObject', () { + cache.writeQuery(typelessTest.request, data: typelessTest.data); + expect( + cache.readQuery(typelessTest.request), + equals(typelessTest.data), + ); + }); + + test('.writeQuery should fail on missing fields', () { + expect( + () => cache.writeQuery(basicTest.request, data: { + ...basicTest.data, + 'a': { + ...basicTest.data['a'], + 'b': { + 'id': 5, + } + }, + }), + throwsA(isA().having( + (e) => e.path, + 'An accurate path to the first missing subfield', + ['a', 'b', '__typename'], + )), + ); + }); + + test('updating nested normalized fragment changes top level operation', () { + cache.writeQuery(basicTest.request, data: basicTest.data); + final idFields = { + '__typename': updatedCValue['__typename'], + 'id': updatedCValue['id'], + }; + cache.writeFragment( + updatedCFragment.asRequest( + idFields: idFields, + ), + data: updatedCValue, + ); + + expect( + cache.readQuery(basicTest.request), + equals(updatedCBasicTestData), + ); + + expect( + cache.readFragment( + updatedCFragment.asRequest( + idFields: idFields, + ), + ), + updatedCValue, + ); + }); + + test('updating subset query only partially overrides superset query', () { + cache.writeQuery(basicTest.request, data: basicTest.data); + + cache.writeQuery( + basicTestSubsetAValue.request, + data: basicTestSubsetAValue.data, + ); + expect( + cache.readQuery(basicTest.request), + equals(getUpdatedSubsetOperationData()), + ); + }); + }); + + group('Handles cyclical references', () { + final QueryCache cache = getTestCache(); + test('lazily reads cyclical references', () { + cache.writeQuery(cyclicalTest.request, data: cyclicalTest.data); + for (final normalized in cyclicalTest.normalizedEntities!) { + final dataId = "${normalized['__typename']}:${normalized['id']}"; + expect(cache.readNormalized(dataId), equals(normalized)); + } + }); + }); + + group('Handles Object/pointer self-references/cycles', () { + final QueryCache cache = getTestCache(); + test('correctly reads cyclical references', () { + cyclicalTest.data = cyclicalObjOperationData; + cache.writeQuery(cyclicalTest.request, data: cyclicalTest.data); + for (final normalized in cyclicalTest.normalizedEntities!) { + final dataId = "${normalized['__typename']}:${normalized['id']}"; + expect(cache.readNormalized(dataId), equals(normalized)); + } + }); + }); + + group( + '.recordOptimisticTransaction', + () { + late QueryCache cache; + + setUp(() { + cache = getTestCache(); + }); + + test( + 'OptimisticCache.readQuery and .readFragment pass through', + () { + cache.writeQuery(basicTest.request, data: basicTest.data); + cache.broadcastRequested = false; + cache.recordOptimisticTransaction( + (proxy) { + expect( + proxy.readQuery(basicTest.request), + equals(basicTest.data), + ); + + final idFields = { + '__typename': originalCValue['__typename'], + 'id': originalCValue['id'], + }; + + expect( + proxy.readFragment(originalCFragment.asRequest( + idFields: idFields, + )), + originalCValue, + ); + + expect( + (proxy as NormalizingDataProxy).broadcastRequested, + isFalse, + ); + + return proxy; + }, + '1', + ); + + // no edits + expect(cache.broadcastRequested, isFalse); + expect(cache.optimisticPatches.first.id, equals('1')); + expect(cache.optimisticPatches.first.data, equals({})); + }, + ); + + test( + '.writeQuery, .readQuery(optimistic: true) round trip', + () { + cache.recordOptimisticTransaction( + (proxy) => proxy + ..writeQuery( + basicTest.request, + data: basicTest.data, + ), + '1', + ); + expect( + cache.readQuery(basicTest.request, optimistic: true), + equals(basicTest.data), + ); + }, + ); + + recordCFragmentUpdate(QueryCache cache) => + cache.recordOptimisticTransaction( + (proxy) => proxy + ..writeFragment( + updatedCFragment.asRequest(idFields: { + '__typename': updatedCValue['__typename'], + 'id': updatedCValue['id'], + }), + data: updatedCValue, + ), + '2', + ); + + test( + 'updating nested normalized fragment changes top level operation', + () { + cache.writeQuery(basicTest.request, data: basicTest.data); + recordCFragmentUpdate(cache); + expect( + cache.readQuery(basicTest.request), + equals(updatedCBasicTestData), + ); + }, + ); + + recordBasicSubsetData(QueryCache cache) => + cache.recordOptimisticTransaction( + (proxy) => proxy + ..writeQuery( + basicTestSubsetAValue.request, + data: basicTestSubsetAValue.data, + ), + '3', + ); + test( + 'updating subset query partially overrides superset query', + () { + cache.writeQuery(basicTest.request, data: basicTest.data); + recordCFragmentUpdate(cache); + recordBasicSubsetData(cache); + expect( + cache.readQuery(basicTest.request, optimistic: true), + equals(getUpdatedSubsetOperationData(withUpdatedC: true)), + ); + }, + ); + + test( + '.removeOptimisticPatch results in data from lower layers on readQuery', + () { + cache.writeQuery(basicTest.request, data: basicTest.data); + recordCFragmentUpdate(cache); + recordBasicSubsetData(cache); + cache.removeOptimisticPatch('2'); + cache.removeOptimisticPatch('3'); + expect( + cache.readQuery(basicTest.request, optimistic: true), + equals(basicTest.data), + ); + }, + ); + }, + ); + + group('Handles MultipartFile variables', () { + late QueryCache cache; + setUp(() { + cache = getTestCache(); + }); + test('.writeQuery .readQuery round trip', () { + cache.writeQuery(fileVarsTest.request, data: fileVarsTest.data); + expect( + cache.readQuery(fileVarsTest.request), + equals(fileVarsTest.data), + ); + }); + }); + + group('custom dataIdFromObject', () { + /// Uses a `/` instead of the default `:` + String? customDataIdFromObject(Object object) { + if (object is Map && + object.containsKey('__typename') && + object.containsKey('id')) + return "${object['__typename']}/${object['id']}"; + return null; + } + + late QueryCache cache; + setUp(() { + cache = QueryCache( + dataIdFromObject: customDataIdFromObject, + partialDataPolicy: PartialDataCachePolicy.reject, + ); + }); + + test('.writeQuery .readQuery round trip', () { + cache.writeQuery(basicTest.request, data: basicTest.data); + expect( + cache.readQuery(basicTest.request), + equals(basicTest.data), + ); + }); + + test('typeless .writeQuery .readQuery round trip', () { + cache.writeQuery(typelessTest.request, data: typelessTest.data); + expect( + cache.readQuery(typelessTest.request), + equals(typelessTest.data), + ); + }); + }); +} diff --git a/packages/fl_query/test/cache/store_test.dart b/packages/fl_query/test/cache/store_test.dart new file mode 100644 index 0000000..30223d7 --- /dev/null +++ b/packages/fl_query/test/cache/store_test.dart @@ -0,0 +1,79 @@ +import 'dart:io'; + +import 'package:fl_query/fl_query.dart'; +import 'package:fl_query/src/utilities/helpers.dart'; +import 'package:test/test.dart'; + +void main() { + group('InMemoryStore', () { + final data = { + 'id': {'key': 'value'}, + 'id2': {'otherKey': false} + }; + test('basic methods', () { + final store = InMemoryStore(); + store.put('id', data['id']); + expect(store.get('id'), equals(data['id'])); + + store.delete('id'); + expect(store.data, equals({})); + }); + test('bulk methods', () { + final store = InMemoryStore(); + + store.putAll(data); + + expect(store.data, equals(data)); + expect(store.toMap(), equals(data)); + + store.reset(); + + expect(data['id'], notNull); // no mutations + }); + }); + + group('HiveStore', () { + final data = { + 'id': {'key': 'value'}, + 'id2': {'otherKey': false} + }; + final path = './test/cache/test_hive_boxes/'; + test('basic methods', () async { + final store = + await HiveStore.open(boxName: 'basic', path: path + 'basic'); + store.put('id', data['id']); + expect(store.get('id'), equals(data['id'])); + + store.delete('id'); + expect(store.toMap(), equals({})); + + await store.box.deleteFromDisk(); + }); + test('bulk methods', () async { + final store = await HiveStore.open(boxName: 'bulk', path: path + 'bulk'); + + store.putAll(data); + expect(store.toMap(), equals(data)); + + await store.reset(); + expect(store.toMap(), equals({})); + + expect(data['id'], notNull); // no mutations + + await store.box.deleteFromDisk(); + }); + + test('box rereferencing', () async { + final store = await HiveStore.open(path: path); + store.putAll(data); + + expect(HiveStore().toMap(), equals(data)); + + await store.box.deleteFromDisk(); + }); + + tearDownAll(() async { + await Directory(path).delete(recursive: true); + }); + }); +} diff --git a/packages/fl_query/test/coverage.dart b/packages/fl_query/test/coverage.dart new file mode 100644 index 0000000..9c0d47b --- /dev/null +++ b/packages/fl_query/test/coverage.dart @@ -0,0 +1,20 @@ +// initially auto-generated by test_coverage, +// but that project is unmaintained https://github.com/pulyaevskiy/test-coverage/issues/40 + +import 'anonymous_operations_test.dart' as anonymous_operations_test; +import 'cache/graphql_cache_test.dart' as cache_graphql_cache_test; +import 'cache/store_test.dart' as cache_store_test; +import 'fetch_policy_test.dart' as fetch_policy_test; +import 'graphql_client_test.dart' as graphql_client_test; +import 'query_options_test.dart' as query_options_test; +import 'websocket_test.dart' as websocket_test; + +void main() { + query_options_test.main(); + cache_store_test.main(); + cache_graphql_cache_test.main(); + fetch_policy_test.main(); + anonymous_operations_test.main(); + websocket_test.main(); + graphql_client_test.main(); +} diff --git a/packages/fl_query/test/fetch_policy_test.dart b/packages/fl_query/test/fetch_policy_test.dart new file mode 100644 index 0000000..6f73c55 --- /dev/null +++ b/packages/fl_query/test/fetch_policy_test.dart @@ -0,0 +1,122 @@ +import 'package:test/test.dart'; +import 'package:mockito/mockito.dart'; + +import 'package:fl_query/fl_query.dart'; +import 'package:gql/language.dart'; + +import './helpers.dart'; + +void main() { + const String readRepositories = r''' + query ReadRepositories($nRepositories: Int!) { + viewer { + repositories(last: $nRepositories) { + nodes { + __typename + id + name + viewerHasStarred + } + } + } + } + '''; + readRepositoryData({withTypenames = true, withIds = true}) { + return { + 'viewer': { + 'repositories': { + 'nodes': [ + { + if (withIds) 'id': 'MDEwOlJlcG9zaXRvcnkyNDgzOTQ3NA==', + 'name': 'pq', + 'viewerHasStarred': false + }, + { + if (withIds) 'id': 'MDEwOlJlcG9zaXRvcnkzMjkyNDQ0Mw==', + 'name': 'go-evercookie', + 'viewerHasStarred': false + }, + { + if (withIds) 'id': 'MDEwOlJlcG9zaXRvcnkzNTA0NjgyNA==', + 'name': 'watchbot', + 'viewerHasStarred': false + }, + ] + .map((map) => + withTypenames ? {'__typename': 'Repository', ...map} : map) + .toList(), + }, + }, + }; + } + + late MockLink link; + late GraphQLClient client; + + group('FetchPolicy', () { + setUp(() { + link = MockLink(); + + client = GraphQLClient( + cache: getTestCache(), + link: link, + ); + }); + + group('query', () { + // TODO cacheFirst code path: Return result from cache. Only fetch from network if cached result is not available. + // TODO cacheAndNetwork code path: Return result from cache first (if it exists), then return network result once it's available. + // TODO cacheOnly code path: Return result from cache if available, fail otherwise. + // TODO noCache code path: Return result from network, fail if network call doesn't succeed, don't save to cache. + // TODO networkOnly code path: Return result from network, fail if network call doesn't succeed, save to cache. + test('switch to cacheOnly returns cached data', () async { + final _options = QueryOptions( + fetchPolicy: FetchPolicy.cacheAndNetwork, + document: parseString(readRepositories), + variables: { + 'nRepositories': 42, + }, + ); + final repoData = readRepositoryData(withTypenames: true); + + when( + link.request(any), + ).thenAnswer( + (_) => Stream.fromIterable([ + Response(data: repoData), + ]), + ); + + final QueryResult r = await client.query(_options); + + verify( + link.request( + Request( + operation: Operation( + document: parseString(readRepositories), + //operationName: 'ReadRepositories', + ), + variables: { + 'nRepositories': 42, + }, + context: Context(), + ), + ), + ); + + expect(r.exception, isNull); + expect(r.data, equals(repoData)); + + final QueryResult cacheResult = await client.query(QueryOptions( + fetchPolicy: FetchPolicy.cacheOnly, + document: parseString(readRepositories), + variables: { + 'nRepositories': 42, + }, + )); + expect(cacheResult.exception, isNull); + expect(cacheResult.data, equals(repoData)); + }); + }); + }); +} diff --git a/packages/fl_query/test/graphql_client_test.dart b/packages/fl_query/test/graphql_client_test.dart new file mode 100644 index 0000000..a149693 --- /dev/null +++ b/packages/fl_query/test/graphql_client_test.dart @@ -0,0 +1,888 @@ +import 'package:fl_query/src/core/result_parser.dart'; +import 'package:test/test.dart'; +import 'package:mockito/mockito.dart'; + +import 'package:fl_query/fl_query.dart'; +import 'package:gql/language.dart'; + +import './helpers.dart'; + +void main() { + const String readSingle = r''' + query ReadSingle($id: ID!) { + single(id: $id) { + id, + __typename, + name + } + } + '''; + + const String writeSingle = r''' + mutation WriteSingle($id: ID!, $name: String!) { + updateSingle(id: $id, name: $name) { + id, + __typename, + name + } + } + '''; + + const String readRepositories = r''' + query ReadRepositories($nRepositories: Int!) { + viewer { + repositories(last: $nRepositories) { + nodes { + __typename + id + name + viewerHasStarred + } + } + } + } + '''; + Map readRepositoryData({ + bool withTypenames = true, + bool withIds = true, + bool viewerHasStarred = false, + }) { + return { + 'viewer': { + 'repositories': { + 'nodes': [ + { + if (withIds) 'id': 'MDEwOlJlcG9zaXRvcnkyNDgzOTQ3NA==', + 'name': 'pq', + 'viewerHasStarred': viewerHasStarred + }, + { + if (withIds) 'id': 'MDEwOlJlcG9zaXRvcnkzMjkyNDQ0Mw==', + 'name': 'go-evercookie', + 'viewerHasStarred': viewerHasStarred + }, + { + if (withIds) 'id': 'MDEwOlJlcG9zaXRvcnkzNTA0NjgyNA==', + 'name': 'watchbot', + 'viewerHasStarred': viewerHasStarred + }, + ] + .map((map) => + withTypenames ? {'__typename': 'Repository', ...map} : map) + .toList(), + }, + }, + }; + } + + const String addStar = r''' + mutation AddStar($starrableId: ID!) { + action: addStar(input: {starrableId: $starrableId}) { + starrable { + viewerHasStarred + } + } + } + '''; + + late MockLink link; + late GraphQLClient client; + + group('simple json', () { + setUp(() { + link = MockLink(); + + client = GraphQLClient( + cache: getTestCache(), + link: link, + ); + }); + + group('query', () { + test('successful response', () async { + final _options = QueryOptions( + document: parseString(readRepositories), + variables: { + 'nRepositories': 42, + }, + ); + final repoData = readRepositoryData(withTypenames: true); + + when( + link.request(any), + ).thenAnswer( + (_) => Stream.fromIterable([ + Response( + data: repoData, + context: Context().withEntry( + HttpLinkResponseContext( + statusCode: 200, + headers: {'foo': 'bar'}, + ), + ), + ), + ]), + ); + + final QueryResult r = await client.query(_options); + + verify( + link.request( + Request( + operation: Operation( + document: parseString(readRepositories), + //operationName: 'ReadRepositories', + ), + variables: { + 'nRepositories': 42, + }, + context: Context(), + ), + ), + ); + + expect(r.exception, isNull); + expect(r.data, equals(repoData)); + + expect( + r.context.entry()!.statusCode, + equals(200), + ); + expect( + r.context.entry()!.headers['foo'], + equals('bar'), + ); + }); + test('successful response with parser', () async { + final ResultParserFn> parserFn = (data) { + return data['viewer']['repositories']['nodes'] + .map((node) => node['name'] as String) + .toList(); + }; + final _options = QueryOptions( + document: parseString(readRepositories), + variables: { + 'nRepositories': 42, + }, + parserFn: parserFn, + ); + final repoData = readRepositoryData(withTypenames: true); + + when( + link.request(any), + ).thenAnswer( + (_) => Stream.fromIterable([ + Response( + data: repoData, + context: Context().withEntry( + HttpLinkResponseContext( + statusCode: 200, + headers: {'foo': 'bar'}, + ), + ), + ), + ]), + ); + + final QueryResult> r = await client.query(_options); + + verify( + link.request( + Request( + operation: Operation( + document: parseString(readRepositories), + //operationName: 'ReadRepositories', + ), + variables: { + 'nRepositories': 42, + }, + context: Context(), + ), + ), + ); + + expect(r.exception, isNull); + expect(r.data, equals(repoData)); + + List? parsedData = r.parsedData; + expect( + parsedData, + equals([ + 'pq', + 'go-evercookie', + 'watchbot', + ])); + + expect( + r.context.entry()!.statusCode, + equals(200), + ); + expect( + r.context.entry()!.headers['foo'], + equals('bar'), + ); + }); + + test('successful response without normalization', () async { + final readUnidentifiedRepositories = parseString(r''' + query ReadRepositories($nRepositories: Int!) { + viewer { + repositories(last: $nRepositories) { + nodes { + name + viewerHasStarred + } + } + } + } + '''); + final repoData = readRepositoryData( + withTypenames: false, + withIds: false, + ); + + final _options = QueryOptions( + document: readUnidentifiedRepositories, + variables: {'nRepositories': 42}, + ); + + when( + link.request(any), + ).thenAnswer( + (_) => Stream.fromIterable([ + Response(data: repoData), + ]), + ); + + final QueryResult r = await client.query(_options); + + verify(link.request(_options.asRequest)); + expect(r.data, equals(repoData)); + }); + test('correct consecutive responses', () async { + final _options = QueryOptions( + fetchPolicy: FetchPolicy.networkOnly, + document: parseString(readRepositories), + variables: { + 'nRepositories': 42, + }, + ); + final firstData = + readRepositoryData(withTypenames: true, viewerHasStarred: false); + final secondData = + readRepositoryData(withTypenames: true, viewerHasStarred: true); + + final resp = (d) => Stream.fromIterable([ + Response( + data: d, + context: Context().withEntry( + HttpLinkResponseContext( + statusCode: 200, + headers: {'foo': 'bar'}, + ), + ), + ) + ]); + + when(link.request(any)).thenAnswer((_) => resp(firstData)); + QueryResult r = await client.query(_options); + expect(r.exception, isNull); + expect(r.data, equals(firstData)); + + when(link.request(any)).thenAnswer((_) => resp(secondData)); + r = await client.query(_options); + expect(r.exception, isNull); + expect(r.data, equals(secondData)); + }); + + test('malformed server response', () async { + final _options = QueryOptions( + document: parseString(readRepositories), + variables: {'nRepositories': 42}, + ); + final malformedRepoData = { + 'viewer': { + // maybe the server doesn't validate response structures properly, + // or a user generates a response on the client, etc + 'repos': readRepositoryData()['viewer']!['repositories'] + }, + }; + + when( + link.request(any), + ).thenAnswer( + (_) => Stream.fromIterable([ + Response(data: malformedRepoData), + ]), + ); + + final QueryResult r = await client.query(_options); + + expect(r.data, equals(malformedRepoData), + reason: 'Malformed data should be passed along with errors'); + + throwsA(isA().having( + (e) => e.path, + 'An accurate path to the first missing subfield', + ['a', 'b', '__typename'], + )); + }); + + test('failed query because of an exception with null string', () async { + final e = Exception(); + + when( + link.request(any), + ).thenAnswer( + (_) => Stream.fromFuture(Future.error(e)), + ); + + final QueryResult r = await client.query( + WatchQueryOptions( + document: parseString(readRepositories), + ), + ); + + expect( + r.exception!.linkException!.originalException, + e, + ); + }); + + test('failed query because of an exception with empty string', () async { + final e = Exception(''); + + when( + link.request(any), + ).thenAnswer( + (_) => Stream.fromFuture(Future.error(e)), + ); + + final QueryResult r = await client.query( + WatchQueryOptions( + document: parseString(readRepositories), + ), + ); + + expect( + r.exception!.linkException!.originalException, + e, + ); + }); +// test('failed query because of because of error response', {}); +// test('failed query because of because of invalid response', () { +// String responseBody = +// '{\"message\":\"Bad credentials\",\"documentation_url\":\"https://developer.github.com/v4\"}'; +// int responseCode = 401; +// }); +// test('partially success query with some errors', {}); + }); + group('mutation', () { + test('query stream notified', () async { + final initialQueryResponse = Response( + data: { + 'single': { + 'id': '1', + '__typename': 'Single', + 'name': 'initialQueryName', + }, + }, + ); + when( + link.request(any), + ).thenAnswer( + (_) => Stream.fromIterable( + [initialQueryResponse], + ), + ); + + final ObservableQuery observable = client.watchQuery( + WatchQueryOptions( + document: parseString(readSingle), + eagerlyFetchResults: true, + variables: {'id': '1'}, + ), + ); + + expect( + observable.stream, + emitsInOrder( + [ + // we have no optimistic result + isA().having( + (result) => result.isLoading, + 'loading result', + true, + ), + isA().having( + (result) => result.data!['single']['name'], + 'initial query result', + 'initialQueryName', + ), + isA().having( + (result) => result.data!['single']['name'], + 'result caused by mutation', + 'newNameFromMutation', + ) + ], + ), + ); + + final mutationResponseWithNewName = Response( + data: { + 'updateSingle': { + 'id': '1', + '__typename': 'Single', + 'name': 'newNameFromMutation', + }, + }, + ); + when( + link.request(any), + ).thenAnswer( + (_) => Stream.fromIterable( + [mutationResponseWithNewName], + ), + ); + + final variables = {'id': '1', 'name': 'newNameFromMutation'}; + + final QueryResult response = await client.mutate(MutationOptions( + document: parseString(writeSingle), variables: variables)); + + expect(response.data!['updateSingle']['name'], variables['name']); + }); + + test('successful mutation', () async { + final MutationOptions _options = MutationOptions( + document: parseString(addStar), + ); + + when( + link.request(any), + ).thenAnswer( + (_) => Stream.fromIterable( + [ + Response( + data: { + 'action': { + 'starrable': { + 'viewerHasStarred': true, + }, + }, + }, + ), + ], + ), + ); + + final QueryResult response = await client.mutate(_options); + + verify( + link.request( + Request( + operation: Operation( + document: parseString(addStar), + //operationName: 'AddStar', + ), + variables: {}, + context: Context(), + ), + ), + ); + + expect(response.exception, isNull); + expect(response.data, isNotNull); + final bool? viewerHasStarred = + response.data!['action']['starrable']['viewerHasStarred'] as bool?; + expect(viewerHasStarred, true); + }); + test('successful mutation with parser', () async { + final ResultParserFn resultParser = + (data) => data['action']['starrable']['viewerHasStarred'] as bool; + final MutationOptions _options = MutationOptions( + document: parseString(addStar), + parserFn: resultParser, + ); + + when( + link.request(any), + ).thenAnswer( + (_) => Stream.fromIterable( + [ + Response( + data: { + 'action': { + 'starrable': { + 'viewerHasStarred': true, + }, + }, + }, + ), + ], + ), + ); + + final QueryResult response = await client.mutate(_options); + + verify( + link.request( + Request( + operation: Operation( + document: parseString(addStar), + //operationName: 'AddStar', + ), + variables: {}, + context: Context(), + ), + ), + ); + final bool parsedResult = response.parsedData; + expect(parsedResult, isTrue); + expect(response.exception, isNull); + expect(response.data, isNotNull); + final bool? viewerHasStarred = + response.data!['action']['starrable']['viewerHasStarred'] as bool?; + expect(viewerHasStarred, true); + }); + + test('successful mutation through watchQuery', () async { + final _options = MutationOptions( + document: parseString(addStar), + variables: {}, + ); + + when( + link.request(any), + ).thenAnswer( + (_) => Stream.fromIterable( + [ + Response( + data: { + 'action': { + 'starrable': { + 'viewerHasStarred': true, + }, + }, + }, + ), + ], + ), + ); + + final observableQuery = client.watchQuery(WatchQueryOptions( + document: _options.document, + variables: _options.variables, + fetchResults: false, + )); + + final result = await observableQuery.fetchResults().networkResult!; + + verify( + link.request( + Request( + operation: Operation( + document: parseString(addStar), + //operationName: 'AddStar', + ), + variables: {}, + context: Context(), + ), + ), + ); + + expect(result.hasException, isFalse); + expect(result.data, isNotNull); + final bool? viewerHasStarred = + result.data!['action']['starrable']['viewerHasStarred'] as bool?; + expect(viewerHasStarred, true); + }); + }); + + group('subscription', () { + test('results', () async { + final responses = [ + { + 'id': '1', + 'name': 'first', + }, + { + 'id': '2', + 'name': 'second', + }, + ].map((item) => Response( + data: { + 'item': { + '__typename': 'Item', + ...item, + }, + }, + )); + when( + link.request(any), + ).thenAnswer( + (_) => Stream.fromIterable(responses), + ); + + final stream = client.subscribe( + SubscriptionOptions( + document: parseString( + r''' + subscription { + item { + id + name + } + } + ''', + ), + ), + ); + + expect( + stream, + emitsInOrder( + [ + isA().having( + (result) => result.data!['item']['name'], + 'first subscription item', + 'first', + ), + isA().having( + (result) => result.data!['item']['name'], + 'second subscription item', + 'second', + ) + ], + ), + ); + }); + test('parses results', () async { + final responses = [ + { + 'id': '1', + 'name': 'first', + }, + { + 'id': '2', + 'name': 'second', + }, + ].map((item) => Response( + data: { + 'item': { + '__typename': 'Item', + ...item, + }, + }, + )); + when( + link.request(any), + ).thenAnswer( + (_) => Stream.fromIterable(responses), + ); + + final ResultParserFn parserFn = + (data) => data['item']['name'] as String; + ; + + final stream = client.subscribe( + SubscriptionOptions( + parserFn: parserFn, + document: parseString( + r''' + subscription { + item { + id + name + } + } + ''', + ), + ), + ); + + expect( + stream, + emitsInOrder(['first', 'second'] + .map((e) => isA>().having((result) { + final String? parsed = result.parsedData; + return parsed; + }, "Parsed item", e))), + ); + }); + + test('wraps stream exceptions', () async { + final ex = ServerException( + parsedResponse: null, + originalException: Error(), + ); + + when( + link.request(any), + ).thenAnswer( + (_) => Stream.error(ex), + ); + + final stream = client.subscribe( + SubscriptionOptions( + document: parseString( + r''' + subscription { + item { + id + name + } + } + ''', + ), + ), + ); + + expect( + stream, + emitsInOrder( + [ + isA().having( + (result) => result.exception!.linkException, + 'wrapped exception', + ex, + ), + ], + ), + ); + }); + test('wraps all exceptions from outside of stream', () async { + final err = Error(); + + when( + link.request(any), + ).thenThrow(err); + + final stream = client.subscribe( + SubscriptionOptions( + document: parseString( + r''' + subscription { + item { + id + name + } + } + ''', + ), + ), + ); + + expect( + stream, + emitsInOrder( + [ + isA().having( + (result) => result.exception!.linkException!.originalException, + 'wrapped exception', + err, + ), + ], + ), + ); + }); + }); + }); + + group('direct cache access', () { + setUp(() { + link = MockLink(); + + client = GraphQLClient( + cache: getTestCache(), + link: link, + ); + }); + + test('all methods with exposition', () { + /// entity identifiers for normalization + final idFields = {'__typename': 'MyType', 'id': 1}; + + /// The direct cache API uses `gql_link` Requests directly + /// These can also be obtained via `options.asRequest` from any `Options` object, + /// or via `Operation(document: gql(...)).asRequest()` + final queryRequest = Request( + operation: Operation( + document: gql( + r'''{ + someField { + id, + myField + } + }''', + ), + ), + ); + + final queryData = { + '__typename': 'Query', + 'someField': { + ...idFields, + 'myField': 'originalValue', + }, + }; + + /// `broadcast: true` (the default) would rebroadcast cache updates to all safe instances of `ObservableQuery` + /// **NOTE**: only `GraphQLClient` can immediately call for a query rebroadcast. if you request a rebroadcast directly + /// from the cache, it still has to wait for the client to check in on it + client.writeQuery(queryRequest, data: queryData, broadcast: false); + + /// `optimistic: true` (the default) integrates optimistic data + /// written to the cache into your read. + expect( + client.readQuery(queryRequest, optimistic: false), equals(queryData)); + + /// While fragments are never executed themselves, we provide a `gql_link`-like API for consistency. + /// These can also be obtained via `Fragment(document: gql(...)).asRequest()`. + final fragmentRequest = FragmentRequest( + fragment: Fragment( + document: gql( + r''' + fragment mySmallSubset on MyType { + myField, + someNewField + } + ''', + ), + ), + idFields: idFields, + ); + + /// We've specified `idFields` and are only editing a subset of the data + final fragmentData = { + 'myField': 'updatedValue', + 'someNewField': [ + {'newData': false} + ], + }; + + /// We didn't disable `broadcast`, so all instances of `ObservableQuery` will be notified of any changes + client.writeFragment(fragmentRequest, data: fragmentData); + + /// __typename is automatically included in all reads + expect( + client.readFragment(fragmentRequest), + equals({ + '__typename': 'MyType', + ...fragmentData, + }), + ); + + final updatedQueryData = { + '__typename': 'Query', + 'someField': { + ...idFields, + 'myField': 'updatedValue', + }, + }; + + /// `myField` is updated, but we don't have `someNewField`, as expected. + expect(client.readQuery(queryRequest), equals(updatedQueryData)); + }); + }); +} diff --git a/packages/fl_query/test/helpers.dart b/packages/fl_query/test/helpers.dart new file mode 100644 index 0000000..6eb7e5c --- /dev/null +++ b/packages/fl_query/test/helpers.dart @@ -0,0 +1,34 @@ +import 'dart:async'; + +import 'package:fl_query/fl_query.dart'; + +import 'package:mockito/mockito.dart'; + +class MockLink extends Mock implements Link { + @override + Stream request(Request? request, [NextLink? forward]) => + super.noSuchMethod( + Invocation.method(#request, [request, forward]), + returnValue: Stream.fromIterable( + [], + ), + ) as Stream; +} + +const debuggingUnexpectedTestFailures = false; + +overridePrint(testFn(List log)) => () { + final log = []; + final spec = ZoneSpecification(print: (_, __, ___, String msg) { + log.add(msg); + }); + return Zone.current.fork(specification: spec).run(() => testFn(log)); + }; + +class TestCache extends QueryCache { + bool get returnPartialData => debuggingUnexpectedTestFailures; + + get partialDataPolicy => PartialDataCachePolicy.reject; +} + +QueryCache getTestCache() => TestCache(); diff --git a/packages/fl_query/test/mock_server/ws_echo_server.dart b/packages/fl_query/test/mock_server/ws_echo_server.dart new file mode 100644 index 0000000..45c5360 --- /dev/null +++ b/packages/fl_query/test/mock_server/ws_echo_server.dart @@ -0,0 +1,26 @@ +/// Web Socket echo server +/// to run the test and cover the web socket test +/// +/// author: https://github.com/vincenzopalazzo +import 'dart:io'; + +const String forceDisconnectCommand = '___force_disconnect___'; + +/// Main function to create and run the echo server over the web socket. +Future runWebSocketServer( + {String host = "127.0.0.1", int port = 5600}) async { + HttpServer server = await HttpServer.bind(host, port); + server.transform(WebSocketTransformer()).listen(onWebSocketData); + return "ws://$host:$port"; +} + +/// Handle event received on server. +void onWebSocketData(WebSocket client) { + client.listen((data) async { + if (data != null && data.toString().contains(forceDisconnectCommand)) { + client.close(WebSocketStatus.normalClosure, 'shutting down'); + } else { + client.add(data); + } + }); +} diff --git a/packages/fl_query/test/query_options_test.dart b/packages/fl_query/test/query_options_test.dart new file mode 100644 index 0000000..90e1dcd --- /dev/null +++ b/packages/fl_query/test/query_options_test.dart @@ -0,0 +1,51 @@ +import 'package:gql/ast.dart'; +import 'package:gql/language.dart'; +import 'package:fl_query/fl_query.dart'; +import 'package:test/test.dart'; + +void main() { + group('query options', () { + group('type getters', () { + test('on QueryOptions', () { + final options = QueryOptions( + document: parseString('query { bar }'), + ); + expect(options.type, equals(OperationType.query)); + expect(options.isQuery, equals(true)); + }); + test('on MutationOptions', () { + final options = MutationOptions( + document: parseString('mutation { bar }'), + ); + expect(options.type, equals(OperationType.mutation)); + expect(options.isMutation, equals(true)); + }); + test('on SubscriptionOptions', () { + final options = SubscriptionOptions( + document: parseString('subscription { bar }'), + ); + expect(options.type, equals(OperationType.subscription)); + expect(options.isSubscription, equals(true)); + }); + }); + group('gql integration', () { + test('Options.asRequest', () { + final options = QueryOptions( + document: parseString('query { bar }'), + variables: { + 'foo': { + 'biz': 'bar', + 'bam': [1] + } + }, + context: Context.fromList([ + HttpLinkHeaders(headers: {'my': 'header'}) + ])); + final req = options.asRequest; + expect(options.document, equals(req.operation.document)); + expect(options.variables, equals(req.variables)); + expect(options.context, equals(req.context)); + }); + }); + }); +} diff --git a/packages/fl_query/test/sample_upload.jpg b/packages/fl_query/test/sample_upload.jpg new file mode 100644 index 0000000..7ee0f5c Binary files /dev/null and b/packages/fl_query/test/sample_upload.jpg differ diff --git a/packages/fl_query/test/sample_upload.mov b/packages/fl_query/test/sample_upload.mov new file mode 100644 index 0000000..4012485 Binary files /dev/null and b/packages/fl_query/test/sample_upload.mov differ diff --git a/packages/fl_query/test/test_helpers.dart b/packages/fl_query/test/test_helpers.dart new file mode 100644 index 0000000..da19da0 --- /dev/null +++ b/packages/fl_query/test/test_helpers.dart @@ -0,0 +1,70 @@ +import 'dart:collection'; + +import 'package:test/test.dart'; + +import 'package:fl_query/src/utilities/helpers.dart'; + +void main() { + group('deeplyMergeLeft', () { + test('shallow', () { + expect( + deeplyMergeLeft([ + {'keyA': 'a1'}, + {'keyA': 'a2', 'keyB': 'b2'}, + {'keyB': 'b3'} + ]), + equals({'keyA': 'a2', 'keyB': 'b3'}), + ); + }); + + test('deep', () { + expect( + deeplyMergeLeft([ + { + 'keyA': 'a1', + 'keyB': { + 'keyC': {'keyD': 'd1'} + } + }, + { + 'keyA': 'a2', + 'keyB': { + 'keyC': {'keyD': 'd2'} + } + }, + ]), + equals({ + 'keyA': 'a2', + 'keyB': { + 'keyC': {'keyD': 'd2'} + } + }), + ); + }); + + test('deep hashmaps are merged', () { + expect( + deeplyMergeLeft([ + HashMap.from({ + 'keyA': 'a1', + 'keyB': { + 'keyC': HashMap.from({'keyD': 'd1'}) + } + }), + { + 'keyA': 'a2', + 'keyB': { + 'keyC': HashMap.from({'keyD': 'd2'}) + } + }, + ]), + equals({ + 'keyA': 'a2', + 'keyB': { + 'keyC': {'keyD': 'd2'} + } + }), + ); + }); + }); +} diff --git a/packages/fl_query/test/websocket_test.dart b/packages/fl_query/test/websocket_test.dart new file mode 100644 index 0000000..21f22e9 --- /dev/null +++ b/packages/fl_query/test/websocket_test.dart @@ -0,0 +1,431 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:test/test.dart'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:gql/language.dart'; +import 'package:fl_query/fl_query.dart'; + +import './helpers.dart'; +import './mock_server/ws_echo_server.dart'; +import 'mock_server/ws_echo_server.dart'; + +SocketClient getTestClient( + {required String wsUrl, + StreamController? controller, + bool autoReconnect = true, + Map? customHeaders, + Duration delayBetweenReconnectionAttempts = + const Duration(milliseconds: 1)}) => + SocketClient( + wsUrl, + config: SocketClientConfig( + autoReconnect: autoReconnect, + headers: customHeaders, + delayBetweenReconnectionAttempts: delayBetweenReconnectionAttempts, + ), + randomBytesForUuid: Uint8List.fromList( + [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16], + ), + ); + +Future main() async { + String wsUrl = await runWebSocketServer(); + group('InitOperation', () { + test('null payload', () { + // ignore: deprecated_member_use_from_same_package + final operation = InitOperation(null); + expect(operation.toJson(), {'type': 'connection_init'}); + }); + test('simple payload', () { + // ignore: deprecated_member_use_from_same_package + final operation = InitOperation(42); + expect(operation.toJson(), {'type': 'connection_init', 'payload': 42}); + }); + test('complex payload', () { + // ignore: deprecated_member_use_from_same_package + final operation = InitOperation({ + 'value': 42, + 'nested': { + 'number': [3, 7], + 'string': ['foo', 'bar'] + } + }); + expect(operation.toJson(), { + 'type': 'connection_init', + 'payload': { + 'value': 42, + 'nested': { + 'number': [3, 7], + 'string': ['foo', 'bar'] + } + } + }); + }); + }); + + group('SocketClient without payload', () { + late SocketClient socketClient; + StreamController controller; + final expectedMessage = r'{' + r'"type":"start","id":"01020304-0506-4708-890a-0b0c0d0e0f10",' + r'"payload":{"operationName":null,"variables":{},"query":"subscription {\n \n}"}' + r'}'; + setUp(overridePrint((log) { + controller = StreamController(sync: true); + socketClient = getTestClient(controller: controller, wsUrl: wsUrl); + })); + tearDown(overridePrint( + (log) => socketClient.dispose(), + )); + test('connection', () async { + await expectLater( + socketClient.connectionState.asBroadcastStream(), + emitsInOrder( + [ + SocketConnectionState.connecting, + SocketConnectionState.connected, + ], + ), + ); + }); + test('disconnect via dispose', () async { + // First wait for connection to complete + await expectLater( + socketClient.connectionState.asBroadcastStream(), + emitsInOrder( + [ + SocketConnectionState.connecting, + SocketConnectionState.connected, + ], + ), + ); + + // We need to begin waiting on the connectionState + // before we issue the command to disconnect; otherwise + // it can reconnect so fast that it will be reconnected + // by the time that the expectLater check is initiated. + await overridePrint((_) async { + Timer(const Duration(milliseconds: 20), () async { + await socketClient.dispose(); + }); + })(); + // The connectionState BehaviorController emits the current state + // to any new listener, so we expect it to start in the connected + // state and transition to notConnected because of dispose. + await expectLater( + socketClient.connectionState, + emitsInOrder([ + SocketConnectionState.connected, + SocketConnectionState.notConnected, + ]), + ); + + // Have to wait for socket close to be fully processed after we reach + // the notConnected state, including updating channel with close code. + await Future.delayed(const Duration(milliseconds: 20)); + + // The websocket should be in a fully closed state at this point, + // we should have a confirmed close code in the channel. + expect(socketClient.socketChannel, isNotNull); + expect(socketClient.socketChannel!.closeCode, isNotNull); + }); + test('subscription data', () async { + final payload = Request( + operation: Operation(document: parseString('subscription {}')), + ); + final waitForConnection = true; + final subscriptionDataStream = + socketClient.subscribe(payload, waitForConnection); + await socketClient.connectionState + .where((state) => state == SocketConnectionState.connected) + .first; + + // ignore: unawaited_futures + socketClient.socketChannel!.stream + .where((message) => message == expectedMessage) + .first + .then((_) { + socketClient.socketChannel!.sink.add(jsonEncode({ + 'type': 'data', + 'id': '01020304-0506-4708-890a-0b0c0d0e0f10', + 'payload': { + 'data': {'foo': 'bar'}, + 'errors': [ + {'message': 'error and data can coexist'} + ] + } + })); + }); + + await expectLater( + subscriptionDataStream, + emits( + // todo should ids be included in response context? probably '01020304-0506-4708-890a-0b0c0d0e0f10' + Response( + data: {'foo': 'bar'}, + errors: [ + GraphQLError(message: 'error and data can coexist'), + ], + context: Context().withEntry(ResponseExtensions(null)), + ), + ), + ); + }); + test('resubscribe', () async { + final payload = Request( + operation: Operation(document: gql('subscription {}')), + ); + final waitForConnection = true; + final subscriptionDataStream = + socketClient.subscribe(payload, waitForConnection); + + await expectLater( + socketClient.connectionState, + emitsInOrder([ + SocketConnectionState.connecting, + SocketConnectionState.connected, + ]), + ); + + await overridePrint((_) async { + socketClient.onConnectionLost(); + })(); + + await expectLater( + socketClient.connectionState, + emitsInOrder([ + SocketConnectionState.notConnected, + SocketConnectionState.connecting, + SocketConnectionState.connected, + ]), + ); + + // ignore: unawaited_futures + socketClient.socketChannel!.stream + .where((message) => message == expectedMessage) + .first + .then((_) { + socketClient.socketChannel!.sink.add(jsonEncode({ + 'type': 'data', + 'id': '01020304-0506-4708-890a-0b0c0d0e0f10', + 'payload': { + 'data': {'foo': 'bar'}, + 'errors': [ + {'message': 'error and data can coexist'} + ] + } + })); + }); + + await expectLater( + subscriptionDataStream, + emits( + // todo should ids be included in response context? probably '01020304-0506-4708-890a-0b0c0d0e0f10' + Response( + data: {'foo': 'bar'}, + errors: [ + GraphQLError(message: 'error and data can coexist'), + ], + context: Context().withEntry(ResponseExtensions(null)), + ), + ), + ); + }); + test('resubscribe after server disconnect', () async { + final payload = Request( + operation: Operation(document: gql('subscription {}')), + ); + final waitForConnection = true; + final subscriptionDataStream = + socketClient.subscribe(payload, waitForConnection); + + await expectLater( + socketClient.connectionState, + emitsInOrder([ + SocketConnectionState.connecting, + SocketConnectionState.connected, + ]), + ); + + // We need to begin waiting on the connectionState + // before we issue the command to disconnect; otherwise + // it can reconnect so fast that it will be reconnected + // by the time that the expectLater check is initiated. + Timer(const Duration(milliseconds: 20), () async { + socketClient.socketChannel!.sink.add(forceDisconnectCommand); + }); + // The connectionState BehaviorController emits the current state + // to any new listener, so we expect it to start in the connected + // state, transition to notConnected, and then reconnect after that. + await expectLater( + socketClient.connectionState, + emitsInOrder([ + SocketConnectionState.connected, + SocketConnectionState.notConnected, + SocketConnectionState.connecting, + SocketConnectionState.connected, + ]), + ); + + // ignore: unawaited_futures + socketClient.socketChannel!.stream + .where((message) => message == expectedMessage) + .first + .then((_) { + socketClient.socketChannel!.sink.add(jsonEncode({ + 'type': 'data', + 'id': '01020304-0506-4708-890a-0b0c0d0e0f10', + 'payload': { + 'data': {'foo': 'bar'}, + 'errors': [ + {'message': 'error and data can coexist'} + ] + } + })); + }); + + await expectLater( + subscriptionDataStream, + emits( + // todo should ids be included in response context? probably '01020304-0506-4708-890a-0b0c0d0e0f10' + Response( + data: {'foo': 'bar'}, + errors: [ + GraphQLError(message: 'error and data can coexist'), + ], + context: Context().withEntry(ResponseExtensions(null)), + ), + ), + ); + }); + }, tags: "integration"); + + group('SocketClient without autoReconnect', () { + late SocketClient socketClient; + StreamController controller; + setUp(overridePrint((log) { + controller = StreamController(sync: true); + socketClient = getTestClient( + controller: controller, wsUrl: wsUrl, autoReconnect: false); + })); + tearDown(overridePrint( + (log) => socketClient.dispose(), + )); + test('server disconnect', () async { + final payload = Request( + operation: Operation(document: gql('subscription {}')), + ); + final waitForConnection = true; + socketClient.subscribe(payload, waitForConnection); + + await expectLater( + socketClient.connectionState, + emitsInOrder([ + SocketConnectionState.connecting, + SocketConnectionState.connected, + ]), + ); + + Timer(const Duration(milliseconds: 20), () async { + socketClient.socketChannel!.sink.add(forceDisconnectCommand); + }); + // Same strategy as elsewhere, start expecting the state on the + // stream before the disconnect actually happens... + await expectLater( + socketClient.connectionState, + emitsInOrder([ + SocketConnectionState.connected, + SocketConnectionState.notConnected, + ]), + ); + + expect( + socketClient.socketChannel!.closeCode, WebSocketStatus.normalClosure); + }); + }, tags: "integration"); + + group('SocketClient with const payload', () { + late SocketClient socketClient; + const initPayload = {'token': 'mytoken'}; + + setUp(overridePrint((log) { + socketClient = SocketClient( + wsUrl, + config: SocketClientConfig(initialPayload: () => initPayload), + ); + })); + + tearDown(overridePrint( + (log) => expectLater( + socketClient.dispose().timeout(Duration(seconds: 1)), + completion(null), + ), + )); + + test('connection', () async { + await socketClient.connectionState + .where((state) => state == SocketConnectionState.connected) + .first; + + await expectLater( + socketClient.socketChannel!.stream.map((s) { + return jsonDecode(s)['payload']; + }), + emits(initPayload)); + }); + }); + + group('SocketClient with future payload', () { + late SocketClient socketClient; + const initPayload = {'token': 'mytoken'}; + + setUp(overridePrint((log) { + socketClient = SocketClient( + wsUrl, + config: SocketClientConfig( + initialPayload: () async { + await Future.delayed(Duration(seconds: 3)); + return initPayload; + }, + ), + ); + })); + + tearDown(overridePrint((log) async { + await socketClient.dispose(); + })); + + test('connection', () async { + await socketClient.connectionState + .where((state) => state == SocketConnectionState.connected) + .first; + + await expectLater( + socketClient.socketChannel!.stream.map((s) { + return jsonDecode(s)['payload']; + }), + emits(initPayload), + ); + }); + + /* + FIXME: Testing the correct header in the request + group('SocketClient with custom headers with const payload', () { + const customHeaders = {'myHeader': 'myHeader'}; + + setUp(overridePrint((log) { + socketClient = getTestClient(wsUrl: wsUrl, customHeaders: customHeaders); + })); + + test('check header', () async { + await socketClient.connectionState + .where((state) => state == SocketConnectionState.notConnected) + .first; + }); + }); + */ + }); +}