diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..0da109f --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "editor.tokenColorCustomizations": { + "comments": "", + "textMateRules": [] + } +} \ No newline at end of file diff --git a/packages/fl_query/example/pubspec.lock b/packages/fl_query/example/pubspec.lock index 69f71b9..64151ee 100644 --- a/packages/fl_query/example/pubspec.lock +++ b/packages/fl_query/example/pubspec.lock @@ -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: diff --git a/packages/fl_query/lib/fl_query.dart b/packages/fl_query/lib/fl_query.dart index 5ce961d..b44307e 100644 --- a/packages/fl_query/lib/fl_query.dart +++ b/packages/fl_query/lib/fl_query.dart @@ -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; diff --git a/packages/fl_query/lib/src/cache/_normalizing_data_proxy.dart b/packages/fl_query/lib/src/cache/_normalizing_data_proxy.dart deleted file mode 100644 index 2becddf..0000000 --- a/packages/fl_query/lib/src/cache/_normalizing_data_proxy.dart +++ /dev/null @@ -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 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? readQuery( - QueryKey queryKey, { - bool? optimistic = true, - }) { - return readNormalized(queryKey.key, optimistic: optimistic); - } - - void writeQuery( - QueryKey queryKey, { - required Map data, - bool? broadcast = true, - }) { - writeNormalized(queryKey.key, data); - if (broadcast ?? true) { - broadcastRequested = true; - } - } -} diff --git a/packages/fl_query/lib/src/cache/_optimistic_transactions.dart b/packages/fl_query/lib/src/cache/_optimistic_transactions.dart deleted file mode 100644 index 4d62be5..0000000 --- a/packages/fl_query/lib/src/cache/_optimistic_transactions.dart +++ /dev/null @@ -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 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 data = HashMap(); - - @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) { - final existing = data[dataId]; - data[dataId] = - existing != null ? deeplyMergeLeft([existing, value]) : value; - } else { - data[dataId] = value; - } - } - - OptimisticPatch asPatch(String id) => OptimisticPatch(id, data); -} diff --git a/packages/fl_query/lib/src/cache/cache.dart b/packages/fl_query/lib/src/cache/cache.dart deleted file mode 100644 index 33c5d98..0000000 --- a/packages/fl_query/lib/src/cache/cache.dart +++ /dev/null @@ -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 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 && patchData is Map) { - 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) { - 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 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; - } -} diff --git a/packages/fl_query/lib/src/cache/data_proxy.dart b/packages/fl_query/lib/src/cache/data_proxy.dart deleted file mode 100644 index 707d557..0000000 --- a/packages/fl_query/lib/src/cache/data_proxy.dart +++ /dev/null @@ -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? 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 data, - bool? broadcast, - }); -} diff --git a/packages/fl_query/lib/src/cache/hive_store.dart b/packages/fl_query/lib/src/cache/hive_store.dart deleted file mode 100644 index 5928243..0000000 --- a/packages/fl_query/lib/src/cache/hive_store.dart +++ /dev/null @@ -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 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? get(String dataId) { - final result = box.get(dataId); - if (result == null) return null; - return Map.from(result); - } - - @override - void put(String dataId, Map? value) { - box.put(dataId, value); - } - - @override - void putAll(Map> data) { - box.putAll(data); - } - - @override - void delete(String dataId) { - box.delete(dataId); - } - - @override - Map> toMap() => Map.unmodifiable(box.toMap()); - - Future reset() => box.clear(); -} diff --git a/packages/fl_query/lib/src/cache/store.dart b/packages/fl_query/lib/src/cache/store.dart deleted file mode 100644 index 58a630b..0000000 --- a/packages/fl_query/lib/src/cache/store.dart +++ /dev/null @@ -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? get(String dataId); - - /// Write [value] into this store under the key [dataId] - void put(String dataId, Map? value); - - /// [put] all entries from [data] into the store - /// - /// Functionally equivalent to `data.map(put);` - void putAll(Map> 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> 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 data; - - /// Creates an InMemoryStore inititalized with [data], - /// which defaults to an empty [HashMap] - InMemoryStore([ - Map? data, - ]) : data = data ?? HashMap(); - - @override - Map? get(String dataId) => data[dataId]; - - @override - void put(String dataId, Map? value) => data[dataId] = value; - - @override - void putAll(Map> entries) => - data.addAll(entries); - - @override - void delete(String dataId) => data.remove(dataId); - - /// Return the underlying [data] as an unmodifiable [Map]. - @override - Map> toMap() => Map.unmodifiable(data); - - void reset() => data.clear(); -} diff --git a/packages/fl_query/lib/src/core/_base_options.dart b/packages/fl_query/lib/src/core/_base_options.dart deleted file mode 100644 index 2928f9e..0000000 --- a/packages/fl_query/lib/src/core/_base_options.dart +++ /dev/null @@ -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 extends MutableDataClass { - BaseOptions({ - required this.document, - this.variables = const {}, - this.operationName, - ResultParserFn? 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 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 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 get properties => [ - document, - operationName, - variables, - optimisticResult, - policies, - context, - ]; - - OperationType get type { - final definitions = - document.definitions.whereType().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; -} diff --git a/packages/fl_query/lib/src/core/_data_class.dart b/packages/fl_query/lib/src/core/_data_class.dart deleted file mode 100644 index 511f7dd..0000000 --- a/packages/fl_query/lib/src/core/_data_class.dart +++ /dev/null @@ -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 get properties; - - /// [properties] based deep equality check - bool equal(MutableDataClass other) => - identical(this, other) || - (runtimeType == other.runtimeType && - const ListEquality( - DeepCollectionEquality(), - ).equals( - other.properties, - properties, - )); -} diff --git a/packages/fl_query/lib/src/core/_query_write_handling.dart b/packages/fl_query/lib/src/core/_query_write_handling.dart deleted file mode 100644 index f0d183d..0000000 --- a/packages/fl_query/lib/src/core/_query_write_handling.dart +++ /dev/null @@ -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? 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? 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? 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; - } - } -} diff --git a/packages/fl_query/lib/src/core/core.dart b/packages/fl_query/lib/src/core/core.dart deleted file mode 100644 index d8e1b00..0000000 --- a/packages/fl_query/lib/src/core/core.dart +++ /dev/null @@ -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'; diff --git a/packages/fl_query/lib/src/core/fetch_more.dart b/packages/fl_query/lib/src/core/fetch_more.dart deleted file mode 100644 index 956aedc..0000000 --- a/packages/fl_query/lib/src/core/fetch_more.dart +++ /dev/null @@ -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> fetchMoreImplementation( - FetchMoreOptions fetchMoreOptions, { - required QueryOptions originalOptions, - required QueryManager queryManager, - required QueryResult previousResult, - String? queryId, -}) async { - // fetch more and update - - final document = (fetchMoreOptions.document ?? originalOptions.document); - final request = originalOptions.asRequest; - - final combinedOptions = QueryOptions( - fetchPolicy: FetchPolicy.noCache, - errorPolicy: originalOptions.errorPolicy, - document: document, - variables: { - ...originalOptions.variables, - ...fetchMoreOptions.variables, - }, - ); - - QueryResult 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; -} diff --git a/packages/fl_query/lib/src/core/models.dart b/packages/fl_query/lib/src/core/models.dart new file mode 100644 index 0000000..061a18e --- /dev/null +++ b/packages/fl_query/lib/src/core/models.dart @@ -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 = Map; +typedef QueryKeyHashFunction = String Function(QueryKey queryKey); +typedef QueryFunction = Function(QueryFunctionContext context); +typedef GetPreviousPageParamFunction = Function( + TQueryFnData firstPage, + List allPages, +); +typedef GetNextPageParamFunction = Function( + TQueryFnData firstPage, + List allPages, +); + +class QueryOptions { + ShouldRetryFunction? retry; + RetryDelayFunction? 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? 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? 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? 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 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 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 toJson() { + return { + "active": active, + "exact": exact, + "inactive": inactive, + "queryKey": queryKey, + "stale": stale, + "fetching": fetching, + "predicate": predicate, + }; + } +} + +class RefetchPageFilters { + bool Function(TPageData lastPage, int index, List allPages)? + refetchPage; +} + +class RefetchQueryFilters + implements QueryFilters, RefetchPageFilters { + @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 allPages)? + refetchPage; + @override + bool? stale; + + RefetchQueryFilters({ + this.active, + this.exact, + this.inactive, + this.predicate, + this.queryKey, + this.stale, + this.fetching, + this.refetchPage, + }); + + @override + Map 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? 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> Function({ + RefetchOptions options, + RefetchQueryFilters 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 toJson() { + final Map 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 + = Duration? Function( + TData? data, + Query query, +); + +enum RefetchOnReconnect { + on, + off, + always, +} + +enum RefetchOnMount { + on, + off, + always, +} + +class QueryObserverOptions + extends QueryOptions { + bool? enabled; + Duration? staleTime; + RefetchIntervalFunction? + 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|'tracked'?*/ dynamic notifyOnChangeProps; + List? 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 { + QueryKey queryKey; + /* AbortSignal */ dynamic? signal; + TPageParam? pageParam; + QueryMeta? meta; + + QueryFunctionContext({ + required this.queryKey, + this.signal, + this.pageParam, + this.meta, + }); +} diff --git a/packages/fl_query/lib/src/core/mutation_options.dart b/packages/fl_query/lib/src/core/mutation_options.dart deleted file mode 100644 index ef1af69..0000000 --- a/packages/fl_query/lib/src/core/mutation_options.dart +++ /dev/null @@ -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 Function(dynamic data); -typedef OnMutationUpdate = FutureOr Function( - JSONDataProxy cache, - QueryResult? result, -); -typedef OnError = FutureOr Function(OperationException? error); - -class MutationOptions extends BaseOptions { - MutationOptions({ - required DocumentNode document, - String? operationName, - Map variables = const {}, - FetchPolicy? fetchPolicy, - ErrorPolicy? errorPolicy, - CacheRereadPolicy? cacheRereadPolicy, - Context? context, - Object? optimisticResult, - this.onCompleted, - this.update, - this.onError, - ResultParserFn? 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 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 get callbacks => - [onCompleted, update, onError].where(notNull).cast(); - - // 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 updateOnData(QueryResult? result) { - if (result!.isOptimistic) { - return optimisticUpdate(result); - } else { - return widgetUpdate!(cache, result); - } - } - - return updateOnData; - } - return null; - } -} diff --git a/packages/fl_query/lib/src/core/notify_manager.dart b/packages/fl_query/lib/src/core/notify_manager.dart new file mode 100644 index 0000000..522c97c --- /dev/null +++ b/packages/fl_query/lib/src/core/notify_manager.dart @@ -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 _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 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 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(); diff --git a/packages/fl_query/lib/src/core/observable_query.dart b/packages/fl_query/lib/src/core/observable_query.dart deleted file mode 100644 index 96e1d91..0000000 --- a/packages/fl_query/lib/src/core/observable_query.dart +++ /dev/null @@ -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 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 { - ObservableQuery({ - required this.queryManager, - required this.options, - }) : queryId = queryManager.generateQueryId().toString() { - if (options.eagerlyFetchResults) { - _latestWasEagerlyFetched = true; - fetchResults(); - } - controller = StreamController>.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 _onDataCallbacks = []; - - /// call [queryManager.maybeRebroadcastQueries] after all other [_onDataCallbacks] - /// - /// Automatically appended as an [OnData] - FutureOr _maybeRebroadcast(QueryResult? result) => - queryManager.maybeRebroadcastQueries(exclude: this); - - /// The most recently seen result from this operation's stream - QueryResult? latestResult; - - QueryLifecycle lifecycle = QueryLifecycle.unexecuted; - - WatchQueryOptions options; - - late StreamController> controller; - - Stream> 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?> refetch() { - if (isRefetchSafe) { - addResult(QueryResult.loading( - data: latestResult?.data, - parserFn: options.parserFn, - )); - return queryManager.refetchQuery(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 fetchResults() { - final MultiSourceResult 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> 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 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 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 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 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; - } -} diff --git a/packages/fl_query/lib/src/core/online_manager.dart b/packages/fl_query/lib/src/core/online_manager.dart new file mode 100644 index 0000000..7265095 --- /dev/null +++ b/packages/fl_query/lib/src/core/online_manager.dart @@ -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 isOnline() { + if (_online != null) { + return Future.value(_online!); + } + + return InternetConnectionChecker().hasConnection; + } +} + +_OnlineManager onlineManager = _OnlineManager(); diff --git a/packages/fl_query/lib/src/core/policies.dart b/packages/fl_query/lib/src/core/policies.dart deleted file mode 100644 index 88a9bcb..0000000 --- a/packages/fl_query/lib/src/core/policies.dart +++ /dev/null @@ -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( - 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 _getChildren() => [ - watchQuery, - query, - watchMutation, - mutate, - subscribe, - ]; - - @override - bool operator ==(Object o) => - identical(this, o) || - (o is DefaultPolicies && - const ListEquality( - DeepCollectionEquality(), - ).equals( - o._getChildren(), - _getChildren(), - )); - - @override - int get hashCode => const ListEquality( - DeepCollectionEquality(), - ).hash( - _getChildren(), - ); -} diff --git a/packages/fl_query/lib/src/core/query.dart b/packages/fl_query/lib/src/core/query.dart new file mode 100644 index 0000000..9093ed1 --- /dev/null +++ b/packages/fl_query/lib/src/core/query.dart @@ -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 { + FutureOr Function() fetchFn; + FetchOptions? fetchOptions; + QueryOptions options; + QueryKey queryKey; + QueryState state; + QueryMeta? meta; + + FetchContext({ + required this.fetchFn, + required this.options, + required this.queryKey, + required this.state, + this.meta, + this.fetchOptions, + }); +} + +class QueryBehavior { + void Function(FetchContext context) onFetch; + QueryBehavior({required this.onFetch}); +} + +class QueryState { + 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 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 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 toJson() { + return {"meta": meta}; + } +} + +class Action { + ActionType type; + Object? meta; + TData? data; + DateTime? dataUpdatedAt; + TError? error; + QueryState? 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 toJson() { + return { + "type": type, + "meta": meta, + "data": data, + "dataUpdatedAt": dataUpdatedAt, + "error": error, + "state": state, + "setStateOptions": setStateOptions, + }; + } +} + +class Query { + QueryKey queryKey; + String queryHash; + late QueryOptions options; + late QueryState initialState; + QueryState? revertState; + late QueryState state; + Duration? cacheTime; + QueryMeta? meta; + + QueryCache _cache; + Future? _future; + Timer? _gcTimeout; + Retryer? _retryer; + List _observers; + QueryOptions? _defaultOptions; + bool _abortSignalConsumed; + bool _hadObservers; + + Query({ + required this.queryKey, + required this.queryHash, + required QueryCache cache, + QueryOptions? options, + QueryOptions? defaultOptions, + QueryState? 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? 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 _getDefaultState( + QueryOptions 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 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(prevData ?? {} as TData, data); + } + // Set data and mark it as cached + _dispatch(Action( + ActionType.success, + data: data, + dataUpdatedAt: updatedAt, + )); + return data; + } + + void setState( + QueryState state, [ + SetStateOptions? setStateOptions, + ]) { + _dispatch(Action( + ActionType.setState, + state: state, + setStateOptions: setStateOptions, + )); + } + + Future 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 fetch([ + QueryOptions? 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 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 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 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 reducer( + QueryState state, + Action 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; + } + } +} diff --git a/packages/fl_query/lib/src/core/query_cache.dart b/packages/fl_query/lib/src/core/query_cache.dart new file mode 100644 index 0000000..684b0c8 --- /dev/null +++ b/packages/fl_query/lib/src/core/query_cache.dart @@ -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; + +class QueryCache extends Subscribable { + List _queries; + QueryHashMap _queriesMap; + + QueryCacheOnError? onError; + QueryCacheOnData? onData; + + QueryCache({ + this.onData, + this.onError, + }) : _queries = [], + _queriesMap = {}, + super(); + + Query build( + QueryClient client, QueryOptions options, + [QueryState? state]) { + QueryKey queryKey = options.queryKey!; + String queryHash = + options.queryHash ?? hashQueryKeyByOptions(queryKey, options); + Query? query = + get(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? get( + String queryHash) { + return _queriesMap[queryHash] as Query?; + } + + List getAll() { + return _queries; + } + + Query? find( + QueryKey queryKey, + QueryFilters queryFilters, + ) { + queryFilters.exact ??= true; + return _queries + .firstWhereOrNull((query) => matchQuery(queryFilters, query)); + } + + List 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(); + }); + }); + } +} diff --git a/packages/fl_query/lib/src/core/query_client.dart b/packages/fl_query/lib/src/core/query_client.dart new file mode 100644 index 0000000..58e70c5 --- /dev/null +++ b/packages/fl_query/lib/src/core/query_client.dart @@ -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 + defaultQueryOptions( + QueryObserverOptions? + options) {} + + QueryObserverOptions + defaultQueryObserverOptions( + QueryObserverOptions? + 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() {} +} diff --git a/packages/fl_query/lib/src/core/query_key.dart b/packages/fl_query/lib/src/core/query_key.dart index ac0d7cc..4e70647 100644 --- a/packages/fl_query/lib/src/core/query_key.dart +++ b/packages/fl_query/lib/src/core/query_key.dart @@ -9,6 +9,7 @@ class QueryKey { QueryKey.parse(String keyStr) : _key = keyStr.split("."); String get key => _key.map((k) => k.replaceAll(".", "")).join("."); + List get keyAsList => _key; @override String toString() { diff --git a/packages/fl_query/lib/src/core/query_manager.dart b/packages/fl_query/lib/src/core/query_manager.dart deleted file mode 100644 index 2084bb4..0000000 --- a/packages/fl_query/lib/src/core/query_manager.dart +++ /dev/null @@ -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 queries = {}; - - /// prevents rebroadcasting for some intensive bulk operation like [refetchSafeQueries] - bool rebroadcastLocked = false; - - ObservableQuery watchQuery( - WatchQueryOptions options) { - final ObservableQuery observableQuery = ObservableQuery( - queryManager: this, - options: options, - ); - - setQuery(observableQuery); - - return observableQuery; - } - - Stream> subscribe( - SubscriptionOptions 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?, - 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? 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> query( - QueryOptions options) async { - final result = await fetchQuery(_oneOffOpId, options); - maybeRebroadcastQueries(); - - return result; - } - - Future> mutate( - MutationOptions 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> fetchQuery( - String queryId, - BaseOptions options, - ) async { - final MultiSourceResult 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 fetchQueryAsMultiSourceResult( - String queryId, - BaseOptions options, - ) { - // create a new request to execute - final request = options.asRequest; - - final QueryResult 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> _resolveQueryOnNetwork( - Request request, - String queryId, - BaseOptions options, - ) async { - Response response; - QueryResult? 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 _resolveQueryEagerly( - Request request, - String queryId, - BaseOptions options, - ) { - QueryResult 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?> refetchQuery(String queryId) { - final WatchQueryOptions options = - queries[queryId]!.options.copy() as WatchQueryOptions; - 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> 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( - Request request, - String? queryId, - QueryResult queryResult, - ) { - final ObservableQuery? observableQuery = - getQuery(queryId) as ObservableQuery?; - - if (observableQuery != null && !observableQuery.controller.isClosed) { - observableQuery.addResult(queryResult); - } - } - - /// Create an optimstic result for the query specified by `queryId`, if it exists - QueryResult _getOptimisticQueryResult( - Request request, { - required String queryId, - required Object? optimisticResult, - required BaseOptions options, - }) { - QueryResult queryResult = QueryResult( - source: QueryResultSource.optimisticResult, - parserFn: options.parserFn, - ); - - attemptCacheWriteFromClient( - request, - optimisticResult as Map?, - 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? 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 mapFetchResultToQueryResult( - Response response, - BaseOptions options, { - required QueryResultSource source, - }) { - List? 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 _wrapFailure( - dynamic ex, - trace, - ResultParserFn parserFn, -) => - QueryResult( - // we set the source to indicate where the source of failure - source: QueryResultSource.network, - exception: coalesceErrors(linkException: translateFailure(ex, trace)), - parserFn: parserFn, - ); diff --git a/packages/fl_query/lib/src/core/query_observer.dart b/packages/fl_query/lib/src/core/query_observer.dart new file mode 100644 index 0000000..289b806 --- /dev/null +++ b/packages/fl_query/lib/src/core/query_observer.dart @@ -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 = void Function( + QueryObserverResult result); + +class NotifyOptions { + bool? cache; + bool? listeners; + bool? onError; + bool? onSuccess; + + NotifyOptions({this.cache, this.listeners, this.onError, this.onSuccess}); + + Map toJson() { + final Map data = new Map(); + data['cache'] = this.cache; + data['listeners'] = this.listeners; + data['onError'] = this.onError; + data['onSuccess'] = this.onSuccess; + return data; + } + + NotifyOptions.fromJson(Map 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 { + TData Function(TQueryData data) fn; + TData result; + SelectQuery(this.fn, this.result); +} + +class QueryObserver + extends Subscribable { + late QueryObserverOptions options; + QueryClient _client; + Query? _currentQuery; + late QueryState _currentQueryInitialState; + late QueryObserverResult _currentResult; + QueryState? _currentResultState; + QueryObserverOptions? + _currentResultOptions; + QueryObserverResult? _previousQueryResult; + Exception? _previousSelectError; + SelectQuery? _previousSelect; + Timer? _staleTimeout; + Timer? _refetchInterval; + Duration? _currentRefetchInterval; + + /// List of tracked keys/properties of [QueryObserverResult] + late List _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? 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 getOptimisticResult( + QueryObserverOptions options, + ) { + final defaultedOptions = _client.defaultQueryObserverOptions(options); + + final query = _client.getQueryCache().build(_client, defaultedOptions); + + return createResult(query, defaultedOptions); + } + + QueryObserverResult 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 trackResult( + QueryObserverResult result, + QueryObserverOptions + defaultedOptions, + ) { + // final Map trackedResult = {}; + // 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> getNextResult( + ResultOptions? options) { + var completer = Completer>(); + 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, + ); + } + } + }); + return completer.future; + } + + Query getCurrentQuery() { + return _currentQuery!; + } + + Future> fetchOptimistic( + QueryObserverOptions options) { + var defaultedOptions = _client.defaultQueryObserverOptions(options); + var query = _client.getQueryCache().build(_client, defaultedOptions); + + return query.fetch().then((val) { + return createResult(query, defaultedOptions); + }); + } + + @protected + Future> fetch( + ObserverFetchOptions fetchOptions, + ) { + return _executeFetch(fetchOptions).then((val) { + updateResult(); + return _currentResult; + }); + } + + Future _executeFetch([ObserverFetchOptions? fetchOptions]) { + // Make sure we reference the latest query as the current one might have been removed + _updateQuery(); + // Fetch + Future future = _currentQuery!.fetch( + this.options, + fetchOptions, + ); + + if (fetchOptions?.throwOnError != null) { + future = future.catchError((e) => e); + } + + return future; + } + + bool _shouldNotifyListeners(QueryObserverResult result, + [QueryObserverResult? prevResult]) { + if (prevResult == null) return true; + if (!options.notifyOnChangeProps && + options.notifyOnChangePropsExclusions == null) { + return true; + } + + if (options.notifyOnChangeProps == 'tracked' && _trackedProps.isEmpty) { + return true; + } + + List? includedProps = options.notifyOnChangeProps == 'tracked' + ? _trackedProps + : options.notifyOnChangeProps; + + Map resultMap = result.toJson(); + Map 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? 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 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 createResult( + Query query, + QueryObserverOptions 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( + 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 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> refetch({ + RefetchQueryFilters? filters, + RefetchOptions? options, + }) { + return fetch( + ObserverFetchOptions( + cancelRefetch: options?.cancelRefetch, + meta: filters?.toJson(), + throwOnError: options?.throwOnError, + ), + ); + } +} + +bool shouldLoadOnMount( + Query query, + QueryObserverOptions options, +) { + return (options.enabled != false && + query.state.dataUpdatedAt != null && + !(query.state.status == QueryStatus.error && + options.retryOnMount == false)); +} + +bool shouldRefetchOnMount( + Query query, + QueryObserverOptions options, +) { + return (options.enabled != false && + query.state.dataUpdatedAt != null && + (options.refetchOnMount == RefetchOnMount.always || + (options.refetchOnMount != RefetchOnMount.off && + isStale(query, options)))); +} + +bool shouldFetchOnMount( + Query query, + QueryObserverOptions options, +) { + return (shouldLoadOnMount(query, options) || + shouldRefetchOnMount(query, options)); +} + +bool shouldFetchOnReconnect( + Query query, + QueryObserverOptions options, +) { + return (options.enabled != false && + (options.refetchOnReconnect == RefetchOnReconnect.always || + (options.refetchOnReconnect != RefetchOnReconnect.off && + isStale(query, options)))); +} + +bool shouldFetchOptionally( + Query query, + Query prevQuery, + QueryObserverOptions options, + QueryObserverOptions prevOptions, +) { + return (options.enabled != false && + (query != prevQuery || prevOptions.enabled == false) && + (options.suspense != true || query.state.status != QueryStatus.error) && + isStale(query, options)); +} + +bool isStale( + Query query, + QueryObserverOptions options, +) { + return query.isStaleByTime(options.staleTime); +} diff --git a/packages/fl_query/lib/src/core/query_options.dart b/packages/fl_query/lib/src/core/query_options.dart deleted file mode 100644 index b5efb52..0000000 --- a/packages/fl_query/lib/src/core/query_options.dart +++ /dev/null @@ -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 extends BaseOptions { - QueryOptions({ - required DocumentNode document, - String? operationName, - Map variables = const {}, - FetchPolicy? fetchPolicy, - ErrorPolicy? errorPolicy, - CacheRereadPolicy? cacheRereadPolicy, - Object? optimisticResult, - this.pollInterval, - Context? context, - ResultParserFn? 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 get properties => [...super.properties, pollInterval]; - - WatchQueryOptions 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 extends BaseOptions { - SubscriptionOptions({ - required DocumentNode document, - String? operationName, - Map variables = const {}, - FetchPolicy? fetchPolicy, - ErrorPolicy? errorPolicy, - CacheRereadPolicy? cacheRereadPolicy, - Object? optimisticResult, - Context? context, - ResultParserFn? 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 extends QueryOptions { - WatchQueryOptions({ - required DocumentNode document, - String? operationName, - Map variables = const {}, - FetchPolicy? fetchPolicy, - ErrorPolicy? errorPolicy, - CacheRereadPolicy? cacheRereadPolicy, - Object? optimisticResult, - Duration? pollInterval, - this.fetchResults = false, - this.carryForwardDataOnException = true, - bool? eagerlyFetchResults, - Context? context, - ResultParserFn? 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 get properties => - [...super.properties, fetchResults, eagerlyFetchResults]; - - WatchQueryOptions copy() => WatchQueryOptions( - 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 variables = const {}, - required UpdateQuery updateQuery, - }) => - FetchMoreOptions( - document: document, - variables: variables, - updateQuery: partialUpdater(updateQuery), - ); - - DocumentNode? document; - - Map 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? UpdateQuery( - Map? previousResultData, - Map? fetchMoreResultData, -); diff --git a/packages/fl_query/lib/src/core/query_result.dart b/packages/fl_query/lib/src/core/query_result.dart deleted file mode 100644 index bc45215..0000000 --- a/packages/fl_query/lib/src/core/query_result.dart +++ /dev/null @@ -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 { - 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? data, - required ResultParserFn parserFn, - }) => - QueryResult( - data: data, - source: QueryResultSource.loading, - parserFn: parserFn, - ); - - factory QueryResult.optimistic({ - Map? data, - required ResultParserFn 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? data; - - /// Response context. Defaults to an empty `Context()` - Context context; - - OperationException? exception; - - ResultParserFn 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 { - MultiSourceResult({ - QueryResult? eagerResult, - this.networkResult, - required ResultParserFn parserFn, - }) : eagerResult = eagerResult ?? QueryResult.loading(parserFn: parserFn), - assert( - eagerResult!.source != QueryResultSource.network, - 'An eager result cannot be gotten from the network', - ); - - QueryResult eagerResult; - FutureOr>? networkResult; -} diff --git a/packages/fl_query/lib/src/core/result_parser.dart b/packages/fl_query/lib/src/core/result_parser.dart deleted file mode 100644 index 9357b2c..0000000 --- a/packages/fl_query/lib/src/core/result_parser.dart +++ /dev/null @@ -1 +0,0 @@ -typedef ResultParserFn = TResult Function(Map data); diff --git a/packages/fl_query/lib/src/core/retryer.dart b/packages/fl_query/lib/src/core/retryer.dart new file mode 100644 index 0000000..6162000 --- /dev/null +++ b/packages/fl_query/lib/src/core/retryer.dart @@ -0,0 +1,196 @@ +import 'dart:async'; + +import 'dart:math' show pow, min; + +import 'package:fl_query/src/core/online_manager.dart'; + +typedef ShouldRetryFunction = int Function( + int failureCount, + TError error, +); +typedef RetryDelayFunction = 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 = void Function(TError error); +typedef OnData = void Function(TData data); + +class Retryer { + late void Function({bool? revert, bool? silent}) cancel; + late void Function() cancelRetry; + late void Function() continueRetry; + late void Function() continueFn; + late Future future; + int failureCount; + bool isPaused; + bool isResolved; + bool isTransportCancelable; + + // config options for the retryer + FutureOr Function() fn; + void Function()? _abort; + OnError? onError; + OnData? onSuccess; + void Function(int failureCount, TError error)? onFail; + void Function()? onPause; + void Function()? onContinue; + ShouldRetryFunction? retry; + RetryDelayFunction? 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 completer = Completer(); + + 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(); + } +} diff --git a/packages/fl_query/lib/src/core/subscribable.dart b/packages/fl_query/lib/src/core/subscribable.dart new file mode 100644 index 0000000..48a68f8 --- /dev/null +++ b/packages/fl_query/lib/src/core/subscribable.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; + +abstract class Subscribable { + @protected + List 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(); +} diff --git a/packages/fl_query/lib/src/core/utils.dart b/packages/fl_query/lib/src/core/utils.dart new file mode 100644 index 0000000..8fc42b4 --- /dev/null +++ b/packages/fl_query/lib/src/core/utils.dart @@ -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 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 = TOutput Function(TInput input); diff --git a/packages/fl_query/lib/src/exceptions.dart b/packages/fl_query/lib/src/exceptions.dart deleted file mode 100644 index 26392f1..0000000 --- a/packages/fl_query/lib/src/exceptions.dart +++ /dev/null @@ -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'; diff --git a/packages/fl_query/lib/src/exceptions/exceptions.dart b/packages/fl_query/lib/src/exceptions/exceptions.dart deleted file mode 100644 index 620d142..0000000 --- a/packages/fl_query/lib/src/exceptions/exceptions.dart +++ /dev/null @@ -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); -} diff --git a/packages/fl_query/lib/src/exceptions/exceptions_next.dart b/packages/fl_query/lib/src/exceptions/exceptions_next.dart deleted file mode 100644 index 4188384..0000000 --- a/packages/fl_query/lib/src/exceptions/exceptions_next.dart +++ /dev/null @@ -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? 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? 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 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 graphqlErrors = []; - -// // generalize to include cache error, etc -// /// Errors encountered during execution such as network or cache errors -// LinkException? linkException; - -// OperationException({ -// this.linkException, -// Iterable 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? 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; -// } diff --git a/packages/fl_query/lib/src/exceptions/network.dart b/packages/fl_query/lib/src/exceptions/network.dart deleted file mode 100644 index 09d5653..0000000 --- a/packages/fl_query/lib/src/exceptions/network.dart +++ /dev/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; -} diff --git a/packages/fl_query/lib/src/exceptions/network_io.dart b/packages/fl_query/lib/src/exceptions/network_io.dart deleted file mode 100644 index d3f1181..0000000 --- a/packages/fl_query/lib/src/exceptions/network_io.dart +++ /dev/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); -} diff --git a/packages/fl_query/lib/src/graphql_client.dart b/packages/fl_query/lib/src/graphql_client.dart deleted file mode 100644 index e305673..0000000 --- a/packages/fl_query/lib/src/graphql_client.dart +++ /dev/null @@ -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 watchQuery( - WatchQueryOptions 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 watchMutation( - WatchQueryOptions 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 repositories = - /// result.data['viewer']['repositories']['nodes'] as List; - /// ``` - /// {@end-tool} - - Future> query( - QueryOptions 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> mutate( - MutationOptions 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> subscribe( - SubscriptionOptions 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> fetchMore( - FetchMoreOptions fetchMoreOptions, { - required QueryOptions originalOptions, - required QueryResult 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>? resetStore({bool refetchQueries = true}) { - cache.store.reset(); - if (refetchQueries) { - return queryManager.refetchSafeQueries(); - } - return null; - } -} diff --git a/packages/fl_query/lib/src/links/auth_link.dart b/packages/fl_query/lib/src/links/auth_link.dart deleted file mode 100644 index 488e7f8..0000000 --- a/packages/fl_query/lib/src/links/auth_link.dart +++ /dev/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 Function(Request request); - -typedef OnException = FutureOr 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 Function() getToken; - - /// Header key to set to the result of [getToken] - final String headerKey; - - static _RequestTransformer transform( - String headerKey, - FutureOr Function() getToken, - ) => - (Request request) async { - final token = await getToken(); - return request.updateContextEntry( - (headers) => HttpLinkHeaders( - headers: { - ...headers?.headers ?? {}, - 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 request( - Request request, [ - NextLink? forward, - ]) async* { - final req = await requestTransformer(request); - - yield* forward!(req); - } -} diff --git a/packages/fl_query/lib/src/links/gql_links.dart b/packages/fl_query/lib/src/links/gql_links.dart deleted file mode 100644 index 4a83fde..0000000 --- a/packages/fl_query/lib/src/links/gql_links.dart +++ /dev/null @@ -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'; diff --git a/packages/fl_query/lib/src/links/links.dart b/packages/fl_query/lib/src/links/links.dart deleted file mode 100644 index acac049..0000000 --- a/packages/fl_query/lib/src/links/links.dart +++ /dev/null @@ -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'; diff --git a/packages/fl_query/lib/src/links/websocket_link/websocket_client.dart b/packages/fl_query/lib/src/links/websocket_link/websocket_client.dart deleted file mode 100644 index 7ab2eda..0000000 --- a/packages/fl_query/lib/src/links/websocket_link/websocket_client.dart +++ /dev/null @@ -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 Function(); - -/// A definition for functions that returns a connected [WebSocketChannel] -typedef WebSocketConnect = FutureOr Function( - Uri uri, - Iterable? 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? headers; - - /// Function to define another connection without call directly - /// the connection function - FutureOr connect( - {required Uri uri, - Iterable? protocols, - Map? 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 get initOperation async { - /// if (initialPayload is Function) { - /// final dynamic payload = await initialPayload(); - /// return InitOperation(payload); - /// } else { - /// return InitOperation(initialPayload); - /// } - /// } - /// ``` - final dynamic initialPayload; - - Future 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? protocols; - final SocketClientConfig config; - - final BehaviorSubject _connectionStateController = - BehaviorSubject(); - - final HashMap _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 get _messages => socketChannel!.messages; - - StreamSubscription? _keepAliveSubscription; - StreamSubscription? _messageSubscription; - - Map Function(Request) get serialize => - config.serializer.serializeRequest; - - Response Function(Map) get parse => - config.parser.parseResponse; - - void _disconnectOnKeepAliveTimeout(Stream messages) { - _keepAliveSubscription = messages.whereType().timeout( - config.inactivityTimeout!, - onTimeout: (EventSink event) { - event.close(); - unawaited(_closeSocketChannel()); - }, - ).listen(null); - } - - Future _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 _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 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>().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 subscribe( - final Request payload, - final bool waitForConnection, - ) { - final String id = _uuid.v4( - options: { - 'random': randomBytesForUuid, - }, - ).toString(); - final StreamController response = StreamController(); - StreamSubscription? sub; - final bool addTimeout = - !payload.isSubscription && config.queryAndMutationTimeout != null; - - final onListen = () { - final Stream waitForConnectedStateWithoutTimeout = - (waitForConnection - ? _connectionStateController - : _connectionStateController - .startWith(SocketConnectionState.connected)) - .where((SocketConnectionState state) => - state == SocketConnectionState.connected) - .take(1); - - final Stream waitForConnectedState = addTimeout - ? waitForConnectedStateWithoutTimeout.timeout( - config.queryAndMutationTimeout!, - onTimeout: (EventSink event) { - print('Connection timed out.'); - response.addError(TimeoutException('Connection timed out.')); - event.close(); - response.close(); - }, - ) - : waitForConnectedStateWithoutTimeout; - - sub = waitForConnectedState.listen((_) { - final Stream 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 subscriptionComplete = addTimeout - ? dataErrorComplete - .where((message) => message is SubscriptionComplete) - .take(1) - .timeout( - config.queryAndMutationTimeout!, - onTimeout: (EventSink 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() - .listen((message) => response.add( - parse(message.toJson()), - )); - - dataErrorComplete - .where((message) => message is SubscriptionError) - .cast() - .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 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? _messages; - - /// Stream of messages from the endpoint parsed as GraphQLSocketMessages - Stream get messages => _messages ??= - stream.map(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); -} diff --git a/packages/fl_query/lib/src/links/websocket_link/websocket_link.dart b/packages/fl_query/lib/src/links/websocket_link/websocket_link.dart deleted file mode 100644 index 8fa02a5..0000000 --- a/packages/fl_query/lib/src/links/websocket_link/websocket_link.dart +++ /dev/null @@ -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 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 dispose() async { - await _socketClient?.dispose(); - _socketClient = null; - } -} diff --git a/packages/fl_query/lib/src/links/websocket_link/websocket_messages.dart b/packages/fl_query/lib/src/links/websocket_link/websocket_messages.dart deleted file mode 100644 index 2205c16..0000000 --- a/packages/fl_query/lib/src/links/websocket_link/websocket_messages.dart +++ /dev/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 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 toJson() => {"type": type}; - - static GraphQLSocketMessage parse(dynamic message) { - final Map map = - json.decode(message as String) as Map; - final String type = (map['type'] ?? 'unknown') as String; - final dynamic payload = map['payload'] ?? {}; - 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 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 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}; -} diff --git a/packages/fl_query/lib/src/scheduler/scheduler.dart b/packages/fl_query/lib/src/scheduler/scheduler.dart deleted file mode 100644 index 2406b1c..0000000 --- a/packages/fl_query/lib/src/scheduler/scheduler.dart +++ /dev/null @@ -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 registeredQueries = - {}; - - /// 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> intervalQueries = >{}; - - /// Map going from polling interval durations to polling timers. - final Map _pollingTimers = {}; - - 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] = [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); - } -} diff --git a/packages/fl_query/lib/src/utilities/helpers.dart b/packages/fl_query/lib/src/utilities/helpers.dart deleted file mode 100644 index a5ff237..0000000 --- a/packages/fl_query/lib/src/utilities/helpers.dart +++ /dev/null @@ -1,45 +0,0 @@ -bool notNull(Object? any) { - return any != null; -} - -Map? _recursivelyAddAll( - Map? target, - Map? source, -) { - target = Map.from(target ?? {}); - source?.forEach((String key, dynamic value) { - if (target!.containsKey(key) && - target[key] is Map && - value != null && - value is Map) { - target[key] = _recursivelyAddAll( - target[key] as Map, - 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? deeplyMergeLeft( - Iterable?> maps, -) { - // prepend an empty literal for functional immutability - return (?>[{}]..addAll(maps)).reduce(_recursivelyAddAll); -} diff --git a/packages/fl_query/lib/src/utilities/platform.dart b/packages/fl_query/lib/src/utilities/platform.dart deleted file mode 100644 index 8abbc35..0000000 --- a/packages/fl_query/lib/src/utilities/platform.dart +++ /dev/null @@ -1 +0,0 @@ -export './platform_html.dart' if (dart.library.io) './platform_io.dart'; diff --git a/packages/fl_query/lib/src/utilities/platform_html.dart b/packages/fl_query/lib/src/utilities/platform_html.dart deleted file mode 100644 index 6caed97..0000000 --- a/packages/fl_query/lib/src/utilities/platform_html.dart +++ /dev/null @@ -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 defaultConnectPlatform( - Uri uri, Iterable? protocols, - {Map? 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(); -} diff --git a/packages/fl_query/lib/src/utilities/platform_io.dart b/packages/fl_query/lib/src/utilities/platform_io.dart deleted file mode 100644 index d7d3eeb..0000000 --- a/packages/fl_query/lib/src/utilities/platform_io.dart +++ /dev/null @@ -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 defaultConnectPlatform( - Uri uri, Iterable? protocols, - {Map? headers}) async { - final webSocket = await WebSocket.connect(uri.toString(), - protocols: protocols, headers: headers); - return IOWebSocketChannel(webSocket).forGraphQL(); -} diff --git a/packages/fl_query/pubspec.yaml b/packages/fl_query/pubspec.yaml index cedd5e2..dff106e 100644 --- a/packages/fl_query/pubspec.yaml +++ b/packages/fl_query/pubspec.yaml @@ -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 diff --git a/packages/fl_query/test/graphql_client_test.dart b/packages/fl_query/test/graphql_client_test.dart index a149693..8fb154f 100644 --- a/packages/fl_query/test/graphql_client_test.dart +++ b/packages/fl_query/test/graphql_client_test.dart @@ -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().having( - (result) => result.exception!.linkException, + (result) => result.exception!.exception, 'wrapped exception', ex, ), @@ -778,7 +778,7 @@ void main() { emitsInOrder( [ isA().having( - (result) => result.exception!.linkException!.originalException, + (result) => result.exception!.exception!.originalException, 'wrapped exception', err, ),