Skip to content

Commit 579c6f4

Browse files
authored
Merge pull request #45 from flutter-news-app-full-source-code/Enhance-Country-Data-Route-(Pagination-&-Smart-Caching)
Enhance country data route (pagination & smart caching)
2 parents 56df6b6 + 22ecbd0 commit 579c6f4

File tree

2 files changed

+101
-68
lines changed

2 files changed

+101
-68
lines changed

lib/src/registry/data_operation_registry.dart

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -130,17 +130,26 @@ class DataOperationRegistry {
130130
pagination: p,
131131
),
132132
'country': (c, uid, f, s, p) async {
133-
// For 'country' model, delegate to CountryService for specialized filtering.
134-
// The CountryService handles the 'usage' filter and returns a List<Country>.
135-
// We then wrap this list in a PaginatedResponse for consistency with
136-
// the generic API response structure.
137-
final countryService = c.read<CountryService>();
138-
final countries = await countryService.getCountries(f);
139-
return PaginatedResponse<Country>(
140-
items: countries,
141-
cursor: null, // No cursor for this type of filtered list
142-
hasMore: false, // No more items as it's a complete filtered set
143-
);
133+
final usage = f?['usage'] as String?;
134+
if (usage != null && usage.isNotEmpty) {
135+
// For 'country' model with 'usage' filter, delegate to CountryService.
136+
// Sorting and pagination are not supported for this specialized query.
137+
final countryService = c.read<CountryService>();
138+
final countries = await countryService.getCountries(f);
139+
return PaginatedResponse<Country>(
140+
items: countries,
141+
cursor: null, // No cursor for this type of filtered list
142+
hasMore: false, // No more items as it's a complete filtered set
143+
);
144+
} else {
145+
// For standard requests, use the repository which supports pagination/sorting.
146+
return c.read<DataRepository<Country>>().readAll(
147+
userId: uid,
148+
filter: f,
149+
sort: s,
150+
pagination: p,
151+
);
152+
}
144153
},
145154
'language': (c, uid, f, s, p) => c
146155
.read<DataRepository<Language>>()

lib/src/services/country_service.dart

Lines changed: 81 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,30 @@ import 'package:core/core.dart';
22
import 'package:data_repository/data_repository.dart';
33
import 'package:logging/logging.dart';
44

