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