initial commit with cache support & example

This commit is contained in:
Kingkor Roy Tirtho
2022-02-07 00:11:15 +06:00
commit c7169b04c7
65 changed files with 7155 additions and 0 deletions
@@ -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;
}
}
}
+6
View File
@@ -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);