5+
/// {@template _cache_entry}
6+
/// A simple class to hold cached data along with its expiration time.
7+
/// {@endtemplate}
8+
class _CacheEntry<T> {
9+
/// {@macro _cache_entry}
10+
const _CacheEntry(this.data, this.expiry);
11+
12+
/// The cached data.
13+
final T data;
14+
15+
/// The time at which the cached data expires.
16+
final DateTime expiry;
17+
18+
/// Checks if the cache entry is still valid (not expired).
19+
bool isValid() => DateTime.now().isBefore(expiry);
20+
}
21+
522
/// {@template country_service}
623
/// A service responsible for retrieving country data, including specialized
724
/// lists like countries associated with headlines or sources.
825
///
926
/// This service leverages database aggregation for efficient data retrieval
10-
/// and includes basic in-memory caching to optimize performance for frequently
11-
/// requested lists.
27+
/// and includes time-based in-memory caching to optimize performance for
28+
/// frequently requested lists.
1229
/// {@endtemplate}
1330
class CountryService {
1431
/// {@macro country_service}
@@ -27,11 +44,12 @@ class CountryService {
2744
final DataRepository<Source> _sourceRepository;
2845
final Logger _log;
2946

30-
// In-memory caches for frequently accessed lists.
31-
// These should be cleared periodically in a real-world application
32-
// or invalidated upon data changes. For this scope, simple caching is used.
33-
List<Country>? _cachedEventCountries;
34-
List<Country>? _cachedHeadquarterCountries;
47+
// Cache duration for aggregated country lists (e.g., 1 hour).
48+
static const Duration _cacheDuration = Duration(hours: 1);
49+
50+
// In-memory caches for frequently accessed lists with time-based invalidation.
51+
_CacheEntry<List<Country>>? _cachedEventCountries;
52+
_CacheEntry<List<Country>>? _cachedHeadquarterCountries;
3553

3654
/// Retrieves a list of countries based on the provided filter.
3755
///
@@ -91,49 +109,26 @@ class CountryService {
91109
/// Uses MongoDB aggregation to efficiently get distinct country IDs
92110
/// and then fetches the full Country objects. Results are cached.
93111
Future<List<Country>> _getEventCountries() async {
94-
if (_cachedEventCountries != null) {
112+
if (_cachedEventCountries != null && _cachedEventCountries!.isValid()) {
95113
_log.finer('Returning cached event countries.');
96-
return _cachedEventCountries!;
114+
return _cachedEventCountries!.data;
97115
}
98116

99117
_log.finer('Fetching distinct event countries via aggregation.');
100-
try {
101-
final pipeline = [
102-
{
103-
r'$match': {
104-
'status': ContentStatus.active.name,
105-
'eventCountry.id': {r'$exists': true},
106-
},
107-
},
108-
{
109-
r'$group': {
110-
'_id': r'$eventCountry.id',
111-
'country': {r'$first': r'$eventCountry'},
112-
},
113-
},
114-
{
115-
r'$replaceRoot': {'newRoot': r'$country'},
116-
},
117-
];
118-
119-
final distinctCountriesJson = await _headlineRepository.aggregate(
120-
pipeline: pipeline,
121-
);
122-
123-
final distinctCountries = distinctCountriesJson
124-
.map(Country.fromJson)
125-
.toList();
126-
127-
_cachedEventCountries = distinctCountries;
128-
_log.info(
129-
'Successfully fetched and cached ${distinctCountries.length} '
130-
'event countries.',
131-
);
132-
return distinctCountries;
133-
} catch (e, s) {
134-
_log.severe('Failed to fetch event countries via aggregation.', e, s);
135-
throw OperationFailedException('Failed to retrieve event countries: $e');
136-
}
118+
final distinctCountries = await _getDistinctCountriesFromAggregation(
119+
repository: _headlineRepository,
120+
fieldName: 'eventCountry',
121+
);
122+
123+
_cachedEventCountries = _CacheEntry(
124+
distinctCountries,
125+
DateTime.now().add(_cacheDuration),
126+
);
127+
_log.info(
128+
'Successfully fetched and cached ${distinctCountries.length} '
129+
'event countries.',
130+
);
131+
return distinctCountries;
137132
}
138133

139134
/// Fetches a distinct list of countries that are referenced as
@@ -142,53 +137,82 @@ class CountryService {
142137
/// Uses MongoDB aggregation to efficiently get distinct country IDs
143138
/// and then fetches the full Country objects. Results are cached.
144139
Future<List<Country>> _getHeadquarterCountries() async {
145-
if (_cachedHeadquarterCountries != null) {
140+
if (_cachedHeadquarterCountries != null &&
141+
_cachedHeadquarterCountries!.isValid()) {
146142
_log.finer('Returning cached headquarter countries.');
147-
return _cachedHeadquarterCountries!;
143+
return _cachedHeadquarterCountries!.data;
148144
}
149145

150146
_log.finer('Fetching distinct headquarter countries via aggregation.');
147+
final distinctCountries = await _getDistinctCountriesFromAggregation(
148+
repository: _sourceRepository,
149+
fieldName: 'headquarters',
150+
);
151+
152+
_cachedHeadquarterCountries = _CacheEntry(
153+
distinctCountries,
154+
DateTime.now().add(_cacheDuration),
155+
);
156+
_log.info(
157+
'Successfully fetched and cached ${distinctCountries.length} '
158+
'headquarter countries.',
159+
);
160+
return distinctCountries;
161+
}
162+
163+
/// Helper method to fetch a distinct list of countries from a given
164+
/// repository and field name using MongoDB aggregation.
165+
///
166+
/// - [repository]: The [DataRepository] to perform the aggregation on.
167+
/// - [fieldName]: The name of the field within the documents that contains
168+
/// the country object (e.g., 'eventCountry', 'headquarters').
169+
///
170+
/// Throws [OperationFailedException] for internal errors during data fetch.
171+
Future<List<Country>> _getDistinctCountriesFromAggregation({
172+
required DataRepository<dynamic> repository,
173+
required String fieldName,
174+
}) async {
175+
_log.finer('Fetching distinct countries for field "$fieldName" via aggregation.');
151176
try {
152177
final pipeline = [
153178
{
154179
r'$match': {
155180
'status': ContentStatus.active.name,
156-
'headquarters.id': {r'$exists': true},
181+
'$fieldName.id': {r'$exists': true},
157182
},
158183
},
159184
{
160185
r'$group': {
161-
'_id': r'$headquarters.id',
162-
'country': {r'$first': r'$headquarters'},
186+
'_id': '\$$fieldName.id',
187+
'country': {r'$first': '\$$fieldName'},
163188
},
164189
},
165190
{
166191
r'$replaceRoot': {'newRoot': r'$country'},
167192
},
168193
];
169194

170-
final distinctCountriesJson = await _sourceRepository.aggregate(
195+
final distinctCountriesJson = await repository.aggregate(
171196
pipeline: pipeline,
172197
);
173198

174199
final distinctCountries = distinctCountriesJson
175200
.map(Country.fromJson)
176201
.toList();
177202

178-
_cachedHeadquarterCountries = distinctCountries;
179203
_log.info(
180-
'Successfully fetched and cached ${distinctCountries.length} '
181-
'headquarter countries.',
204+
'Successfully fetched ${distinctCountries.length} distinct countries '
205+
'for field "$fieldName".',
182206
);
183207
return distinctCountries;
184208
} catch (e, s) {
185209
_log.severe(
186-
'Failed to fetch headquarter countries via aggregation.',
210+
'Failed to fetch distinct countries for field "$fieldName".',
187211
e,
188212
s,
189213
);
190214
throw OperationFailedException(
191-
'Failed to retrieve headquarter countries: $e',
215+
'Failed to retrieve distinct countries for field "$fieldName": $e',
192216
);
193217
}
194218
}

0 commit comments

Comments
 (0)