From 73711ee77dcfbf7a6a498ffc0f42186aa3632674 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Wed, 22 Jun 2022 17:56:24 +0600 Subject: [PATCH] Depended queries support added --- .../example/lib/dependent_query_example.dart | 36 +++++ packages/example/lib/lazy_query.dart | 2 +- packages/example/lib/main.dart | 15 +- .../example/lib/query_with_external_data.dart | 2 +- packages/fl_query/lib/src/base_operation.dart | 4 +- .../fl_query/lib/src/hooks/use_query.dart | 141 +++++++----------- .../fl_query/lib/src/models/query_job.dart | 17 +++ packages/fl_query/lib/src/query.dart | 79 +++++++++- packages/fl_query/lib/src/query_bowl.dart | 21 ++- packages/fl_query/lib/src/query_builder.dart | 44 +++--- 10 files changed, 234 insertions(+), 127 deletions(-) create mode 100644 packages/example/lib/dependent_query_example.dart diff --git a/packages/example/lib/dependent_query_example.dart b/packages/example/lib/dependent_query_example.dart new file mode 100644 index 0000000..3705e5d --- /dev/null +++ b/packages/example/lib/dependent_query_example.dart @@ -0,0 +1,36 @@ +import 'package:example/main.dart'; +import 'package:fl_query/fl_query.dart'; +import 'package:flutter/material.dart'; + +final dependentQueryJob = QueryJob( + queryKey: "dependent-query-job", + task: (queryKey, externalData, query) { + final successQuery = + query.dependOnQuery(successJob, externalData: null); + final failedQuery = + query.dependOnQuery(failedJob, externalData: null); + if (failedQuery.hasError) return failedQuery.error.toString(); + if (successQuery.hasData) return successQuery.data!; + return "No data from success query yet"; + }); + +class DependentQueryExample extends StatelessWidget { + const DependentQueryExample({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(), + body: QueryBuilder( + job: dependentQueryJob, + externalData: null, + builder: (context, query) { + if (query.isLoading || query.isRefetching || !query.hasData) { + return const CircularProgressIndicator(); + } + return Text(query.data!); + }, + ), + ); + } +} diff --git a/packages/example/lib/lazy_query.dart b/packages/example/lib/lazy_query.dart index a409e9c..5f76bde 100644 --- a/packages/example/lib/lazy_query.dart +++ b/packages/example/lib/lazy_query.dart @@ -4,7 +4,7 @@ import 'package:flutter/material.dart'; final lazyQueryJob = QueryJob( queryKey: "non_enabled_query", enabled: false, - task: (queryKey, data) { + task: (queryKey, data, _) { return Future.delayed(const Duration(milliseconds: 500), () => "Hello from $queryKey with $data"); }); diff --git a/packages/example/lib/main.dart b/packages/example/lib/main.dart index c9fa097..e16e821 100644 --- a/packages/example/lib/main.dart +++ b/packages/example/lib/main.dart @@ -1,6 +1,7 @@ import 'dart:math'; import 'package:example/another_component.dart'; +import 'package:example/dependent_query_example.dart'; import 'package:example/hooks_example.dart'; import 'package:example/lazy_query.dart'; import 'package:example/mutation_example.dart'; @@ -34,13 +35,13 @@ class MyApp extends StatelessWidget { final successJob = QueryJob( queryKey: "greetings", - task: (queryKey, _) => Future.delayed(const Duration(seconds: 2), + task: (queryKey, _, __) => Future.delayed(const Duration(seconds: 2), () => "Welcome ($queryKey) ${Random.secure().nextInt(100)}"), ); final failedJob = QueryJob( queryKey: "failure", - task: (queryKey, _) => Random().nextBool() + task: (queryKey, _, __) => Random().nextBool() ? Future.error("[$queryKey] Failed for unknown reason") : Future.value( "Success, you'll get slowly ${Random().nextInt(100)}!", @@ -178,6 +179,16 @@ class _MyHomePageState extends State with WidgetsBindingObserver { ); }, ), + ElevatedButton( + child: const Text("Dependent Query Example"), + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const DependentQueryExample(), + ), + ); + }, + ), const AnotherComponent(), ], ), diff --git a/packages/example/lib/query_with_external_data.dart b/packages/example/lib/query_with_external_data.dart index d500c7c..e05af05 100644 --- a/packages/example/lib/query_with_external_data.dart +++ b/packages/example/lib/query_with_external_data.dart @@ -6,7 +6,7 @@ import 'package:flutter/material.dart'; final jobWithExternalData = QueryJob( queryKey: "external_data", cacheTime: const Duration(seconds: 10), - task: (queryKey, data) { + task: (queryKey, data, _) { return Future.delayed(const Duration(milliseconds: 500), () => "Hello from $queryKey with $data"); }); diff --git a/packages/fl_query/lib/src/base_operation.dart b/packages/fl_query/lib/src/base_operation.dart index 89ead8a..2e3f0fb 100644 --- a/packages/fl_query/lib/src/base_operation.dart +++ b/packages/fl_query/lib/src/base_operation.dart @@ -60,6 +60,6 @@ abstract class BaseOperation extends ChangeNotifier { bool get isLoading; bool get isIdle; bool get isInactive => mounts.isEmpty; - bool get hasData => isSuccess && data != null; - bool get hasError => isError && error != null; + bool get hasData => data != null; + bool get hasError => error != null; } diff --git a/packages/fl_query/lib/src/hooks/use_query.dart b/packages/fl_query/lib/src/hooks/use_query.dart index ea74d72..fe2f0bc 100644 --- a/packages/fl_query/lib/src/hooks/use_query.dart +++ b/packages/fl_query/lib/src/hooks/use_query.dart @@ -17,106 +17,67 @@ Query useQuery({ QueryListener? onError, List? keys, }) { - return use(_UseQuery( - externalData: externalData, - job: job, - onData: onData, - onError: onError, - keys: keys, - )); -} + final context = useContext(); + QueryBowl queryBowl = QueryBowl.of(context); + final ValueKey uKey = useMemoized(() => ValueKey(uuid.v4()), []); + Query query = useMemoized( + () => Query.fromOptions( + job, + externalData: externalData, + queryBowl: queryBowl, + onData: onData, + onError: onError, + ), + []); -class _UseQuery extends Hook> { - final QueryJob job; - final Outside externalData; + final oldExternalData = usePrevious(externalData); + final oldOnData = usePrevious(onData); + final oldOnError = usePrevious(onError); - /// Called when the query returns new data, on query - /// refetch or query gets expired - final QueryListener? onData; - - /// Called when the query returns error - final QueryListener? onError; - const _UseQuery({ - required this.job, - required this.externalData, - this.onData, - this.onError, - super.keys, - }); - - @override - HookState, Hook>> createState() => - _UseQueryHookState(); -} - -class _UseQueryHookState - extends HookState, _UseQuery> { - late QueryBowl queryBowl; - late final ValueKey uKey; - late Query query; - - @override - void initHook() { - super.initHook(); - uKey = ValueKey(uuid.v4()); - query = Query.fromOptions( - hook.job, - externalData: hook.externalData, + useEffect(() { + queryBowl.addQuery( + query, + key: uKey, + onData: onData, + onError: onError, ); - WidgetsBinding.instance.addPostFrameCallback((_) async { - query = QueryBowl.of(context).addQuery( - query, - key: uKey, - onData: hook.onData, - onError: hook.onError, - ); - final hasExternalDataChanged = query.externalData != null && - query.prevUsedExternalData != null && - !isShallowEqual(query.externalData!, query.prevUsedExternalData!); - (query.fetched && query.refetchOnMount == true) || hasExternalDataChanged - ? await query.refetch() - : await query.fetch(); - }); - } + final hasExternalDataChanged = query.externalData != null && + query.prevUsedExternalData != null && + !isShallowEqual(query.externalData!, query.prevUsedExternalData!); + (query.fetched && query.refetchOnMount == true) || hasExternalDataChanged + ? query.refetch() + : query.fetch(); - @override - void didUpdateHook(_UseQuery oldHook) { - if (oldHook.externalData != null && - hook.externalData != null && - !isShallowEqual(oldHook.externalData!, hook.externalData!)) { + return () { + query.unmount(uKey); + if (onData != null) query.onDataListeners.remove(onData); + if (onError != null) query.onErrorListeners.remove(onError); + }; + }, []); + + useEffect(() { + if (oldExternalData != null && + externalData != null && + !isShallowEqual(oldExternalData, externalData)) { QueryBowl.of(context).fetchQuery( - hook.job, - externalData: hook.externalData, - onData: hook.onData, - onError: hook.onError, + job, + externalData: externalData, + onData: onData, + onError: onError, key: uKey, ); } else { - if (oldHook.onData != hook.onData && oldHook.onData != null) { - query.onDataListeners.remove(oldHook.onData); - if (hook.onData != null) query.onDataListeners.add(hook.onData!); + if (oldOnData != onData && oldOnData != null) { + query.onDataListeners.remove(oldOnData); + if (onData != null) query.onDataListeners.add(onData); } - if (oldHook.onError != hook.onError && oldHook.onError != null) { - query.onErrorListeners.remove(oldHook.onError); - if (hook.onError != null) query.onErrorListeners.add(hook.onError!); + if (oldOnError != onError && oldOnError != null) { + query.onErrorListeners.remove(oldOnError); + if (onError != null) query.onErrorListeners.add(onError); } } - super.didUpdateHook(oldHook); - } + return null; + }); - @override - void dispose() { - query.unmount(uKey); - if (hook.onData != null) query.onDataListeners.remove(hook.onData); - if (hook.onError != null) query.onErrorListeners.remove(hook.onError); - } - - @override - Query build(BuildContext context) { - queryBowl = QueryBowl.of(context); - return queryBowl.getQuery(query.queryKey) ?? query; - } - - @override - String get debugLabel => 'useQuery'; + return queryBowl.getQuery(job.queryKey) ?? query; } diff --git a/packages/fl_query/lib/src/models/query_job.dart b/packages/fl_query/lib/src/models/query_job.dart index f4f11e3..c4e2007 100644 --- a/packages/fl_query/lib/src/models/query_job.dart +++ b/packages/fl_query/lib/src/models/query_job.dart @@ -1,5 +1,22 @@ import 'package:fl_query/src/query.dart'; +/// How to make dependent Queries? +/// +/// Pass a [QueryBowl] class/object to [task] that contains a method +/// [QueryBowl.dependOnQuery] that takes a [QueryJob] and uses that to get +/// the appropriate query for it or if it doesn't exist creates a new +/// instance. It listens to the changes of [Query] of the passed +/// [QueryJob] & calls the [notifyListener] method of running [Query] +/// +/// If shown briefly +/// +/// ```data +/// task: (queryKey, external, queryBowl){ +/// final dependentQuery = queryBowl.dependOnQuery(dependentQueryJob); +/// return someAsyncTask(); +/// } +/// ``` + class QueryJob { // all params String _queryKey; diff --git a/packages/fl_query/lib/src/query.dart b/packages/fl_query/lib/src/query.dart index b63a590..fc37ce5 100644 --- a/packages/fl_query/lib/src/query.dart +++ b/packages/fl_query/lib/src/query.dart @@ -2,7 +2,10 @@ import 'dart:async'; import 'package:fl_query/src/base_operation.dart'; import 'package:fl_query/src/models/query_job.dart'; +import 'package:fl_query/src/query_bowl.dart'; +import 'package:fl_query/src/utils.dart'; import 'package:flutter/widgets.dart'; +import 'package:collection/collection.dart'; enum QueryStatus { /// in times when an error occurs @@ -23,7 +26,11 @@ enum QueryStatus { refetching; } -typedef QueryTaskFunction = FutureOr Function(String, Outside); +typedef QueryTaskFunction = FutureOr Function( + String queryKey, + Outside externalData, + Query self, +); typedef QueryListener = FutureOr Function(T); @@ -64,6 +71,8 @@ class Query extends BaseOperation { Timer? _refetchIntervalTimer; + final QueryBowl queryBowl; + Query({ required this.queryKey, required this.task, @@ -72,6 +81,7 @@ class Query extends BaseOperation { required Outside externalData, required super.retries, required super.retryDelay, + required this.queryBowl, this.refetchOnMount, this.refetchInterval, this.enabled = true, @@ -95,6 +105,7 @@ class Query extends BaseOperation { Query.fromOptions( QueryJob options, { + required this.queryBowl, required Outside externalData, QueryListener? onData, QueryListener? onError, @@ -136,7 +147,11 @@ class Query extends BaseOperation { Future _execute() async { try { retryAttempts = 0; - data = await task(queryKey, _externalData); + data = await task( + queryKey, + _externalData, + this, + ); _prevUsedExternalData = _externalData; updatedAt = DateTime.now(); status = QueryStatus.success; @@ -157,7 +172,11 @@ class Query extends BaseOperation { while (retryAttempts <= retries) { await Future.delayed(retryDelay); try { - data = await task(queryKey, _externalData); + data = await task( + queryKey, + _externalData, + this, + ); _prevUsedExternalData = _externalData; status = QueryStatus.success; for (final onData in onDataListeners) { @@ -182,7 +201,9 @@ class Query extends BaseOperation { } Future fetch() async { - if (!enabled) return null; + /// if isLoading/isRefetching is true that means its already fetching/ + /// refetching. So [_execute] again can create a race condition + if (!enabled || isLoading || isRefetching) return null; if (hasData) { return data; } @@ -195,13 +216,14 @@ class Query extends BaseOperation { } Future refetch() async { - // cannot let run multiple refetch at the same time. It can cause - // race-condition - if (isRefetching) return null; + /// if isLoading/isRefetching is true that means its already fetching/ + /// refetching. So [_execute] again can create a race condition + if (isRefetching || isLoading) return null; status = QueryStatus.refetching; refetchCount++; // disabling the lazy query bound when query was actually called - if (!enabled) enabled = false; + if (!enabled) enabled = true; + if (enabled && !fetched) await fetch(); notifyListeners(); return await _execute().then((_) => data); } @@ -239,6 +261,11 @@ class Query extends BaseOperation { fetched = false; status = QueryStatus.idle; retryAttempts = 0; + for (final queryEntry in _dependencyQueries.entries) { + queryEntry.value.unmount(queryEntry.key); + queryEntry.value.removeListener(refetch); + } + _dependencyQueries = {}; onDataListeners.clear(); onErrorListeners.clear(); mounts.clear(); @@ -268,6 +295,42 @@ class Query extends BaseOperation { notifyListeners(); } + Map, Query> _dependencyQueries = {}; + + /// only usable inside any task method + Query dependOnQuery( + QueryJob job, { + required Outside externalData, + }) { + final key = ValueKey(uuid.v4()); + final query = queryBowl.addQuery( + Query.fromOptions( + job, + externalData: externalData, + queryBowl: queryBowl, + ), + key: key, + ); + // removing listener if it was already hooked to it previously + query.removeListener(refetch); + query.addListener(refetch); + final uKey = + _dependencyQueries.keys.firstWhereOrNull((k) => k.value == key.value) ?? + key; + _dependencyQueries[uKey] = query; + if (!query.fetched) query.fetch(); + return query; + } + + @override + void dispose() { + for (final queryEntry in _dependencyQueries.entries) { + queryEntry.value.unmount(queryEntry.key); + queryEntry.value.removeListener(refetch); + } + super.dispose(); + } + bool get isStale { /// when [_staleTime] is [Duration.zero], the query will always be /// stale & will never refetch in the background. But can be inactive diff --git a/packages/fl_query/lib/src/query_bowl.dart b/packages/fl_query/lib/src/query_bowl.dart index 1d36e3f..3f1c08e 100644 --- a/packages/fl_query/lib/src/query_bowl.dart +++ b/packages/fl_query/lib/src/query_bowl.dart @@ -30,7 +30,7 @@ class QueryBowlScope extends StatefulWidget { const QueryBowlScope({ required this.child, this.staleTime = Duration.zero, - this.cacheTime = const Duration(minutes: 5), + this.cacheTime = const Duration(seconds: 5), this.refetchInterval = Duration.zero, this.refetchOnMount = false, this.refetchOnReconnect = true, @@ -79,9 +79,23 @@ class _QueryBowlScopeState extends State { } void updateQueries(Query query) { + // checking & not including inactive queries + // basically garbage collecting queries + if (query.isInactive) { + /// there's a bug currently, + /// where if somehow a [Query] (queryA) is depending on another + /// [Query] (queryB) & queryA has become inactive so its getting + /// disposed but at the same moment queryB got refetched will make + /// queryB's [BaseOperation.unmount] to throw [RangeError] for + /// calling [ChangeNotifier.notifyListener] after [cacheDelay] + /// inside [Timer.periodic] callback + /// + /// To mitigate this, [Query.reset] is used instead of using + /// [Query.dispose] as it doesn't call [super.dispose] + query.reset(); + } + setState(() { - // checking & not including inactive queries - // basically garbage collecting queries queries = Set.from( query.isInactive ? queries.where((el) => el.queryKey != query.queryKey) @@ -240,6 +254,7 @@ class QueryBowl extends InheritedWidget { final query = Query.fromOptions( options, externalData: externalData, + queryBowl: this, onData: onData, onError: onError, ); diff --git a/packages/fl_query/lib/src/query_builder.dart b/packages/fl_query/lib/src/query_builder.dart index 7a699b0..96f0dc6 100644 --- a/packages/fl_query/lib/src/query_builder.dart +++ b/packages/fl_query/lib/src/query_builder.dart @@ -34,29 +34,30 @@ class _QueryBuilderState extends State> { late QueryBowl queryBowl; late final ValueKey uKey; - late Query query; + Query? query; @override void initState() { super.initState(); uKey = ValueKey(uuid.v4()); - query = Query.fromOptions( - widget.job, - externalData: widget.externalData, - ); WidgetsBinding.instance.addPostFrameCallback((_) async { query = QueryBowl.of(context).addQuery( - query, + Query.fromOptions( + widget.job, + externalData: widget.externalData, + queryBowl: QueryBowl.of(context), + ), key: uKey, onData: widget.onData, onError: widget.onError, ); - final hasExternalDataChanged = query.externalData != null && - query.prevUsedExternalData != null && - !isShallowEqual(query.externalData!, query.prevUsedExternalData!); - (query.fetched && query.refetchOnMount == true) || hasExternalDataChanged - ? await query.refetch() - : await query.fetch(); + final hasExternalDataChanged = query!.externalData != null && + query!.prevUsedExternalData != null && + !isShallowEqual(query!.externalData!, query!.prevUsedExternalData!); + (query!.fetched && query!.refetchOnMount == true) || + hasExternalDataChanged + ? await query!.refetch() + : await query!.fetch(); }); } @@ -74,12 +75,13 @@ class _QueryBuilderState ); } else { if (oldWidget.onData != widget.onData && oldWidget.onData != null) { - query.onDataListeners.remove(oldWidget.onData); - if (widget.onData != null) query.onDataListeners.add(widget.onData!); + query?.onDataListeners.remove(oldWidget.onData); + if (widget.onData != null) query?.onDataListeners.add(widget.onData!); } if (oldWidget.onError != widget.onError && oldWidget.onError != null) { - query.onErrorListeners.remove(oldWidget.onError); - if (widget.onError != null) query.onErrorListeners.add(widget.onError!); + query?.onErrorListeners.remove(oldWidget.onError); + if (widget.onError != null) + query?.onErrorListeners.add(widget.onError!); } } super.didUpdateWidget(oldWidget); @@ -87,16 +89,18 @@ class _QueryBuilderState @override void dispose() { - query.unmount(uKey); - if (widget.onData != null) query.onDataListeners.remove(widget.onData); - if (widget.onError != null) query.onErrorListeners.remove(widget.onError); + query?.unmount(uKey); + if (widget.onData != null) query?.onDataListeners.remove(widget.onData); + if (widget.onError != null) query?.onErrorListeners.remove(widget.onError); super.dispose(); } @override Widget build(BuildContext context) { queryBowl = QueryBowl.of(context); - final latestQuery = queryBowl.getQuery(query.queryKey) ?? query; + final latestQuery = + queryBowl.getQuery(widget.job.queryKey) ?? query; + if (latestQuery == null) return Container(); return widget.builder(context, latestQuery); } }