+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"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
|
||||
@@ -1,13 +1 @@
|
||||
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(".");
|
||||
|
||||
String get key => _key.map((k) => k.replaceAll(".", "")).join(".");
|
||||
List<String> get keyAsList => _key;
|
||||
|
||||
@override
|
||||
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
|
||||
rxdart: ^0.27.1
|
||||
uuid: ^3.0.1
|
||||
internet_connection_checker: ^0.0.1+3
|
||||
|
||||
dev_dependencies:
|
||||
async: ^2.5.0
|
||||
|
||||
@@ -344,7 +344,7 @@ void main() {
|
||||
);
|
||||
|
||||
expect(
|
||||
r.exception!.linkException!.originalException,
|
||||
r.exception!.exception!.originalException,
|
||||
e,
|
||||
);
|
||||
});
|
||||
@@ -365,7 +365,7 @@ void main() {
|
||||
);
|
||||
|
||||
expect(
|
||||
r.exception!.linkException!.originalException,
|
||||
r.exception!.exception!.originalException,
|
||||
e,
|
||||
);
|
||||
});
|
||||
@@ -743,7 +743,7 @@ void main() {
|
||||
emitsInOrder(
|
||||
[
|
||||
isA<QueryResult>().having(
|
||||
(result) => result.exception!.linkException,
|
||||
(result) => result.exception!.exception,
|
||||
'wrapped exception',
|
||||
ex,
|
||||
),
|
||||
@@ -778,7 +778,7 @@ void main() {
|
||||
emitsInOrder(
|
||||
[
|
||||
isA<QueryResult>().having(
|
||||
(result) => result.exception!.linkException!.originalException,
|
||||
(result) => result.exception!.exception!.originalException,
|
||||
'wrapped exception',
|
||||
err,
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user