diff --git a/packages/graphql/lib/client.dart b/packages/graphql/lib/client.dart index 1428bd2fb..e349600bd 100644 --- a/packages/graphql/lib/client.dart +++ b/packages/graphql/lib/client.dart @@ -9,4 +9,5 @@ export 'package:graphql/src/graphql_client.dart'; export 'package:graphql/src/links/links.dart'; -export 'package:graphql/src/utilities/helpers.dart' show gql; +export 'package:graphql/src/utilities/helpers.dart' + show gql, optimizedDeepEquals; diff --git a/packages/graphql/lib/src/cache/cache.dart b/packages/graphql/lib/src/cache/cache.dart index ff5cf169c..f69659fc2 100644 --- a/packages/graphql/lib/src/cache/cache.dart +++ b/packages/graphql/lib/src/cache/cache.dart @@ -204,9 +204,23 @@ class GraphQLCache extends NormalizingDataProxy { /// /// This allows for hierarchical optimism that is automatically cleaned up /// without having to tightly couple optimistic changes + /// + /// This is called on every network result as cleanup void removeOptimisticPatch(String removeId) { + final patchesToRemove = optimisticPatches + .where( + (patch) => + patch.id == removeId || _parentPatchId(patch.id) == removeId, + ) + .toList(); + + if (patchesToRemove.isEmpty) { + return; + } + // Only remove + mark broadcast requested if something was actually removed. + // This is to prevent unnecessary rebroadcasts optimisticPatches.removeWhere( - (patch) => patch.id == removeId || _parentPatchId(patch.id) == removeId, + (patch) => patchesToRemove.contains(patch), ); broadcastRequested = true; } diff --git a/packages/graphql/lib/src/cache/fragment.dart b/packages/graphql/lib/src/cache/fragment.dart index 302ca3591..26608e998 100644 --- a/packages/graphql/lib/src/cache/fragment.dart +++ b/packages/graphql/lib/src/cache/fragment.dart @@ -1,10 +1,10 @@ import 'dart:convert'; +import "package:graphql/client.dart"; import "package:meta/meta.dart"; import "package:collection/collection.dart"; import "package:gql/ast.dart"; import 'package:gql/language.dart'; -import "package:gql_exec/gql_exec.dart"; import 'package:normalize/utils.dart'; /// A fragment in a [document], optionally defined by [fragmentName] @@ -32,9 +32,7 @@ class Fragment { bool operator ==(Object o) => identical(this, o) || (o is Fragment && - const ListEquality( - DeepCollectionEquality(), - ).equals( + gqlDeepEquals( o._getChildren(), _getChildren(), )); @@ -89,9 +87,7 @@ class FragmentRequest { bool operator ==(Object o) => identical(this, o) || (o is FragmentRequest && - const ListEquality( - DeepCollectionEquality(), - ).equals( + gqlDeepEquals( o._getChildren(), _getChildren(), )); diff --git a/packages/graphql/lib/src/core/_base_options.dart b/packages/graphql/lib/src/core/_base_options.dart index ecd816c47..fa3fcb624 100644 --- a/packages/graphql/lib/src/core/_base_options.dart +++ b/packages/graphql/lib/src/core/_base_options.dart @@ -104,9 +104,7 @@ abstract class BaseOptions { identical(this, other) || (other is BaseOptions && runtimeType == other.runtimeType && - const ListEquality( - DeepCollectionEquality(), - ).equals( + gqlDeepEquals( other.properties, properties, )); diff --git a/packages/graphql/lib/src/core/observable_query.dart b/packages/graphql/lib/src/core/observable_query.dart index 17d2b7f53..99d0ba612 100644 --- a/packages/graphql/lib/src/core/observable_query.dart +++ b/packages/graphql/lib/src/core/observable_query.dart @@ -93,8 +93,19 @@ class ObservableQuery { /// call [queryManager.maybeRebroadcastQueries] after all other [_onDataCallbacks] /// /// Automatically appended as an [OnData] - FutureOr _maybeRebroadcast(QueryResult? result) => - queryManager.maybeRebroadcastQueries(exclude: this); + FutureOr _maybeRebroadcast(QueryResult? result) { + if (_onDataCallbacks.isEmpty && + result?.hasException == true && + result?.data == null) { + // We don't need to rebroadcast if there was an exception and there was no + // data. It's valid GQL to have data _and_ exception. If options.carryForwardDataOnException + // are true, this condition may never get hit. + // If there are onDataCallbacks, it's possible they modify cache and are + // depending on maybeRebroadcastQueries being called. + return false; + } + return queryManager.maybeRebroadcastQueries(exclude: this); + } /// The most recently seen result from this operation's stream QueryResult? latestResult; @@ -242,12 +253,13 @@ class ObservableQuery { } /// Add a [result] to the [stream] unless it was created - /// before [lasestResult]. + /// before [latestResult]. /// /// Copies the [QueryResult.source] from the [latestResult] /// if it is set to `null`. /// - /// Called internally by the [QueryManager] + /// Called internally by the [QueryManager]. Do not call this directly except + /// for [QueryResult.loading] void addResult(QueryResult result, {bool fromRebroadcast = false}) { // don't overwrite results due to some async/optimism issue if (latestResult != null && diff --git a/packages/graphql/lib/src/core/policies.dart b/packages/graphql/lib/src/core/policies.dart index dfe9ddb84..f14a6d1f5 100644 --- a/packages/graphql/lib/src/core/policies.dart +++ b/packages/graphql/lib/src/core/policies.dart @@ -307,9 +307,7 @@ class DefaultPolicies { bool operator ==(Object o) => identical(this, o) || (o is DefaultPolicies && - const ListEquality( - DeepCollectionEquality(), - ).equals( + gqlDeepEquals( o._getChildren(), _getChildren(), )); diff --git a/packages/graphql/lib/src/core/query_manager.dart b/packages/graphql/lib/src/core/query_manager.dart index 494506588..f1fa36f0c 100644 --- a/packages/graphql/lib/src/core/query_manager.dart +++ b/packages/graphql/lib/src/core/query_manager.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:graphql/src/utilities/helpers.dart'; import 'package:graphql/src/utilities/response.dart'; import 'package:meta/meta.dart'; import 'package:collection/collection.dart'; @@ -19,8 +20,13 @@ import 'package:graphql/src/scheduler/scheduler.dart'; import 'package:graphql/src/core/_query_write_handling.dart'; -bool Function(dynamic a, dynamic b) _deepEquals = - const DeepCollectionEquality().equals; +typedef DeepEqualsFn = bool Function(dynamic a, dynamic b); + +/// The equality function used for comparing cached and new data. +/// +/// You can alternatively provide [optimizedDeepEquals] for a faster +/// equality check. Or provide your own via [GqlClient] constructor. +DeepEqualsFn gqlDeepEquals = const DeepCollectionEquality().equals; class QueryManager { QueryManager({ @@ -28,10 +34,16 @@ class QueryManager { required this.cache, this.alwaysRebroadcast = false, this.requestTimeout = const Duration(seconds: 5), + DeepEqualsFn? deepEquals, + bool deduplicatePollers = false, }) { scheduler = QueryScheduler( queryManager: this, + deduplicatePollers: deduplicatePollers, ); + if (deepEquals != null) { + gqlDeepEquals = deepEquals; + } } final Link link; @@ -314,14 +326,26 @@ class QueryManager { // we attempt to resolve the from the cache if (shouldRespondEagerlyFromCache(options.fetchPolicy) && !queryResult.isOptimistic) { - final data = cache.readQuery(request, optimistic: false); - // we only push an eager query with data - if (data != null) { - queryResult = QueryResult( - options: options, - data: data, + final latestResult = _getQueryResultByRequest(request); + if (latestResult != null && latestResult.data != null) { + // we have a result already cached + deserialized for this request + // so we reuse it. + // latest result won't be for loading, it must contain data + queryResult = latestResult.copyWith( source: QueryResultSource.cache, ); + } else { + // otherwise, we try to find the query in cache (which will require + // deserialization) + final data = cache.readQuery(request, optimistic: false); + // we only push an eager query with data + if (data != null) { + queryResult = QueryResult( + options: options, + data: data, + source: QueryResultSource.cache, + ); + } } if (options.fetchPolicy == FetchPolicy.cacheOnly && @@ -361,6 +385,18 @@ class QueryManager { return queryResult; } + /// If a request already has a result associated with it in cache (as + /// determined by [ObservableQuery.latestResult]), we can return it without + /// needing to denormalize + parse again. + QueryResult? _getQueryResultByRequest(Request request) { + for (final query in queries.values) { + if (query.options.asRequest == request) { + return query.latestResult as QueryResult?; + } + } + return null; + } + /// Refetch the [ObservableQuery] referenced by [queryId], /// overriding any present non-network-only [FetchPolicy]. Future?> refetchQuery(String queryId) { @@ -386,11 +422,11 @@ class QueryManager { return results; } - ObservableQuery? getQuery(String? queryId) { - if (!queries.containsKey(queryId)) { + ObservableQuery? getQuery(final String? queryId) { + if (!queries.containsKey(queryId) || queryId == null) { return null; } - final query = queries[queryId!]; + final query = queries[queryId]; if (query is ObservableQuery) { return query; } @@ -405,16 +441,17 @@ class QueryManager { void addQueryResult( Request request, String? queryId, - QueryResult queryResult, - ) { + QueryResult queryResult, { + bool fromRebroadcast = false, + }) { final observableQuery = getQuery(queryId); if (observableQuery != null && !observableQuery.controller.isClosed) { - observableQuery.addResult(queryResult); + observableQuery.addResult(queryResult, fromRebroadcast: fromRebroadcast); } } - /// Create an optimstic result for the query specified by `queryId`, if it exists + /// Create an optimistic result for the query specified by `queryId`, if it exists QueryResult _getOptimisticQueryResult( Request request, { required String queryId, @@ -466,27 +503,55 @@ class QueryManager { return false; } - final shouldBroadast = cache.shouldBroadcast(claimExecution: true); + final shouldBroadcast = cache.shouldBroadcast(claimExecution: true); - if (!shouldBroadast && !force) { + if (!shouldBroadcast && !force) { return false; } - for (var query in queries.values) { - if (query != exclude && query.isRebroadcastSafe) { + // If two ObservableQueries are backed by the same [Request], we only need + // to [readQuery] for it once. + final Map> diffQueryResultCache = {}; + final Map ignoreQueryResults = {}; + for (final query in queries.values) { + final Request request = query.options.asRequest; + final cachedQueryResult = diffQueryResultCache[request]; + if (query == exclude || !query.isRebroadcastSafe) { + continue; + } + if (cachedQueryResult != null) { + // We've already done the diff and denormalized, emit to the observable + addQueryResult( + request, + query.queryId, + cachedQueryResult, + fromRebroadcast: true, + ); + } else if (ignoreQueryResults.containsKey(request)) { + // We've already seen this one and don't need to notify + continue; + } else { + // We haven't seen this one yet, denormalize from cache and diff final cachedData = cache.readQuery( query.options.asRequest, optimistic: query.options.policies.mergeOptimisticData, ); if (_cachedDataHasChangedFor(query, cachedData)) { - query.addResult( - mapFetchResultToQueryResult( - Response(data: cachedData, response: {}), - query.options, - source: QueryResultSource.cache, - ), + // The data has changed + final queryResult = QueryResult( + data: cachedData, + options: query.options, + source: QueryResultSource.cache, + ); + diffQueryResultCache[request] = queryResult; + addQueryResult( + request, + query.queryId, + queryResult, fromRebroadcast: true, ); + } else { + ignoreQueryResults[request] = true; } } } @@ -499,7 +564,8 @@ class QueryManager { ) => cachedData != null && query.latestResult != null && - (alwaysRebroadcast || !_deepEquals(query.latestResult!.data, cachedData)); + (alwaysRebroadcast || + !gqlDeepEquals(query.latestResult!.data, cachedData)); void setQuery(ObservableQuery observableQuery) { queries[observableQuery.queryId] = observableQuery; diff --git a/packages/graphql/lib/src/core/query_options.dart b/packages/graphql/lib/src/core/query_options.dart index 529b06219..7a4a43b28 100644 --- a/packages/graphql/lib/src/core/query_options.dart +++ b/packages/graphql/lib/src/core/query_options.dart @@ -55,6 +55,37 @@ class QueryOptions extends BaseOptions { onError, ]; + /// Generic copyWith for all fields. There are other, more specific options: + /// - [copyWithPolicies] and [withFetchMoreOptions] + QueryOptions copyWithOptions({ + DocumentNode? document, + String? operationName, + Map? variables, + FetchPolicy? fetchPolicy, + ErrorPolicy? errorPolicy, + CacheRereadPolicy? cacheRereadPolicy, + Object? optimisticResult, + Duration? pollInterval, + Context? context, + ResultParserFn? parserFn, + OnQueryComplete? onComplete, + OnQueryError? onError, + }) => + QueryOptions( + document: document ?? this.document, + operationName: operationName ?? this.operationName, + variables: variables ?? this.variables, + fetchPolicy: fetchPolicy ?? this.fetchPolicy, + errorPolicy: errorPolicy ?? this.errorPolicy, + cacheRereadPolicy: cacheRereadPolicy ?? this.cacheRereadPolicy, + optimisticResult: optimisticResult ?? this.optimisticResult, + pollInterval: pollInterval ?? this.pollInterval, + context: context ?? this.context, + parserFn: parserFn ?? this.parserFn, + onComplete: onComplete ?? this.onComplete, + onError: onError ?? this.onError, + ); + QueryOptions withFetchMoreOptions( FetchMoreOptions fetchMoreOptions, ) => @@ -168,10 +199,13 @@ class WatchQueryOptions extends QueryOptions { parserFn: parserFn, ); - /// Whether or not to fetch results + /// Whether or not to fetch results every time a new listener is added. + /// If [eagerlyFetchResults] is `true`, fetch is triggered during instantiation. final bool fetchResults; - /// Whether to [fetchResults] immediately on instantiation. + /// Whether to [fetchResults] immediately on instantiation of [ObservableQuery]. + /// If available, cache results are emitted when the first listener is added. + /// Network results are then emitted when they return to any attached listeners. /// Defaults to [fetchResults]. final bool eagerlyFetchResults; @@ -187,6 +221,40 @@ class WatchQueryOptions extends QueryOptions { carryForwardDataOnException, ]; + /// Generic copyWith for all fields. There are other, more specific options: + /// - [copyWithFetchPolicy], [copyWithVariables], etc + WatchQueryOptions copyWith({ + DocumentNode? document, + String? operationName, + Map? variables, + FetchPolicy? fetchPolicy, + ErrorPolicy? errorPolicy, + CacheRereadPolicy? cacheRereadPolicy, + Object? optimisticResult, + Duration? pollInterval, + bool? fetchResults, + bool? carryForwardDataOnException, + bool? eagerlyFetchResults, + Context? context, + ResultParserFn? parserFn, + }) => + WatchQueryOptions( + document: document ?? this.document, + operationName: operationName ?? this.operationName, + variables: variables ?? this.variables, + fetchPolicy: fetchPolicy ?? this.fetchPolicy, + errorPolicy: errorPolicy ?? this.errorPolicy, + cacheRereadPolicy: cacheRereadPolicy ?? this.cacheRereadPolicy, + optimisticResult: optimisticResult ?? this.optimisticResult, + pollInterval: pollInterval ?? this.pollInterval, + fetchResults: fetchResults ?? this.fetchResults, + eagerlyFetchResults: eagerlyFetchResults ?? this.eagerlyFetchResults, + carryForwardDataOnException: + carryForwardDataOnException ?? this.carryForwardDataOnException, + context: context ?? this.context, + parserFn: parserFn ?? this.parserFn, + ); + WatchQueryOptions copyWithFetchPolicy( FetchPolicy? fetchPolicy, ) => diff --git a/packages/graphql/lib/src/core/query_result.dart b/packages/graphql/lib/src/core/query_result.dart index 5b5b5b38f..da4922ca2 100644 --- a/packages/graphql/lib/src/core/query_result.dart +++ b/packages/graphql/lib/src/core/query_result.dart @@ -48,7 +48,9 @@ class QueryResult { this.context = const Context(), required this.parserFn, required this.source, - }) : timestamp = DateTime.now() { + TParsed? cachedParsedData, + }) : timestamp = DateTime.now(), + _cachedParsedData = cachedParsedData { _data = data; } @@ -160,6 +162,23 @@ class QueryResult { return _cachedParsedData = parserFn(data); } + QueryResult copyWith({ + Map? data, + OperationException? exception, + Context? context, + QueryResultSource? source, + TParsed? cachedParsedData, + }) { + return QueryResult.internal( + data: data ?? this.data, + exception: exception ?? this.exception, + context: context ?? this.context, + parserFn: parserFn, + source: source ?? this.source, + cachedParsedData: cachedParsedData ?? _cachedParsedData, + ); + } + @override String toString() => 'QueryResult(' 'source: $source, ' diff --git a/packages/graphql/lib/src/graphql_client.dart b/packages/graphql/lib/src/graphql_client.dart index 396ffe145..f6179e9db 100644 --- a/packages/graphql/lib/src/graphql_client.dart +++ b/packages/graphql/lib/src/graphql_client.dart @@ -27,12 +27,16 @@ class GraphQLClient implements GraphQLDataProxy { DefaultPolicies? defaultPolicies, bool alwaysRebroadcast = false, Duration queryRequestTimeout = const Duration(seconds: 5), + DeepEqualsFn? deepEquals, + bool deduplicatePollers = false, }) : defaultPolicies = defaultPolicies ?? DefaultPolicies(), queryManager = QueryManager( link: link, cache: cache, alwaysRebroadcast: alwaysRebroadcast, requestTimeout: queryRequestTimeout, + deepEquals: deepEquals, + deduplicatePollers: deduplicatePollers, ); /// The default [Policies] to set for each client action diff --git a/packages/graphql/lib/src/scheduler/scheduler.dart b/packages/graphql/lib/src/scheduler/scheduler.dart index 9d930775f..118aa1b94 100644 --- a/packages/graphql/lib/src/scheduler/scheduler.dart +++ b/packages/graphql/lib/src/scheduler/scheduler.dart @@ -1,5 +1,7 @@ import 'dart:async'; +import 'package:collection/collection.dart'; +import 'package:gql_exec/gql_exec.dart'; import 'package:graphql/src/core/query_manager.dart'; import 'package:graphql/src/core/query_options.dart'; import 'package:graphql/src/core/observable_query.dart'; @@ -8,9 +10,11 @@ import 'package:graphql/src/core/observable_query.dart'; class QueryScheduler { QueryScheduler({ this.queryManager, - }); + bool deduplicatePollers = false, + }) : _deduplicatePollers = deduplicatePollers; QueryManager? queryManager; + final bool _deduplicatePollers; /// Map going from query ids to the [WatchQueryOptions] associated with those queries. Map registeredQueries = @@ -68,25 +72,81 @@ class QueryScheduler { options.pollInterval != null && options.pollInterval! > Duration.zero, ); - registeredQueries[queryId] = options; + final existingEntry = _fastestEntryForRequest(options.asRequest); + final String? existingQueryId = existingEntry?.key; + final Duration? existingInterval = existingEntry?.value.pollInterval; - final interval = options.pollInterval; + // Update or add the query in registeredQueries + registeredQueries[queryId] = options; - if (intervalQueries.containsKey(interval)) { - intervalQueries[interval]!.add(queryId); + final Duration interval; + + if (existingInterval != null && _deduplicatePollers) { + if (existingInterval > options.pollInterval!) { + // The new one is faster, remove the old one and add the new one + intervalQueries[existingInterval]!.remove(existingQueryId); + interval = options.pollInterval!; + } else { + // The new one is slower or the same. Don't add it to the list + return; + } } else { - intervalQueries[interval] = Set.of([queryId]); - - _pollingTimers[interval] = Timer.periodic( - interval!, - (Timer timer) => fetchQueriesOnInterval(timer, interval), - ); + // If there is no existing interval, we'll add the new one + interval = options.pollInterval!; } + + // Add new query to intervalQueries + _addInterval(queryId, interval); } /// Removes the [ObservableQuery] from one of the registered queries. /// The fetchQueriesOnInterval will then take care of not firing it anymore. void stopPollingQuery(String queryId) { - registeredQueries.remove(queryId); + final removedQuery = registeredQueries.remove(queryId); + + if (removedQuery == null || + removedQuery.pollInterval == null || + !_deduplicatePollers) { + return; + } + + // If there is a registered query that has the same `asRequest` as this one + // Add the next fastest duration to the intervalQueries + final fastestEntry = _fastestEntryForRequest(removedQuery.asRequest); + final String? fastestQueryId = fastestEntry?.key; + final Duration? fastestInterval = fastestEntry?.value.pollInterval; + + if (fastestQueryId == null || fastestInterval == null) { + // There is no other query, return. + return; + } + + _addInterval(fastestQueryId, fastestInterval); + } + + /// Adds a [queryId] to the [intervalQueries] for a specific [interval] + /// and starts the timer if it doesn't exist. + void _addInterval(String queryId, Duration interval) { + final existingSet = intervalQueries[interval]; + if (existingSet != null) { + existingSet.add(queryId); + } else { + intervalQueries[interval] = {queryId}; + _pollingTimers[interval] = Timer.periodic( + interval, (Timer timer) => fetchQueriesOnInterval(timer, interval)); + } + } + + /// Returns the fastest query that matches the [request] or null if none exists. + MapEntry>? _fastestEntryForRequest( + Request request) { + return registeredQueries.entries + // All existing queries mapping to the same request. + .where((entry) => + entry.value.asRequest == request && + entry.value.pollInterval != null) + // Ascending is default (shortest poll interval first) + .sortedBy((entry) => entry.value.pollInterval!) + .firstOrNull; } } diff --git a/packages/graphql/lib/src/utilities/helpers.dart b/packages/graphql/lib/src/utilities/helpers.dart index 1784e5a4f..0dce7177a 100644 --- a/packages/graphql/lib/src/utilities/helpers.dart +++ b/packages/graphql/lib/src/utilities/helpers.dart @@ -90,3 +90,46 @@ SanitizeVariables variableSanitizer( toEncodable: sanitizeVariables, ), ) as Map; + +/// Compare two json-like objects for equality +/// [a] and [b] must be one of +/// - null +/// - num +/// - bool +/// - String +/// - List of above types +/// - Map of above types +/// +/// This is an alternative too [DeepCollectionEquality().equals], +/// which is very slow and has O(n^2) complexity: +bool optimizedDeepEquals(Object? a, Object? b) { + if (identical(a, b)) { + return true; + } + if (a == b) { + return true; + } + if (a is Map) { + if (b is! Map) { + return false; + } + if (a.length != b.length) return false; + for (var key in a.keys) { + if (!b.containsKey(key)) return false; + if (!optimizedDeepEquals(a[key], b[key])) return false; + } + return true; + } + if (a is List) { + if (b is! List) { + return false; + } + final length = a.length; + if (length != b.length) return false; + for (var i = 0; i < length; i++) { + if (!optimizedDeepEquals(a[i], b[i])) return false; + } + return true; + } + return false; +}