+QueryObserver
+Query +notifyManager +onlineManager +QueryCache
This commit is contained in:
Vendored
+6
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"editor.tokenColorCustomizations": {
|
||||||
|
"comments": "",
|
||||||
|
"textMateRules": []
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,6 +69,13 @@ packages:
|
|||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.0.0"
|
version: "4.0.0"
|
||||||
|
internet_connection_checker:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: internet_connection_checker
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "0.0.1+3"
|
||||||
lints:
|
lints:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -1,13 +1 @@
|
|||||||
library fl_query;
|
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;
|
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
/// 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
@@ -1,173 +0,0 @@
|
|||||||
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
@@ -1,29 +0,0 @@
|
|||||||
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
@@ -1,68 +0,0 @@
|
|||||||
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
@@ -1,66 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
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,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
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';
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
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,419 @@
|
|||||||
|
import 'package:fl_query/src/core/query.dart';
|
||||||
|
import 'package:fl_query/src/core/query_key.dart';
|
||||||
|
import 'package:fl_query/src/core/retryer.dart';
|
||||||
|
|
||||||
|
typedef QueryMeta<T> = Map<String, T>;
|
||||||
|
typedef QueryKeyHashFunction = String Function(QueryKey queryKey);
|
||||||
|
typedef QueryFunction<T, TPageParam> = Function(QueryFunctionContext context);
|
||||||
|
typedef GetPreviousPageParamFunction<TQueryFnData> = Function(
|
||||||
|
TQueryFnData firstPage,
|
||||||
|
List<TQueryFnData> allPages,
|
||||||
|
);
|
||||||
|
typedef GetNextPageParamFunction<TQueryFnData> = Function(
|
||||||
|
TQueryFnData firstPage,
|
||||||
|
List<TQueryFnData> allPages,
|
||||||
|
);
|
||||||
|
|
||||||
|
class QueryOptions<TQueryFnData, TError, TData> {
|
||||||
|
ShouldRetryFunction<TError>? retry;
|
||||||
|
RetryDelayFunction<TError>? retryDelay;
|
||||||
|
Duration? cacheTime;
|
||||||
|
bool Function(TData? oldData, TData newData)? isDataEqual;
|
||||||
|
QueryFunction? queryFn;
|
||||||
|
QueryKey? queryKey;
|
||||||
|
|
||||||
|
/// Basically [QueryKey.key] in short form
|
||||||
|
String? queryHash;
|
||||||
|
QueryKeyHashFunction? queryKeyHashFn;
|
||||||
|
TData? initialData;
|
||||||
|
DateTime? initialDataUpdatedAt;
|
||||||
|
QueryBehavior<TQueryFnData, TError, TData>? behavior;
|
||||||
|
|
||||||
|
/// Set this to `false` to disable structural sharing between query results\
|
||||||
|
/// Defaults to `true`.
|
||||||
|
bool? structuralSharing;
|
||||||
|
|
||||||
|
/// This function can be set to automatically get the previous cursor for infinite queries.
|
||||||
|
/// The result will also be used to determine the value of `hasPreviousPage`.
|
||||||
|
GetPreviousPageParamFunction<TQueryFnData>? getPreviousPageParam;
|
||||||
|
|
||||||
|
/// This function can be set to automatically get the next cursor for
|
||||||
|
/// infinite queries.
|
||||||
|
/// The result will also be used to determine the value of
|
||||||
|
/// `hasNextPage`.
|
||||||
|
GetNextPageParamFunction<TQueryFnData>? getNextPageParam;
|
||||||
|
bool? defaulted;
|
||||||
|
|
||||||
|
/// Additional payload to be stored on each query.
|
||||||
|
/// Use this property to pass information that can be used in other places.
|
||||||
|
QueryMeta? meta;
|
||||||
|
|
||||||
|
QueryOptions({
|
||||||
|
this.retry,
|
||||||
|
this.retryDelay,
|
||||||
|
this.queryKey,
|
||||||
|
this.queryKeyHashFn,
|
||||||
|
this.cacheTime,
|
||||||
|
this.isDataEqual,
|
||||||
|
this.queryFn,
|
||||||
|
this.defaulted,
|
||||||
|
this.initialData,
|
||||||
|
this.initialDataUpdatedAt,
|
||||||
|
this.meta,
|
||||||
|
this.queryHash,
|
||||||
|
this.structuralSharing,
|
||||||
|
this.getPreviousPageParam,
|
||||||
|
this.getNextPageParam,
|
||||||
|
});
|
||||||
|
|
||||||
|
QueryOptions.fromJson(Map<String, dynamic> json) {
|
||||||
|
queryKey = json["queryKey"];
|
||||||
|
queryKeyHashFn = json["queryKeyHashFn"];
|
||||||
|
cacheTime = json["cacheTime"];
|
||||||
|
isDataEqual = json["isDataEqual"];
|
||||||
|
queryFn = json["queryFn"];
|
||||||
|
queryHash = json["queryHash"];
|
||||||
|
initialData = json["initialData"];
|
||||||
|
initialDataUpdatedAt = json["initialDataUpdatedAt"];
|
||||||
|
meta = json["meta"];
|
||||||
|
structuralSharing = json["structuralSharing"];
|
||||||
|
defaulted = json["defaulted"];
|
||||||
|
retry = json["retry"];
|
||||||
|
retryDelay = json["retryDelay"];
|
||||||
|
behavior = json["behavior"];
|
||||||
|
getPreviousPageParam = json["getPreviousPageParam"];
|
||||||
|
getNextPageParam = json["getNextPageParam"];
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
"queryKey": queryKey,
|
||||||
|
"queryKeyHashFn": queryKeyHashFn,
|
||||||
|
"cacheTime": cacheTime,
|
||||||
|
"isDataEqual": isDataEqual,
|
||||||
|
"queryFn": queryFn,
|
||||||
|
"queryHash": queryHash,
|
||||||
|
"initialData": initialData,
|
||||||
|
"initialDataUpdatedAt": initialDataUpdatedAt,
|
||||||
|
"meta": meta,
|
||||||
|
"structuralSharing": structuralSharing,
|
||||||
|
"defaulted": defaulted,
|
||||||
|
"retry": retry,
|
||||||
|
"retryDelay": retryDelay,
|
||||||
|
"behavior": behavior,
|
||||||
|
"getPreviousPageParam": getPreviousPageParam,
|
||||||
|
"getNextPageParam": getNextPageParam,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class QueryFilters {
|
||||||
|
bool? active;
|
||||||
|
bool? exact;
|
||||||
|
bool? inactive;
|
||||||
|
bool Function(Query query)? predicate;
|
||||||
|
bool? queryKey;
|
||||||
|
bool? stale;
|
||||||
|
bool? fetching;
|
||||||
|
|
||||||
|
QueryFilters({
|
||||||
|
this.active,
|
||||||
|
this.exact,
|
||||||
|
this.inactive,
|
||||||
|
this.predicate,
|
||||||
|
this.queryKey,
|
||||||
|
this.stale,
|
||||||
|
this.fetching,
|
||||||
|
});
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
"active": active,
|
||||||
|
"exact": exact,
|
||||||
|
"inactive": inactive,
|
||||||
|
"queryKey": queryKey,
|
||||||
|
"stale": stale,
|
||||||
|
"fetching": fetching,
|
||||||
|
"predicate": predicate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class RefetchPageFilters<TPageData> {
|
||||||
|
bool Function(TPageData lastPage, int index, List<TPageData> allPages)?
|
||||||
|
refetchPage;
|
||||||
|
}
|
||||||
|
|
||||||
|
class RefetchQueryFilters<TPageData>
|
||||||
|
implements QueryFilters, RefetchPageFilters<TPageData> {
|
||||||
|
@override
|
||||||
|
bool? active;
|
||||||
|
@override
|
||||||
|
bool? exact;
|
||||||
|
@override
|
||||||
|
bool? fetching;
|
||||||
|
@override
|
||||||
|
bool? inactive;
|
||||||
|
@override
|
||||||
|
bool Function(Query query)? predicate;
|
||||||
|
@override
|
||||||
|
bool? queryKey;
|
||||||
|
@override
|
||||||
|
bool Function(TPageData lastPage, int index, List<TPageData> allPages)?
|
||||||
|
refetchPage;
|
||||||
|
@override
|
||||||
|
bool? stale;
|
||||||
|
|
||||||
|
RefetchQueryFilters({
|
||||||
|
this.active,
|
||||||
|
this.exact,
|
||||||
|
this.inactive,
|
||||||
|
this.predicate,
|
||||||
|
this.queryKey,
|
||||||
|
this.stale,
|
||||||
|
this.fetching,
|
||||||
|
this.refetchPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
"active": active,
|
||||||
|
"exact": exact,
|
||||||
|
"inactive": inactive,
|
||||||
|
"queryKey": queryKey,
|
||||||
|
"stale": stale,
|
||||||
|
"fetching": fetching,
|
||||||
|
"predicate": predicate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ResultOptions {
|
||||||
|
bool? throwOnError;
|
||||||
|
}
|
||||||
|
|
||||||
|
class RefetchOptions implements ResultOptions {
|
||||||
|
bool? cancelRefetch;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool? throwOnError;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum QueryStatus {
|
||||||
|
idle,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
success,
|
||||||
|
}
|
||||||
|
|
||||||
|
class QueryObserverResult<TData, TError> {
|
||||||
|
TData? data;
|
||||||
|
DateTime? dataUpdatedAt;
|
||||||
|
TError? error;
|
||||||
|
DateTime? errorUpdatedAt;
|
||||||
|
int failureCount;
|
||||||
|
bool isError;
|
||||||
|
bool isFetched;
|
||||||
|
bool isFetchedAfterMount;
|
||||||
|
bool isFetching;
|
||||||
|
bool isIdle;
|
||||||
|
bool isLoading;
|
||||||
|
bool isLoadingError;
|
||||||
|
bool isPlaceholderData;
|
||||||
|
bool isPreviousData;
|
||||||
|
bool isRefetchError;
|
||||||
|
bool isRefetching;
|
||||||
|
bool isStale;
|
||||||
|
bool isSuccess;
|
||||||
|
Future<QueryObserverResult<TData, TError>> Function<TPageData>({
|
||||||
|
RefetchOptions options,
|
||||||
|
RefetchQueryFilters<TPageData> filters,
|
||||||
|
}) refetch;
|
||||||
|
void Function() remove;
|
||||||
|
QueryStatus status;
|
||||||
|
|
||||||
|
QueryObserverResult({
|
||||||
|
required this.failureCount,
|
||||||
|
required this.isError,
|
||||||
|
required this.isFetched,
|
||||||
|
required this.isFetchedAfterMount,
|
||||||
|
required this.isFetching,
|
||||||
|
required this.isIdle,
|
||||||
|
required this.isLoading,
|
||||||
|
required this.isLoadingError,
|
||||||
|
required this.isPlaceholderData,
|
||||||
|
required this.isPreviousData,
|
||||||
|
required this.isRefetchError,
|
||||||
|
required this.isRefetching,
|
||||||
|
required this.isStale,
|
||||||
|
required this.isSuccess,
|
||||||
|
required this.refetch,
|
||||||
|
required this.remove,
|
||||||
|
required this.status,
|
||||||
|
this.data,
|
||||||
|
this.error,
|
||||||
|
this.dataUpdatedAt,
|
||||||
|
this.errorUpdatedAt,
|
||||||
|
}) {
|
||||||
|
String errorLabel =
|
||||||
|
"[QueryObserverResult.QueryObserverResult] status = `$status` but parent has wrong set of properties";
|
||||||
|
if (status == QueryStatus.idle &&
|
||||||
|
(data != null ||
|
||||||
|
error != null ||
|
||||||
|
isError ||
|
||||||
|
!isIdle ||
|
||||||
|
isLoading ||
|
||||||
|
isLoadingError ||
|
||||||
|
isRefetchError ||
|
||||||
|
isSuccess)) throw Exception(errorLabel);
|
||||||
|
|
||||||
|
if (status == QueryStatus.loading &&
|
||||||
|
(data != null ||
|
||||||
|
error != null ||
|
||||||
|
isError ||
|
||||||
|
isIdle ||
|
||||||
|
!isLoading ||
|
||||||
|
isLoadingError ||
|
||||||
|
isRefetchError ||
|
||||||
|
isSuccess != false)) throw Exception(errorLabel);
|
||||||
|
|
||||||
|
if (status == QueryStatus.error &&
|
||||||
|
((!(error is TError)) || !isError || isIdle || isLoading || isSuccess))
|
||||||
|
throw Exception(errorLabel);
|
||||||
|
|
||||||
|
if (status == QueryStatus.success &&
|
||||||
|
(!(data is TData) ||
|
||||||
|
error != null ||
|
||||||
|
isError ||
|
||||||
|
isIdle ||
|
||||||
|
isLoading ||
|
||||||
|
isLoadingError ||
|
||||||
|
isRefetchError ||
|
||||||
|
!isSuccess)) throw Exception(errorLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
final Map<String, dynamic> data = {
|
||||||
|
'data': this.data,
|
||||||
|
'dataUpdatedAt': dataUpdatedAt,
|
||||||
|
'error': error,
|
||||||
|
'errorUpdatedAt': errorUpdatedAt,
|
||||||
|
'failureCount': failureCount,
|
||||||
|
'isError': isError,
|
||||||
|
'isFetched': isFetched,
|
||||||
|
'isFetchedAfterMount': isFetchedAfterMount,
|
||||||
|
'isFetching': isFetching,
|
||||||
|
'isIdle': isIdle,
|
||||||
|
'isLoading': isLoading,
|
||||||
|
'isLoadingError': isLoadingError,
|
||||||
|
'isPlaceholderData': isPlaceholderData,
|
||||||
|
'isPreviousData': isPreviousData,
|
||||||
|
'isRefetchError': isRefetchError,
|
||||||
|
'isRefetching': isRefetching,
|
||||||
|
'isStale': isStale,
|
||||||
|
'isSuccess': isSuccess,
|
||||||
|
'refetch': refetch,
|
||||||
|
'remove': remove,
|
||||||
|
'status': status,
|
||||||
|
};
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
typedef RefetchIntervalFunction<TQueryFnData, TError, TQueryData, TData>
|
||||||
|
= Duration? Function(
|
||||||
|
TData? data,
|
||||||
|
Query<TQueryFnData, TError, TQueryData> query,
|
||||||
|
);
|
||||||
|
|
||||||
|
enum RefetchOnReconnect {
|
||||||
|
on,
|
||||||
|
off,
|
||||||
|
always,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum RefetchOnMount {
|
||||||
|
on,
|
||||||
|
off,
|
||||||
|
always,
|
||||||
|
}
|
||||||
|
|
||||||
|
class QueryObserverOptions<TQueryFnData, TError, TData, TQueryData>
|
||||||
|
extends QueryOptions<TQueryFnData, TError, TQueryData> {
|
||||||
|
bool? enabled;
|
||||||
|
Duration? staleTime;
|
||||||
|
RefetchIntervalFunction<TQueryFnData, TError, TQueryData, TData>?
|
||||||
|
refetchInterval;
|
||||||
|
bool? refetchIntervalInBackground;
|
||||||
|
RefetchOnReconnect? refetchOnReconnect;
|
||||||
|
RefetchOnMount? refetchOnMount;
|
||||||
|
bool? retryOnMount;
|
||||||
|
OnData? onSuccess;
|
||||||
|
OnError? onError;
|
||||||
|
void Function(TData? data, [TError? error])? onSettled;
|
||||||
|
bool Function(TError error)? useErrorBoundary;
|
||||||
|
TData Function(TQueryData? data)? select;
|
||||||
|
bool? suspense;
|
||||||
|
bool? keepPreviousData;
|
||||||
|
TQueryData? placeholderData;
|
||||||
|
bool? optimisticResults;
|
||||||
|
/*List<String>|'tracked'?*/ dynamic notifyOnChangeProps;
|
||||||
|
List<String>? notifyOnChangePropsExclusions;
|
||||||
|
|
||||||
|
QueryObserverOptions({
|
||||||
|
this.enabled,
|
||||||
|
this.staleTime,
|
||||||
|
this.refetchInterval,
|
||||||
|
this.refetchIntervalInBackground,
|
||||||
|
this.refetchOnReconnect,
|
||||||
|
this.refetchOnMount,
|
||||||
|
this.retryOnMount,
|
||||||
|
this.onSuccess,
|
||||||
|
this.onError,
|
||||||
|
this.onSettled,
|
||||||
|
this.useErrorBoundary,
|
||||||
|
this.select,
|
||||||
|
this.suspense,
|
||||||
|
this.keepPreviousData,
|
||||||
|
this.placeholderData,
|
||||||
|
this.optimisticResults,
|
||||||
|
QueryKey? queryKey,
|
||||||
|
QueryKeyHashFunction? queryKeyHashFn,
|
||||||
|
Duration? cacheTime,
|
||||||
|
bool Function(TQueryData? oldData, TQueryData newData)? isDataEqual,
|
||||||
|
QueryFunction? queryFn,
|
||||||
|
String? queryHash,
|
||||||
|
TQueryData? initialData,
|
||||||
|
DateTime? initialDataUpdatedAt,
|
||||||
|
QueryMeta? meta,
|
||||||
|
bool? structuralSharing,
|
||||||
|
bool? defaulted,
|
||||||
|
}) : super(
|
||||||
|
queryKey: queryKey,
|
||||||
|
queryKeyHashFn: queryKeyHashFn,
|
||||||
|
cacheTime: cacheTime,
|
||||||
|
isDataEqual: isDataEqual,
|
||||||
|
queryFn: queryFn,
|
||||||
|
queryHash: queryHash,
|
||||||
|
initialData: initialData,
|
||||||
|
initialDataUpdatedAt: initialDataUpdatedAt,
|
||||||
|
meta: meta,
|
||||||
|
structuralSharing: structuralSharing,
|
||||||
|
defaulted: defaulted,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class QueryFunctionContext<TPageParam> {
|
||||||
|
QueryKey queryKey;
|
||||||
|
/* AbortSignal */ dynamic? signal;
|
||||||
|
TPageParam? pageParam;
|
||||||
|
QueryMeta? meta;
|
||||||
|
|
||||||
|
QueryFunctionContext({
|
||||||
|
required this.queryKey,
|
||||||
|
this.signal,
|
||||||
|
this.pageParam,
|
||||||
|
this.meta,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
// 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,95 @@
|
|||||||
|
// TYPES
|
||||||
|
|
||||||
|
import 'package:fl_query/src/core/utils.dart';
|
||||||
|
|
||||||
|
typedef NotifyCallback = void Function();
|
||||||
|
|
||||||
|
typedef NotifyFunction = void Function(void Function() callback);
|
||||||
|
|
||||||
|
typedef BatchNotifyFunction = void Function(void Function() callback);
|
||||||
|
|
||||||
|
class _NotifyManager {
|
||||||
|
List<NotifyCallback> _queue;
|
||||||
|
int _transactions;
|
||||||
|
late NotifyFunction _notifyFn;
|
||||||
|
late BatchNotifyFunction _batchNotifyFn;
|
||||||
|
|
||||||
|
_NotifyManager()
|
||||||
|
: _queue = [],
|
||||||
|
_transactions = 0 {
|
||||||
|
_notifyFn = (void Function() callback) {
|
||||||
|
callback();
|
||||||
|
};
|
||||||
|
|
||||||
|
_batchNotifyFn = (void Function() callback) {
|
||||||
|
callback();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
T batch<T>(T Function() callback) {
|
||||||
|
T result;
|
||||||
|
_transactions++;
|
||||||
|
try {
|
||||||
|
result = callback();
|
||||||
|
} finally {
|
||||||
|
_transactions--;
|
||||||
|
if (_transactions == 0) {
|
||||||
|
flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
schedule(NotifyCallback callback) {
|
||||||
|
if (_transactions > 0) {
|
||||||
|
_queue.add(callback);
|
||||||
|
} else {
|
||||||
|
scheduleMicrotask((val) {
|
||||||
|
_notifyFn(callback);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All calls to the wrapped function will be batched.
|
||||||
|
T batchCalls<T extends void Function(List? args)>(T callback) {
|
||||||
|
void fn(List? args) {
|
||||||
|
schedule(() {
|
||||||
|
callback(args);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
;
|
||||||
|
return fn as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
void flush() {
|
||||||
|
var queue = _queue;
|
||||||
|
_queue = [];
|
||||||
|
if (queue.isNotEmpty) {
|
||||||
|
scheduleMicrotask((val) {
|
||||||
|
_batchNotifyFn(() {
|
||||||
|
queue.forEach((fn) {
|
||||||
|
_notifyFn(fn);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
///Use this method to set a custom notify function.
|
||||||
|
void setNotifyFunction(NotifyFunction fn) {
|
||||||
|
_notifyFn = fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Use this method to set a custom function to batch notifications
|
||||||
|
/// together into a single tick.
|
||||||
|
/// By default React Query will use the batch function provided by
|
||||||
|
/// ReactDOM or React Native.
|
||||||
|
void setBatchNotifyFunction(BatchNotifyFunction fn) {
|
||||||
|
_batchNotifyFn = fn;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SINGLETON
|
||||||
|
|
||||||
|
_NotifyManager notifyManager = new _NotifyManager();
|
||||||
@@ -1,386 +0,0 @@
|
|||||||
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,72 @@
|
|||||||
|
import 'package:fl_query/src/core/subscribable.dart';
|
||||||
|
import 'package:internet_connection_checker/internet_connection_checker.dart';
|
||||||
|
|
||||||
|
typedef SetupFn = void Function()? Function(
|
||||||
|
void Function([bool? online]) setOnline);
|
||||||
|
|
||||||
|
class _OnlineManager extends Subscribable {
|
||||||
|
bool? _online;
|
||||||
|
void Function()? _cleanup;
|
||||||
|
SetupFn? _setup;
|
||||||
|
|
||||||
|
_OnlineManager() {
|
||||||
|
_setup = (listener) {
|
||||||
|
var subscription = InternetConnectionChecker()
|
||||||
|
.onStatusChange
|
||||||
|
.listen((status) => listener());
|
||||||
|
return () {
|
||||||
|
subscription.cancel();
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onSubscribe() {
|
||||||
|
if (_cleanup == null) {
|
||||||
|
setEventListener(_setup!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onUnsubscribe() {
|
||||||
|
if (!hasListeners()) {
|
||||||
|
_cleanup?.call();
|
||||||
|
_cleanup = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setEventListener(SetupFn setup) {
|
||||||
|
_setup = setup;
|
||||||
|
_cleanup?.call();
|
||||||
|
_cleanup = setup(([bool? online]) {
|
||||||
|
if (online != null) {
|
||||||
|
setOnline(online);
|
||||||
|
} else {
|
||||||
|
onOnline();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void setOnline(bool? online) {
|
||||||
|
_online = online;
|
||||||
|
if (online != null && online) {
|
||||||
|
onOnline();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void onOnline() {
|
||||||
|
listeners.forEach((listener) {
|
||||||
|
listener();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> isOnline() {
|
||||||
|
if (_online != null) {
|
||||||
|
return Future.value(_online!);
|
||||||
|
}
|
||||||
|
|
||||||
|
return InternetConnectionChecker().hasConnection;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_OnlineManager onlineManager = _OnlineManager();
|
||||||
@@ -1,323 +0,0 @@
|
|||||||
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,642 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:fl_query/src/core/models.dart';
|
||||||
|
import 'package:fl_query/src/core/notify_manager.dart';
|
||||||
|
import 'package:fl_query/src/core/query_cache.dart';
|
||||||
|
import 'package:fl_query/src/core/query_key.dart';
|
||||||
|
import 'package:fl_query/src/core/query_observer.dart';
|
||||||
|
import 'package:fl_query/src/core/retryer.dart';
|
||||||
|
import 'package:fl_query/src/core/utils.dart';
|
||||||
|
import 'package:meta/meta.dart';
|
||||||
|
import 'package:collection/collection.dart';
|
||||||
|
|
||||||
|
class FetchOptions {
|
||||||
|
bool? cancelRefetch;
|
||||||
|
dynamic meta;
|
||||||
|
FetchOptions({this.cancelRefetch, this.meta});
|
||||||
|
}
|
||||||
|
|
||||||
|
class FetchContext<TQueryFnData, TError, TData> {
|
||||||
|
FutureOr Function() fetchFn;
|
||||||
|
FetchOptions? fetchOptions;
|
||||||
|
QueryOptions<TQueryFnData, TError, TData> options;
|
||||||
|
QueryKey queryKey;
|
||||||
|
QueryState<TData, TError> state;
|
||||||
|
QueryMeta? meta;
|
||||||
|
|
||||||
|
FetchContext({
|
||||||
|
required this.fetchFn,
|
||||||
|
required this.options,
|
||||||
|
required this.queryKey,
|
||||||
|
required this.state,
|
||||||
|
this.meta,
|
||||||
|
this.fetchOptions,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class QueryBehavior<TQueryFnData, TError, TData> {
|
||||||
|
void Function(FetchContext<TQueryFnData, TError, TData> context) onFetch;
|
||||||
|
QueryBehavior({required this.onFetch});
|
||||||
|
}
|
||||||
|
|
||||||
|
class QueryState<TData, TError> {
|
||||||
|
TData? data;
|
||||||
|
TError? error;
|
||||||
|
QueryStatus status;
|
||||||
|
DateTime? dataUpdatedAt;
|
||||||
|
int dataUpdateCount;
|
||||||
|
DateTime? errorUpdatedAt;
|
||||||
|
int errorUpdateCount;
|
||||||
|
int fetchFailureCount;
|
||||||
|
dynamic fetchMeta;
|
||||||
|
bool isFetching;
|
||||||
|
bool isInvalidated;
|
||||||
|
bool isPaused;
|
||||||
|
|
||||||
|
QueryState({
|
||||||
|
required this.status,
|
||||||
|
required this.dataUpdatedAt,
|
||||||
|
required this.dataUpdateCount,
|
||||||
|
required this.errorUpdatedAt,
|
||||||
|
required this.errorUpdateCount,
|
||||||
|
required this.fetchFailureCount,
|
||||||
|
required this.fetchMeta,
|
||||||
|
required this.isFetching,
|
||||||
|
required this.isInvalidated,
|
||||||
|
required this.isPaused,
|
||||||
|
this.data,
|
||||||
|
this.error,
|
||||||
|
});
|
||||||
|
|
||||||
|
QueryState.fromJson(Map<String, dynamic> json)
|
||||||
|
: data = json["data"],
|
||||||
|
error = json["error"],
|
||||||
|
status = json["status"],
|
||||||
|
dataUpdatedAt = json["dataUpdatedAt"],
|
||||||
|
dataUpdateCount = json["dataUpdateCount"],
|
||||||
|
errorUpdatedAt = json["errorUpdatedAt"],
|
||||||
|
errorUpdateCount = json["errorUpdateCount"],
|
||||||
|
fetchFailureCount = json["fetchFailureCount"],
|
||||||
|
fetchMeta = json["fetchMeta"],
|
||||||
|
isFetching = json["isFetching"],
|
||||||
|
isInvalidated = json["isInvalidated"],
|
||||||
|
isPaused = json["isPaused"];
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
"data": data,
|
||||||
|
"error": error,
|
||||||
|
"status": status,
|
||||||
|
"dataUpdatedAt": dataUpdatedAt,
|
||||||
|
"dataUpdateCount": dataUpdateCount,
|
||||||
|
"errorUpdatedAt": errorUpdatedAt,
|
||||||
|
"errorUpdateCount": errorUpdateCount,
|
||||||
|
"fetchFailureCount": fetchFailureCount,
|
||||||
|
"fetchMeta": fetchMeta,
|
||||||
|
"isFetching": isFetching,
|
||||||
|
"isInvalidated": isInvalidated,
|
||||||
|
"isPaused": isPaused,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ActionType {
|
||||||
|
failed,
|
||||||
|
fetch,
|
||||||
|
success,
|
||||||
|
error,
|
||||||
|
invalidate,
|
||||||
|
pause,
|
||||||
|
resume,
|
||||||
|
setState,
|
||||||
|
}
|
||||||
|
|
||||||
|
class SetStateOptions {
|
||||||
|
Object? meta;
|
||||||
|
SetStateOptions({this.meta});
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {"meta": meta};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Action<TData, TError> {
|
||||||
|
ActionType type;
|
||||||
|
Object? meta;
|
||||||
|
TData? data;
|
||||||
|
DateTime? dataUpdatedAt;
|
||||||
|
TError? error;
|
||||||
|
QueryState<TData, TError>? state;
|
||||||
|
SetStateOptions? setStateOptions;
|
||||||
|
|
||||||
|
Action(
|
||||||
|
this.type, {
|
||||||
|
this.meta,
|
||||||
|
this.data,
|
||||||
|
this.dataUpdatedAt,
|
||||||
|
this.error,
|
||||||
|
this.state,
|
||||||
|
this.setStateOptions,
|
||||||
|
}) {
|
||||||
|
if (type == ActionType.error && error == null)
|
||||||
|
throw Exception(
|
||||||
|
"[Action.Action] property `error` can't be null when `type` = `$type`");
|
||||||
|
|
||||||
|
if (type == ActionType.setState && state == null)
|
||||||
|
throw Exception(
|
||||||
|
"[Action.Action] property `state` can't be null when `type` = `$type`");
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
"type": type,
|
||||||
|
"meta": meta,
|
||||||
|
"data": data,
|
||||||
|
"dataUpdatedAt": dataUpdatedAt,
|
||||||
|
"error": error,
|
||||||
|
"state": state,
|
||||||
|
"setStateOptions": setStateOptions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Query<TQueryFnData, TError, TData> {
|
||||||
|
QueryKey queryKey;
|
||||||
|
String queryHash;
|
||||||
|
late QueryOptions<TQueryFnData, TError, TData> options;
|
||||||
|
late QueryState<TData, TError> initialState;
|
||||||
|
QueryState<TData, TError>? revertState;
|
||||||
|
late QueryState<TData, TError> state;
|
||||||
|
Duration? cacheTime;
|
||||||
|
QueryMeta? meta;
|
||||||
|
|
||||||
|
QueryCache _cache;
|
||||||
|
Future<TData>? _future;
|
||||||
|
Timer? _gcTimeout;
|
||||||
|
Retryer<TData, TError>? _retryer;
|
||||||
|
List<QueryObserver> _observers;
|
||||||
|
QueryOptions<TQueryFnData, TError, TData>? _defaultOptions;
|
||||||
|
bool _abortSignalConsumed;
|
||||||
|
bool _hadObservers;
|
||||||
|
|
||||||
|
Query({
|
||||||
|
required this.queryKey,
|
||||||
|
required this.queryHash,
|
||||||
|
required QueryCache cache,
|
||||||
|
QueryOptions<TQueryFnData, TError, TData>? options,
|
||||||
|
QueryOptions<TQueryFnData, TError, TData>? defaultOptions,
|
||||||
|
QueryState<TData, TError>? state,
|
||||||
|
QueryMeta? meta,
|
||||||
|
}) : _abortSignalConsumed = false,
|
||||||
|
_hadObservers = false,
|
||||||
|
_defaultOptions = defaultOptions,
|
||||||
|
_observers = [],
|
||||||
|
_cache = cache {
|
||||||
|
_setOptions(options);
|
||||||
|
initialState = state ?? _getDefaultState(this.options);
|
||||||
|
this.state = initialState;
|
||||||
|
this.meta = meta;
|
||||||
|
_scheduleGc();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _scheduleGc() {
|
||||||
|
this._clearGcTimeout();
|
||||||
|
if (this.cacheTime != null) {
|
||||||
|
_gcTimeout = Timer(cacheTime!, () {
|
||||||
|
this._optionalRemove();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _clearGcTimeout() {
|
||||||
|
_gcTimeout?.cancel();
|
||||||
|
_gcTimeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _optionalRemove() {
|
||||||
|
if (_observers.isEmpty) {
|
||||||
|
if (state.isFetching) {
|
||||||
|
if (_hadObservers) {
|
||||||
|
_scheduleGc();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_cache.remove(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setOptions(QueryOptions<TQueryFnData, TError, TData>? options) {
|
||||||
|
this.options = QueryOptions.fromJson({
|
||||||
|
...(_defaultOptions?.toJson() ?? {}),
|
||||||
|
...(options?.toJson() ?? {}),
|
||||||
|
});
|
||||||
|
meta = options?.meta;
|
||||||
|
|
||||||
|
/// Default to [5 minutes] if cache time isn't set
|
||||||
|
cacheTime = Duration(
|
||||||
|
milliseconds: max(
|
||||||
|
cacheTime?.inMilliseconds ?? 0,
|
||||||
|
this.options.cacheTime?.inMilliseconds ?? 5 * 60 * 1000,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryState<TData, TError> _getDefaultState(
|
||||||
|
QueryOptions<TQueryFnData, TError, TData> options) {
|
||||||
|
var data = options.initialData;
|
||||||
|
bool hasData = data != null;
|
||||||
|
|
||||||
|
DateTime? initialDataUpdatedAt =
|
||||||
|
hasData ? options.initialDataUpdatedAt : null;
|
||||||
|
|
||||||
|
return QueryState(
|
||||||
|
data: data,
|
||||||
|
dataUpdateCount: 0,
|
||||||
|
dataUpdatedAt: hasData ? initialDataUpdatedAt ?? DateTime.now() : null,
|
||||||
|
error: null,
|
||||||
|
errorUpdateCount: 0,
|
||||||
|
errorUpdatedAt: null,
|
||||||
|
fetchFailureCount: 0,
|
||||||
|
fetchMeta: null,
|
||||||
|
isFetching: false,
|
||||||
|
isInvalidated: false,
|
||||||
|
isPaused: false,
|
||||||
|
status: hasData ? QueryStatus.success : QueryStatus.idle,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
TData setData(
|
||||||
|
DataUpdateFunction<TData?, TData> updater, {
|
||||||
|
DateTime? updatedAt,
|
||||||
|
}) {
|
||||||
|
var prevData = this.state.data;
|
||||||
|
var data = updater(prevData);
|
||||||
|
// Use prev data if an isDataEqual function is defined and returns `true`
|
||||||
|
if (this.options.isDataEqual?.call(prevData, data) == true) {
|
||||||
|
data = prevData as TData;
|
||||||
|
} else if (this.options.structuralSharing != false) {
|
||||||
|
// Structurally share data between prev and new data if needed
|
||||||
|
data = replaceEqualDeep<TData>(prevData ?? {} as TData, data);
|
||||||
|
}
|
||||||
|
// Set data and mark it as cached
|
||||||
|
_dispatch(Action(
|
||||||
|
ActionType.success,
|
||||||
|
data: data,
|
||||||
|
dataUpdatedAt: updatedAt,
|
||||||
|
));
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
void setState(
|
||||||
|
QueryState<TData, TError> state, [
|
||||||
|
SetStateOptions? setStateOptions,
|
||||||
|
]) {
|
||||||
|
_dispatch(Action(
|
||||||
|
ActionType.setState,
|
||||||
|
state: state,
|
||||||
|
setStateOptions: setStateOptions,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> cancel({bool? revert, bool? silent}) {
|
||||||
|
var future = _future;
|
||||||
|
_retryer?.cancel(revert: revert, silent: silent);
|
||||||
|
return future != null ? future.then(noop).catchError(noop) : Future.value();
|
||||||
|
}
|
||||||
|
|
||||||
|
void reset() {
|
||||||
|
destroy();
|
||||||
|
setState(initialState);
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
_clearGcTimeout();
|
||||||
|
cancel(silent: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isActive() {
|
||||||
|
return _observers.any((observer) => observer.options.enabled != false);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isFetching() {
|
||||||
|
return this.state.isFetching;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<TData> fetch([
|
||||||
|
QueryOptions<TQueryFnData, TError, TData>? options,
|
||||||
|
ObserverFetchOptions? fetchOptions,
|
||||||
|
]) {
|
||||||
|
if (this.state.isFetching) {
|
||||||
|
if (this.state.dataUpdatedAt != null &&
|
||||||
|
fetchOptions?.cancelRefetch == true) {
|
||||||
|
// Silently cancel current fetch if the user wants to cancel re-fetches
|
||||||
|
this.cancel(silent: true);
|
||||||
|
} else if (_future != null) {
|
||||||
|
// make sure that retries that were potentially cancelled due to unmounts can continue
|
||||||
|
_retryer?.continueRetry();
|
||||||
|
// Return current promise if we are already fetching
|
||||||
|
return _future!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update config if passed, otherwise the config from the last execution is used
|
||||||
|
if (options != null) {
|
||||||
|
_setOptions(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the options from the first observer with a query function if no function is found.
|
||||||
|
// This can happen when the query is hydrated or created with setQueryData.
|
||||||
|
if (this.options.queryFn == null) {
|
||||||
|
var observer =
|
||||||
|
_observers.firstWhereOrNull((x) => x.options.queryFn != null);
|
||||||
|
if (observer != null) {
|
||||||
|
_setOptions(QueryOptions(
|
||||||
|
queryKey: observer.options.queryKey,
|
||||||
|
queryKeyHashFn: observer.options.queryKeyHashFn,
|
||||||
|
cacheTime: observer.options.cacheTime,
|
||||||
|
isDataEqual: observer.options.isDataEqual,
|
||||||
|
queryFn: observer.options.queryFn,
|
||||||
|
queryHash: observer.options.queryHash,
|
||||||
|
initialData: observer.options.initialData,
|
||||||
|
initialDataUpdatedAt: observer.options.initialDataUpdatedAt,
|
||||||
|
meta: observer.options.meta,
|
||||||
|
structuralSharing: observer.options.structuralSharing,
|
||||||
|
defaulted: observer.options.defaulted,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryFunctionContext queryFnContext = QueryFunctionContext(
|
||||||
|
queryKey: queryKey,
|
||||||
|
meta: meta,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// !!LANGUAGE LIMITATION!! There's no equivalent of [AbortController]
|
||||||
|
/// the [get] can be implemented using Dart's getter but it'd be
|
||||||
|
/// useless since there's no equivalent of AbortController.
|
||||||
|
/// Have to find a better way to control ABORTION
|
||||||
|
|
||||||
|
// Object.defineProperty(queryFnContext, 'signal', {
|
||||||
|
// enumerable: true,
|
||||||
|
// get: () {
|
||||||
|
// if (abortController) {
|
||||||
|
// this.abortSignalConsumed = true
|
||||||
|
// return abortController.signal
|
||||||
|
// }
|
||||||
|
// return undefined
|
||||||
|
// },
|
||||||
|
// })
|
||||||
|
|
||||||
|
// Create fetch function
|
||||||
|
fetchFn() {
|
||||||
|
if (this.options.queryFn == null) {
|
||||||
|
return Future.error('Missing queryFn');
|
||||||
|
}
|
||||||
|
_abortSignalConsumed = false;
|
||||||
|
return options?.queryFn?.call(queryFnContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger behavior hook
|
||||||
|
FetchContext<TQueryFnData, TError, TData> context = FetchContext(
|
||||||
|
fetchOptions: fetchOptions,
|
||||||
|
options: this.options,
|
||||||
|
queryKey: queryKey,
|
||||||
|
state: this.state,
|
||||||
|
fetchFn: fetchFn,
|
||||||
|
meta: this.meta,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.options.behavior?.onFetch(context);
|
||||||
|
// Store state in case the current fetch needs to be reverted
|
||||||
|
this.revertState = this.state;
|
||||||
|
|
||||||
|
// Set to fetching state if not already in it
|
||||||
|
if (!this.state.isFetching ||
|
||||||
|
this.state.fetchMeta != context.fetchOptions?.meta) {
|
||||||
|
_dispatch(Action(ActionType.fetch, meta: context.fetchOptions?.meta));
|
||||||
|
}
|
||||||
|
|
||||||
|
_retryer = Retryer(
|
||||||
|
fn: context.fetchFn as FutureOr<TData> Function(),
|
||||||
|
// abort: abortController?.abort?.bind(abortController),
|
||||||
|
onSuccess: (data) {
|
||||||
|
this.setData((_) => data);
|
||||||
|
|
||||||
|
// Notify cache callback
|
||||||
|
_cache.onData?.call(data, this);
|
||||||
|
|
||||||
|
// Remove query after fetching if cache time is 0
|
||||||
|
if (this.cacheTime == null || this.cacheTime == Duration.zero) {
|
||||||
|
_optionalRemove();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (TError error) {
|
||||||
|
// Optimistically update state if needed
|
||||||
|
if (!(isCancelledError(error) && (error as dynamic)?.silent == true)) {
|
||||||
|
_dispatch(Action(ActionType.error, error: error));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isCancelledError(error)) {
|
||||||
|
// Notify cache callback
|
||||||
|
_cache.onError?.call(error, this);
|
||||||
|
|
||||||
|
// Log error
|
||||||
|
// getLogger().error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove query after fetching if cache time is 0
|
||||||
|
if (this.cacheTime == null || this.cacheTime == Duration.zero) {
|
||||||
|
_optionalRemove();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onFail: (failureCount, error) {
|
||||||
|
_dispatch(Action(ActionType.failed));
|
||||||
|
},
|
||||||
|
onPause: () {
|
||||||
|
_dispatch(Action(ActionType.pause));
|
||||||
|
},
|
||||||
|
onContinue: () {
|
||||||
|
_dispatch(Action(ActionType.resume));
|
||||||
|
},
|
||||||
|
retry: context.options.retry,
|
||||||
|
retryDelay: context.options.retryDelay,
|
||||||
|
);
|
||||||
|
|
||||||
|
this._future = _retryer!.future;
|
||||||
|
return this._future!;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _dispatch(Action<TData, TError> action) {
|
||||||
|
this.state = this.reducer(this.state, action);
|
||||||
|
|
||||||
|
notifyManager.batch(() {
|
||||||
|
_observers.forEach((observer) {
|
||||||
|
observer.onQueryUpdate(action);
|
||||||
|
});
|
||||||
|
_cache.notify(QueryCacheNotifyEvent(
|
||||||
|
QueryCacheNotifyEventType.queryUpdated,
|
||||||
|
this,
|
||||||
|
action: action,
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void addObserver(QueryObserver observer) {
|
||||||
|
if (_observers.indexOf(observer) == -1) {
|
||||||
|
_observers.add(observer);
|
||||||
|
_hadObservers = true;
|
||||||
|
|
||||||
|
// Stop the query from being garbage collected
|
||||||
|
_clearGcTimeout();
|
||||||
|
|
||||||
|
_cache.notify(QueryCacheNotifyEvent(
|
||||||
|
QueryCacheNotifyEventType.observerAdded,
|
||||||
|
this,
|
||||||
|
observer: observer,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void removeObserver(QueryObserver observer) {
|
||||||
|
if (_observers.indexOf(observer) != -1) {
|
||||||
|
_observers = _observers.where((x) => x != observer).toList();
|
||||||
|
|
||||||
|
if (_observers.isEmpty) {
|
||||||
|
// If the transport layer does not support cancellation
|
||||||
|
// we'll let the query continue so the result can be cached
|
||||||
|
if (_retryer != null) {
|
||||||
|
if (_retryer?.isTransportCancelable == true || _abortSignalConsumed) {
|
||||||
|
_retryer?.cancel(revert: true);
|
||||||
|
} else {
|
||||||
|
_retryer?.cancelRetry();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cacheTime != null) {
|
||||||
|
_scheduleGc();
|
||||||
|
} else {
|
||||||
|
_cache.remove(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_cache.notify(QueryCacheNotifyEvent(
|
||||||
|
QueryCacheNotifyEventType.observerRemoved,
|
||||||
|
this,
|
||||||
|
observer: observer,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int getObserversCount() {
|
||||||
|
return _observers.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
void invalidate() {
|
||||||
|
if (!this.state.isInvalidated) {
|
||||||
|
_dispatch(Action(ActionType.invalidate));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isStale() {
|
||||||
|
return (this.state.isInvalidated ||
|
||||||
|
this.state.dataUpdatedAt == null ||
|
||||||
|
_observers.any((observer) => observer.getCurrentResult().isStale));
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isStaleByTime(Duration? staleTime) {
|
||||||
|
return (this.state.isInvalidated ||
|
||||||
|
this.state.dataUpdatedAt == null ||
|
||||||
|
timeUntilStale(this.state.dataUpdatedAt!, staleTime) == Duration.zero);
|
||||||
|
}
|
||||||
|
|
||||||
|
void onOnline() {
|
||||||
|
var observer = _observers
|
||||||
|
.firstWhereOrNull((x) => x.shouldFetchCurrentQueryOnReconnect());
|
||||||
|
|
||||||
|
if (observer != null) {
|
||||||
|
observer.refetch();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Continue fetch if currently paused
|
||||||
|
_retryer?.continueFn();
|
||||||
|
}
|
||||||
|
|
||||||
|
@protected
|
||||||
|
QueryState<TData, TError> reducer(
|
||||||
|
QueryState<TData, TError> state,
|
||||||
|
Action<TData, TError> action,
|
||||||
|
) {
|
||||||
|
switch (action.type) {
|
||||||
|
case ActionType.failed:
|
||||||
|
return QueryState.fromJson({
|
||||||
|
...state.toJson(),
|
||||||
|
"fetchFailureCount": state.fetchFailureCount + 1,
|
||||||
|
});
|
||||||
|
case ActionType.fetch:
|
||||||
|
return QueryState.fromJson({
|
||||||
|
...state.toJson(),
|
||||||
|
"fetchFailureCount": 0,
|
||||||
|
"fetchMeta": action.meta,
|
||||||
|
"isFetching": true,
|
||||||
|
"isPaused": false,
|
||||||
|
if (state.dataUpdatedAt == null)
|
||||||
|
...({
|
||||||
|
"error": null,
|
||||||
|
"status": QueryStatus.loading,
|
||||||
|
})
|
||||||
|
});
|
||||||
|
case ActionType.success:
|
||||||
|
return QueryState.fromJson({
|
||||||
|
...state.toJson(),
|
||||||
|
"data": action.data,
|
||||||
|
"dataUpdateCount": state.dataUpdateCount + 1,
|
||||||
|
"dataUpdatedAt": action.dataUpdatedAt ?? DateTime.now(),
|
||||||
|
"error": null,
|
||||||
|
"fetchFailureCount": 0,
|
||||||
|
"isFetching": false,
|
||||||
|
"isInvalidated": false,
|
||||||
|
"isPaused": false,
|
||||||
|
"status": QueryStatus.success,
|
||||||
|
});
|
||||||
|
case ActionType.error:
|
||||||
|
var error = action.error as dynamic;
|
||||||
|
if (isCancelledError(error) &&
|
||||||
|
error?.revert == true &&
|
||||||
|
revertState != null) {
|
||||||
|
return QueryState.fromJson(revertState!.toJson());
|
||||||
|
}
|
||||||
|
|
||||||
|
return QueryState.fromJson({
|
||||||
|
...state.toJson(),
|
||||||
|
"error": error as TError,
|
||||||
|
"errorUpdateCount": state.errorUpdateCount + 1,
|
||||||
|
"errorUpdatedAt": DateTime.now(),
|
||||||
|
"fetchFailureCount": state.fetchFailureCount + 1,
|
||||||
|
"isFetching": false,
|
||||||
|
"isPaused": false,
|
||||||
|
"status": QueryStatus.error,
|
||||||
|
});
|
||||||
|
case ActionType.invalidate:
|
||||||
|
return QueryState.fromJson({
|
||||||
|
...state.toJson(),
|
||||||
|
"isInvalidated": true,
|
||||||
|
});
|
||||||
|
case ActionType.pause:
|
||||||
|
return QueryState.fromJson({
|
||||||
|
...state.toJson(),
|
||||||
|
"isPaused": true,
|
||||||
|
});
|
||||||
|
case ActionType.resume:
|
||||||
|
return QueryState.fromJson({
|
||||||
|
...state.toJson(),
|
||||||
|
"isPaused": false,
|
||||||
|
});
|
||||||
|
case ActionType.setState:
|
||||||
|
return QueryState.fromJson({
|
||||||
|
...state.toJson(),
|
||||||
|
...(action.state?.toJson() ?? {}),
|
||||||
|
});
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import 'package:fl_query/src/core/models.dart';
|
||||||
|
import 'package:fl_query/src/core/notify_manager.dart';
|
||||||
|
import 'package:fl_query/src/core/query.dart';
|
||||||
|
import 'package:fl_query/src/core/query_client.dart';
|
||||||
|
import 'package:fl_query/src/core/query_key.dart';
|
||||||
|
import 'package:fl_query/src/core/subscribable.dart';
|
||||||
|
import 'package:fl_query/src/core/utils.dart';
|
||||||
|
import 'package:collection/collection.dart';
|
||||||
|
|
||||||
|
enum QueryCacheNotifyEventType {
|
||||||
|
observerResultsUpdated,
|
||||||
|
observerRemoved,
|
||||||
|
observerAdded,
|
||||||
|
queryUpdated,
|
||||||
|
queryRemoved,
|
||||||
|
queryAdded
|
||||||
|
}
|
||||||
|
|
||||||
|
class QueryCacheNotifyEvent {
|
||||||
|
Query query;
|
||||||
|
Object? observer;
|
||||||
|
Object? action;
|
||||||
|
QueryCacheNotifyEventType type;
|
||||||
|
QueryCacheNotifyEvent(
|
||||||
|
this.type,
|
||||||
|
this.query, {
|
||||||
|
this.observer,
|
||||||
|
this.action,
|
||||||
|
}) {
|
||||||
|
if ([
|
||||||
|
QueryCacheNotifyEventType.observerAdded,
|
||||||
|
QueryCacheNotifyEventType.observerRemoved
|
||||||
|
].contains(type) &&
|
||||||
|
observer == null)
|
||||||
|
throw Exception(
|
||||||
|
"[QueryCacheNotifyEvent.constructor] property `observer` can't be `null` for `QueryCacheNotifyEventType.observerAdded` & `QueryCacheNotifyEventType.observerRemoved`");
|
||||||
|
if (type == QueryCacheNotifyEventType.queryUpdated && action == null)
|
||||||
|
throw Exception(
|
||||||
|
"[QueryCacheNotifyEvent.constructor] property `action` can't be `null` for `QueryCacheNotifyEventType.queryUpdated`");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
typedef QueryCacheListener = void Function(QueryCacheNotifyEvent? event);
|
||||||
|
typedef QueryCacheOnError = void Function(dynamic error, Query query);
|
||||||
|
typedef QueryCacheOnData = void Function(dynamic data, Query query);
|
||||||
|
typedef QueryHashMap = Map<String, Query>;
|
||||||
|
|
||||||
|
class QueryCache extends Subscribable<QueryCacheListener> {
|
||||||
|
List<Query> _queries;
|
||||||
|
QueryHashMap _queriesMap;
|
||||||
|
|
||||||
|
QueryCacheOnError? onError;
|
||||||
|
QueryCacheOnData? onData;
|
||||||
|
|
||||||
|
QueryCache({
|
||||||
|
this.onData,
|
||||||
|
this.onError,
|
||||||
|
}) : _queries = [],
|
||||||
|
_queriesMap = {},
|
||||||
|
super();
|
||||||
|
|
||||||
|
Query<TQueryFnData, TError, TData> build<TQueryFnData, TError, TData>(
|
||||||
|
QueryClient client, QueryOptions<TQueryFnData, TError, TData> options,
|
||||||
|
[QueryState<TData, TError>? state]) {
|
||||||
|
QueryKey queryKey = options.queryKey!;
|
||||||
|
String queryHash =
|
||||||
|
options.queryHash ?? hashQueryKeyByOptions(queryKey, options);
|
||||||
|
Query<TQueryFnData, TError, TData>? query =
|
||||||
|
get<TQueryFnData, TError, TData>(queryHash);
|
||||||
|
|
||||||
|
if (query == null) {
|
||||||
|
query = Query(
|
||||||
|
cache: this,
|
||||||
|
queryKey: queryKey,
|
||||||
|
queryHash: queryHash,
|
||||||
|
options: client.defaultQueryOptions(options),
|
||||||
|
state: state,
|
||||||
|
defaultOptions: client.getQueryDefaults(queryKey),
|
||||||
|
meta: options.meta,
|
||||||
|
);
|
||||||
|
add(query);
|
||||||
|
}
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
|
||||||
|
void add(Query query) {
|
||||||
|
if (!_queriesMap.containsKey(query.queryHash)) {
|
||||||
|
_queriesMap[query.queryHash] = query;
|
||||||
|
_queries.add(query);
|
||||||
|
notify(
|
||||||
|
QueryCacheNotifyEvent(
|
||||||
|
QueryCacheNotifyEventType.queryAdded,
|
||||||
|
query,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void remove(Query query) {
|
||||||
|
Query? queryInMap = _queriesMap[query.queryHash];
|
||||||
|
if (queryInMap == null) return;
|
||||||
|
query.destroy();
|
||||||
|
_queries = _queries.where((x) => x != query).toList();
|
||||||
|
if (queryInMap == query) {
|
||||||
|
_queriesMap.remove(query.queryHash);
|
||||||
|
}
|
||||||
|
notify(QueryCacheNotifyEvent(
|
||||||
|
QueryCacheNotifyEventType.queryRemoved,
|
||||||
|
query,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
void clear() {
|
||||||
|
notifyManager.batch(() {
|
||||||
|
for (var query in _queries) {
|
||||||
|
remove(query);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Query<TQueryFnData, TError, TData>? get<TQueryFnData, TError, TData>(
|
||||||
|
String queryHash) {
|
||||||
|
return _queriesMap[queryHash] as Query<TQueryFnData, TError, TData>?;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Query> getAll() {
|
||||||
|
return _queries;
|
||||||
|
}
|
||||||
|
|
||||||
|
Query? find<TQueryFnData, TError, TData>(
|
||||||
|
QueryKey queryKey,
|
||||||
|
QueryFilters queryFilters,
|
||||||
|
) {
|
||||||
|
queryFilters.exact ??= true;
|
||||||
|
return _queries
|
||||||
|
.firstWhereOrNull((query) => matchQuery(queryFilters, query));
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Query> findAll(QueryKey? queryKey, [QueryFilters? filters]) {
|
||||||
|
if (queryKey == null && filters == null)
|
||||||
|
throw Exception(
|
||||||
|
"[QueryCache.findAll] both `queryKey` & `filters` can't be null");
|
||||||
|
bool filterIsEmpty =
|
||||||
|
filters?.toJson().entries.every((map) => map.value == null) ?? false;
|
||||||
|
return filterIsEmpty
|
||||||
|
? _queries
|
||||||
|
: _queries.where((query) => matchQuery(filters!, query)).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
void notify(QueryCacheNotifyEvent event) {
|
||||||
|
notifyManager.batch(() {
|
||||||
|
for (var listener in listeners) {
|
||||||
|
listener(event);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onSubscribe() {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onUnsubscribe() {}
|
||||||
|
|
||||||
|
/// Dummy function just to keep the API similar to react-query
|
||||||
|
void onFocus() {}
|
||||||
|
|
||||||
|
void onOnline() {
|
||||||
|
notifyManager.batch(() {
|
||||||
|
_queries.forEach((query) {
|
||||||
|
query.onOnline();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import 'package:fl_query/src/core/models.dart';
|
||||||
|
import 'package:fl_query/src/core/query_cache.dart';
|
||||||
|
|
||||||
|
class QueryClient {
|
||||||
|
Object? options;
|
||||||
|
QueryCache _queryCache;
|
||||||
|
QueryCache _mutationCache;
|
||||||
|
|
||||||
|
QueryClient({
|
||||||
|
QueryCache? queryCache,
|
||||||
|
QueryCache? mutationCache,
|
||||||
|
this.options,
|
||||||
|
}) : _queryCache = queryCache ?? QueryCache(),
|
||||||
|
_mutationCache = mutationCache ?? QueryCache() {}
|
||||||
|
|
||||||
|
QueryObserverOptions<TQueryFnData, TError, TData, TQueryData>
|
||||||
|
defaultQueryOptions<TQueryFnData, TError, TData, TQueryData>(
|
||||||
|
QueryObserverOptions<TQueryFnData, TError, TData, TQueryData>?
|
||||||
|
options) {}
|
||||||
|
|
||||||
|
QueryObserverOptions<TQueryFnData, TError, TData, TQueryData>
|
||||||
|
defaultQueryObserverOptions<TQueryFnData, TError, TData, TQueryData>(
|
||||||
|
QueryObserverOptions<TQueryFnData, TError, TData, TQueryData>?
|
||||||
|
options) {
|
||||||
|
return this.defaultQueryOptions(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchQuery() {}
|
||||||
|
getQueryData() {}
|
||||||
|
setQueryData() {}
|
||||||
|
getQueryState() {}
|
||||||
|
invalidateQueries() {}
|
||||||
|
refetchQueries() {}
|
||||||
|
cancelQueries() {}
|
||||||
|
removeQueries() {}
|
||||||
|
resetQueries() {}
|
||||||
|
bool get isFetching => false;
|
||||||
|
bool get isMutating => false;
|
||||||
|
getDefaultOptions() {}
|
||||||
|
setDefaultOptions() {}
|
||||||
|
getQueryDefaults() {}
|
||||||
|
setQueryDefaults() {}
|
||||||
|
getMutationDefaults() {}
|
||||||
|
setMutationDefaults() {}
|
||||||
|
QueryCache getQueryCache() {}
|
||||||
|
getMutationCache() {}
|
||||||
|
clear() {}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ class QueryKey {
|
|||||||
QueryKey.parse(String keyStr) : _key = keyStr.split(".");
|
QueryKey.parse(String keyStr) : _key = keyStr.split(".");
|
||||||
|
|
||||||
String get key => _key.map((k) => k.replaceAll(".", "")).join(".");
|
String get key => _key.map((k) => k.replaceAll(".", "")).join(".");
|
||||||
|
List<String> get keyAsList => _key;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
|
|||||||
@@ -1,547 +0,0 @@
|
|||||||
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,660 @@
|
|||||||
|
/// `TQueryData`, `TQueryFnData`, `TData` should be [Map]s for shallow/deep equality checks
|
||||||
|
/// Or these can be data classes that have `toJson` method & `fromJson`
|
||||||
|
/// constructor. This also requires the data-class to be passed to the
|
||||||
|
/// [Query] constructor parameters e.g ([dataType])
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:fl_query/src/core/models.dart';
|
||||||
|
import 'package:fl_query/src/core/notify_manager.dart';
|
||||||
|
import 'package:fl_query/src/core/query.dart';
|
||||||
|
import 'package:fl_query/src/core/query_cache.dart';
|
||||||
|
import 'package:fl_query/src/core/query_client.dart';
|
||||||
|
import 'package:fl_query/src/core/retryer.dart';
|
||||||
|
import 'package:fl_query/src/core/subscribable.dart';
|
||||||
|
import 'package:fl_query/src/core/utils.dart';
|
||||||
|
import 'package:meta/meta.dart';
|
||||||
|
|
||||||
|
typedef QueryObserverListener<TData, TError> = void Function(
|
||||||
|
QueryObserverResult<TData, TError> result);
|
||||||
|
|
||||||
|
class NotifyOptions {
|
||||||
|
bool? cache;
|
||||||
|
bool? listeners;
|
||||||
|
bool? onError;
|
||||||
|
bool? onSuccess;
|
||||||
|
|
||||||
|
NotifyOptions({this.cache, this.listeners, this.onError, this.onSuccess});
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
final Map<String, dynamic> data = new Map<String, dynamic>();
|
||||||
|
data['cache'] = this.cache;
|
||||||
|
data['listeners'] = this.listeners;
|
||||||
|
data['onError'] = this.onError;
|
||||||
|
data['onSuccess'] = this.onSuccess;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
NotifyOptions.fromJson(Map<String, dynamic> json) {
|
||||||
|
cache = json['cache'];
|
||||||
|
listeners = json['listeners'];
|
||||||
|
onError = json['onError'];
|
||||||
|
onSuccess = json['onSuccess'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ObserverFetchOptions extends FetchOptions {
|
||||||
|
bool? throwOnError;
|
||||||
|
ObserverFetchOptions({
|
||||||
|
this.throwOnError,
|
||||||
|
bool? cancelRefetch,
|
||||||
|
dynamic meta,
|
||||||
|
}) : super(cancelRefetch: cancelRefetch, meta: meta);
|
||||||
|
}
|
||||||
|
|
||||||
|
class SelectQuery<TQueryData, TData> {
|
||||||
|
TData Function(TQueryData data) fn;
|
||||||
|
TData result;
|
||||||
|
SelectQuery(this.fn, this.result);
|
||||||
|
}
|
||||||
|
|
||||||
|
class QueryObserver<TQueryFnData, TError, TData, TQueryData>
|
||||||
|
extends Subscribable<QueryObserverListener> {
|
||||||
|
late QueryObserverOptions<TQueryFnData, TError, TData, TQueryData> options;
|
||||||
|
QueryClient _client;
|
||||||
|
Query<TQueryFnData, TError, TQueryData>? _currentQuery;
|
||||||
|
late QueryState<TQueryData, TError> _currentQueryInitialState;
|
||||||
|
late QueryObserverResult<TData, TError> _currentResult;
|
||||||
|
QueryState<TQueryData, TError>? _currentResultState;
|
||||||
|
QueryObserverOptions<TQueryFnData, TError, TData, TQueryData>?
|
||||||
|
_currentResultOptions;
|
||||||
|
QueryObserverResult<TData, TError>? _previousQueryResult;
|
||||||
|
Exception? _previousSelectError;
|
||||||
|
SelectQuery? _previousSelect;
|
||||||
|
Timer? _staleTimeout;
|
||||||
|
Timer? _refetchInterval;
|
||||||
|
Duration? _currentRefetchInterval;
|
||||||
|
|
||||||
|
/// List of tracked keys/properties of [QueryObserverResult]
|
||||||
|
late List<String> _trackedProps;
|
||||||
|
|
||||||
|
QueryObserver(this._client, options)
|
||||||
|
: _trackedProps = [],
|
||||||
|
_previousSelectError = null {
|
||||||
|
this.setOptions(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool shouldFetchCurrentQueryOnReconnect() {
|
||||||
|
return shouldFetchOnReconnect(_currentQuery!, this.options);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onSubscribe() {
|
||||||
|
if (listeners.length == 1) {
|
||||||
|
_currentQuery?.addObserver(this);
|
||||||
|
|
||||||
|
if (_currentQuery != null &&
|
||||||
|
shouldFetchOnMount(_currentQuery!, options)) {
|
||||||
|
_executeFetch();
|
||||||
|
}
|
||||||
|
|
||||||
|
_updateTimers();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onUnsubscribe() {
|
||||||
|
if (listeners.isEmpty) {
|
||||||
|
this.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void destroy() {
|
||||||
|
listeners = [];
|
||||||
|
_clearTimers();
|
||||||
|
_currentQuery?.removeObserver(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
setOptions(
|
||||||
|
QueryObserverOptions<TQueryFnData, TError, TData, TQueryData>? options, [
|
||||||
|
NotifyOptions? notifyOptions,
|
||||||
|
]) {
|
||||||
|
var prevOptions = this.options;
|
||||||
|
var prevQuery = _currentQuery;
|
||||||
|
|
||||||
|
this.options = this._client.defaultQueryObserverOptions(options);
|
||||||
|
|
||||||
|
this.options.queryKey ??= prevOptions.queryKey;
|
||||||
|
|
||||||
|
_updateQuery();
|
||||||
|
|
||||||
|
bool mounted = hasListeners();
|
||||||
|
|
||||||
|
if (mounted &&
|
||||||
|
_currentQuery != null &&
|
||||||
|
prevQuery != null &&
|
||||||
|
shouldFetchOptionally(
|
||||||
|
_currentQuery!, prevQuery, this.options, prevOptions)) {
|
||||||
|
_executeFetch();
|
||||||
|
}
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryObserverResult<TData, TError> getOptimisticResult(
|
||||||
|
QueryObserverOptions<TQueryFnData, TError, TData, TQueryData> options,
|
||||||
|
) {
|
||||||
|
final defaultedOptions = _client.defaultQueryObserverOptions(options);
|
||||||
|
|
||||||
|
final query = _client.getQueryCache().build(_client, defaultedOptions);
|
||||||
|
|
||||||
|
return createResult(query, defaultedOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryObserverResult<TData, TError> getCurrentResult() {
|
||||||
|
return _currentResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// There's nothing similar to JS [defineProperty] in dart native
|
||||||
|
/// objects thus modifying the underlying property `get` method is
|
||||||
|
/// impossible so [trackProp] can't be implemented at the moment
|
||||||
|
/// At least not following this procedure
|
||||||
|
QueryObserverResult<TData, TError> trackResult(
|
||||||
|
QueryObserverResult<TData, TError> result,
|
||||||
|
QueryObserverOptions<TQueryFnData, TError, TData, TQueryData>
|
||||||
|
defaultedOptions,
|
||||||
|
) {
|
||||||
|
// final Map<String, dynamic> trackedResult = <String, dynamic>{};
|
||||||
|
// const trackProp = (key: keyof QueryObserverResult) => {
|
||||||
|
// if (!this.trackedProps.includes(key)) {
|
||||||
|
// this.trackedProps.push(key)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// Object.keys(result).forEach(key => {
|
||||||
|
// Object.defineProperty(trackedResult, key, {
|
||||||
|
// configurable: false,
|
||||||
|
// enumerable: true,
|
||||||
|
// get: () => {
|
||||||
|
// trackProp(key as keyof QueryObserverResult)
|
||||||
|
// return result[key as keyof QueryObserverResult]
|
||||||
|
// },
|
||||||
|
// })
|
||||||
|
// })
|
||||||
|
// if (defaultedOptions.useErrorBoundary || defaultedOptions.suspense) {
|
||||||
|
// trackProp('error')
|
||||||
|
// }
|
||||||
|
// return trackedResult
|
||||||
|
|
||||||
|
throw UnimplementedError("COULD NOT IMPLEMENT DUE TO LANGUAGE LIMITATIONS");
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<QueryObserverResult<TData, TError>> getNextResult(
|
||||||
|
ResultOptions? options) {
|
||||||
|
var completer = Completer<QueryObserverResult<TData, TError>>();
|
||||||
|
var unsubscribe;
|
||||||
|
unsubscribe = subscribe((result) {
|
||||||
|
if (!result.isFetching) {
|
||||||
|
unsubscribe?.call();
|
||||||
|
if (result.isError && options?.throwOnError == true) {
|
||||||
|
if (!completer.isCompleted)
|
||||||
|
completer.completeError(result.error as Object);
|
||||||
|
} else {
|
||||||
|
if (!completer.isCompleted)
|
||||||
|
completer.complete(
|
||||||
|
result as QueryObserverResult<TData, TError>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return completer.future;
|
||||||
|
}
|
||||||
|
|
||||||
|
Query<TQueryFnData, TError, TQueryData> getCurrentQuery() {
|
||||||
|
return _currentQuery!;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<QueryObserverResult<TData, TError>> fetchOptimistic(
|
||||||
|
QueryObserverOptions<TQueryFnData, TError, TData, TQueryData> options) {
|
||||||
|
var defaultedOptions = _client.defaultQueryObserverOptions(options);
|
||||||
|
var query = _client.getQueryCache().build(_client, defaultedOptions);
|
||||||
|
|
||||||
|
return query.fetch().then((val) {
|
||||||
|
return createResult(query, defaultedOptions);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@protected
|
||||||
|
Future<QueryObserverResult<TData, TError>> fetch(
|
||||||
|
ObserverFetchOptions fetchOptions,
|
||||||
|
) {
|
||||||
|
return _executeFetch(fetchOptions).then((val) {
|
||||||
|
updateResult();
|
||||||
|
return _currentResult;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<TQueryData?> _executeFetch([ObserverFetchOptions? fetchOptions]) {
|
||||||
|
// Make sure we reference the latest query as the current one might have been removed
|
||||||
|
_updateQuery();
|
||||||
|
// Fetch
|
||||||
|
Future<TQueryData?> future = _currentQuery!.fetch(
|
||||||
|
this.options,
|
||||||
|
fetchOptions,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (fetchOptions?.throwOnError != null) {
|
||||||
|
future = future.catchError((e) => e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return future;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _shouldNotifyListeners(QueryObserverResult<TData, TError> result,
|
||||||
|
[QueryObserverResult<TData, TError>? prevResult]) {
|
||||||
|
if (prevResult == null) return true;
|
||||||
|
if (!options.notifyOnChangeProps &&
|
||||||
|
options.notifyOnChangePropsExclusions == null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.notifyOnChangeProps == 'tracked' && _trackedProps.isEmpty) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String>? includedProps = options.notifyOnChangeProps == 'tracked'
|
||||||
|
? _trackedProps
|
||||||
|
: options.notifyOnChangeProps;
|
||||||
|
|
||||||
|
Map<String, dynamic> resultMap = result.toJson();
|
||||||
|
Map<String, dynamic> prevResultMap = prevResult.toJson();
|
||||||
|
|
||||||
|
return resultMap.keys.any((key) {
|
||||||
|
var changed = resultMap[key] != prevResultMap[key];
|
||||||
|
bool? isIncluded = includedProps?.any((x) => x == key);
|
||||||
|
bool isExcluded =
|
||||||
|
options.notifyOnChangePropsExclusions?.any((x) => x == key) ?? false;
|
||||||
|
return changed &&
|
||||||
|
!isExcluded &&
|
||||||
|
(includedProps == null || isIncluded == true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void updateResult([NotifyOptions? notifyOptions]) {
|
||||||
|
QueryObserverResult<TData, TError>? prevResult = _currentResult;
|
||||||
|
|
||||||
|
if (_currentQuery != null)
|
||||||
|
_currentResult = this.createResult(_currentQuery!, this.options);
|
||||||
|
_currentResultState = _currentQuery?.state;
|
||||||
|
_currentResultOptions = this.options;
|
||||||
|
|
||||||
|
// Only notify if something has changed
|
||||||
|
if (shallowEqualMap(_currentResult.toJson(), prevResult.toJson())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
NotifyOptions defaultNotifyOptions = NotifyOptions(cache: true);
|
||||||
|
if (notifyOptions?.listeners != false &&
|
||||||
|
_shouldNotifyListeners(_currentResult, prevResult)) {
|
||||||
|
defaultNotifyOptions.listeners = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
_notify(NotifyOptions.fromJson({
|
||||||
|
...defaultNotifyOptions.toJson(),
|
||||||
|
...(notifyOptions?.toJson() ?? {}),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _updateQuery() {
|
||||||
|
var query = this._client.getQueryCache().build(this._client, this.options);
|
||||||
|
|
||||||
|
if (query == _currentQuery) return;
|
||||||
|
|
||||||
|
var prevQuery = _currentQuery;
|
||||||
|
_currentQuery = query;
|
||||||
|
_currentQueryInitialState = query.state;
|
||||||
|
_previousQueryResult = _currentResult;
|
||||||
|
|
||||||
|
if (hasListeners()) {
|
||||||
|
prevQuery?.removeObserver(this);
|
||||||
|
query.addObserver(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void onQueryUpdate(Action<TData, TError> action) {
|
||||||
|
final NotifyOptions notifyOptions = NotifyOptions();
|
||||||
|
|
||||||
|
if (action.type == 'success') {
|
||||||
|
notifyOptions.onSuccess = true;
|
||||||
|
} else if (action.type == 'error' && !isCancelledError(action.error)) {
|
||||||
|
notifyOptions.onError = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateResult(notifyOptions);
|
||||||
|
|
||||||
|
if (this.hasListeners()) {
|
||||||
|
_updateTimers();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryObserverResult<TData, TError> createResult(
|
||||||
|
Query<TQueryFnData, TError, TQueryData> query,
|
||||||
|
QueryObserverOptions<TQueryFnData, TError, TData, TQueryData> options,
|
||||||
|
) {
|
||||||
|
var prevQuery = _currentQuery;
|
||||||
|
var prevOptions = this.options;
|
||||||
|
var prevResult = _currentResult;
|
||||||
|
var prevResultState = _currentResultState;
|
||||||
|
var prevResultOptions = _currentResultOptions;
|
||||||
|
bool queryChange = query != prevQuery;
|
||||||
|
var queryInitialState =
|
||||||
|
queryChange ? query.state : _currentQueryInitialState;
|
||||||
|
var prevQueryResult = queryChange ? _currentResult : _previousQueryResult;
|
||||||
|
|
||||||
|
var state = query.state;
|
||||||
|
var dataUpdatedAt = state.dataUpdatedAt;
|
||||||
|
var error = state.error;
|
||||||
|
var errorUpdatedAt = state.errorUpdatedAt;
|
||||||
|
var isFetching = state.isFetching;
|
||||||
|
var status = state.status;
|
||||||
|
|
||||||
|
bool isPreviousData = false;
|
||||||
|
bool isPlaceholderData = false;
|
||||||
|
TData? data;
|
||||||
|
|
||||||
|
// Optimistically set result in fetching state if needed
|
||||||
|
if (options.optimisticResults == true) {
|
||||||
|
var mounted = hasListeners();
|
||||||
|
|
||||||
|
var fetchOnMount = !mounted && shouldFetchOnMount(query, options);
|
||||||
|
|
||||||
|
bool fetchOptionally = mounted &&
|
||||||
|
prevQuery != null &&
|
||||||
|
shouldFetchOptionally(query, prevQuery, options, prevOptions);
|
||||||
|
|
||||||
|
if (fetchOnMount || fetchOptionally) {
|
||||||
|
isFetching = true;
|
||||||
|
if (dataUpdatedAt == null) {
|
||||||
|
status = QueryStatus.loading;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep previous data if needed
|
||||||
|
if (prevQueryResult != null &&
|
||||||
|
options.keepPreviousData == true &&
|
||||||
|
state.dataUpdateCount == 0 &&
|
||||||
|
prevQueryResult.isSuccess == true &&
|
||||||
|
status != QueryStatus.error) {
|
||||||
|
data = prevQueryResult.data;
|
||||||
|
dataUpdatedAt = prevQueryResult.dataUpdatedAt;
|
||||||
|
status = prevQueryResult.status;
|
||||||
|
isPreviousData = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select data if needed
|
||||||
|
else if (options.select != null && state.data != null) {
|
||||||
|
if (prevResult != null &&
|
||||||
|
state.data == prevResultState?.data &&
|
||||||
|
options.select == _previousSelect?.fn &&
|
||||||
|
_previousSelectError == null) {
|
||||||
|
data = _previousSelect?.result;
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
data = options.select?.call(state.data);
|
||||||
|
if (options.structuralSharing != false) {
|
||||||
|
data = replaceEqualDeep(prevResult?.data, data);
|
||||||
|
}
|
||||||
|
if (options.select != null && data != null) {
|
||||||
|
_previousSelect = SelectQuery<TQueryData, TData>(
|
||||||
|
options.select!,
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_previousSelectError = null;
|
||||||
|
} catch (selectError) {
|
||||||
|
// getLogger().error(selectError);
|
||||||
|
error = selectError as TError;
|
||||||
|
_previousSelectError = selectError as Exception;
|
||||||
|
errorUpdatedAt = DateTime.now();
|
||||||
|
status = QueryStatus.error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Use query data
|
||||||
|
else {
|
||||||
|
data = state.data as TData;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.placeholderData != null &&
|
||||||
|
data == null &&
|
||||||
|
(status == QueryStatus.loading || status == QueryStatus.idle)) {
|
||||||
|
var placeholderData;
|
||||||
|
|
||||||
|
if (prevResult?.isPlaceholderData == true &&
|
||||||
|
options.placeholderData == prevResultOptions?.placeholderData) {
|
||||||
|
placeholderData = prevResult.data;
|
||||||
|
} else {
|
||||||
|
placeholderData = options.placeholderData;
|
||||||
|
if (options.select != null && placeholderData != null) {
|
||||||
|
try {
|
||||||
|
placeholderData = options.select?.call(placeholderData);
|
||||||
|
if (options.structuralSharing != false) {
|
||||||
|
placeholderData =
|
||||||
|
replaceEqualDeep(prevResult?.data, placeholderData);
|
||||||
|
}
|
||||||
|
_previousSelectError = null;
|
||||||
|
} catch (selectError) {
|
||||||
|
// getLogger().error(selectError);
|
||||||
|
error = selectError as TError;
|
||||||
|
_previousSelectError = selectError as Exception;
|
||||||
|
errorUpdatedAt = DateTime.now();
|
||||||
|
status = QueryStatus.error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (placeholderData != null) {
|
||||||
|
status = QueryStatus.success;
|
||||||
|
data = placeholderData as TData;
|
||||||
|
isPlaceholderData = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryObserverResult<TData, TError> result = QueryObserverResult(
|
||||||
|
status: status,
|
||||||
|
dataUpdatedAt: dataUpdatedAt,
|
||||||
|
isLoading: status == QueryStatus.loading,
|
||||||
|
isSuccess: status == QueryStatus.success,
|
||||||
|
isError: status == QueryStatus.error,
|
||||||
|
isIdle: status == QueryStatus.idle,
|
||||||
|
data: data,
|
||||||
|
error: error,
|
||||||
|
failureCount: state.fetchFailureCount,
|
||||||
|
isFetched: state.dataUpdateCount > 0 || state.errorUpdateCount > 0,
|
||||||
|
isFetchedAfterMount:
|
||||||
|
state.dataUpdateCount > queryInitialState.dataUpdateCount ||
|
||||||
|
state.errorUpdateCount > queryInitialState.errorUpdateCount,
|
||||||
|
isFetching: isFetching,
|
||||||
|
isRefetching: isFetching && status != QueryStatus.loading,
|
||||||
|
isLoadingError:
|
||||||
|
status == QueryStatus.error && state.dataUpdatedAt == null,
|
||||||
|
isPlaceholderData: isPlaceholderData,
|
||||||
|
isPreviousData: isPreviousData,
|
||||||
|
isRefetchError: status == 'error' && state.dataUpdatedAt != 0,
|
||||||
|
isStale: isStale(query, options),
|
||||||
|
refetch: this.refetch,
|
||||||
|
remove: this.remove,
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _notify(NotifyOptions notifyOptions) {
|
||||||
|
notifyManager.batch(() {
|
||||||
|
// First trigger the configuration callbacks
|
||||||
|
if (notifyOptions.onSuccess == true) {
|
||||||
|
this.options.onSuccess?.call(_currentResult.data!);
|
||||||
|
this.options.onSettled?.call(_currentResult.data!);
|
||||||
|
} else if (notifyOptions.onError == true) {
|
||||||
|
this.options.onError?.call(_currentResult.error!);
|
||||||
|
this.options.onSettled?.call(null, _currentResult.error!);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then trigger the listeners
|
||||||
|
if (notifyOptions.listeners == true) {
|
||||||
|
this.listeners.forEach((listener) {
|
||||||
|
listener(_currentResult);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then the cache listeners
|
||||||
|
if (notifyOptions.cache == true && _currentQuery != null) {
|
||||||
|
_client.getQueryCache().notify(
|
||||||
|
QueryCacheNotifyEvent(
|
||||||
|
QueryCacheNotifyEventType.observerResultsUpdated,
|
||||||
|
_currentQuery as Query,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Duration? _computeRefetchInterval() {
|
||||||
|
return this.options.refetchInterval != null && _currentQuery != null
|
||||||
|
? this.options.refetchInterval!(_currentResult.data, _currentQuery!)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _updateTimers() {
|
||||||
|
_updateStaleTimeout();
|
||||||
|
_updateRefetchInterval(_computeRefetchInterval());
|
||||||
|
}
|
||||||
|
|
||||||
|
void _updateStaleTimeout() {
|
||||||
|
_clearStaleTimeout();
|
||||||
|
if (_currentResult.isStale ||
|
||||||
|
options.staleTime == null ||
|
||||||
|
_currentResult.dataUpdatedAt == null) return;
|
||||||
|
|
||||||
|
// The timeout is sometimes triggered 1 ms before the stale time
|
||||||
|
// expiration. To mitigate this issue we always add 1 ms to the
|
||||||
|
// timeout.
|
||||||
|
Duration time = Duration(
|
||||||
|
milliseconds:
|
||||||
|
timeUntilStale(_currentResult.dataUpdatedAt!, this.options.staleTime)
|
||||||
|
.inMilliseconds +
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
|
||||||
|
_staleTimeout = Timer(time, () {
|
||||||
|
if (!_currentResult.isStale) {
|
||||||
|
this.updateResult();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_updateRefetchInterval(Duration? nextInterval) {
|
||||||
|
_clearRefetchInterval();
|
||||||
|
|
||||||
|
_currentRefetchInterval = nextInterval;
|
||||||
|
|
||||||
|
if (this.options.enabled == false ||
|
||||||
|
_currentRefetchInterval == null ||
|
||||||
|
_currentRefetchInterval == Duration.zero) return;
|
||||||
|
|
||||||
|
_refetchInterval = Timer.periodic(_currentRefetchInterval!, (t) {
|
||||||
|
if (this.options.refetchIntervalInBackground == true) {
|
||||||
|
_executeFetch();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _clearTimers() {
|
||||||
|
_clearStaleTimeout();
|
||||||
|
_clearRefetchInterval();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _clearStaleTimeout() {
|
||||||
|
_staleTimeout?.cancel();
|
||||||
|
_staleTimeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _clearRefetchInterval() {
|
||||||
|
_refetchInterval?.cancel();
|
||||||
|
_refetchInterval = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void remove() {
|
||||||
|
_client.getQueryCache().remove(_currentQuery as Query);
|
||||||
|
_clearTimers();
|
||||||
|
_currentQuery?.removeObserver(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<QueryObserverResult<TData, TError>> refetch<TPageData>({
|
||||||
|
RefetchQueryFilters<TPageData>? filters,
|
||||||
|
RefetchOptions? options,
|
||||||
|
}) {
|
||||||
|
return fetch(
|
||||||
|
ObserverFetchOptions(
|
||||||
|
cancelRefetch: options?.cancelRefetch,
|
||||||
|
meta: filters?.toJson(),
|
||||||
|
throwOnError: options?.throwOnError,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool shouldLoadOnMount<TQueryFnData, TError, TData, TQueryData>(
|
||||||
|
Query<TQueryFnData, TError, TQueryData> query,
|
||||||
|
QueryObserverOptions<TQueryFnData, TError, TData, TQueryData> options,
|
||||||
|
) {
|
||||||
|
return (options.enabled != false &&
|
||||||
|
query.state.dataUpdatedAt != null &&
|
||||||
|
!(query.state.status == QueryStatus.error &&
|
||||||
|
options.retryOnMount == false));
|
||||||
|
}
|
||||||
|
|
||||||
|
bool shouldRefetchOnMount<TQueryFnData, TError, TData, TQueryData>(
|
||||||
|
Query<TQueryFnData, TError, TQueryData> query,
|
||||||
|
QueryObserverOptions<TQueryFnData, TError, TData, TQueryData> options,
|
||||||
|
) {
|
||||||
|
return (options.enabled != false &&
|
||||||
|
query.state.dataUpdatedAt != null &&
|
||||||
|
(options.refetchOnMount == RefetchOnMount.always ||
|
||||||
|
(options.refetchOnMount != RefetchOnMount.off &&
|
||||||
|
isStale(query, options))));
|
||||||
|
}
|
||||||
|
|
||||||
|
bool shouldFetchOnMount<TQueryFnData, TError, TData, TQueryData>(
|
||||||
|
Query<TQueryFnData, TError, TQueryData> query,
|
||||||
|
QueryObserverOptions<TQueryFnData, TError, TData, TQueryData> options,
|
||||||
|
) {
|
||||||
|
return (shouldLoadOnMount(query, options) ||
|
||||||
|
shouldRefetchOnMount(query, options));
|
||||||
|
}
|
||||||
|
|
||||||
|
bool shouldFetchOnReconnect<TQueryFnData, TError, TData, TQueryData>(
|
||||||
|
Query<TQueryFnData, TError, TQueryData> query,
|
||||||
|
QueryObserverOptions<TQueryFnData, TError, TData, TQueryData> options,
|
||||||
|
) {
|
||||||
|
return (options.enabled != false &&
|
||||||
|
(options.refetchOnReconnect == RefetchOnReconnect.always ||
|
||||||
|
(options.refetchOnReconnect != RefetchOnReconnect.off &&
|
||||||
|
isStale(query, options))));
|
||||||
|
}
|
||||||
|
|
||||||
|
bool shouldFetchOptionally<TQueryFnData, TError, TData, TQueryData>(
|
||||||
|
Query<TQueryFnData, TError, TQueryData> query,
|
||||||
|
Query<TQueryFnData, TError, TQueryData> prevQuery,
|
||||||
|
QueryObserverOptions<TQueryFnData, TError, TData, TQueryData> options,
|
||||||
|
QueryObserverOptions<TQueryFnData, TError, TData, TQueryData> prevOptions,
|
||||||
|
) {
|
||||||
|
return (options.enabled != false &&
|
||||||
|
(query != prevQuery || prevOptions.enabled == false) &&
|
||||||
|
(options.suspense != true || query.state.status != QueryStatus.error) &&
|
||||||
|
isStale(query, options));
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isStale<TQueryFnData, TError, TData, TQueryData>(
|
||||||
|
Query<TQueryFnData, TError, TQueryData> query,
|
||||||
|
QueryObserverOptions<TQueryFnData, TError, TData, TQueryData> options,
|
||||||
|
) {
|
||||||
|
return query.isStaleByTime(options.staleTime);
|
||||||
|
}
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
// 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,
|
|
||||||
);
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
typedef ResultParserFn<TResult> = TResult Function(Map<String, dynamic> data);
|
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'dart:math' show pow, min;
|
||||||
|
|
||||||
|
import 'package:fl_query/src/core/online_manager.dart';
|
||||||
|
|
||||||
|
typedef ShouldRetryFunction<TError> = int Function(
|
||||||
|
int failureCount,
|
||||||
|
TError error,
|
||||||
|
);
|
||||||
|
typedef RetryDelayFunction<TError> = double Function(
|
||||||
|
int failureCount,
|
||||||
|
TError error,
|
||||||
|
);
|
||||||
|
|
||||||
|
double defaultRetryDelay(int failureCount) {
|
||||||
|
return min(pow(1000 * 2, failureCount), 30000).toDouble();
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class Cancelable {
|
||||||
|
void cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isCancelable(value) {
|
||||||
|
return value is Cancelable;
|
||||||
|
}
|
||||||
|
|
||||||
|
class CancelledError {
|
||||||
|
bool? revert;
|
||||||
|
bool? silent;
|
||||||
|
CancelledError({this.revert, this.silent});
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isCancelledError(value) {
|
||||||
|
return value is CancelledError;
|
||||||
|
}
|
||||||
|
|
||||||
|
typedef OnError<TError> = void Function(TError error);
|
||||||
|
typedef OnData<TData> = void Function(TData data);
|
||||||
|
|
||||||
|
class Retryer<TData, TError> {
|
||||||
|
late void Function({bool? revert, bool? silent}) cancel;
|
||||||
|
late void Function() cancelRetry;
|
||||||
|
late void Function() continueRetry;
|
||||||
|
late void Function() continueFn;
|
||||||
|
late Future<TData> future;
|
||||||
|
int failureCount;
|
||||||
|
bool isPaused;
|
||||||
|
bool isResolved;
|
||||||
|
bool isTransportCancelable;
|
||||||
|
|
||||||
|
// config options for the retryer
|
||||||
|
FutureOr<TData> Function() fn;
|
||||||
|
void Function()? _abort;
|
||||||
|
OnError<TError>? onError;
|
||||||
|
OnData<TData>? onSuccess;
|
||||||
|
void Function(int failureCount, TError error)? onFail;
|
||||||
|
void Function()? onPause;
|
||||||
|
void Function()? onContinue;
|
||||||
|
ShouldRetryFunction<TError>? retry;
|
||||||
|
RetryDelayFunction<TError>? retryDelay;
|
||||||
|
|
||||||
|
Retryer({
|
||||||
|
required this.fn,
|
||||||
|
void Function()? abort,
|
||||||
|
this.onError,
|
||||||
|
this.onSuccess,
|
||||||
|
this.onFail,
|
||||||
|
this.onPause,
|
||||||
|
this.onContinue,
|
||||||
|
this.retry,
|
||||||
|
this.retryDelay,
|
||||||
|
}) : _abort = abort,
|
||||||
|
failureCount = 0,
|
||||||
|
isPaused = false,
|
||||||
|
isResolved = false,
|
||||||
|
isTransportCancelable = false {
|
||||||
|
bool cancelRetry = false;
|
||||||
|
void Function({bool? revert, bool? silent})? cancelFn;
|
||||||
|
void Function([dynamic value])? continueFn;
|
||||||
|
cancel = ({bool? revert, bool? silent}) {
|
||||||
|
cancelFn?.call();
|
||||||
|
};
|
||||||
|
|
||||||
|
this.cancelRetry = () {
|
||||||
|
cancelRetry = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
this.continueRetry = () {
|
||||||
|
cancelRetry = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
this.continueFn = () => continueFn?.call();
|
||||||
|
|
||||||
|
Completer<TData> completer = Completer<TData>();
|
||||||
|
|
||||||
|
this.future = completer.future;
|
||||||
|
|
||||||
|
resolve(value) {
|
||||||
|
if (!this.isResolved) {
|
||||||
|
this.isResolved = true;
|
||||||
|
onSuccess?.call(value);
|
||||||
|
continueFn?.call();
|
||||||
|
if (!completer.isCompleted) completer.complete(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reject(value) {
|
||||||
|
if (!this.isResolved) {
|
||||||
|
this.isResolved = true;
|
||||||
|
onError?.call(value);
|
||||||
|
continueFn?.call();
|
||||||
|
if (!completer.isCompleted) completer.completeError(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pause() {
|
||||||
|
Completer completer = Completer();
|
||||||
|
if (!completer.isCompleted) continueFn = completer.complete;
|
||||||
|
this.isPaused = true;
|
||||||
|
onPause?.call();
|
||||||
|
return completer.future.then((val) {
|
||||||
|
continueFn = null;
|
||||||
|
this.isPaused = false;
|
||||||
|
onContinue?.call();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
run() {
|
||||||
|
// Do nothing if already resolved
|
||||||
|
if (this.isResolved) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var promiseOrValue;
|
||||||
|
|
||||||
|
// Execute query
|
||||||
|
try {
|
||||||
|
promiseOrValue = fn();
|
||||||
|
} catch (error) {
|
||||||
|
promiseOrValue = Future.error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create callback to cancel this fetch
|
||||||
|
cancelFn = ({bool? revert, bool? silent}) {
|
||||||
|
if (!this.isResolved) {
|
||||||
|
reject(new CancelledError(revert: revert, silent: silent));
|
||||||
|
|
||||||
|
abort?.call();
|
||||||
|
|
||||||
|
// Cancel transport if supported
|
||||||
|
if (isCancelable(promiseOrValue)) {
|
||||||
|
try {
|
||||||
|
promiseOrValue.cancel();
|
||||||
|
} catch (error) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if the transport layer support cancellation
|
||||||
|
this.isTransportCancelable = isCancelable(promiseOrValue);
|
||||||
|
Future.value(promiseOrValue).then(resolve).catchError((error) {
|
||||||
|
// Stop if the fetch is already resolved
|
||||||
|
if (this.isResolved) return;
|
||||||
|
// Do we need to retry the request?
|
||||||
|
int _retry = retry?.call(failureCount, error) ?? 3;
|
||||||
|
double _retryDelay = retryDelay?.call(failureCount, error) ??
|
||||||
|
defaultRetryDelay(failureCount);
|
||||||
|
bool shouldRetry = _retry > 0 && _retry > failureCount;
|
||||||
|
if (cancelRetry || !shouldRetry) {
|
||||||
|
// We are done if the query does not need to be retried
|
||||||
|
reject(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.failureCount++;
|
||||||
|
|
||||||
|
// Notify on fail
|
||||||
|
onFail?.call(this.failureCount, error);
|
||||||
|
Future.delayed(Duration(milliseconds: _retryDelay.toInt()))
|
||||||
|
.then((val) async {
|
||||||
|
if (!await onlineManager.isOnline()) {
|
||||||
|
return pause();
|
||||||
|
}
|
||||||
|
}).then((val) {
|
||||||
|
if (cancelRetry) {
|
||||||
|
reject(error);
|
||||||
|
} else {
|
||||||
|
run();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start loop
|
||||||
|
run();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
abstract class Subscribable<TListener extends Function> {
|
||||||
|
@protected
|
||||||
|
List<TListener> listeners;
|
||||||
|
Subscribable() : listeners = [];
|
||||||
|
|
||||||
|
void Function() subscribe(TListener? listener) {
|
||||||
|
var callback = listener ?? (() => null);
|
||||||
|
|
||||||
|
listeners.add(callback as TListener);
|
||||||
|
|
||||||
|
onSubscribe();
|
||||||
|
|
||||||
|
return () {
|
||||||
|
listeners = listeners.where((x) => x != callback).toList();
|
||||||
|
onUnsubscribe();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
bool hasListeners() {
|
||||||
|
return listeners.isNotEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void onSubscribe();
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void onUnsubscribe();
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import 'package:fl_query/src/core/models.dart';
|
||||||
|
import 'package:fl_query/src/core/query.dart';
|
||||||
|
import 'package:fl_query/src/core/query_key.dart';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedules a microtask.
|
||||||
|
* This can be useful to schedule state updates after rendering.
|
||||||
|
*/
|
||||||
|
void scheduleMicrotask(void Function(dynamic val) callback) {
|
||||||
|
Future.value()
|
||||||
|
.then(callback)
|
||||||
|
.catchError((error) => Future.delayed(Duration.zero, () => throw error));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default query keys hash function.
|
||||||
|
/// Dummy function just to fill the gaps for original react-query like
|
||||||
|
/// function body signatures
|
||||||
|
/// It is not required as a Standardized [QueryKey] data-class is used to
|
||||||
|
/// create the queryKey
|
||||||
|
String hashQueryKeyByOptions(
|
||||||
|
QueryKey queryKey,
|
||||||
|
QueryOptions? options,
|
||||||
|
) {
|
||||||
|
return options?.queryKeyHashFn?.call(queryKey) ?? queryKey.key;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum QueryStatusFilter {
|
||||||
|
all,
|
||||||
|
active,
|
||||||
|
inactive,
|
||||||
|
none,
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryStatusFilter mapQueryStatusFilter(
|
||||||
|
bool? active,
|
||||||
|
bool? inactive,
|
||||||
|
) {
|
||||||
|
if ((active == true && inactive == true) ||
|
||||||
|
(active == null && inactive == null)) {
|
||||||
|
return QueryStatusFilter.all;
|
||||||
|
} else if (active == false && inactive == false) {
|
||||||
|
return QueryStatusFilter.none;
|
||||||
|
} else {
|
||||||
|
// At this point, active|inactive can only be true|false or false|true
|
||||||
|
// so, when only one value is provided, the missing one has to be the negated value
|
||||||
|
bool isActive = active ?? !(inactive ?? false);
|
||||||
|
return isActive ? QueryStatusFilter.active : QueryStatusFilter.inactive;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool matchQuery(QueryFilters filters, Query query, [QueryKey? queryKey]) {
|
||||||
|
if (queryKey != null) {
|
||||||
|
if (filters.exact! &&
|
||||||
|
query.queryHash != hashQueryKeyByOptions(queryKey, query.options))
|
||||||
|
return false;
|
||||||
|
else if (query.queryKey.key != queryKey) return false;
|
||||||
|
}
|
||||||
|
QueryStatusFilter queryStatusFilter =
|
||||||
|
mapQueryStatusFilter(filters.active, filters.inactive);
|
||||||
|
|
||||||
|
if (queryStatusFilter == QueryStatusFilter.none) {
|
||||||
|
return false;
|
||||||
|
} else if (queryStatusFilter != QueryStatusFilter.all) {
|
||||||
|
bool isActive = query.isActive();
|
||||||
|
if (queryStatusFilter == QueryStatusFilter.active && !isActive) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (queryStatusFilter == QueryStatusFilter.inactive && isActive) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.stale != null && query.isStale() != filters.stale) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.fetching != null && query.isFetching() != filters.fetching) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.predicate != null && !filters.predicate!(query)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void noop([e]) => null;
|
||||||
|
|
||||||
|
bool shallowEqualMap(Map? a, Map? b) {
|
||||||
|
if ((a != null && b == null) || (b != null && a == null)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var item in a!.entries) {
|
||||||
|
var aVal = item.value;
|
||||||
|
var bVal = b?[item.key];
|
||||||
|
if (aVal != bVal) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This function returns `a` if `b` is deeply equal\
|
||||||
|
/// If not, it will replace any deeply equal children of `b` with those
|
||||||
|
/// of `a`\
|
||||||
|
/// This can be used for structural sharing between JSON values for example.
|
||||||
|
/// `a` & `b` can only be Type of [Iterable] or [Map]
|
||||||
|
T replaceEqualDeep<T>(T a, T b) {
|
||||||
|
if (a == b) {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isList = (a is Iterable && b is Iterable);
|
||||||
|
if (isList || (a is Map && b is Map)) {
|
||||||
|
var aSize = isList ? a.length : (a as Map).keys.length;
|
||||||
|
var bItems = isList ? b : (b as Map).keys;
|
||||||
|
var bSize = bItems.length;
|
||||||
|
var copy;
|
||||||
|
|
||||||
|
int equalItems = 0;
|
||||||
|
|
||||||
|
for (int i = 0; i < bSize; i++) {
|
||||||
|
var key = isList ? i : (bItems as Map)[i];
|
||||||
|
if (isList) {
|
||||||
|
copy ??= [];
|
||||||
|
copy[key] = replaceEqualDeep((a as List)[key], (b as List)[key]);
|
||||||
|
if (copy[key] == a[key]) {
|
||||||
|
equalItems++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
copy ??= {};
|
||||||
|
copy[key] = replaceEqualDeep((a as Map)[key], (b as Map)[key]);
|
||||||
|
if (copy[key] == a[key]) {
|
||||||
|
equalItems++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return aSize == bSize && equalItems == aSize ? a : copy as T;
|
||||||
|
}
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
Duration timeUntilStale(DateTime updatedAt, [Duration? staleTime]) =>
|
||||||
|
updatedAt.add(staleTime ?? Duration.zero).difference(DateTime.now());
|
||||||
|
|
||||||
|
typedef DataUpdateFunction<TInput, TOutput> = TOutput Function(TInput input);
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
/// 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';
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
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;
|
|
||||||
// }
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@@ -1,273 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
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';
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
// 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';
|
|
||||||
@@ -1,498 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,248 +0,0 @@
|
|||||||
// 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};
|
|
||||||
}
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export './platform_html.dart' if (dart.library.io) './platform_io.dart';
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
@@ -27,6 +27,7 @@ dependencies:
|
|||||||
stream_channel: ^2.1.0
|
stream_channel: ^2.1.0
|
||||||
rxdart: ^0.27.1
|
rxdart: ^0.27.1
|
||||||
uuid: ^3.0.1
|
uuid: ^3.0.1
|
||||||
|
internet_connection_checker: ^0.0.1+3
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
async: ^2.5.0
|
async: ^2.5.0
|
||||||
|
|||||||
@@ -344,7 +344,7 @@ void main() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
r.exception!.linkException!.originalException,
|
r.exception!.exception!.originalException,
|
||||||
e,
|
e,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -365,7 +365,7 @@ void main() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
r.exception!.linkException!.originalException,
|
r.exception!.exception!.originalException,
|
||||||
e,
|
e,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -743,7 +743,7 @@ void main() {
|
|||||||
emitsInOrder(
|
emitsInOrder(
|
||||||
[
|
[
|
||||||
isA<QueryResult>().having(
|
isA<QueryResult>().having(
|
||||||
(result) => result.exception!.linkException,
|
(result) => result.exception!.exception,
|
||||||
'wrapped exception',
|
'wrapped exception',
|
||||||
ex,
|
ex,
|
||||||
),
|
),
|
||||||
@@ -778,7 +778,7 @@ void main() {
|
|||||||
emitsInOrder(
|
emitsInOrder(
|
||||||
[
|
[
|
||||||
isA<QueryResult>().having(
|
isA<QueryResult>().having(
|
||||||
(result) => result.exception!.linkException!.originalException,
|
(result) => result.exception!.exception!.originalException,
|
||||||
'wrapped exception',
|
'wrapped exception',
|
||||||
err,
|
err,
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user