initial commit with cache support & example
This commit is contained in:
@@ -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/
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
## 0.0.1
|
||||
|
||||
* TODO: Describe initial release.
|
||||
@@ -0,0 +1 @@
|
||||
TODO: Add your license here.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
# Files and directories created by pub.
|
||||
.dart_tool/
|
||||
.packages
|
||||
|
||||
# Conventional directory for build output.
|
||||
build/
|
||||
@@ -0,0 +1,3 @@
|
||||
## 1.0.0
|
||||
|
||||
- Initial version.
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
@@ -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}
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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.
@@ -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'}
|
||||
}
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
*/
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user