+QueryObserver

+Query
+notifyManager
+onlineManager
+QueryCache
This commit is contained in:
Kingkor Roy Tirtho
2022-02-10 18:29:42 +06:00
parent f1415f55f4
commit 3ec81de606
51 changed files with 2503 additions and 4083 deletions
+6
View File
@@ -0,0 +1,6 @@
{
"editor.tokenColorCustomizations": {
"comments": "",
"textMateRules": []
}
}
+7
View File
@@ -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:
-12
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
}
}
-6
View File
@@ -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;
}
+419
View File
@@ -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(),
);
}
+642
View File
@@ -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);
+196
View File
@@ -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();
}
+148
View File
@@ -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();
}
+1
View File
@@ -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,
),