initial commit with cache support & example
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
import 'package:fl_query/src/core/_data_class.dart';
|
||||
import 'package:fl_query/fl_query.dart';
|
||||
import 'package:fl_query/src/core/result_parser.dart';
|
||||
|
||||
/// TODO refactor into [Request] container
|
||||
/// Base options.
|
||||
abstract class BaseOptions<TParsed> extends MutableDataClass {
|
||||
BaseOptions({
|
||||
required this.document,
|
||||
this.variables = const {},
|
||||
this.operationName,
|
||||
ResultParserFn<TParsed>? parserFn,
|
||||
Context? context,
|
||||
FetchPolicy? fetchPolicy,
|
||||
ErrorPolicy? errorPolicy,
|
||||
CacheRereadPolicy? cacheRereadPolicy,
|
||||
this.optimisticResult,
|
||||
}) : policies = Policies(
|
||||
fetch: fetchPolicy,
|
||||
error: errorPolicy,
|
||||
cacheReread: cacheRereadPolicy,
|
||||
),
|
||||
context = context ?? Context(),
|
||||
parserFn = parserFn ??
|
||||
((d) => throw UnimplementedError(
|
||||
"Please provide a parser function to support result parsing.",
|
||||
));
|
||||
|
||||
/// Document containing at least one [OperationDefinitionNode]
|
||||
DocumentNode document;
|
||||
|
||||
/// Name of the executable definition
|
||||
///
|
||||
/// Must be specified if [document] contains more than one [OperationDefinitionNode]
|
||||
String? operationName;
|
||||
|
||||
/// A map going from variable name to variable value, where the variables are used
|
||||
/// within the GraphQL query.
|
||||
Map<String, dynamic> variables;
|
||||
|
||||
/// An optimistic result to eagerly add to the operation stream
|
||||
Object? optimisticResult;
|
||||
|
||||
/// Specifies the [Policies] to be used during execution.
|
||||
Policies policies;
|
||||
|
||||
FetchPolicy? get fetchPolicy => policies.fetch;
|
||||
|
||||
ErrorPolicy? get errorPolicy => policies.error;
|
||||
|
||||
CacheRereadPolicy? get cacheRereadPolicy => policies.cacheReread;
|
||||
|
||||
/// Context to be passed to link execution chain.
|
||||
Context context;
|
||||
|
||||
ResultParserFn<TParsed> parserFn;
|
||||
|
||||
// TODO consider inverting this relationship
|
||||
/// Resolve these options into a request
|
||||
Request get asRequest => Request(
|
||||
operation: Operation(
|
||||
document: document,
|
||||
operationName: operationName,
|
||||
),
|
||||
variables: variables,
|
||||
context: context,
|
||||
);
|
||||
|
||||
@override
|
||||
List<Object?> get properties => [
|
||||
document,
|
||||
operationName,
|
||||
variables,
|
||||
optimisticResult,
|
||||
policies,
|
||||
context,
|
||||
];
|
||||
|
||||
OperationType get type {
|
||||
final definitions =
|
||||
document.definitions.whereType<OperationDefinitionNode>().toList();
|
||||
if (operationName != null) {
|
||||
definitions.removeWhere(
|
||||
(node) => node.name!.value != operationName,
|
||||
);
|
||||
}
|
||||
// TODO differentiate error types, add exception
|
||||
assert(definitions.length == 1);
|
||||
return definitions.first.type;
|
||||
}
|
||||
|
||||
bool get isQuery => type == OperationType.query;
|
||||
bool get isMutation => type == OperationType.mutation;
|
||||
bool get isSubscription => type == OperationType.subscription;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import 'package:meta/meta.dart';
|
||||
import "package:collection/collection.dart";
|
||||
|
||||
/// Helper for making mutable data classes with
|
||||
/// a [properties] based [equal] helper
|
||||
///
|
||||
/// NOTE: I (@micimize) settled on this helper instead of truly immutable classes
|
||||
/// because I didn't want to deal with the issue of `copyWith(field: null)`,
|
||||
/// but also didn't want to commit to adding a true dataclass generator
|
||||
/// like `freezed` or `built_value` yet. I consider this a stopgap,
|
||||
/// and think we should eventually have a truly immutable API
|
||||
abstract class MutableDataClass {
|
||||
const MutableDataClass();
|
||||
|
||||
/// identifying properties for the inheriting class
|
||||
@protected
|
||||
List<Object?> get properties;
|
||||
|
||||
/// [properties] based deep equality check
|
||||
bool equal(MutableDataClass other) =>
|
||||
identical(this, other) ||
|
||||
(runtimeType == other.runtimeType &&
|
||||
const ListEquality<Object?>(
|
||||
DeepCollectionEquality(),
|
||||
).equals(
|
||||
other.properties,
|
||||
properties,
|
||||
));
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import 'package:fl_query/fl_query.dart';
|
||||
import 'package:fl_query/src/core/query_key.dart';
|
||||
|
||||
/// Internal writeQuery wrapper
|
||||
typedef _IntWriteQuery = void Function(
|
||||
QueryKey queryKey, Map<String, dynamic>? data);
|
||||
|
||||
extension InternalQueryWriteHandling on QueryManager {
|
||||
/// Merges exceptions into `queryResult` and
|
||||
/// returns `true` on success.
|
||||
///
|
||||
/// This is named `*OrSetExceptionOnQueryResult` because it is very imperative,
|
||||
/// and edits the [queryResult] inplace.
|
||||
bool _writeQueryOrSetExceptionOnQueryResult(
|
||||
QueryKey queryKey,
|
||||
Map<String, dynamic>? data,
|
||||
QueryResult? queryResult, {
|
||||
required _IntWriteQuery writeQuery,
|
||||
}) {
|
||||
try {
|
||||
writeQuery(queryKey, data);
|
||||
return true;
|
||||
} on CacheMisconfigurationException catch (failure) {
|
||||
queryResult!.exception = coalesceErrors(
|
||||
exception: queryResult.exception,
|
||||
linkException: failure,
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Part of [InternalQueryWriteHandling], and not exposed outside the
|
||||
/// library.
|
||||
///
|
||||
/// Returns `true` if a reread should be attempted to incorporate potential optimistic data.
|
||||
///
|
||||
/// If we have no data, we skip caching, thus taking [ErrorPolicy.none]
|
||||
/// into account.
|
||||
///
|
||||
/// networked wrapper for [_writeQueryOrSetExceptionOnQueryResult]
|
||||
/// NOTE: mapFetchResultToQueryResult must be called beforehand
|
||||
bool attemptCacheWriteFromResponse(
|
||||
Policies policies,
|
||||
Request request,
|
||||
Response response,
|
||||
QueryResult? queryResult,
|
||||
) =>
|
||||
(policies.fetch == FetchPolicy.noCache || queryResult!.data == null)
|
||||
? false
|
||||
: _writeQueryOrSetExceptionOnQueryResult(
|
||||
request,
|
||||
response.data,
|
||||
queryResult,
|
||||
writeQuery: (req, data) => cache.writeQuery(req, data: data!),
|
||||
onPartial: (failure) => UnexpectedResponseStructureException(
|
||||
failure,
|
||||
queryKey: request,
|
||||
parsedResponse: response,
|
||||
),
|
||||
) &&
|
||||
policies.mergeOptimisticData;
|
||||
|
||||
/// Part of [InternalQueryWriteHandling], and not exposed outside the
|
||||
/// library.
|
||||
///
|
||||
/// client-side wrapper for [_writeQueryOrSetExceptionOnQueryResult]
|
||||
bool attemptCacheWriteFromClient(
|
||||
Request request,
|
||||
Map<String, dynamic>? data,
|
||||
QueryResult queryResult, {
|
||||
required _IntWriteQuery writeQuery,
|
||||
}) =>
|
||||
_writeQueryOrSetExceptionOnQueryResult(
|
||||
request,
|
||||
data,
|
||||
queryResult,
|
||||
writeQuery: writeQuery,
|
||||
onPartial: (failure) => MismatchedDataStructureException(
|
||||
failure,
|
||||
queryKey: request,
|
||||
data: data,
|
||||
),
|
||||
);
|
||||
|
||||
/// Reread the request into the result from the cache,
|
||||
/// adding a [CacheMissException] if it fails to do so
|
||||
void attempCacheRereadIntoResult(Request request, QueryResult? queryResult) {
|
||||
// normalize results if previously written
|
||||
final rereadData = cache.readQuery(request);
|
||||
if (rereadData == null) {
|
||||
queryResult!.exception = coalesceErrors(
|
||||
exception: queryResult.exception,
|
||||
linkException: CacheMissException(
|
||||
'Round trip cache re-read failed: cache.readQuery(request) returned null',
|
||||
request,
|
||||
expectedData: queryResult.data,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
queryResult!.data = rereadData;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
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';
|
||||
@@ -0,0 +1,84 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:fl_query/fl_query.dart';
|
||||
|
||||
import 'package:fl_query/src/core/_query_write_handling.dart';
|
||||
|
||||
/// Fetch more results and then merge them with [previousResult]
|
||||
/// according to [FetchMoreOptions.updateQuery]
|
||||
///
|
||||
/// Will add results if [ObservableQuery.queryId] is supplied,
|
||||
/// and broadcast any cache changes
|
||||
///
|
||||
/// This is the **Internal Implementation**,
|
||||
/// used by [ObservableQuery] and [GraphQLCLient.fetchMore]
|
||||
Future<QueryResult<TParsed>> fetchMoreImplementation<TParsed>(
|
||||
FetchMoreOptions fetchMoreOptions, {
|
||||
required QueryOptions<TParsed> originalOptions,
|
||||
required QueryManager queryManager,
|
||||
required QueryResult<TParsed> previousResult,
|
||||
String? queryId,
|
||||
}) async {
|
||||
// fetch more and update
|
||||
|
||||
final document = (fetchMoreOptions.document ?? originalOptions.document);
|
||||
final request = originalOptions.asRequest;
|
||||
|
||||
final combinedOptions = QueryOptions<TParsed>(
|
||||
fetchPolicy: FetchPolicy.noCache,
|
||||
errorPolicy: originalOptions.errorPolicy,
|
||||
document: document,
|
||||
variables: {
|
||||
...originalOptions.variables,
|
||||
...fetchMoreOptions.variables,
|
||||
},
|
||||
);
|
||||
|
||||
QueryResult<TParsed> fetchMoreResult =
|
||||
await queryManager.query(combinedOptions);
|
||||
|
||||
try {
|
||||
// combine the query with the new query, using the function provided by the user
|
||||
final data = fetchMoreOptions.updateQuery(
|
||||
previousResult.data,
|
||||
fetchMoreResult.data,
|
||||
)!;
|
||||
|
||||
fetchMoreResult.data = data;
|
||||
|
||||
if (originalOptions.fetchPolicy != FetchPolicy.noCache) {
|
||||
queryManager.attemptCacheWriteFromClient(
|
||||
request,
|
||||
data,
|
||||
fetchMoreResult,
|
||||
writeQuery: (req, data) => queryManager.cache.writeQuery(
|
||||
req,
|
||||
data: data!,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// will add to a stream with `queryId` and rebroadcast if appropriate
|
||||
queryManager.addQueryResult(
|
||||
request,
|
||||
queryId,
|
||||
fetchMoreResult,
|
||||
);
|
||||
} catch (error) {
|
||||
if (fetchMoreResult.hasException) {
|
||||
// because the updateQuery failure might have been because of these errors,
|
||||
// we just add them to the old errors
|
||||
previousResult.exception = coalesceErrors(
|
||||
exception: previousResult.exception,
|
||||
graphqlErrors: fetchMoreResult.exception!.graphqlErrors,
|
||||
linkException: fetchMoreResult.exception!.linkException,
|
||||
);
|
||||
return previousResult;
|
||||
} else {
|
||||
// TODO merge results OperationException
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
return fetchMoreResult;
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
// ignore_for_file: deprecated_member_use_from_same_package
|
||||
import 'dart:async';
|
||||
import 'package:fl_query/src/cache/cache.dart';
|
||||
import 'package:fl_query/src/core/_base_options.dart';
|
||||
import 'package:fl_query/src/core/observable_query.dart';
|
||||
|
||||
import 'package:fl_query/src/core/result_parser.dart';
|
||||
|
||||
import 'package:fl_query/src/exceptions.dart';
|
||||
import 'package:fl_query/src/core/query_result.dart';
|
||||
import 'package:fl_query/src/utilities/helpers.dart';
|
||||
import 'package:fl_query/src/core/policies.dart';
|
||||
|
||||
typedef OnMutationCompleted = FutureOr<void> Function(dynamic data);
|
||||
typedef OnMutationUpdate = FutureOr<void> Function(
|
||||
JSONDataProxy cache,
|
||||
QueryResult? result,
|
||||
);
|
||||
typedef OnError = FutureOr<void> Function(OperationException? error);
|
||||
|
||||
class MutationOptions<TParsed> extends BaseOptions<TParsed> {
|
||||
MutationOptions({
|
||||
required DocumentNode document,
|
||||
String? operationName,
|
||||
Map<String, dynamic> variables = const {},
|
||||
FetchPolicy? fetchPolicy,
|
||||
ErrorPolicy? errorPolicy,
|
||||
CacheRereadPolicy? cacheRereadPolicy,
|
||||
Context? context,
|
||||
Object? optimisticResult,
|
||||
this.onCompleted,
|
||||
this.update,
|
||||
this.onError,
|
||||
ResultParserFn<TParsed>? parserFn,
|
||||
}) : super(
|
||||
fetchPolicy: fetchPolicy,
|
||||
errorPolicy: errorPolicy,
|
||||
cacheRereadPolicy: cacheRereadPolicy,
|
||||
document: document,
|
||||
operationName: operationName,
|
||||
variables: variables,
|
||||
context: context,
|
||||
optimisticResult: optimisticResult,
|
||||
parserFn: parserFn,
|
||||
);
|
||||
|
||||
final OnMutationCompleted? onCompleted;
|
||||
final OnMutationUpdate? update;
|
||||
final OnError? onError;
|
||||
|
||||
@override
|
||||
List<Object?> get properties =>
|
||||
[...super.properties, onCompleted, update, onError];
|
||||
}
|
||||
|
||||
/// Handles execution of mutation `update`, `onCompleted`, and `onError` callbacks
|
||||
class MutationCallbackHandler {
|
||||
final MutationOptions options;
|
||||
final QueryCache cache;
|
||||
final String queryId;
|
||||
|
||||
MutationCallbackHandler({
|
||||
required this.options,
|
||||
required this.cache,
|
||||
required this.queryId,
|
||||
});
|
||||
|
||||
// callbacks will be called against each result in the stream,
|
||||
// which should then rebroadcast queries with the appropriate optimism
|
||||
Iterable<OnData> get callbacks =>
|
||||
<OnData?>[onCompleted, update, onError].where(notNull).cast<OnData>();
|
||||
|
||||
// Todo: probably move this to its own class
|
||||
OnData? get onCompleted {
|
||||
if (options.onCompleted != null) {
|
||||
return (QueryResult? result) {
|
||||
if (!result!.isLoading && !result.isOptimistic) {
|
||||
return options.onCompleted!(result.data);
|
||||
}
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
OnData? get onError {
|
||||
if (options.onError != null) {
|
||||
return (QueryResult? result) {
|
||||
if (!result!.isLoading &&
|
||||
result.hasException &&
|
||||
options.errorPolicy != ErrorPolicy.ignore) {
|
||||
return options.onError!(result.exception);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// The optimistic cache layer id `update` will write to
|
||||
/// is a "child patch" of the default optimistic patch
|
||||
/// created by the query manager
|
||||
String get _patchId => '${queryId}.update';
|
||||
|
||||
/// apply the user's patch
|
||||
void _optimisticUpdate(QueryResult? result) {
|
||||
final String patchId = _patchId;
|
||||
// this is also done in query_manager, but better safe than sorry
|
||||
cache.recordOptimisticTransaction(
|
||||
(JSONDataProxy cache) {
|
||||
options.update!(cache, result);
|
||||
return cache;
|
||||
},
|
||||
patchId,
|
||||
);
|
||||
}
|
||||
|
||||
// optimistic patches will be cleaned up by the query_manager
|
||||
// cleanup is handled by heirarchical optimism -
|
||||
// as in, because our patch id is prefixed with '${observableQuery.queryId}.',
|
||||
// it will be discarded along with the observableQuery.queryId patch
|
||||
// TODO this results in an implicit coupling with the patch id system
|
||||
OnData? get update {
|
||||
if (options.update != null) {
|
||||
// dereference all variables that might be needed if the widget is disposed
|
||||
final OnMutationUpdate? widgetUpdate = options.update;
|
||||
final OnData optimisticUpdate = _optimisticUpdate;
|
||||
|
||||
// wrap update logic to handle optimism
|
||||
FutureOr<void> updateOnData(QueryResult? result) {
|
||||
if (result!.isOptimistic) {
|
||||
return optimisticUpdate(result);
|
||||
} else {
|
||||
return widgetUpdate!(cache, result);
|
||||
}
|
||||
}
|
||||
|
||||
return updateOnData;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
import 'dart:async';
|
||||
import 'package:fl_query/fl_query.dart';
|
||||
import 'package:meta/meta.dart';
|
||||
|
||||
import 'package:fl_query/src/core/fetch_more.dart';
|
||||
import 'package:fl_query/src/scheduler/scheduler.dart';
|
||||
|
||||
/// Side effect to register for execution when data is received
|
||||
typedef OnData = FutureOr<void> Function(QueryResult? result);
|
||||
|
||||
/// Lifecycle states for [ObservableQuery.lifecycle]
|
||||
enum QueryLifecycle {
|
||||
/// No results have been requested or fetched
|
||||
unexecuted,
|
||||
|
||||
/// Results are being fetched, and will be side-effect free
|
||||
pending,
|
||||
|
||||
/// Polling for results periodically
|
||||
polling,
|
||||
|
||||
/// Was polling but [ObservableQuery.stopPolling()] was called
|
||||
pollingStopped,
|
||||
|
||||
/// Results are being fetched, and will trigger
|
||||
/// the callbacks registered with [ObservableQuery.onData]
|
||||
sideEffectsPending,
|
||||
|
||||
/// Pending side effects are preventing [ObservableQuery.close],
|
||||
/// and the [ObservableQuery] will be discarded after fetch completes
|
||||
/// and side effects are resolved.
|
||||
sideEffectsBlocking,
|
||||
|
||||
/// The operation was executed and is not [polling]
|
||||
completed,
|
||||
|
||||
/// [ObservableQuery.close] was called and all activity
|
||||
/// from this [ObservableQuery] has ceased.
|
||||
closed
|
||||
}
|
||||
|
||||
/// An Observable/Stream-based API for both queries and mutations.
|
||||
///
|
||||
/// Returned from [GraphQLClient.watchQuery] for use in reactive programming,
|
||||
/// for instance in `graphql_flutter` widgets.
|
||||
/// It is modelled closely after [Apollo's ObservableQuery][apollo_oq]
|
||||
///
|
||||
/// [ObservableQuery]'s core api/usage is to [fetchResults], then listen to the [stream].
|
||||
/// [fetchResults] will be called on instantiation if [options.eagerlyFetchResults] is set,
|
||||
/// which in turn defaults to [options.fetchResults].
|
||||
///
|
||||
/// Beyond that, [ObservableQuery] is a bit of a kitchen sink:
|
||||
/// * There are [refetch] and [fetchMore] methods for fetching more results
|
||||
/// * An [onData] method for registering callbacks (namely for mutations)
|
||||
/// * [lifecycle] for tracking polling, side effect, an inflight execution state
|
||||
/// * [latestResult] – the most recent result from this operation
|
||||
///
|
||||
/// And a handful of internally leveraged methods.
|
||||
///
|
||||
/// [apollo_oq]: https://www.apollographql.com/docs/react/v3.0-beta/api/core/ObservableQuery/
|
||||
class ObservableQuery<TParsed> {
|
||||
ObservableQuery({
|
||||
required this.queryManager,
|
||||
required this.options,
|
||||
}) : queryId = queryManager.generateQueryId().toString() {
|
||||
if (options.eagerlyFetchResults) {
|
||||
_latestWasEagerlyFetched = true;
|
||||
fetchResults();
|
||||
}
|
||||
controller = StreamController<QueryResult<TParsed>>.broadcast(
|
||||
onListen: onListen,
|
||||
);
|
||||
}
|
||||
|
||||
// set to true when eagerly fetched to prevent back-to-back queries
|
||||
bool _latestWasEagerlyFetched = false;
|
||||
|
||||
/// The identity of this query within the [QueryManager]
|
||||
final String queryId;
|
||||
|
||||
@protected
|
||||
final QueryManager queryManager;
|
||||
|
||||
@protected
|
||||
QueryScheduler? get scheduler => queryManager.scheduler;
|
||||
|
||||
/// callbacks registered with [onData]
|
||||
List<OnData> _onDataCallbacks = [];
|
||||
|
||||
/// call [queryManager.maybeRebroadcastQueries] after all other [_onDataCallbacks]
|
||||
///
|
||||
/// Automatically appended as an [OnData]
|
||||
FutureOr<void> _maybeRebroadcast(QueryResult? result) =>
|
||||
queryManager.maybeRebroadcastQueries(exclude: this);
|
||||
|
||||
/// The most recently seen result from this operation's stream
|
||||
QueryResult<TParsed>? latestResult;
|
||||
|
||||
QueryLifecycle lifecycle = QueryLifecycle.unexecuted;
|
||||
|
||||
WatchQueryOptions<TParsed> options;
|
||||
|
||||
late StreamController<QueryResult<TParsed>> controller;
|
||||
|
||||
Stream<QueryResult<TParsed>> get stream => controller.stream;
|
||||
bool get isCurrentlyPolling => lifecycle == QueryLifecycle.polling;
|
||||
|
||||
bool get isRefetchSafe {
|
||||
if (!options.isQuery) {
|
||||
return false;
|
||||
}
|
||||
switch (lifecycle) {
|
||||
case QueryLifecycle.completed:
|
||||
case QueryLifecycle.polling:
|
||||
case QueryLifecycle.pollingStopped:
|
||||
return true;
|
||||
|
||||
case QueryLifecycle.pending:
|
||||
case QueryLifecycle.closed:
|
||||
case QueryLifecycle.unexecuted:
|
||||
case QueryLifecycle.sideEffectsPending:
|
||||
case QueryLifecycle.sideEffectsBlocking:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Attempts to refetch _on the network_, throwing error if not refetch safe
|
||||
///
|
||||
/// **NOTE:** overrides any present non-network-only [FetchPolicy],
|
||||
/// as refetching from the `cache` does not make sense.
|
||||
Future<QueryResult<TParsed>?> refetch() {
|
||||
if (isRefetchSafe) {
|
||||
addResult(QueryResult.loading(
|
||||
data: latestResult?.data,
|
||||
parserFn: options.parserFn,
|
||||
));
|
||||
return queryManager.refetchQuery<TParsed>(queryId);
|
||||
}
|
||||
throw Exception('Query is not refetch safe');
|
||||
}
|
||||
|
||||
/// Whether it is safe to rebroadcast results due to cache
|
||||
/// changes based on policies and [lifecycle].
|
||||
///
|
||||
/// Called internally by the [QueryManager]
|
||||
bool get isRebroadcastSafe {
|
||||
if (!options.policies.allowsRebroadcasting) {
|
||||
return false;
|
||||
}
|
||||
switch (lifecycle) {
|
||||
case QueryLifecycle.pending:
|
||||
case QueryLifecycle.completed:
|
||||
case QueryLifecycle.polling:
|
||||
case QueryLifecycle.pollingStopped:
|
||||
return true;
|
||||
|
||||
case QueryLifecycle.unexecuted: // this might be ok
|
||||
case QueryLifecycle.closed:
|
||||
case QueryLifecycle.sideEffectsPending:
|
||||
case QueryLifecycle.sideEffectsBlocking:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
void onListen() {
|
||||
if (_latestWasEagerlyFetched) {
|
||||
_latestWasEagerlyFetched = false;
|
||||
|
||||
// eager results are resolved synchronously,
|
||||
// so we have to add them manually now that
|
||||
// the stream is available
|
||||
if (!controller.isClosed && latestResult != null) {
|
||||
controller.add(latestResult!);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (options.fetchResults) {
|
||||
fetchResults();
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch results based on [options.fetchPolicy]
|
||||
///
|
||||
/// Will [startPolling] if [options.pollInterval] is set
|
||||
MultiSourceResult<TParsed> fetchResults() {
|
||||
final MultiSourceResult<TParsed> allResults =
|
||||
queryManager.fetchQueryAsMultiSourceResult(queryId, options);
|
||||
latestResult ??= allResults.eagerResult;
|
||||
|
||||
if (allResults.networkResult == null) {
|
||||
// This path is only possible for cacheFirst and cacheOnly fetch policies.
|
||||
lifecycle = QueryLifecycle.completed;
|
||||
} else {
|
||||
// if onData callbacks have been registered,
|
||||
// they are waited on by default
|
||||
lifecycle = _onDataCallbacks.isNotEmpty
|
||||
? QueryLifecycle.sideEffectsPending
|
||||
: QueryLifecycle.pending;
|
||||
}
|
||||
|
||||
if (options.pollInterval != null && options.pollInterval! > Duration.zero) {
|
||||
startPolling(options.pollInterval);
|
||||
}
|
||||
|
||||
return allResults;
|
||||
}
|
||||
|
||||
/// fetch more results and then merge them with the [latestResult]
|
||||
/// according to [FetchMoreOptions.updateQuery].
|
||||
///
|
||||
/// The results will then be added to to stream for listeners to react to,
|
||||
/// such as for triggering `grahphql_flutter` widget rebuilds
|
||||
///
|
||||
/// **NOTE**: with the addition of strict data structure checking in v4,
|
||||
/// it is easy to make mistakes in writing [updateQuery].
|
||||
///
|
||||
/// To mitigate this, [FetchMoreOptions.partial] has been provided.
|
||||
Future<QueryResult<TParsed>> fetchMore(
|
||||
FetchMoreOptions fetchMoreOptions) async {
|
||||
addResult(QueryResult.loading(
|
||||
data: latestResult?.data,
|
||||
parserFn: options.parserFn,
|
||||
));
|
||||
|
||||
return fetchMoreImplementation(
|
||||
fetchMoreOptions,
|
||||
originalOptions: options,
|
||||
queryManager: queryManager,
|
||||
previousResult: latestResult!,
|
||||
queryId: queryId,
|
||||
);
|
||||
}
|
||||
|
||||
/// Add a [result] to the [stream] unless it was created
|
||||
/// before [lasestResult].
|
||||
///
|
||||
/// Copies the [QueryResult.source] from the [latestResult]
|
||||
/// if it is set to `null`.
|
||||
///
|
||||
/// Called internally by the [QueryManager]
|
||||
void addResult(QueryResult<TParsed> result, {bool fromRebroadcast = false}) {
|
||||
// don't overwrite results due to some async/optimism issue
|
||||
if (latestResult != null &&
|
||||
latestResult!.timestamp.isAfter(result.timestamp)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.carryForwardDataOnException && result.hasException) {
|
||||
result.data ??= latestResult?.data;
|
||||
}
|
||||
|
||||
if (lifecycle == QueryLifecycle.pending && result.isConcrete) {
|
||||
lifecycle = QueryLifecycle.completed;
|
||||
}
|
||||
|
||||
latestResult = result;
|
||||
|
||||
// TODO should callbacks be applied before or after streaming
|
||||
if (!controller.isClosed) {
|
||||
controller.add(result);
|
||||
}
|
||||
|
||||
if (result.isNotLoading) {
|
||||
_applyCallbacks(result, fromRebroadcast: fromRebroadcast);
|
||||
}
|
||||
}
|
||||
|
||||
// most mutation behavior happens here
|
||||
/// Register [callbacks] to trigger when [stream] has new results
|
||||
/// where [QueryResult.isNotLoading]
|
||||
///
|
||||
/// Will deregister [callbacks] after calling them on the first
|
||||
/// result that [QueryResult.isConcrete],
|
||||
/// handling the resolution of [lifecycle] from
|
||||
/// [QueryLifecycle.sideEffectsBlocking] to [QueryLifecycle.completed]
|
||||
/// as appropriate
|
||||
void onData(Iterable<OnData> callbacks) => _onDataCallbacks.addAll(callbacks);
|
||||
|
||||
/// Applies [onData] callbacks at the end of [addResult]
|
||||
///
|
||||
/// [fromRebroadcast] is used to avoid the super-edge case of infinite rebroadcasts
|
||||
/// (not sure if it's even possible)
|
||||
void _applyCallbacks(
|
||||
QueryResult? result, {
|
||||
bool fromRebroadcast = false,
|
||||
}) async {
|
||||
final callbacks = [
|
||||
..._onDataCallbacks,
|
||||
if (!fromRebroadcast) _maybeRebroadcast
|
||||
];
|
||||
for (final callback in callbacks) {
|
||||
await callback(result);
|
||||
}
|
||||
|
||||
if (lifecycle == QueryLifecycle.closed) {
|
||||
// .close(force: true) was called
|
||||
return;
|
||||
}
|
||||
|
||||
if (result!.isConcrete) {
|
||||
// avoid removing new callbacks
|
||||
_onDataCallbacks.removeWhere((cb) => callbacks.contains(cb));
|
||||
|
||||
// if there are new callbacks, there is maybe another inflight mutation
|
||||
if (_onDataCallbacks.isEmpty) {
|
||||
if (lifecycle == QueryLifecycle.sideEffectsBlocking) {
|
||||
lifecycle = QueryLifecycle.completed;
|
||||
close();
|
||||
}
|
||||
// the mutation has been completed, but disposal has not been requested
|
||||
if (lifecycle == QueryLifecycle.sideEffectsPending) {
|
||||
lifecycle = QueryLifecycle.completed;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Poll the server periodically for results.
|
||||
///
|
||||
/// Will be called by [fetchResults] automatically if [options.pollInterval] is set
|
||||
void startPolling(Duration? pollInterval) {
|
||||
if (options.fetchPolicy == FetchPolicy.cacheFirst ||
|
||||
options.fetchPolicy == FetchPolicy.cacheOnly) {
|
||||
throw Exception(
|
||||
'Queries that specify the cacheFirst and cacheOnly fetch policies cannot also be polling queries.',
|
||||
);
|
||||
}
|
||||
|
||||
if (isCurrentlyPolling) {
|
||||
scheduler!.stopPollingQuery(queryId);
|
||||
}
|
||||
|
||||
options.pollInterval = pollInterval;
|
||||
lifecycle = QueryLifecycle.polling;
|
||||
scheduler!.startPollingQuery(options, queryId);
|
||||
}
|
||||
|
||||
void stopPolling() {
|
||||
if (isCurrentlyPolling) {
|
||||
scheduler!.stopPollingQuery(queryId);
|
||||
options.pollInterval = null;
|
||||
lifecycle = QueryLifecycle.pollingStopped;
|
||||
}
|
||||
}
|
||||
|
||||
set variables(Map<String, dynamic> variables) =>
|
||||
options.variables = variables;
|
||||
|
||||
/// [onData] callbacks have het to be run
|
||||
///
|
||||
/// inlcudes `lifecycle == QueryLifecycle.sideEffectsBlocking`
|
||||
bool get sideEffectsArePending =>
|
||||
(lifecycle == QueryLifecycle.sideEffectsPending ||
|
||||
lifecycle == QueryLifecycle.sideEffectsBlocking);
|
||||
|
||||
/// Closes the query or mutation, or else queues it for closing.
|
||||
///
|
||||
/// To preserve Mutation side effects, [close] checks the [lifecycle],
|
||||
/// queuing the stream for closing if [sideEffectsArePending].
|
||||
/// You can override this check with `force: true`.
|
||||
///
|
||||
/// Returns a [FutureOr] of the resultant lifecycle, either
|
||||
/// [QueryLifecycle.sideEffectsBlocking] or [QueryLifecycle.closed]
|
||||
FutureOr<QueryLifecycle> close({
|
||||
bool force = false,
|
||||
bool fromManager = false,
|
||||
}) async {
|
||||
if (lifecycle == QueryLifecycle.sideEffectsPending && !force) {
|
||||
lifecycle = QueryLifecycle.sideEffectsBlocking;
|
||||
// stop closing because we're waiting on something
|
||||
return lifecycle;
|
||||
}
|
||||
|
||||
// `fromManager` is used by the query manager when it wants to close a query to avoid infinite loops
|
||||
if (!fromManager) {
|
||||
queryManager.closeQuery(this, fromQuery: true);
|
||||
}
|
||||
|
||||
stopPolling();
|
||||
|
||||
await controller.close();
|
||||
|
||||
lifecycle = QueryLifecycle.closed;
|
||||
return QueryLifecycle.closed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
import 'package:fl_query/fl_query.dart';
|
||||
import 'package:meta/meta.dart';
|
||||
import "package:collection/collection.dart";
|
||||
|
||||
/// [FetchPolicy] determines where the client may return a result from.
|
||||
///
|
||||
/// * [cacheFirst]: return result from cache. Only fetch from network if cached result is not available.
|
||||
/// * [cacheAndNetwork]: return result from cache first (if it exists), then return network result once it's available.
|
||||
/// * [cacheOnly]: return result from cache if available, fail otherwise.
|
||||
/// * [noCache]: return result from network, fail if network call doesn't succeed, don't save to cache.
|
||||
/// * [networkOnly]: return result from network, fail if network call doesn't succeed, save to cache.
|
||||
///
|
||||
/// The default `fetchPolicy` for each method are:
|
||||
/// * `watchQuery`: [cacheAndNetwork]
|
||||
/// * `watchMutation`: [cacheAndNetwork]
|
||||
/// * `query`: [cacheFirst]
|
||||
/// * `mutation`: [networkOnly]
|
||||
/// * `subscribe`: [networkOnly]
|
||||
///
|
||||
/// These can be overriden at client construction time by passing
|
||||
/// a [DefaultPolicies] instance to `defaultPolicies`.
|
||||
enum FetchPolicy {
|
||||
/// Return result from cache. Only fetch from network if cached result is not available.
|
||||
cacheFirst,
|
||||
|
||||
/// Return result from cache first (if it exists), then return network result once it's available.
|
||||
cacheAndNetwork,
|
||||
|
||||
/// Return result from cache if available, fail otherwise.
|
||||
cacheOnly,
|
||||
|
||||
/// Return result from network, fail if network call doesn't succeed, don't save to cache.
|
||||
noCache,
|
||||
|
||||
/// Return result from network, fail if network call doesn't succeed, save to cache.
|
||||
networkOnly,
|
||||
}
|
||||
|
||||
// TODO investigate the relationship between optimistic results
|
||||
// and policy in flutter
|
||||
bool shouldRespondEagerlyFromCache(FetchPolicy? fetchPolicy) =>
|
||||
fetchPolicy == FetchPolicy.cacheFirst ||
|
||||
fetchPolicy == FetchPolicy.cacheAndNetwork ||
|
||||
fetchPolicy == FetchPolicy.cacheOnly;
|
||||
|
||||
bool shouldStopAtCache(FetchPolicy? fetchPolicy) =>
|
||||
fetchPolicy == FetchPolicy.cacheFirst ||
|
||||
fetchPolicy == FetchPolicy.cacheOnly;
|
||||
|
||||
bool willAlwaysExecuteOnNetwork(FetchPolicy? policy) {
|
||||
switch (policy) {
|
||||
case FetchPolicy.noCache:
|
||||
case FetchPolicy.networkOnly:
|
||||
return true;
|
||||
case FetchPolicy.cacheFirst:
|
||||
case FetchPolicy.cacheAndNetwork:
|
||||
case FetchPolicy.cacheOnly:
|
||||
case null:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// [ErrorPolicy] determines the level of events for GraphQL Errors in the execution result. The options are:
|
||||
///
|
||||
/// While the default for all client methods is [none],
|
||||
/// [all] is recommended for notifying your users of potential issues.
|
||||
///
|
||||
/// * [none] (default): Any GraphQL Errors are treated the same as network errors and any data is ignored from the response.
|
||||
/// * [ignore]: Ignore allows you to read any data that is returned alongside GraphQL Errors,
|
||||
/// but doesn't save the errors or report them to your UI.
|
||||
/// * [all]: Saves both data and errors into the `cache` so your UI can use them.
|
||||
/// It is recommended for notifying your users of potential issues,
|
||||
/// while still showing as much data as possible from your server.
|
||||
///
|
||||
/// **NOTE**: [ErrorPolicy] only effects **GraphQL Errors**.
|
||||
/// Client side and network exceptions are added to a [QueryResult] as they occur,
|
||||
/// and can co-exist alongside data.
|
||||
enum ErrorPolicy {
|
||||
/// Any GraphQL Errors are treated the same as network errors and any data is ignored from the response. (default)
|
||||
none,
|
||||
|
||||
/// Ignore allows you to read any data that is returned alongside GraphQL Errors,
|
||||
/// but doesn't save the errors or report them to your UI.
|
||||
ignore,
|
||||
|
||||
/// Saves both data and errors into the `cache` so your UI can use them.
|
||||
///
|
||||
/// It is recommended for notifying your users of potential issues,
|
||||
/// while still showing as much data as possible from your server.
|
||||
all,
|
||||
}
|
||||
|
||||
/// [CacheRereadPolicy] determines whether and how cache data will be merged into
|
||||
/// the final [QueryResult] `data` before it is returned.
|
||||
///
|
||||
/// It _does not_ effect `optimisticResults` added to [QueryOptions], etc.
|
||||
///
|
||||
/// * [mergeOptimistic]: Merge relevant optimistic data from the cache before returning.
|
||||
/// * [ignoreOptimistic]: Ignore optimistic data, but still allow for non-optimistic cache rebroadcasts
|
||||
/// **if applicable**.
|
||||
/// * [ignoreAll]: Ignore all cache data besides the result, and never rebroadcast the result,
|
||||
/// even if the underlying cache data changes.
|
||||
///
|
||||
/// The default `cacheRereadPolicy` for each method are:
|
||||
/// * `watchQuery`: [mergeOptimistic]
|
||||
/// * `watchMutation`: [ignoreAll]
|
||||
/// * `query`: [mergeOptimistic]
|
||||
/// * `mutation`: [ignoreAll]
|
||||
/// * `subscribe`: [mergeOptimistic]
|
||||
enum CacheRereadPolicy {
|
||||
/// Merge relevant optimistic data from the cache before returning.
|
||||
mergeOptimistic,
|
||||
|
||||
/// Ignore optimistic data, but still allow for non-optimistic cache rebroadcasts
|
||||
/// **if applicable**.
|
||||
ignoreOptimisitic,
|
||||
|
||||
/// Ignore all cache data besides the result, and never rebroadcast the result,
|
||||
/// even if the underlying cache data changes.
|
||||
ignoreAll,
|
||||
}
|
||||
|
||||
/// Container for supplying [fetch], [error], and [cacheReread] policies.
|
||||
///
|
||||
/// If any are `null`, the appropriate policy will be selected from [DefaultPolicies]
|
||||
@immutable
|
||||
class Policies {
|
||||
/// Specifies the [FetchPolicy] to be used.
|
||||
final FetchPolicy? fetch;
|
||||
|
||||
/// Specifies the [ErrorPolicy] to be used.
|
||||
final ErrorPolicy? error;
|
||||
|
||||
/// Specifies the [CacheRereadPolicy] to be used.
|
||||
final CacheRereadPolicy? cacheReread;
|
||||
|
||||
bool get mergeOptimisticData =>
|
||||
cacheReread == CacheRereadPolicy.mergeOptimistic;
|
||||
|
||||
Policies({
|
||||
this.fetch,
|
||||
this.error,
|
||||
this.cacheReread,
|
||||
});
|
||||
|
||||
Policies.safe(
|
||||
FetchPolicy this.fetch,
|
||||
ErrorPolicy this.error,
|
||||
CacheRereadPolicy this.cacheReread,
|
||||
);
|
||||
|
||||
Policies withOverrides([Policies? overrides]) => Policies.safe(
|
||||
overrides?.fetch ?? fetch!,
|
||||
overrides?.error ?? error!,
|
||||
overrides?.cacheReread ?? cacheReread!,
|
||||
);
|
||||
|
||||
Policies copyWith({FetchPolicy? fetch, ErrorPolicy? error}) =>
|
||||
Policies(fetch: fetch, error: error, cacheReread: cacheReread);
|
||||
|
||||
operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
(other is Policies &&
|
||||
fetch == other.fetch &&
|
||||
error == other.error &&
|
||||
cacheReread == other.cacheReread);
|
||||
|
||||
@override
|
||||
int get hashCode => const ListEquality<Object?>(
|
||||
DeepCollectionEquality(),
|
||||
).hash([fetch, error, cacheReread]);
|
||||
|
||||
/// Returns `false` if either [fetch] or [cacheReread] policies have disabled rebroadcast.
|
||||
bool get allowsRebroadcasting => !(fetch == FetchPolicy.noCache ||
|
||||
cacheReread == CacheRereadPolicy.ignoreAll);
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'Policies(fetch: $fetch, error: $error, cacheReread: $cacheReread)';
|
||||
}
|
||||
|
||||
/// The default [Policies] to set for each client action.
|
||||
@immutable
|
||||
class DefaultPolicies {
|
||||
/// The default [Policies] for watchQuery.
|
||||
/// Defaults to
|
||||
/// ```
|
||||
/// Policies(
|
||||
/// FetchPolicy.cacheAndNetwork,
|
||||
/// ErrorPolicy.none,
|
||||
/// CacheRereadPolicy.mergeOptimistic,
|
||||
/// )
|
||||
/// ```
|
||||
final Policies watchQuery;
|
||||
|
||||
/// The default [Policies] for watchMutation.
|
||||
/// Defaults to
|
||||
/// ```
|
||||
/// Policies(
|
||||
/// FetchPolicy.networkOnly,
|
||||
/// ErrorPolicy.none,
|
||||
/// CacheRereadPolicy.ignoreAll,
|
||||
/// )
|
||||
/// ```
|
||||
final Policies watchMutation;
|
||||
|
||||
/// The default [Policies] for query.
|
||||
/// Defaults to
|
||||
/// ```
|
||||
/// Policies(
|
||||
/// FetchPolicy.cacheFirst,
|
||||
/// ErrorPolicy.none,
|
||||
/// CacheRereadPolicy.mergeOptimistic,
|
||||
/// )
|
||||
/// ```
|
||||
final Policies query;
|
||||
|
||||
/// The default [Policies] for mutate.
|
||||
/// Defaults to
|
||||
/// ```
|
||||
/// Policies(
|
||||
/// FetchPolicy.networkOnly,
|
||||
/// ErrorPolicy.none,
|
||||
/// CacheRereadPolicy.ignore,
|
||||
/// )
|
||||
/// ```
|
||||
final Policies mutate;
|
||||
|
||||
/// The default [Policies] for subscribe.
|
||||
/// Defaults to
|
||||
/// ```
|
||||
/// Policies(
|
||||
/// FetchPolicy.networkOnly,
|
||||
/// ErrorPolicy.none,
|
||||
/// CacheRereadPolicy.mergeOptimistic,
|
||||
/// )
|
||||
/// ```
|
||||
///
|
||||
/// The subscription spec is very flexible, so we default to `FetchPolicy.networkOnly`
|
||||
/// to avoid breaking some use-cases by default.
|
||||
///
|
||||
/// `FetchPolicy.cacheOnly` is invalid for subscriptions. This is because `FetchPolicy` changes do
|
||||
/// little to change subscription behavior, only determining
|
||||
/// whether an eager result is first read from the cache.
|
||||
final Policies subscribe;
|
||||
|
||||
DefaultPolicies({
|
||||
Policies? watchQuery,
|
||||
Policies? watchMutation,
|
||||
Policies? query,
|
||||
Policies? mutate,
|
||||
Policies? subscribe,
|
||||
}) : watchQuery = _watchQueryDefaults.withOverrides(watchQuery),
|
||||
watchMutation = _mutateDefaults.withOverrides(watchMutation),
|
||||
query = _queryDefaults.withOverrides(query),
|
||||
mutate = _mutateDefaults.withOverrides(mutate),
|
||||
subscribe = _subscribeDefaults.withOverrides(subscribe);
|
||||
|
||||
static final _watchQueryDefaults = Policies.safe(
|
||||
FetchPolicy.cacheAndNetwork,
|
||||
ErrorPolicy.none,
|
||||
CacheRereadPolicy.mergeOptimistic,
|
||||
);
|
||||
|
||||
static final _queryDefaults = Policies.safe(
|
||||
FetchPolicy.cacheFirst,
|
||||
ErrorPolicy.none,
|
||||
CacheRereadPolicy.mergeOptimistic,
|
||||
);
|
||||
|
||||
static final _mutateDefaults = Policies.safe(
|
||||
FetchPolicy.networkOnly,
|
||||
ErrorPolicy.none,
|
||||
CacheRereadPolicy.ignoreAll,
|
||||
);
|
||||
|
||||
static final _subscribeDefaults = Policies.safe(
|
||||
FetchPolicy.networkOnly,
|
||||
ErrorPolicy.none,
|
||||
CacheRereadPolicy.mergeOptimistic,
|
||||
);
|
||||
|
||||
DefaultPolicies copyWith({
|
||||
Policies? watchQuery,
|
||||
Policies? query,
|
||||
Policies? watchMutation,
|
||||
Policies? mutate,
|
||||
Policies? subscribe,
|
||||
}) =>
|
||||
DefaultPolicies(
|
||||
watchQuery: watchQuery,
|
||||
query: query,
|
||||
watchMutation: watchMutation,
|
||||
mutate: mutate,
|
||||
subscribe: subscribe,
|
||||
);
|
||||
|
||||
List<Object> _getChildren() => [
|
||||
watchQuery,
|
||||
query,
|
||||
watchMutation,
|
||||
mutate,
|
||||
subscribe,
|
||||
];
|
||||
|
||||
@override
|
||||
bool operator ==(Object o) =>
|
||||
identical(this, o) ||
|
||||
(o is DefaultPolicies &&
|
||||
const ListEquality<Object?>(
|
||||
DeepCollectionEquality(),
|
||||
).equals(
|
||||
o._getChildren(),
|
||||
_getChildren(),
|
||||
));
|
||||
|
||||
@override
|
||||
int get hashCode => const ListEquality<Object?>(
|
||||
DeepCollectionEquality(),
|
||||
).hash(
|
||||
_getChildren(),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/// Used for defining a unique identifier for a specific query
|
||||
/// that can be used to read/modify/delete the query from the
|
||||
/// store
|
||||
class QueryKey {
|
||||
List<String> _key;
|
||||
QueryKey(String key) : _key = [key];
|
||||
|
||||
QueryKey.fromList(List<String> key) : _key = key;
|
||||
QueryKey.parse(String keyStr) : _key = keyStr.split(".");
|
||||
|
||||
String get key => _key.map((k) => k.replaceAll(".", "")).join(".");
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'QueryKey("$key")';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,547 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:meta/meta.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
|
||||
import 'package:fl_query/src/core/result_parser.dart';
|
||||
import 'package:fl_query/src/cache/cache.dart';
|
||||
import 'package:fl_query/src/core/observable_query.dart';
|
||||
import 'package:fl_query/src/core/_base_options.dart';
|
||||
import 'package:fl_query/src/core/mutation_options.dart';
|
||||
import 'package:fl_query/src/core/query_options.dart';
|
||||
import 'package:fl_query/src/core/query_result.dart';
|
||||
import 'package:fl_query/src/core/policies.dart';
|
||||
import 'package:fl_query/src/exceptions.dart';
|
||||
import 'package:fl_query/src/scheduler/scheduler.dart';
|
||||
import 'package:fl_query/src/core/_query_write_handling.dart';
|
||||
|
||||
bool Function(dynamic a, dynamic b) _deepEquals =
|
||||
const DeepCollectionEquality().equals;
|
||||
|
||||
class QueryManager {
|
||||
QueryManager({
|
||||
required this.link,
|
||||
required this.cache,
|
||||
this.alwaysRebroadcast = false,
|
||||
}) {
|
||||
scheduler = QueryScheduler(
|
||||
queryManager: this,
|
||||
);
|
||||
}
|
||||
|
||||
final Link link;
|
||||
final QueryCache cache;
|
||||
|
||||
/// Whether to skip deep equality checks in [maybeRebroadcastQueries]
|
||||
final bool alwaysRebroadcast;
|
||||
|
||||
QueryScheduler? scheduler;
|
||||
static final _oneOffOpId = '0';
|
||||
int idCounter = 1;
|
||||
|
||||
/// [ObservableQuery] registry
|
||||
Map<String, ObservableQuery> queries = <String, ObservableQuery>{};
|
||||
|
||||
/// prevents rebroadcasting for some intensive bulk operation like [refetchSafeQueries]
|
||||
bool rebroadcastLocked = false;
|
||||
|
||||
ObservableQuery<TParsed> watchQuery<TParsed>(
|
||||
WatchQueryOptions<TParsed> options) {
|
||||
final ObservableQuery<TParsed> observableQuery = ObservableQuery<TParsed>(
|
||||
queryManager: this,
|
||||
options: options,
|
||||
);
|
||||
|
||||
setQuery(observableQuery);
|
||||
|
||||
return observableQuery;
|
||||
}
|
||||
|
||||
Stream<QueryResult<TParsed>> subscribe<TParsed>(
|
||||
SubscriptionOptions<TParsed> options) async* {
|
||||
assert(
|
||||
options.fetchPolicy != FetchPolicy.cacheOnly,
|
||||
"Cannot subscribe with FetchPolicy.cacheOnly: $options",
|
||||
);
|
||||
final request = options.asRequest;
|
||||
|
||||
// Add optimistic or cache-based result to the stream if any
|
||||
if (options.optimisticResult != null) {
|
||||
// TODO optimisticResults for streams just skip the cache for now
|
||||
yield QueryResult.optimistic(
|
||||
data: options.optimisticResult as Map<String, dynamic>?,
|
||||
parserFn: options.parserFn,
|
||||
);
|
||||
} else if (shouldRespondEagerlyFromCache(options.fetchPolicy)) {
|
||||
final cacheResult = cache.readQuery(
|
||||
request,
|
||||
optimistic: options.policies.mergeOptimisticData,
|
||||
);
|
||||
if (cacheResult != null) {
|
||||
yield QueryResult(
|
||||
source: QueryResultSource.cache,
|
||||
data: cacheResult,
|
||||
parserFn: options.parserFn,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
yield* link.queryKey(request).map((response) {
|
||||
QueryResult<TParsed>? queryResult;
|
||||
bool rereadFromCache = false;
|
||||
try {
|
||||
queryResult = mapFetchResultToQueryResult(
|
||||
response,
|
||||
options,
|
||||
source: QueryResultSource.network,
|
||||
);
|
||||
|
||||
rereadFromCache = attemptCacheWriteFromResponse(
|
||||
options.policies,
|
||||
request,
|
||||
response,
|
||||
queryResult,
|
||||
);
|
||||
} catch (failure, trace) {
|
||||
// we set the source to indicate where the source of failure
|
||||
queryResult ??= QueryResult(
|
||||
source: QueryResultSource.network,
|
||||
parserFn: options.parserFn,
|
||||
);
|
||||
|
||||
queryResult.exception = coalesceErrors(
|
||||
exception: queryResult.exception,
|
||||
linkException: translateFailure(failure, trace),
|
||||
);
|
||||
}
|
||||
|
||||
if (rereadFromCache) {
|
||||
// normalize results if previously written
|
||||
attempCacheRereadIntoResult(request, queryResult);
|
||||
}
|
||||
|
||||
return queryResult;
|
||||
}).transform(StreamTransformer.fromHandlers(
|
||||
handleError: (err, trace, sink) => sink.add(_wrapFailure(
|
||||
err,
|
||||
trace,
|
||||
options.parserFn,
|
||||
)),
|
||||
));
|
||||
} catch (ex, trace) {
|
||||
yield* Stream.fromIterable([
|
||||
_wrapFailure(
|
||||
ex,
|
||||
trace,
|
||||
options.parserFn,
|
||||
)
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
Future<QueryResult<TParsed>> query<TParsed>(
|
||||
QueryOptions<TParsed> options) async {
|
||||
final result = await fetchQuery(_oneOffOpId, options);
|
||||
maybeRebroadcastQueries();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<QueryResult<TParsed>> mutate<TParsed>(
|
||||
MutationOptions<TParsed> options) async {
|
||||
final result = await fetchQuery(_oneOffOpId, options);
|
||||
// once the mutation has been process successfully, execute callbacks
|
||||
// before returning the results
|
||||
final mutationCallbacks = MutationCallbackHandler(
|
||||
cache: cache,
|
||||
options: options,
|
||||
queryId: _oneOffOpId,
|
||||
);
|
||||
|
||||
final callbacks = mutationCallbacks.callbacks;
|
||||
|
||||
for (final callback in callbacks) {
|
||||
await callback(result);
|
||||
}
|
||||
|
||||
/// wait until callbacks complete to rebroadcast
|
||||
maybeRebroadcastQueries();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<QueryResult<TParsed>> fetchQuery<TParsed>(
|
||||
String queryId,
|
||||
BaseOptions<TParsed> options,
|
||||
) async {
|
||||
final MultiSourceResult<TParsed> allResults =
|
||||
fetchQueryAsMultiSourceResult(queryId, options);
|
||||
return allResults.networkResult ?? allResults.eagerResult;
|
||||
}
|
||||
|
||||
/// Wrap both the `eagerResult` and `networkResult` future in a `MultiSourceResult`
|
||||
/// if the cache policy precludes a network request, `networkResult` will be `null`
|
||||
MultiSourceResult<TParsed> fetchQueryAsMultiSourceResult<TParsed>(
|
||||
String queryId,
|
||||
BaseOptions<TParsed> options,
|
||||
) {
|
||||
// create a new request to execute
|
||||
final request = options.asRequest;
|
||||
|
||||
final QueryResult<TParsed> eagerResult = _resolveQueryEagerly(
|
||||
request,
|
||||
queryId,
|
||||
options,
|
||||
);
|
||||
|
||||
// _resolveQueryEagerly handles cacheOnly,
|
||||
// so if we're loading + cacheFirst we continue to network
|
||||
return MultiSourceResult(
|
||||
parserFn: options.parserFn,
|
||||
eagerResult: eagerResult,
|
||||
networkResult:
|
||||
(shouldStopAtCache(options.fetchPolicy) && !eagerResult.isLoading)
|
||||
? null
|
||||
: _resolveQueryOnNetwork(request, queryId, options),
|
||||
);
|
||||
}
|
||||
|
||||
/// Resolve the query on the network,
|
||||
/// negotiating any necessary cache edits / optimistic cleanup
|
||||
Future<QueryResult<TParsed>> _resolveQueryOnNetwork<TParsed>(
|
||||
Request request,
|
||||
String queryId,
|
||||
BaseOptions<TParsed> options,
|
||||
) async {
|
||||
Response response;
|
||||
QueryResult<TParsed>? queryResult;
|
||||
|
||||
bool rereadFromCache = false;
|
||||
|
||||
try {
|
||||
// execute the request through the provided link(s)
|
||||
response = await link.queryKey(request).first;
|
||||
|
||||
queryResult = mapFetchResultToQueryResult(
|
||||
response,
|
||||
options,
|
||||
source: QueryResultSource.network,
|
||||
);
|
||||
|
||||
rereadFromCache = attemptCacheWriteFromResponse(
|
||||
options.policies,
|
||||
request,
|
||||
response,
|
||||
queryResult,
|
||||
);
|
||||
} catch (failure, trace) {
|
||||
// we set the source to indicate where the source of failure
|
||||
queryResult ??= QueryResult(
|
||||
source: QueryResultSource.network,
|
||||
parserFn: options.parserFn,
|
||||
);
|
||||
|
||||
queryResult.exception = coalesceErrors(
|
||||
exception: queryResult.exception,
|
||||
linkException: translateFailure(failure, trace),
|
||||
);
|
||||
}
|
||||
|
||||
// cleanup optimistic results
|
||||
cache.removeOptimisticPatch(queryId);
|
||||
|
||||
if (rereadFromCache) {
|
||||
// normalize results if previously written
|
||||
attempCacheRereadIntoResult(request, queryResult);
|
||||
}
|
||||
|
||||
// one off operations do not have an ObservableQuery to add to
|
||||
if (queryId != _oneOffOpId) {
|
||||
addQueryResult(request, queryId, queryResult);
|
||||
}
|
||||
|
||||
return queryResult;
|
||||
}
|
||||
|
||||
/// Add an eager cache response to the stream if possible,
|
||||
/// based on `fetchPolicy` and `optimisticResults`
|
||||
QueryResult<TParsed> _resolveQueryEagerly<TParsed>(
|
||||
Request request,
|
||||
String queryId,
|
||||
BaseOptions<TParsed> options,
|
||||
) {
|
||||
QueryResult<TParsed> queryResult = QueryResult.loading(
|
||||
parserFn: options.parserFn,
|
||||
);
|
||||
|
||||
try {
|
||||
if (options.optimisticResult != null) {
|
||||
queryResult = _getOptimisticQueryResult(
|
||||
request,
|
||||
queryId: queryId,
|
||||
optimisticResult: options.optimisticResult,
|
||||
options: options,
|
||||
);
|
||||
}
|
||||
|
||||
// if we haven't already resolved results optimistically,
|
||||
// we attempt to resolve the from the cache
|
||||
if (shouldRespondEagerlyFromCache(options.fetchPolicy) &&
|
||||
!queryResult.isOptimistic) {
|
||||
final dynamic data = cache.readQuery(request, optimistic: false);
|
||||
// we only push an eager query with data
|
||||
if (data != null) {
|
||||
queryResult = QueryResult(
|
||||
data: data,
|
||||
source: QueryResultSource.cache,
|
||||
parserFn: options.parserFn,
|
||||
);
|
||||
}
|
||||
|
||||
if (options.fetchPolicy == FetchPolicy.cacheOnly &&
|
||||
queryResult.isLoading) {
|
||||
queryResult = QueryResult(
|
||||
source: QueryResultSource.cache,
|
||||
parserFn: options.parserFn,
|
||||
exception: OperationException(
|
||||
linkException: CacheMissException(
|
||||
'Could not resolve the given request against the cache. (FetchPolicy.cacheOnly)',
|
||||
request,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (failure, trace) {
|
||||
queryResult.exception = coalesceErrors(
|
||||
exception: queryResult.exception,
|
||||
linkException: translateFailure(failure, trace),
|
||||
);
|
||||
}
|
||||
|
||||
// If not a regular eager cache resolution,
|
||||
// will either be loading, or optimistic.
|
||||
//
|
||||
// if there's an optimistic result, we add it regardless of fetchPolicy.
|
||||
// This is undefined-ish behavior/edge case, but still better than just
|
||||
// ignoring a provided optimisticResult.
|
||||
// Would probably be better to add it ignoring the cache in such cases
|
||||
//
|
||||
// one off operations do not have an ObservableQuery to add to
|
||||
if (queryId != _oneOffOpId) {
|
||||
addQueryResult(request, queryId, queryResult);
|
||||
}
|
||||
|
||||
return queryResult;
|
||||
}
|
||||
|
||||
/// Refetch the [ObservableQuery] referenced by [queryId],
|
||||
/// overriding any present non-network-only [FetchPolicy].
|
||||
Future<QueryResult<TParsed>?> refetchQuery<TParsed>(String queryId) {
|
||||
final WatchQueryOptions<TParsed> options =
|
||||
queries[queryId]!.options.copy() as WatchQueryOptions<TParsed>;
|
||||
if (!willAlwaysExecuteOnNetwork(options.fetchPolicy)) {
|
||||
options.policies = options.policies.copyWith(
|
||||
fetch: FetchPolicy.networkOnly,
|
||||
);
|
||||
}
|
||||
|
||||
// create a new request to execute
|
||||
final request = options.asRequest;
|
||||
|
||||
return _resolveQueryOnNetwork(request, queryId, options);
|
||||
}
|
||||
|
||||
@experimental
|
||||
Future<List<QueryResult?>> refetchSafeQueries() async {
|
||||
rebroadcastLocked = true;
|
||||
final results = await Future.wait(
|
||||
queries.values.where((q) => q.isRefetchSafe).map((q) => q.refetch()),
|
||||
);
|
||||
rebroadcastLocked = false;
|
||||
maybeRebroadcastQueries();
|
||||
return results;
|
||||
}
|
||||
|
||||
ObservableQuery? getQuery(String? queryId) {
|
||||
if (queries.containsKey(queryId)) {
|
||||
return queries[queryId!];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Add a result to the [ObservableQuery] specified by `queryId`, if it exists.
|
||||
///
|
||||
/// Will [maybeRebroadcastQueries] from [ObservableQuery.addResult] if the [cache] has flagged the need to.
|
||||
///
|
||||
/// Queries are registered via [setQuery] and [watchQuery]
|
||||
void addQueryResult<TParsed>(
|
||||
Request request,
|
||||
String? queryId,
|
||||
QueryResult<TParsed> queryResult,
|
||||
) {
|
||||
final ObservableQuery<TParsed>? observableQuery =
|
||||
getQuery(queryId) as ObservableQuery<TParsed>?;
|
||||
|
||||
if (observableQuery != null && !observableQuery.controller.isClosed) {
|
||||
observableQuery.addResult(queryResult);
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an optimstic result for the query specified by `queryId`, if it exists
|
||||
QueryResult<TParsed> _getOptimisticQueryResult<TParsed>(
|
||||
Request request, {
|
||||
required String queryId,
|
||||
required Object? optimisticResult,
|
||||
required BaseOptions<TParsed> options,
|
||||
}) {
|
||||
QueryResult<TParsed> queryResult = QueryResult(
|
||||
source: QueryResultSource.optimisticResult,
|
||||
parserFn: options.parserFn,
|
||||
);
|
||||
|
||||
attemptCacheWriteFromClient(
|
||||
request,
|
||||
optimisticResult as Map<String, dynamic>?,
|
||||
queryResult,
|
||||
writeQuery: (req, data) => cache.recordOptimisticTransaction(
|
||||
(proxy) => proxy..writeQuery(req, data: data!),
|
||||
queryId,
|
||||
),
|
||||
);
|
||||
|
||||
if (!queryResult.hasException) {
|
||||
queryResult.data = cache.readQuery(
|
||||
request,
|
||||
optimistic: true,
|
||||
);
|
||||
}
|
||||
|
||||
return queryResult;
|
||||
}
|
||||
|
||||
/// Rebroadcast cached queries with changed underlying data if [cache.broadcastRequested] or [force].
|
||||
///
|
||||
/// Push changed data from cache to query streams.
|
||||
/// [exclude] is used to skip a query if it was recently executed
|
||||
/// (normally the query that caused the rebroadcast)
|
||||
///
|
||||
/// Returns whether a broadcast was executed, which depends on the state of the cache.
|
||||
/// If there are multiple in-flight cache updates, we wait until they all complete
|
||||
///
|
||||
/// **Note on internal implementation details**:
|
||||
/// There is sometimes confusion on when this is called, but rebroadcasts are requested
|
||||
/// from every [addQueryResult] where `result.isNotLoading` as an [OnData] callback from [ObservableQuery].
|
||||
bool maybeRebroadcastQueries({ObservableQuery? exclude, bool force = false}) {
|
||||
if (rebroadcastLocked && !force) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final shouldBroadast = cache.shouldBroadcast(claimExecution: true);
|
||||
|
||||
if (!shouldBroadast && !force) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (ObservableQuery query in queries.values) {
|
||||
if (query != exclude && query.isRebroadcastSafe) {
|
||||
final cachedData = cache.readQuery(
|
||||
query.options.asRequest,
|
||||
optimistic: query.options.policies.mergeOptimisticData,
|
||||
);
|
||||
if (_cachedDataHasChangedFor(query, cachedData)) {
|
||||
query.addResult(
|
||||
mapFetchResultToQueryResult(
|
||||
Response(data: cachedData),
|
||||
query.options,
|
||||
source: QueryResultSource.cache,
|
||||
),
|
||||
fromRebroadcast: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool _cachedDataHasChangedFor(
|
||||
ObservableQuery query,
|
||||
Map<String, dynamic>? cachedData,
|
||||
) =>
|
||||
cachedData != null &&
|
||||
(alwaysRebroadcast || !_deepEquals(query.latestResult!.data, cachedData));
|
||||
|
||||
void setQuery(ObservableQuery observableQuery) {
|
||||
queries[observableQuery.queryId] = observableQuery;
|
||||
}
|
||||
|
||||
void closeQuery(ObservableQuery observableQuery, {bool fromQuery = false}) {
|
||||
if (!fromQuery) {
|
||||
observableQuery.close(fromManager: true);
|
||||
}
|
||||
queries.remove(observableQuery.queryId);
|
||||
}
|
||||
|
||||
int generateQueryId() {
|
||||
final int requestId = idCounter;
|
||||
|
||||
idCounter++;
|
||||
|
||||
return requestId;
|
||||
}
|
||||
|
||||
QueryResult<TParsed> mapFetchResultToQueryResult<TParsed>(
|
||||
Response response,
|
||||
BaseOptions<TParsed> options, {
|
||||
required QueryResultSource source,
|
||||
}) {
|
||||
List<GraphQLError>? errors;
|
||||
dynamic data;
|
||||
|
||||
// check if there are errors and apply the error policy if so
|
||||
// in a nutshell: `ignore` swallows errors, `none` swallows data
|
||||
if (response.errors != null && response.errors!.isNotEmpty) {
|
||||
switch (options.errorPolicy) {
|
||||
case ErrorPolicy.all:
|
||||
// handle both errors and data
|
||||
errors = response.errors;
|
||||
data = response.data;
|
||||
break;
|
||||
case ErrorPolicy.ignore:
|
||||
// ignore errors
|
||||
data = response.data;
|
||||
break;
|
||||
case ErrorPolicy.none:
|
||||
default:
|
||||
// TODO not actually sure if apollo even casts graphql errors in `none` mode,
|
||||
// it's also kind of legacy
|
||||
errors = response.errors;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
data = response.data;
|
||||
}
|
||||
|
||||
return QueryResult(
|
||||
data: data,
|
||||
context: response.context,
|
||||
source: source,
|
||||
exception: coalesceErrors(graphqlErrors: errors),
|
||||
parserFn: options.parserFn,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
QueryResult<TParsed> _wrapFailure<TParsed>(
|
||||
dynamic ex,
|
||||
trace,
|
||||
ResultParserFn<TParsed> parserFn,
|
||||
) =>
|
||||
QueryResult(
|
||||
// we set the source to indicate where the source of failure
|
||||
source: QueryResultSource.network,
|
||||
exception: coalesceErrors(linkException: translateFailure(ex, trace)),
|
||||
parserFn: parserFn,
|
||||
);
|
||||
@@ -0,0 +1,191 @@
|
||||
// ignore_for_file: deprecated_member_use_from_same_package
|
||||
import 'package:fl_query/src/core/_base_options.dart';
|
||||
import 'package:fl_query/src/core/result_parser.dart';
|
||||
import 'package:fl_query/src/utilities/helpers.dart';
|
||||
|
||||
import 'package:fl_query/fl_query.dart';
|
||||
|
||||
/// Query options.
|
||||
class QueryOptions<TParsed> extends BaseOptions<TParsed> {
|
||||
QueryOptions({
|
||||
required DocumentNode document,
|
||||
String? operationName,
|
||||
Map<String, dynamic> variables = const {},
|
||||
FetchPolicy? fetchPolicy,
|
||||
ErrorPolicy? errorPolicy,
|
||||
CacheRereadPolicy? cacheRereadPolicy,
|
||||
Object? optimisticResult,
|
||||
this.pollInterval,
|
||||
Context? context,
|
||||
ResultParserFn<TParsed>? parserFn,
|
||||
}) : super(
|
||||
fetchPolicy: fetchPolicy,
|
||||
errorPolicy: errorPolicy,
|
||||
cacheRereadPolicy: cacheRereadPolicy,
|
||||
document: document,
|
||||
operationName: operationName,
|
||||
variables: variables,
|
||||
context: context,
|
||||
optimisticResult: optimisticResult,
|
||||
parserFn: parserFn,
|
||||
);
|
||||
|
||||
/// The time interval on which this query should be re-fetched from the server.
|
||||
Duration? pollInterval;
|
||||
|
||||
@override
|
||||
List<Object?> get properties => [...super.properties, pollInterval];
|
||||
|
||||
WatchQueryOptions<TParsed> asWatchQueryOptions({bool fetchResults = true}) =>
|
||||
WatchQueryOptions(
|
||||
document: document,
|
||||
operationName: operationName,
|
||||
variables: variables,
|
||||
fetchPolicy: fetchPolicy,
|
||||
errorPolicy: errorPolicy,
|
||||
cacheRereadPolicy: cacheRereadPolicy,
|
||||
pollInterval: pollInterval,
|
||||
fetchResults: fetchResults,
|
||||
context: context,
|
||||
optimisticResult: optimisticResult,
|
||||
parserFn: this.parserFn,
|
||||
);
|
||||
}
|
||||
|
||||
class SubscriptionOptions<TParsed> extends BaseOptions<TParsed> {
|
||||
SubscriptionOptions({
|
||||
required DocumentNode document,
|
||||
String? operationName,
|
||||
Map<String, dynamic> variables = const {},
|
||||
FetchPolicy? fetchPolicy,
|
||||
ErrorPolicy? errorPolicy,
|
||||
CacheRereadPolicy? cacheRereadPolicy,
|
||||
Object? optimisticResult,
|
||||
Context? context,
|
||||
ResultParserFn<TParsed>? parserFn,
|
||||
}) : super(
|
||||
fetchPolicy: fetchPolicy,
|
||||
errorPolicy: errorPolicy,
|
||||
cacheRereadPolicy: cacheRereadPolicy,
|
||||
document: document,
|
||||
operationName: operationName,
|
||||
variables: variables,
|
||||
context: context,
|
||||
optimisticResult: optimisticResult,
|
||||
parserFn: parserFn,
|
||||
);
|
||||
|
||||
/// An optimistic first result to eagerly add to the subscription stream
|
||||
Object? optimisticResult;
|
||||
}
|
||||
|
||||
class WatchQueryOptions<TParsed> extends QueryOptions<TParsed> {
|
||||
WatchQueryOptions({
|
||||
required DocumentNode document,
|
||||
String? operationName,
|
||||
Map<String, dynamic> variables = const {},
|
||||
FetchPolicy? fetchPolicy,
|
||||
ErrorPolicy? errorPolicy,
|
||||
CacheRereadPolicy? cacheRereadPolicy,
|
||||
Object? optimisticResult,
|
||||
Duration? pollInterval,
|
||||
this.fetchResults = false,
|
||||
this.carryForwardDataOnException = true,
|
||||
bool? eagerlyFetchResults,
|
||||
Context? context,
|
||||
ResultParserFn<TParsed>? parserFn,
|
||||
}) : eagerlyFetchResults = eagerlyFetchResults ?? fetchResults,
|
||||
super(
|
||||
document: document,
|
||||
operationName: operationName,
|
||||
variables: variables,
|
||||
fetchPolicy: fetchPolicy,
|
||||
errorPolicy: errorPolicy,
|
||||
cacheRereadPolicy: cacheRereadPolicy,
|
||||
pollInterval: pollInterval,
|
||||
context: context,
|
||||
optimisticResult: optimisticResult,
|
||||
parserFn: parserFn,
|
||||
);
|
||||
|
||||
/// Whether or not to fetch results
|
||||
bool fetchResults;
|
||||
|
||||
/// Whether to [fetchResults] immediately on instantiation.
|
||||
/// Defaults to [fetchResults].
|
||||
bool eagerlyFetchResults;
|
||||
|
||||
/// carry forward previous data in the result of errors and no data.
|
||||
/// defaults to `true`.
|
||||
bool carryForwardDataOnException;
|
||||
|
||||
@override
|
||||
List<Object?> get properties =>
|
||||
[...super.properties, fetchResults, eagerlyFetchResults];
|
||||
|
||||
WatchQueryOptions<TParsed> copy() => WatchQueryOptions<TParsed>(
|
||||
document: document,
|
||||
operationName: operationName,
|
||||
variables: variables,
|
||||
fetchPolicy: fetchPolicy,
|
||||
errorPolicy: errorPolicy,
|
||||
cacheRereadPolicy: cacheRereadPolicy,
|
||||
optimisticResult: optimisticResult,
|
||||
pollInterval: pollInterval,
|
||||
fetchResults: fetchResults,
|
||||
eagerlyFetchResults: eagerlyFetchResults,
|
||||
carryForwardDataOnException: carryForwardDataOnException,
|
||||
context: context,
|
||||
parserFn: parserFn,
|
||||
);
|
||||
}
|
||||
|
||||
/// options for fetchMore operations
|
||||
///
|
||||
/// **NOTE**: with the addition of strict data structure checking in v4,
|
||||
/// it is easy to make mistakes in writing [updateQuery].
|
||||
///
|
||||
/// To mitigate this, [FetchMoreOptions.partial] has been provided.
|
||||
class FetchMoreOptions {
|
||||
FetchMoreOptions({
|
||||
this.document,
|
||||
this.variables = const {},
|
||||
required this.updateQuery,
|
||||
});
|
||||
|
||||
/// Automatically merge the results of [updateQuery] into `previousResultData`.
|
||||
///
|
||||
/// This is useful if you only want to, say, extract some list data
|
||||
/// from the newly fetched result, and don't want to worry about
|
||||
/// structural inconsistencies while merging.
|
||||
static FetchMoreOptions partial({
|
||||
DocumentNode? document,
|
||||
Map<String, dynamic> variables = const {},
|
||||
required UpdateQuery updateQuery,
|
||||
}) =>
|
||||
FetchMoreOptions(
|
||||
document: document,
|
||||
variables: variables,
|
||||
updateQuery: partialUpdater(updateQuery),
|
||||
);
|
||||
|
||||
DocumentNode? document;
|
||||
|
||||
Map<String, dynamic> variables;
|
||||
|
||||
/// Strategy for merging the fetchMore result data
|
||||
/// with the result data already in the cache
|
||||
UpdateQuery updateQuery;
|
||||
|
||||
/// Wrap an [UpdateQuery] in a [deeplyMergeLeft] of the `previousResultData`.
|
||||
static UpdateQuery partialUpdater(UpdateQuery update) =>
|
||||
(previous, fetched) => deeplyMergeLeft(
|
||||
[previous, update(previous, fetched)],
|
||||
);
|
||||
}
|
||||
|
||||
/// merge fetchMore result data with earlier result data
|
||||
typedef Map<String, dynamic>? UpdateQuery(
|
||||
Map<String, dynamic>? previousResultData,
|
||||
Map<String, dynamic>? fetchMoreResultData,
|
||||
);
|
||||
@@ -0,0 +1,155 @@
|
||||
import 'dart:async' show FutureOr;
|
||||
import 'package:fl_query/fl_query.dart';
|
||||
import 'package:fl_query/src/core/result_parser.dart';
|
||||
|
||||
/// The source of the result data contained
|
||||
///
|
||||
/// * [loading]: No data has been specified from any source
|
||||
/// for the _most recent_ operation
|
||||
/// * [cache]: A result has been eagerly resolved from the cache
|
||||
/// * [optimisticResult]: An optimistic result has been specified
|
||||
/// May include eager results from the cache.
|
||||
/// * [network]: The query has been resolved on the network
|
||||
///
|
||||
/// Both [optimisticResult] and [cache] sources are considered "Eager" results.
|
||||
enum QueryResultSource {
|
||||
/// No data has been specified from any source for the _most recent_ operation
|
||||
loading,
|
||||
|
||||
/// A result has been eagerly resolved from the cache
|
||||
cache,
|
||||
|
||||
/// An optimistic result has been specified.
|
||||
/// May include eager results from the cache
|
||||
optimisticResult,
|
||||
|
||||
/// The query has been resolved on the network
|
||||
network,
|
||||
}
|
||||
|
||||
extension Getters on QueryResultSource {
|
||||
/// Whether this result source is considered "eager" (is [cache] or [optimisticResult])
|
||||
bool get isEager => _eagerSources.contains(this);
|
||||
}
|
||||
|
||||
final _eagerSources = {
|
||||
QueryResultSource.cache,
|
||||
QueryResultSource.optimisticResult
|
||||
};
|
||||
|
||||
/// A single operation result
|
||||
class QueryResult<TParsed> {
|
||||
QueryResult({
|
||||
this.data,
|
||||
this.exception,
|
||||
this.context = const Context(),
|
||||
required this.parserFn,
|
||||
required this.source,
|
||||
}) : timestamp = DateTime.now();
|
||||
|
||||
/// Unexecuted singleton, used as a placeholder for mutations,
|
||||
/// etc.
|
||||
static final unexecuted = QueryResult(
|
||||
source: null,
|
||||
parserFn: (d) =>
|
||||
throw UnimplementedError("Unexecuted query data can not be parsed."),
|
||||
)..timestamp = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
|
||||
factory QueryResult.loading({
|
||||
Map<String, dynamic>? data,
|
||||
required ResultParserFn<TParsed> parserFn,
|
||||
}) =>
|
||||
QueryResult(
|
||||
data: data,
|
||||
source: QueryResultSource.loading,
|
||||
parserFn: parserFn,
|
||||
);
|
||||
|
||||
factory QueryResult.optimistic({
|
||||
Map<String, dynamic>? data,
|
||||
required ResultParserFn<TParsed> parserFn,
|
||||
}) =>
|
||||
QueryResult(
|
||||
data: data,
|
||||
source: QueryResultSource.optimisticResult,
|
||||
parserFn: parserFn,
|
||||
);
|
||||
|
||||
DateTime timestamp;
|
||||
|
||||
/// The source of the result data.
|
||||
///
|
||||
/// `null` when unexecuted.
|
||||
/// Will be set when encountering an error during any execution attempt
|
||||
QueryResultSource? source;
|
||||
|
||||
/// Response data
|
||||
Map<String, dynamic>? data;
|
||||
|
||||
/// Response context. Defaults to an empty `Context()`
|
||||
Context context;
|
||||
|
||||
OperationException? exception;
|
||||
|
||||
ResultParserFn<TParsed> parserFn;
|
||||
|
||||
/// [data] has yet to be specified from any source
|
||||
/// for the _most recent_ operation
|
||||
/// (including [QueryResultSource.optimisticResult])
|
||||
///
|
||||
/// **NOTE:** query updating methods like `fetchMore` and `refetch` will send
|
||||
/// an [isLoading], so it is best practice to check both `isLoading && data != null`
|
||||
/// before assuming there is no data that should be displayed.
|
||||
bool get isLoading => source == QueryResultSource.loading;
|
||||
|
||||
/// [data] been specified (including [QueryResultSource.optimisticResult])
|
||||
bool get isNotLoading => !isLoading;
|
||||
|
||||
/// [data] has been specified as an [QueryResultSource.optimisticResult]
|
||||
///
|
||||
/// May include eager results from the cache.
|
||||
bool get isOptimistic => source == QueryResultSource.optimisticResult;
|
||||
|
||||
/// [data] has been specified and is **not** an [QueryResultSource.optimisticResult]
|
||||
///
|
||||
/// shorthand for `!isLoading && !isOptimistic`
|
||||
bool get isConcrete => !isLoading && !isOptimistic;
|
||||
|
||||
/// Whether the response includes an [exception]
|
||||
bool get hasException => (exception != null);
|
||||
|
||||
/// If a parserFn is provided, this getter can be used to fetch the parsed data.
|
||||
TParsed? get parsedData {
|
||||
final data = this.data;
|
||||
final parserFn = this.parserFn;
|
||||
|
||||
if (data == null) {
|
||||
return null;
|
||||
}
|
||||
return parserFn(data);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => 'QueryResult('
|
||||
'source: $source, '
|
||||
'data: $data, '
|
||||
'context: $context, '
|
||||
'exception: $exception, '
|
||||
'timestamp: $timestamp'
|
||||
')';
|
||||
}
|
||||
|
||||
class MultiSourceResult<TParsed> {
|
||||
MultiSourceResult({
|
||||
QueryResult<TParsed>? eagerResult,
|
||||
this.networkResult,
|
||||
required ResultParserFn<TParsed> parserFn,
|
||||
}) : eagerResult = eagerResult ?? QueryResult.loading(parserFn: parserFn),
|
||||
assert(
|
||||
eagerResult!.source != QueryResultSource.network,
|
||||
'An eager result cannot be gotten from the network',
|
||||
);
|
||||
|
||||
QueryResult<TParsed> eagerResult;
|
||||
FutureOr<QueryResult<TParsed>>? networkResult;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
typedef ResultParserFn<TResult> = TResult Function(Map<String, dynamic> data);
|
||||
Reference in New Issue
Block a user