initial commit with cache support & example

This commit is contained in:
Kingkor Roy Tirtho
2022-02-07 00:11:15 +06:00
commit c7169b04c7
65 changed files with 7155 additions and 0 deletions
+29
View File
@@ -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/
+10
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
## 0.0.1
* TODO: Describe initial release.
+1
View File
@@ -0,0 +1 @@
TODO: Add your license here.
+39
View File
@@ -0,0 +1,39 @@
<!--
This README describes the package. If you publish this package to pub.dev,
this README's contents appear on the landing page for your package.
For information about how to write a good package README, see the guide for
[writing package pages](https://dart.dev/guides/libraries/writing-package-pages).
For general information about developing packages, see the Dart guide for
[creating packages](https://dart.dev/guides/libraries/create-library-packages)
and the Flutter guide for
[developing packages and plugins](https://flutter.dev/developing-packages).
-->
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.
+4
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
# Files and directories created by pub.
.dart_tool/
.packages
# Conventional directory for build output.
build/
+3
View File
@@ -0,0 +1,3 @@
## 1.0.0
- Initial version.
+1
View File
@@ -0,0 +1 @@
A simple command-line application.
@@ -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
+146
View File
@@ -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<String> 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);
}
}
+163
View File
@@ -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"
+16
View File
@@ -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
+13
View File
@@ -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;
@@ -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<String, Object?> 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<String, dynamic>? readQuery(
QueryKey queryKey, {
bool? optimistic = true,
}) {
return readNormalized(queryKey.key, optimistic: optimistic);
}
void writeQuery(
QueryKey queryKey, {
required Map<String, dynamic> data,
bool? broadcast = true,
}) {
writeNormalized(queryKey.key, data);
if (broadcast ?? true) {
broadcastRequested = true;
}
}
}
@@ -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<String, dynamic> 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<String, dynamic> data = HashMap<String, dynamic>();
@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<String, Object>) {
final existing = data[dataId];
data[dataId] =
existing != null ? deeplyMergeLeft([existing, value]) : value;
} else {
data[dataId] = value;
}
}
OptimisticPatch asPatch(String id) => OptimisticPatch(id, data);
}
+173
View File
@@ -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<OptimisticPatch> 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<String, Object> && patchData is Map<String, Object>) {
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<String, Object>) {
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<String> 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;
}
}
+29
View File
@@ -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<String, dynamic>? 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<String, dynamic> data,
bool? broadcast,
});
}
+68
View File
@@ -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<HiveStore> 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<String, dynamic>? get(String dataId) {
final result = box.get(dataId);
if (result == null) return null;
return Map.from(result);
}
@override
void put(String dataId, Map<String, dynamic>? value) {
box.put(dataId, value);
}
@override
void putAll(Map<String, Map<String, dynamic>> data) {
box.putAll(data);
}
@override
void delete(String dataId) {
box.delete(dataId);
}
@override
Map<String, Map<String, dynamic>> toMap() => Map.unmodifiable(box.toMap());
Future<void> reset() => box.clear();
}
+66
View File
@@ -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<String, dynamic>? get(String dataId);
/// Write [value] into this store under the key [dataId]
void put(String dataId, Map<String, dynamic>? value);
/// [put] all entries from [data] into the store
///
/// Functionally equivalent to `data.map(put);`
void putAll(Map<String, Map<String, dynamic>> 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<String, Map<String, dynamic>> 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<String, dynamic> data;
/// Creates an InMemoryStore inititalized with [data],
/// which defaults to an empty [HashMap]
InMemoryStore([
Map<String, dynamic>? data,
]) : data = data ?? HashMap<String, dynamic>();
@override
Map<String, dynamic>? get(String dataId) => data[dataId];
@override
void put(String dataId, Map<String, dynamic>? value) => data[dataId] = value;
@override
void putAll(Map<String, Map<String, dynamic>> entries) =>
data.addAll(entries);
@override
void delete(String dataId) => data.remove(dataId);
/// Return the underlying [data] as an unmodifiable [Map].
@override
Map<String, Map<String, dynamic>> toMap() => Map.unmodifiable(data);
void reset() => data.clear();
}
@@ -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<TParsed> extends MutableDataClass {
BaseOptions({
required this.document,
this.variables = const {},
this.operationName,
ResultParserFn<TParsed>? 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<String, dynamic> 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<TParsed> 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<Object?> get properties => [
document,
operationName,
variables,
optimisticResult,
policies,
context,
];
OperationType get type {
final definitions =
document.definitions.whereType<OperationDefinitionNode>().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;
}
@@ -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<Object?> get properties;
/// [properties] based deep equality check
bool equal(MutableDataClass other) =>
identical(this, other) ||
(runtimeType == other.runtimeType &&
const ListEquality<Object?>(
DeepCollectionEquality(),
).equals(
other.properties,
properties,
));
}
@@ -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<String, dynamic>? 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<String, dynamic>? 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<String, dynamic>? 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;
}
}
}
+6
View File
@@ -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';
@@ -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<QueryResult<TParsed>> fetchMoreImplementation<TParsed>(
FetchMoreOptions fetchMoreOptions, {
required QueryOptions<TParsed> originalOptions,
required QueryManager queryManager,
required QueryResult<TParsed> previousResult,
String? queryId,
}) async {
// fetch more and update
final document = (fetchMoreOptions.document ?? originalOptions.document);
final request = originalOptions.asRequest;
final combinedOptions = QueryOptions<TParsed>(
fetchPolicy: FetchPolicy.noCache,
errorPolicy: originalOptions.errorPolicy,
document: document,
variables: {
...originalOptions.variables,
...fetchMoreOptions.variables,
},
);
QueryResult<TParsed> 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;
}
@@ -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<void> Function(dynamic data);
typedef OnMutationUpdate = FutureOr<void> Function(
JSONDataProxy cache,
QueryResult? result,
);
typedef OnError = FutureOr<void> Function(OperationException? error);
class MutationOptions<TParsed> extends BaseOptions<TParsed> {
MutationOptions({
required DocumentNode document,
String? operationName,
Map<String, dynamic> variables = const {},
FetchPolicy? fetchPolicy,
ErrorPolicy? errorPolicy,
CacheRereadPolicy? cacheRereadPolicy,
Context? context,
Object? optimisticResult,
this.onCompleted,
this.update,
this.onError,
ResultParserFn<TParsed>? 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<Object?> 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<OnData> get callbacks =>
<OnData?>[onCompleted, update, onError].where(notNull).cast<OnData>();
// 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<void> updateOnData(QueryResult? result) {
if (result!.isOptimistic) {
return optimisticUpdate(result);
} else {
return widgetUpdate!(cache, result);
}
}
return updateOnData;
}
return null;
}
}
@@ -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<void> 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<TParsed> {
ObservableQuery({
required this.queryManager,
required this.options,
}) : queryId = queryManager.generateQueryId().toString() {
if (options.eagerlyFetchResults) {
_latestWasEagerlyFetched = true;
fetchResults();
}
controller = StreamController<QueryResult<TParsed>>.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<OnData> _onDataCallbacks = [];
/// call [queryManager.maybeRebroadcastQueries] after all other [_onDataCallbacks]
///
/// Automatically appended as an [OnData]
FutureOr<void> _maybeRebroadcast(QueryResult? result) =>
queryManager.maybeRebroadcastQueries(exclude: this);
/// The most recently seen result from this operation's stream
QueryResult<TParsed>? latestResult;
QueryLifecycle lifecycle = QueryLifecycle.unexecuted;
WatchQueryOptions<TParsed> options;
late StreamController<QueryResult<TParsed>> controller;
Stream<QueryResult<TParsed>> 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<QueryResult<TParsed>?> refetch() {
if (isRefetchSafe) {
addResult(QueryResult.loading(
data: latestResult?.data,
parserFn: options.parserFn,
));
return queryManager.refetchQuery<TParsed>(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<TParsed> fetchResults() {
final MultiSourceResult<TParsed> 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<QueryResult<TParsed>> 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<TParsed> 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<OnData> 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<String, dynamic> 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<QueryLifecycle> 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;
}
}
@@ -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<Object?>(
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<Object> _getChildren() => [
watchQuery,
query,
watchMutation,
mutate,
subscribe,
];
@override
bool operator ==(Object o) =>
identical(this, o) ||
(o is DefaultPolicies &&
const ListEquality<Object?>(
DeepCollectionEquality(),
).equals(
o._getChildren(),
_getChildren(),
));
@override
int get hashCode => const ListEquality<Object?>(
DeepCollectionEquality(),
).hash(
_getChildren(),
);
}
@@ -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<String> _key;
QueryKey(String key) : _key = [key];
QueryKey.fromList(List<String> 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")';
}
}
@@ -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<String, ObservableQuery> queries = <String, ObservableQuery>{};
/// prevents rebroadcasting for some intensive bulk operation like [refetchSafeQueries]
bool rebroadcastLocked = false;
ObservableQuery<TParsed> watchQuery<TParsed>(
WatchQueryOptions<TParsed> options) {
final ObservableQuery<TParsed> observableQuery = ObservableQuery<TParsed>(
queryManager: this,
options: options,
);
setQuery(observableQuery);
return observableQuery;
}
Stream<QueryResult<TParsed>> subscribe<TParsed>(
SubscriptionOptions<TParsed> 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<String, dynamic>?,
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<TParsed>? 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<QueryResult<TParsed>> query<TParsed>(
QueryOptions<TParsed> options) async {
final result = await fetchQuery(_oneOffOpId, options);
maybeRebroadcastQueries();
return result;
}
Future<QueryResult<TParsed>> mutate<TParsed>(
MutationOptions<TParsed> 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<QueryResult<TParsed>> fetchQuery<TParsed>(
String queryId,
BaseOptions<TParsed> options,
) async {
final MultiSourceResult<TParsed> 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<TParsed> fetchQueryAsMultiSourceResult<TParsed>(
String queryId,
BaseOptions<TParsed> options,
) {
// create a new request to execute
final request = options.asRequest;
final QueryResult<TParsed> 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<QueryResult<TParsed>> _resolveQueryOnNetwork<TParsed>(
Request request,
String queryId,
BaseOptions<TParsed> options,
) async {
Response response;
QueryResult<TParsed>? 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<TParsed> _resolveQueryEagerly<TParsed>(
Request request,
String queryId,
BaseOptions<TParsed> options,
) {
QueryResult<TParsed> 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<QueryResult<TParsed>?> refetchQuery<TParsed>(String queryId) {
final WatchQueryOptions<TParsed> options =
queries[queryId]!.options.copy() as WatchQueryOptions<TParsed>;
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<List<QueryResult?>> 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<TParsed>(
Request request,
String? queryId,
QueryResult<TParsed> queryResult,
) {
final ObservableQuery<TParsed>? observableQuery =
getQuery(queryId) as ObservableQuery<TParsed>?;
if (observableQuery != null && !observableQuery.controller.isClosed) {
observableQuery.addResult(queryResult);
}
}
/// Create an optimstic result for the query specified by `queryId`, if it exists
QueryResult<TParsed> _getOptimisticQueryResult<TParsed>(
Request request, {
required String queryId,
required Object? optimisticResult,
required BaseOptions<TParsed> options,
}) {
QueryResult<TParsed> queryResult = QueryResult(
source: QueryResultSource.optimisticResult,
parserFn: options.parserFn,
);
attemptCacheWriteFromClient(
request,
optimisticResult as Map<String, dynamic>?,
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<String, dynamic>? 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<TParsed> mapFetchResultToQueryResult<TParsed>(
Response response,
BaseOptions<TParsed> options, {
required QueryResultSource source,
}) {
List<GraphQLError>? 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<TParsed> _wrapFailure<TParsed>(
dynamic ex,
trace,
ResultParserFn<TParsed> parserFn,
) =>
QueryResult(
// we set the source to indicate where the source of failure
source: QueryResultSource.network,
exception: coalesceErrors(linkException: translateFailure(ex, trace)),
parserFn: parserFn,
);
@@ -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<TParsed> extends BaseOptions<TParsed> {
QueryOptions({
required DocumentNode document,
String? operationName,
Map<String, dynamic> variables = const {},
FetchPolicy? fetchPolicy,
ErrorPolicy? errorPolicy,
CacheRereadPolicy? cacheRereadPolicy,
Object? optimisticResult,
this.pollInterval,
Context? context,
ResultParserFn<TParsed>? 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<Object?> get properties => [...super.properties, pollInterval];
WatchQueryOptions<TParsed> 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<TParsed> extends BaseOptions<TParsed> {
SubscriptionOptions({
required DocumentNode document,
String? operationName,
Map<String, dynamic> variables = const {},
FetchPolicy? fetchPolicy,
ErrorPolicy? errorPolicy,
CacheRereadPolicy? cacheRereadPolicy,
Object? optimisticResult,
Context? context,
ResultParserFn<TParsed>? 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<TParsed> extends QueryOptions<TParsed> {
WatchQueryOptions({
required DocumentNode document,
String? operationName,
Map<String, dynamic> variables = const {},
FetchPolicy? fetchPolicy,
ErrorPolicy? errorPolicy,
CacheRereadPolicy? cacheRereadPolicy,
Object? optimisticResult,
Duration? pollInterval,
this.fetchResults = false,
this.carryForwardDataOnException = true,
bool? eagerlyFetchResults,
Context? context,
ResultParserFn<TParsed>? 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<Object?> get properties =>
[...super.properties, fetchResults, eagerlyFetchResults];
WatchQueryOptions<TParsed> copy() => WatchQueryOptions<TParsed>(
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<String, dynamic> variables = const {},
required UpdateQuery updateQuery,
}) =>
FetchMoreOptions(
document: document,
variables: variables,
updateQuery: partialUpdater(updateQuery),
);
DocumentNode? document;
Map<String, dynamic> 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<String, dynamic>? UpdateQuery(
Map<String, dynamic>? previousResultData,
Map<String, dynamic>? fetchMoreResultData,
);
@@ -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<TParsed> {
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<String, dynamic>? data,
required ResultParserFn<TParsed> parserFn,
}) =>
QueryResult(
data: data,
source: QueryResultSource.loading,
parserFn: parserFn,
);
factory QueryResult.optimistic({
Map<String, dynamic>? data,
required ResultParserFn<TParsed> 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<String, dynamic>? data;
/// Response context. Defaults to an empty `Context()`
Context context;
OperationException? exception;
ResultParserFn<TParsed> 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<TParsed> {
MultiSourceResult({
QueryResult<TParsed>? eagerResult,
this.networkResult,
required ResultParserFn<TParsed> parserFn,
}) : eagerResult = eagerResult ?? QueryResult.loading(parserFn: parserFn),
assert(
eagerResult!.source != QueryResultSource.network,
'An eager result cannot be gotten from the network',
);
QueryResult<TParsed> eagerResult;
FutureOr<QueryResult<TParsed>>? networkResult;
}
@@ -0,0 +1 @@
typedef ResultParserFn<TResult> = TResult Function(Map<String, dynamic> data);
@@ -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';
@@ -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);
}
@@ -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<String, dynamic>? 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<String, dynamic>? 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<String, dynamic> 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<GraphQLError> graphqlErrors = [];
// // generalize to include cache error, etc
// /// Errors encountered during execution such as network or cache errors
// LinkException? linkException;
// OperationException({
// this.linkException,
// Iterable<GraphQLError> 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<GraphQLError>? 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;
// }
@@ -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;
}
@@ -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);
}
@@ -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<TParsed> watchQuery<TParsed>(
WatchQueryOptions<TParsed> 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<TParsed> watchMutation<TParsed>(
WatchQueryOptions<TParsed> 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<dynamic> repositories =
/// result.data['viewer']['repositories']['nodes'] as List<dynamic>;
/// ```
/// {@end-tool}
Future<QueryResult<TParsed>> query<TParsed>(
QueryOptions<TParsed> 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<QueryResult<TParsed>> mutate<TParsed>(
MutationOptions<TParsed> 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<QueryResult<TParsed>> subscribe<TParsed>(
SubscriptionOptions<TParsed> 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<QueryResult<TParsed>> fetchMore<TParsed>(
FetchMoreOptions fetchMoreOptions, {
required QueryOptions<TParsed> originalOptions,
required QueryResult<TParsed> 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<List<QueryResult?>>? resetStore({bool refetchQueries = true}) {
cache.store.reset();
if (refetchQueries) {
return queryManager.refetchSafeQueries();
}
return null;
}
}
@@ -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<Request> Function(Request request);
typedef OnException = FutureOr<String> 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<String?> Function() getToken;
/// Header key to set to the result of [getToken]
final String headerKey;
static _RequestTransformer transform(
String headerKey,
FutureOr<String?> Function() getToken,
) =>
(Request request) async {
final token = await getToken();
return request.updateContextEntry<HttpLinkHeaders>(
(headers) => HttpLinkHeaders(
headers: <String, String>{
...headers?.headers ?? <String, String>{},
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<Response> request(
Request request, [
NextLink? forward,
]) async* {
final req = await requestTransformer(request);
yield* forward!(req);
}
}
@@ -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';
@@ -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';
@@ -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<dynamic> Function();
/// A definition for functions that returns a connected [WebSocketChannel]
typedef WebSocketConnect = FutureOr<WebSocketChannel> Function(
Uri uri,
Iterable<String>? 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<String, dynamic>? headers;
/// Function to define another connection without call directly
/// the connection function
FutureOr<WebSocketChannel> connect(
{required Uri uri,
Iterable<String>? protocols,
Map<String, dynamic>? 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<InitOperation> get initOperation async {
/// if (initialPayload is Function) {
/// final dynamic payload = await initialPayload();
/// return InitOperation(payload);
/// } else {
/// return InitOperation(initialPayload);
/// }
/// }
/// ```
final dynamic initialPayload;
Future<InitOperation> 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<String>? protocols;
final SocketClientConfig config;
final BehaviorSubject<SocketConnectionState> _connectionStateController =
BehaviorSubject<SocketConnectionState>();
final HashMap<String, SubscriptionListener> _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<GraphQLSocketMessage> get _messages => socketChannel!.messages;
StreamSubscription<ConnectionKeepAlive>? _keepAliveSubscription;
StreamSubscription<GraphQLSocketMessage>? _messageSubscription;
Map<String, dynamic> Function(Request) get serialize =>
config.serializer.serializeRequest;
Response Function(Map<String, dynamic>) get parse =>
config.parser.parseResponse;
void _disconnectOnKeepAliveTimeout(Stream<GraphQLSocketMessage> messages) {
_keepAliveSubscription = messages.whereType<ConnectionKeepAlive>().timeout(
config.inactivityTimeout!,
onTimeout: (EventSink<ConnectionKeepAlive> event) {
event.close();
unawaited(_closeSocketChannel());
},
).listen(null);
}
Future<void> _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<void> _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<void> 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<Future<dynamic>>().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<Response> subscribe(
final Request payload,
final bool waitForConnection,
) {
final String id = _uuid.v4(
options: {
'random': randomBytesForUuid,
},
).toString();
final StreamController<Response> response = StreamController<Response>();
StreamSubscription<SocketConnectionState>? sub;
final bool addTimeout =
!payload.isSubscription && config.queryAndMutationTimeout != null;
final onListen = () {
final Stream<SocketConnectionState> waitForConnectedStateWithoutTimeout =
(waitForConnection
? _connectionStateController
: _connectionStateController
.startWith(SocketConnectionState.connected))
.where((SocketConnectionState state) =>
state == SocketConnectionState.connected)
.take(1);
final Stream<SocketConnectionState> waitForConnectedState = addTimeout
? waitForConnectedStateWithoutTimeout.timeout(
config.queryAndMutationTimeout!,
onTimeout: (EventSink<SocketConnectionState> event) {
print('Connection timed out.');
response.addError(TimeoutException('Connection timed out.'));
event.close();
response.close();
},
)
: waitForConnectedStateWithoutTimeout;
sub = waitForConnectedState.listen((_) {
final Stream<GraphQLSocketMessage> 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<GraphQLSocketMessage> subscriptionComplete = addTimeout
? dataErrorComplete
.where((message) => message is SubscriptionComplete)
.take(1)
.timeout(
config.queryAndMutationTimeout!,
onTimeout: (EventSink<GraphQLSocketMessage> 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<SubscriptionData>()
.listen((message) => response.add(
parse(message.toJson()),
));
dataErrorComplete
.where((message) => message is SubscriptionError)
.cast<SubscriptionError>()
.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<SocketConnectionState> 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<GraphQLSocketMessage>? _messages;
/// Stream of messages from the endpoint parsed as GraphQLSocketMessages
Stream<GraphQLSocketMessage> get messages => _messages ??=
stream.map<GraphQLSocketMessage>(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);
}
@@ -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<Response> 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<void> dispose() async {
await _socketClient?.dispose();
_socketClient = null;
}
}
@@ -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<String, dynamic> 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<String, dynamic> toJson() => <String, dynamic>{"type": type};
static GraphQLSocketMessage parse(dynamic message) {
final Map<String, dynamic> map =
json.decode(message as String) as Map<String, dynamic>;
final String type = (map['type'] ?? 'unknown') as String;
final dynamic payload = map['payload'] ?? <String, dynamic>{};
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<String, dynamic> 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<String, dynamic> 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};
}
@@ -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<String, WatchQueryOptions> registeredQueries =
<String, WatchQueryOptions>{};
/// 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<Duration?, List<String>> intervalQueries = <Duration?, List<String>>{};
/// Map going from polling interval durations to polling timers.
final Map<Duration?, Timer> _pollingTimers = <Duration?, Timer>{};
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] = <String>[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);
}
}
@@ -0,0 +1,45 @@
bool notNull(Object? any) {
return any != null;
}
Map<String, dynamic>? _recursivelyAddAll(
Map<String, dynamic>? target,
Map<String, dynamic>? source,
) {
target = Map<String, dynamic>.from(target ?? {});
source?.forEach((String key, dynamic value) {
if (target!.containsKey(key) &&
target[key] is Map<String, dynamic> &&
value != null &&
value is Map<String, dynamic>) {
target[key] = _recursivelyAddAll(
target[key] as Map<String, dynamic>,
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<String, dynamic>? deeplyMergeLeft(
Iterable<Map<String, dynamic>?> maps,
) {
// prepend an empty literal for functional immutability
return (<Map<String, dynamic>?>[{}]..addAll(maps)).reduce(_recursivelyAddAll);
}
@@ -0,0 +1 @@
export './platform_html.dart' if (dart.library.io) './platform_io.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<WebSocketChannel> defaultConnectPlatform(
Uri uri, Iterable<String>? protocols,
{Map<String, dynamic>? 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();
}
@@ -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<WebSocketChannel> defaultConnectPlatform(
Uri uri, Iterable<String>? protocols,
{Map<String, dynamic>? headers}) async {
final webSocket = await WebSocket.connect(uri.toString(),
protocols: protocols, headers: headers);
return IOWebSocketChannel(webSocket).forGraphQL();
}
+40
View File
@@ -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:
@@ -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: <String, dynamic>{},
);
when(
link.request(any),
).thenAnswer(
(_) => Stream.fromIterable(
[
Response(
data: <String, dynamic>{
'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: <String, dynamic>{},
context: Context(),
),
),
);
expect(r.exception, isNull);
expect(r.data, isNotNull);
final List<Map<String, dynamic>> nodes =
(r.data!['viewer']['repositories']['nodes'] as List<dynamic>)
.cast<Map<String, dynamic>>();
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: <String, dynamic>{
'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);
});
});
});
}
+362
View File
@@ -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<String, dynamic> variables = const <String, dynamic>{},
this.normalizedEntities,
}) : request = Request(
operation: Operation(document: parseString(operation)),
variables: variables,
context: Context(),
);
Request request;
/// data to write to cache
Map<String, dynamic> data;
/// entities to inspect the store for, if any
List<Map<String, dynamic>>? 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 = <String, dynamic>{
'__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 = <String, dynamic>{
'__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<String, dynamic> get cyclicalObjOperationData {
Map<String, dynamic> a;
Map<String, dynamic> 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}
},
},
);
+313
View File
@@ -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: <String, dynamic>{
...basicTest.data,
'a': <String, dynamic>{
...basicTest.data['a'],
'b': <String, dynamic>{
'id': 5,
}
},
}),
throwsA(isA<PartialDataException>().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<String, Object> &&
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),
);
});
});
}
+79
View File
@@ -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);
});
});
}
+20
View File
@@ -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();
}
@@ -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: <String, dynamic>{
'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: <String, dynamic>{
'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: <String, dynamic>{
'nRepositories': 42,
},
));
expect(cacheResult.exception, isNull);
expect(cacheResult.data, equals(repoData));
});
});
});
}
@@ -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<String, dynamic> 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: <String, dynamic>{
'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: <String, dynamic>{
'nRepositories': 42,
},
context: Context(),
),
),
);
expect(r.exception, isNull);
expect(r.data, equals(repoData));
expect(
r.context.entry<HttpLinkResponseContext>()!.statusCode,
equals(200),
);
expect(
r.context.entry<HttpLinkResponseContext>()!.headers['foo'],
equals('bar'),
);
});
test('successful response with parser', () async {
final ResultParserFn<List<String>> parserFn = (data) {
return data['viewer']['repositories']['nodes']
.map<String>((node) => node['name'] as String)
.toList();
};
final _options = QueryOptions(
document: parseString(readRepositories),
variables: <String, dynamic>{
'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<List<String>> r = await client.query(_options);
verify(
link.request(
Request(
operation: Operation(
document: parseString(readRepositories),
//operationName: 'ReadRepositories',
),
variables: <String, dynamic>{
'nRepositories': 42,
},
context: Context(),
),
),
);
expect(r.exception, isNull);
expect(r.data, equals(repoData));
List<String>? parsedData = r.parsedData;
expect(
parsedData,
equals([
'pq',
'go-evercookie',
'watchbot',
]));
expect(
r.context.entry<HttpLinkResponseContext>()!.statusCode,
equals(200),
);
expect(
r.context.entry<HttpLinkResponseContext>()!.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: <String, dynamic>{
'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<PartialDataException>().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: <String, dynamic>{
'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<QueryResult>().having(
(result) => result.isLoading,
'loading result',
true,
),
isA<QueryResult>().having(
(result) => result.data!['single']['name'],
'initial query result',
'initialQueryName',
),
isA<QueryResult>().having(
(result) => result.data!['single']['name'],
'result caused by mutation',
'newNameFromMutation',
)
],
),
);
final mutationResponseWithNewName = Response(
data: <String, dynamic>{
'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: <String, dynamic>{
'action': {
'starrable': {
'viewerHasStarred': true,
},
},
},
),
],
),
);
final QueryResult response = await client.mutate(_options);
verify(
link.request(
Request(
operation: Operation(
document: parseString(addStar),
//operationName: 'AddStar',
),
variables: <String, dynamic>{},
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<bool> 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: <String, dynamic>{
'action': {
'starrable': {
'viewerHasStarred': true,
},
},
},
),
],
),
);
final QueryResult response = await client.mutate(_options);
verify(
link.request(
Request(
operation: Operation(
document: parseString(addStar),
//operationName: 'AddStar',
),
variables: <String, dynamic>{},
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: <String, dynamic>{
'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: <String, dynamic>{},
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: <String, dynamic>{
'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<QueryResult>().having(
(result) => result.data!['item']['name'],
'first subscription item',
'first',
),
isA<QueryResult>().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: <String, dynamic>{
'item': {
'__typename': 'Item',
...item,
},
},
));
when(
link.request(any),
).thenAnswer(
(_) => Stream.fromIterable(responses),
);
final ResultParserFn<String> 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<QueryResult<String>>().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<QueryResult>().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<QueryResult>().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));
});
});
}
+34
View File
@@ -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<Response> request(Request? request, [NextLink? forward]) =>
super.noSuchMethod(
Invocation.method(#request, [request, forward]),
returnValue: Stream.fromIterable(
<Response>[],
),
) as Stream<Response>;
}
const debuggingUnexpectedTestFailures = false;
overridePrint(testFn(List<String> log)) => () {
final log = <String>[];
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();
@@ -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<String> 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);
}
});
}
@@ -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));
});
});
});
}
Binary file not shown.
Binary file not shown.
+70
View File
@@ -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([
<String, dynamic>{
'keyA': 'a1',
'keyB': {
'keyC': {'keyD': 'd1'}
}
},
<String, dynamic>{
'keyA': 'a2',
'keyB': {
'keyC': {'keyD': 'd2'}
}
},
]),
equals({
'keyA': 'a2',
'keyB': {
'keyC': {'keyD': 'd2'}
}
}),
);
});
test('deep hashmaps are merged', () {
expect(
deeplyMergeLeft([
HashMap<String, dynamic>.from({
'keyA': 'a1',
'keyB': {
'keyC': HashMap.from({'keyD': 'd1'})
}
}),
{
'keyA': 'a2',
'keyB': {
'keyC': HashMap.from({'keyD': 'd2'})
}
},
]),
equals({
'keyA': 'a2',
'keyB': {
'keyC': {'keyD': 'd2'}
}
}),
);
});
});
}
+431
View File
@@ -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<String, dynamic>? 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<void> 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;
});
});
*/
});
}