diff --git a/packages/graphql/lib/src/core/observable_query.dart b/packages/graphql/lib/src/core/observable_query.dart index 4d450afd6..e792e652a 100644 --- a/packages/graphql/lib/src/core/observable_query.dart +++ b/packages/graphql/lib/src/core/observable_query.dart @@ -26,11 +26,18 @@ class 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; + final String queryId; final QueryManager queryManager; @@ -39,7 +46,8 @@ class ObservableQuery { final Set> _onDataSubscriptions = >{}; - QueryResult previousResult; + /// The most recently seen result from this operation's stream + QueryResult latestResult; QueryLifecycle lifecycle = QueryLifecycle.UNEXECUTED; @@ -94,13 +102,19 @@ class ObservableQuery { } void onListen() { + if (_latestWasEagerlyFetched) { + _latestWasEagerlyFetched = false; + return; + } if (options.fetchResults) { fetchResults(); } } - void fetchResults() { - queryManager.fetchQuery(queryId, options); + MultiSourceResult fetchResults() { + final MultiSourceResult allResults = + queryManager.fetchQueryAsMultiSourceResult(queryId, options); + latestResult ??= allResults.eagerResult; // if onData callbacks have been registered, // they are waited on by default @@ -111,30 +125,33 @@ class ObservableQuery { if (options.pollInterval != null && options.pollInterval > 0) { startPolling(options.pollInterval); } + + return allResults; } /// add a result to the stream, /// copying `loading` and `optimistic` - /// from the `previousResult` if they aren't set. + /// from the `latestResult` if they aren't set. void addResult(QueryResult result) { // don't overwrite results due to some async/optimism issue - if (previousResult != null && - previousResult.timestamp.isAfter(result.timestamp)) { + if (latestResult != null && + latestResult.timestamp.isAfter(result.timestamp)) { return; } - if (previousResult != null) { - result.loading ??= previousResult.loading; - result.optimistic ??= previousResult.optimistic; + if (latestResult != null) { + result.source ??= latestResult.source; } if (lifecycle == QueryLifecycle.PENDING && result.optimistic != true) { lifecycle = QueryLifecycle.COMPLETED; } - previousResult = result; + latestResult = result; - controller.add(result); + if (!controller.isClosed) { + controller.add(result); + } } // most mutation behavior happens here diff --git a/packages/graphql/lib/src/core/query_manager.dart b/packages/graphql/lib/src/core/query_manager.dart index f0adca4c7..c006b0bd1 100644 --- a/packages/graphql/lib/src/core/query_manager.dart +++ b/packages/graphql/lib/src/core/query_manager.dart @@ -64,34 +64,47 @@ class QueryManager { String queryId, BaseOptions options, ) async { - // create a new operation to fetch - final Operation operation = Operation.fromOptions(options); + final MultiSourceResult allResults = + fetchQueryAsMultiSourceResult(queryId, options); + return allResults.networkResult ?? allResults.eagerResult; + } - if (options.optimisticResult != null) { - addOptimisticQueryResult( - queryId, - cacheKey: operation.toKey(), - optimisticResult: options.optimisticResult, - ); - } + /// 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, + ) { + final QueryResult eagerResult = _resolveQueryEagerly( + queryId, + options, + ); + + // _resolveQueryEagerly handles cacheOnly, + // so if we're loading + cacheFirst we continue to network + return MultiSourceResult( + eagerResult: eagerResult, + networkResult: + (shouldStopAtCache(options.fetchPolicy) && !eagerResult.loading) + ? null + : _resolveQueryOnNetwork(queryId, options), + ); + } + + /// Resolve the query on the network, + /// negotiating any necessary cache edits / optimistic cleanup + Future _resolveQueryOnNetwork( + String queryId, + BaseOptions options, + ) async { + // create a new operation to fetch + final Operation operation = Operation.fromOptions(options) + ..setContext(options.context); FetchResult fetchResult; QueryResult queryResult; try { - if (options.context != null) { - operation.setContext(options.context); - } - queryResult = _addEagerCacheResult( - queryId, - operation.toKey(), - options.fetchPolicy, - ); - - if (shouldStopAtCache(options.fetchPolicy) && queryResult != null) { - return queryResult; - } - // execute the operation through the provided link(s) fetchResult = await execute( link: link, @@ -119,14 +132,11 @@ class QueryManager { queryResult = mapFetchResultToQueryResult( fetchResult, options, - loading: false, - optimistic: false, + source: QueryResultSource.Network, ); } catch (error) { - queryResult ??= QueryResult( - loading: false, - optimistic: false, - ); + // we set the source to indicate where the source of failure + queryResult ??= QueryResult(source: QueryResultSource.Network); queryResult.addError(_attemptToWrapError(error)); } @@ -143,6 +153,66 @@ class QueryManager { return queryResult; } + /// Add an eager cache response to the stream if possible, + /// based on `fetchPolicy` and `optimisticResults` + QueryResult _resolveQueryEagerly( + String queryId, + BaseOptions options, + ) { + final String cacheKey = options.toKey(); + + QueryResult queryResult = QueryResult(loading: true); + + try { + if (options.optimisticResult != null) { + queryResult = _getOptimisticQueryResult( + queryId, + cacheKey: cacheKey, + optimisticResult: options.optimisticResult, + ); + } + + // if we haven't already resolved results optimistically, + // we attempt to resolve the from the cache + if (shouldRespondEagerlyFromCache(options.fetchPolicy) && + !queryResult.optimistic) { + final dynamic data = cache.read(cacheKey); + // we only push an eager query with data + if (data != null) { + queryResult = QueryResult( + data: data, + source: QueryResultSource.Cache, + ); + } + + if (options.fetchPolicy == FetchPolicy.cacheOnly && + queryResult.loading) { + queryResult = QueryResult( + source: QueryResultSource.Cache, + errors: [ + GraphQLError( + message: + 'Could not find that operation in the cache. (FetchPolicy.cacheOnly)', + ), + ], + ); + } + } + } catch (error) { + queryResult.addError(_attemptToWrapError(error)); + } + + // 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 + addQueryResult(queryId, queryResult); + return queryResult; + } + void refetchQuery(String queryId) { final WatchQueryOptions options = queries[queryId].options; fetchQuery(queryId, options); @@ -180,38 +250,8 @@ class QueryManager { } } - // TODO what should the relationship to optimism be here - // TODO we should switch to quiver Optionals - /// Add an eager cache response to the stream if possible based on `fetchPolicy` - QueryResult _addEagerCacheResult( - String queryId, String cacheKey, FetchPolicy fetchPolicy) { - if (shouldRespondEagerlyFromCache(fetchPolicy)) { - final dynamic cachedData = cache.read(cacheKey); - - if (cachedData != null) { - // we're rebroadcasting from cache, - // so don't override optimism - final QueryResult queryResult = QueryResult( - data: cachedData, - loading: false, - ); - - addQueryResult(queryId, queryResult); - - return queryResult; - } - - if (fetchPolicy == FetchPolicy.cacheOnly) { - throw Exception( - 'Could not find that operation in the cache. (${fetchPolicy.toString()})', - ); - } - } - return null; - } - - /// Add an optimstic result to the query specified by `queryId`, if it exists - void addOptimisticQueryResult( + /// Create an optimstic result for the query specified by `queryId`, if it exists + QueryResult _getOptimisticQueryResult( String queryId, { @required String cacheKey, @required Object optimisticResult, @@ -224,10 +264,9 @@ class QueryManager { final QueryResult queryResult = QueryResult( data: cache.read(cacheKey), - loading: false, - optimistic: true, + source: QueryResultSource.OptimisticResult, ); - addQueryResult(queryId, queryResult); + return queryResult; } /// Remove the optimistic patch for `cacheKey`, if any @@ -250,6 +289,7 @@ class QueryManager { mapFetchResultToQueryResult( FetchResult(data: cachedData), query.options, + source: QueryResultSource.Cache, ), ); } @@ -279,8 +319,7 @@ class QueryManager { QueryResult mapFetchResultToQueryResult( FetchResult fetchResult, BaseOptions options, { - bool loading, - bool optimistic = false, + @required QueryResultSource source, }) { List errors; dynamic data; @@ -312,8 +351,7 @@ class QueryManager { return QueryResult( data: data, errors: errors, - loading: loading, - optimistic: optimistic, + source: source, ); } diff --git a/packages/graphql/lib/src/core/query_options.dart b/packages/graphql/lib/src/core/query_options.dart index e4f8ee5d0..818957603 100644 --- a/packages/graphql/lib/src/core/query_options.dart +++ b/packages/graphql/lib/src/core/query_options.dart @@ -115,7 +115,8 @@ class WatchQueryOptions extends QueryOptions { ErrorPolicy errorPolicy = ErrorPolicy.none, Object optimisticResult, int pollInterval, - this.fetchResults, + this.fetchResults = false, + this.eagerlyFetchResults, Map context, }) : super( document: document, @@ -125,10 +126,13 @@ class WatchQueryOptions extends QueryOptions { pollInterval: pollInterval, context: context, optimisticResult: optimisticResult, - ); + ) { + this.eagerlyFetchResults ??= fetchResults; + } /// Whether or not to fetch result. bool fetchResults; + bool eagerlyFetchResults; /// Checks if the [WatchQueryOptions] in this class are equal to some given options. bool areEqualTo(WatchQueryOptions otherOptions) { diff --git a/packages/graphql/lib/src/core/query_result.dart b/packages/graphql/lib/src/core/query_result.dart index 6fd825c50..5b618d1b6 100644 --- a/packages/graphql/lib/src/core/query_result.dart +++ b/packages/graphql/lib/src/core/query_result.dart @@ -1,24 +1,63 @@ +import 'dart:async' show FutureOr; + import 'package:graphql/src/core/graphql_error.dart'; +/// The source of the result data contained +/// +/// Loading: No data has been specified from any source +/// Cache: A result has been eagerly resolved from the cache +/// OptimisticResults: An optimistic result has been specified +/// (may include eager results from the cache) +/// Network: The query has been resolved on the network +/// +/// Both Optimistic and Cache sources are considered "Eager" results +enum QueryResultSource { + Loading, + Cache, + OptimisticResult, + Network, +} + +final eagerSources = { + QueryResultSource.Cache, + QueryResultSource.OptimisticResult +}; + class QueryResult { QueryResult({ this.data, this.errors, - this.loading, - this.stale, - this.optimistic = false, - }) : timestamp = DateTime.now(); + bool loading, + bool optimistic, + QueryResultSource source, + }) : timestamp = DateTime.now(), + this.source = source ?? + ((loading == true) + ? QueryResultSource.Loading + : (optimistic == true) + ? QueryResultSource.OptimisticResult + : null); DateTime timestamp; + /// The source of the result data. + /// + /// null when unexecuted. + /// Will be set when encountering an error during any execution attempt + QueryResultSource source; + /// List or Map dynamic data; List errors; - bool loading; - // TODO not sure what this is for - bool stale; - bool optimistic; + /// Whether data has been specified from either the cache or network) + bool get loading => source == QueryResultSource.Loading; + + /// Whether an optimistic result has been specified + /// (may include eager results from the cache) + bool get optimistic => source == QueryResultSource.OptimisticResult; + + /// Whether the response includes any graphql errors bool get hasErrors { if (errors == null) { return false; @@ -35,3 +74,18 @@ class QueryResult { } } } + +class MultiSourceResult { + MultiSourceResult({ + this.eagerResult, + this.networkResult, + }) : assert( + eagerResult.source != QueryResultSource.Network, + 'An eager result cannot be gotten from the network', + ) { + eagerResult ??= QueryResult(loading: true); + } + + QueryResult eagerResult; + FutureOr networkResult; +} diff --git a/packages/graphql/lib/src/link/operation.dart b/packages/graphql/lib/src/link/operation.dart index a3fcaa448..77b1e369b 100644 --- a/packages/graphql/lib/src/link/operation.dart +++ b/packages/graphql/lib/src/link/operation.dart @@ -26,7 +26,9 @@ class Operation extends RawOperationData { /// Sets the context of an operation by merging the new context with the old one. void setContext(Map next) { - _context.addAll(next); + if (next != null) { + _context.addAll(next); + } } Map getContext() { diff --git a/packages/graphql_flutter/lib/src/widgets/mutation.dart b/packages/graphql_flutter/lib/src/widgets/mutation.dart index eaf0b9fb3..8a79b4d95 100644 --- a/packages/graphql_flutter/lib/src/widgets/mutation.dart +++ b/packages/graphql_flutter/lib/src/widgets/mutation.dart @@ -92,7 +92,7 @@ class MutationState extends State { /// created by the query manager String get _patchId => '${observableQuery.queryId}.update'; - /// apply the user's + /// apply the user's patch void _optimisticUpdate(QueryResult result) { final Cache cache = client.cache; final String patchId = _patchId; @@ -136,14 +136,18 @@ class MutationState extends State { Iterable get callbacks => [onCompleted, update].where(notNull); - void runMutation(Map variables, {Object optimisticResult}) { - observableQuery - ..variables = variables - ..options.optimisticResult = optimisticResult - ..onData(callbacks) // add callbacks to observable - ..addResult(QueryResult(loading: true)) - ..fetchResults(); - } + /// Run the mutation with the given `variables` and `optimisticResult`, + /// returning a [MultiSourceResult] for handling both the eager and network results + MultiSourceResult runMutation( + Map variables, { + Object optimisticResult, + }) => + (observableQuery + ..variables = variables + ..options.optimisticResult = optimisticResult + ..onData(callbacks) // add callbacks to observable + ) + .fetchResults(); @override void dispose() { @@ -158,10 +162,7 @@ class MutationState extends State { // toggling mutations at the same place in the tree, // such as is done in the example, won't result in bugs key: Key(observableQuery?.options?.toKey()), - initialData: QueryResult( - loading: false, - optimistic: false, - ), + initialData: observableQuery?.latestResult ?? QueryResult(), stream: observableQuery?.stream, builder: ( BuildContext buildContext, diff --git a/packages/graphql_flutter/lib/src/widgets/query.dart b/packages/graphql_flutter/lib/src/widgets/query.dart index d35afe53f..da012aeb4 100644 --- a/packages/graphql_flutter/lib/src/widgets/query.dart +++ b/packages/graphql_flutter/lib/src/widgets/query.dart @@ -68,7 +68,6 @@ class QueryState extends State { void didUpdateWidget(Query oldWidget) { super.didUpdateWidget(oldWidget); - // TODO @micimize - investigate why/if this was causing issues if (!observableQuery.options.areEqualTo(_options)) { _initQuery(); } @@ -83,9 +82,7 @@ class QueryState extends State { @override Widget build(BuildContext context) { return StreamBuilder( - initialData: QueryResult( - loading: true, - ), + initialData: observableQuery?.latestResult ?? QueryResult(loading: true), stream: observableQuery.stream, builder: ( BuildContext buildContext, diff --git a/packages/graphql_flutter/pubspec.yaml b/packages/graphql_flutter/pubspec.yaml index 00d4d52c4..3d0582b76 100644 --- a/packages/graphql_flutter/pubspec.yaml +++ b/packages/graphql_flutter/pubspec.yaml @@ -1,11 +1,12 @@ name: graphql_flutter -description: A GraphQL client for Flutter, bringing all the features from a modern +description: + A GraphQL client for Flutter, bringing all the features from a modern GraphQL client to one easy to use package. version: 1.1.1-beta.6 authors: -- Eus Dima -- Zino Hofmann -- Michael Joseph Rosenthal + - Eus Dima + - Zino Hofmann + - Michael Joseph Rosenthal homepage: https://github.com/zino-app/graphql-flutter/tree/master/packages/graphql_flutter dependencies: graphql: ^1.1.1-beta.6 @@ -22,4 +23,8 @@ dev_dependencies: sdk: flutter test: ^1.5.3 environment: - sdk: '>=2.2.0 <3.0.0' + sdk: ">=2.2.0 <3.0.0" + +# dependency_overrides: +# graphql: +# path: ../graphql