Skip to content

Commit 53e0df1

Browse files
authored
Merge pull request #50 from flutter-news-app-full-source-code/integrate-name-based-filtering-for-the-country-model
Integrate name based filtering for the country model
2 parents fb98c6e + 228cca9 commit 53e0df1

File tree

3 files changed

+162
-71
lines changed

3 files changed

+162
-71
lines changed

lib/src/registry/data_operation_registry.dart

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -131,9 +131,12 @@ class DataOperationRegistry {
131131
),
132132
'country': (c, uid, f, s, p) async {
133133
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.
134+
final name = f?['name'] as String?;
135+
136+
// If either 'usage' or 'name' filter is present, delegate to CountryService.
137+
// Sorting and pagination are handled by CountryService for these specialized queries.
138+
if ((usage != null && usage.isNotEmpty) ||
139+
(name != null && name.isNotEmpty)) {
137140
final countryService = c.read<CountryService>();
138141
final countries = await countryService.getCountries(f);
139142
return PaginatedResponse<Country>(
@@ -142,13 +145,14 @@ class DataOperationRegistry {
142145
hasMore: false, // No more items as it's a complete filtered set
143146
);
144147
} else {
145-
// For standard requests, use the repository which supports pagination/sorting.
148+
// For standard requests without specialized filters, use the repository
149+
// which supports pagination/sorting.
146150
return c.read<DataRepository<Country>>().readAll(
147-
userId: uid,
148-
filter: f,
149-
sort: s,
150-
pagination: p,
151-
);
151+
userId: uid,
152+
filter: f,
153+
sort: s,
154+
pagination: p,
155+
);
152156
}
153157
},
154158
'language': (c, uid, f, s, p) => c

lib/src/services/country_service.dart

Lines changed: 144 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -48,42 +48,60 @@ class CountryService {
4848
static const Duration _cacheDuration = Duration(hours: 1);
4949

5050
// In-memory caches for frequently accessed lists with time-based invalidation.
51-
_CacheEntry<List<Country>>? _cachedEventCountries;
52-
_CacheEntry<List<Country>>? _cachedHeadquarterCountries;
51+
final Map<String, _CacheEntry<List<Country>>> _cachedEventCountries = {};
52+
final Map<String, _CacheEntry<List<Country>>> _cachedHeadquarterCountries =
53+
{};
5354

5455
// Futures to hold in-flight aggregation requests to prevent cache stampedes.
55-
Future<List<Country>>? _eventCountriesFuture;
56-
Future<List<Country>>? _headquarterCountriesFuture;
56+
final Map<String, Future<List<Country>>> _eventCountriesFutures = {};
57+
final Map<String, Future<List<Country>>> _headquarterCountriesFutures = {};
5758

5859
/// Retrieves a list of countries based on the provided filter.
5960
///
6061
/// Supports filtering by 'usage' to get countries that are either
6162
/// 'eventCountry' in headlines or 'headquarters' in sources.
62-
/// If no specific usage filter is provided, it returns all active countries.
63+
/// It also supports filtering by 'name' (full or partial match).
6364
///
6465
/// - [filter]: An optional map containing query parameters.
6566
/// Expected keys:
6667
/// - `'usage'`: String, can be 'eventCountry' or 'headquarters'.
68+
/// - `'name'`: String, a full or partial country name for search.
6769
///
6870
/// Throws [BadRequestException] if an unsupported usage filter is provided.
6971
/// Throws [OperationFailedException] for internal errors during data fetch.
7072
Future<List<Country>> getCountries(Map<String, dynamic>? filter) async {
7173
_log.info('Fetching countries with filter: $filter');
7274

7375
final usage = filter?['usage'] as String?;
76+
final name = filter?['name'] as String?;
77+
78+
Map<String, dynamic>? nameFilter;
79+
if (name != null && name.isNotEmpty) {
80+
// Create a case-insensitive regex filter for the name.
81+
nameFilter = {r'$regex': name, r'$options': 'i'};
82+
}
7483

7584
if (usage == null || usage.isEmpty) {
76-
_log.fine('No usage filter provided. Fetching all active countries.');
77-
return _getAllCountries();
85+
_log.fine(
86+
'No usage filter provided. Fetching all active countries '
87+
'with nameFilter: $nameFilter.',
88+
);
89+
return _getAllCountries(nameFilter: nameFilter);
7890
}
7991

8092
switch (usage) {
8193
case 'eventCountry':
82-
_log.fine('Fetching countries used as event countries in headlines.');
83-
return _getEventCountries();
94+
_log.fine(
95+
'Fetching countries used as event countries in headlines '
96+
'with nameFilter: $nameFilter.',
97+
);
98+
return _getEventCountries(nameFilter: nameFilter);
8499
case 'headquarters':
85-
_log.fine('Fetching countries used as headquarters in sources.');
86-
return _getHeadquarterCountries();
100+
_log.fine(
101+
'Fetching countries used as headquarters in sources '
102+
'with nameFilter: $nameFilter.',
103+
);
104+
return _getHeadquarterCountries(nameFilter: nameFilter);
87105
default:
88106
_log.warning('Unsupported country usage filter: "$usage"');
89107
throw BadRequestException(
@@ -94,15 +112,30 @@ class CountryService {
94112
}
95113

96114
/// Fetches all active countries from the repository.
97-
Future<List<Country>> _getAllCountries() async {
98-
_log.finer('Retrieving all active countries from repository.');
115+
///
116+
/// - [nameFilter]: An optional map containing a regex filter for the country name.
117+
Future<List<Country>> _getAllCountries({
118+
Map<String, dynamic>? nameFilter,
119+
}) async {
120+
_log.finer(
121+
'Retrieving all active countries from repository with nameFilter: $nameFilter.',
122+
);
99123
try {
100-
final response = await _countryRepository.readAll(
101-
filter: {'status': ContentStatus.active.name},
102-
);
124+
final combinedFilter = <String, dynamic>{
125+
'status': ContentStatus.active.name,
126+
};
127+
if (nameFilter != null && nameFilter.isNotEmpty) {
128+
combinedFilter.addAll({'name': nameFilter});
129+
}
130+
131+
final response = await _countryRepository.readAll(filter: combinedFilter);
103132
return response.items;
104133
} catch (e, s) {
105-
_log.severe('Failed to fetch all countries.', e, s);
134+
_log.severe(
135+
'Failed to fetch all countries with nameFilter: $nameFilter.',
136+
e,
137+
s,
138+
);
106139
throw OperationFailedException('Failed to retrieve all countries: $e');
107140
}
108141
}
@@ -112,56 +145,84 @@ class CountryService {
112145
///
113146
/// Uses MongoDB aggregation to efficiently get distinct country IDs
114147
/// and then fetches the full Country objects. Results are cached.
115-
Future<List<Country>> _getEventCountries() async {
116-
if (_cachedEventCountries != null && _cachedEventCountries!.isValid()) {
117-
_log.finer('Returning cached event countries.');
118-
return _cachedEventCountries!.data;
148+
///
149+
/// - [nameFilter]: An optional map containing a regex filter for the country name.
150+
Future<List<Country>> _getEventCountries({
151+
Map<String, dynamic>? nameFilter,
152+
}) async {
153+
final cacheKey = 'eventCountry_${nameFilter ?? 'noFilter'}';
154+
if (_cachedEventCountries.containsKey(cacheKey) &&
155+
_cachedEventCountries[cacheKey]!.isValid()) {
156+
_log.finer('Returning cached event countries for key: $cacheKey.');
157+
return _cachedEventCountries[cacheKey]!.data;
158+
}
159+
// Atomically retrieve or create the future for the specific cache key.
160+
var future = _eventCountriesFutures[cacheKey];
161+
if (future == null) {
162+
future = _fetchAndCacheEventCountries(
163+
nameFilter: nameFilter,
164+
).whenComplete(() => _eventCountriesFutures.remove(cacheKey));
165+
_eventCountriesFutures[cacheKey] = future;
119166
}
120-
// Atomically assign the future if no fetch is in progress,
121-
// and clear it when the future completes.
122-
_eventCountriesFuture ??= _fetchAndCacheEventCountries()
123-
.whenComplete(() => _eventCountriesFuture = null);
124-
return _eventCountriesFuture!;
167+
return future;
125168
}
126169

127170
/// Fetches a distinct list of countries that are referenced as
128171
/// `headquarters` in sources.
129172
///
130173
/// Uses MongoDB aggregation to efficiently get distinct country IDs
131174
/// and then fetches the full Country objects. Results are cached.
132-
Future<List<Country>> _getHeadquarterCountries() async {
133-
if (_cachedHeadquarterCountries != null &&
134-
_cachedHeadquarterCountries!.isValid()) {
135-
_log.finer('Returning cached headquarter countries.');
136-
return _cachedHeadquarterCountries!.data;
175+
///
176+
/// - [nameFilter]: An optional map containing a regex filter for the country name.
177+
Future<List<Country>> _getHeadquarterCountries({
178+
Map<String, dynamic>? nameFilter,
179+
}) async {
180+
final cacheKey = 'headquarters_${nameFilter ?? 'noFilter'}';
181+
if (_cachedHeadquarterCountries.containsKey(cacheKey) &&
182+
_cachedHeadquarterCountries[cacheKey]!.isValid()) {
183+
_log.finer('Returning cached headquarter countries for key: $cacheKey.');
184+
return _cachedHeadquarterCountries[cacheKey]!.data;
137185
}
138-
// Atomically assign the future if no fetch is in progress,
139-
// and clear it when the future completes.
140-
_headquarterCountriesFuture ??= _fetchAndCacheHeadquarterCountries()
141-
.whenComplete(() => _headquarterCountriesFuture = null);
142-
return _headquarterCountriesFuture!;
186+
// Atomically retrieve or create the future for the specific cache key.
187+
var future = _headquarterCountriesFutures[cacheKey];
188+
if (future == null) {
189+
future = _fetchAndCacheHeadquarterCountries(
190+
nameFilter: nameFilter,
191+
).whenComplete(() => _headquarterCountriesFutures.remove(cacheKey));
192+
_headquarterCountriesFutures[cacheKey] = future;
193+
}
194+
return future;
143195
}
144196

145197
/// Helper method to fetch and cache distinct event countries.
146-
Future<List<Country>> _fetchAndCacheEventCountries() async {
147-
_log.finer('Fetching distinct event countries via aggregation.');
198+
///
199+
/// - [nameFilter]: An optional map containing a regex filter for the country name.
200+
Future<List<Country>> _fetchAndCacheEventCountries({
201+
Map<String, dynamic>? nameFilter,
202+
}) async {
203+
_log.finer(
204+
'Fetching distinct event countries via aggregation with nameFilter: $nameFilter.',
205+
);
148206
try {
149207
final distinctCountries = await _getDistinctCountriesFromAggregation(
150208
repository: _headlineRepository,
151209
fieldName: 'eventCountry',
210+
nameFilter: nameFilter,
152211
);
153-
_cachedEventCountries = _CacheEntry(
212+
final cacheKey = 'eventCountry_${nameFilter ?? 'noFilter'}';
213+
_cachedEventCountries[cacheKey] = _CacheEntry(
154214
distinctCountries,
155215
DateTime.now().add(_cacheDuration),
156216
);
157217
_log.info(
158218
'Successfully fetched and cached ${distinctCountries.length} '
159-
'event countries.',
219+
'event countries for key: $cacheKey.',
160220
);
161221
return distinctCountries;
162222
} catch (e, s) {
163223
_log.severe(
164-
'Failed to fetch distinct event countries via aggregation.',
224+
'Failed to fetch distinct event countries via aggregation '
225+
'with nameFilter: $nameFilter.',
165226
e,
166227
s,
167228
);
@@ -170,25 +231,34 @@ class CountryService {
170231
}
171232

172233
/// Helper method to fetch and cache distinct headquarter countries.
173-
Future<List<Country>> _fetchAndCacheHeadquarterCountries() async {
174-
_log.finer('Fetching distinct headquarter countries via aggregation.');
234+
///
235+
/// - [nameFilter]: An optional map containing a regex filter for the country name.
236+
Future<List<Country>> _fetchAndCacheHeadquarterCountries({
237+
Map<String, dynamic>? nameFilter,
238+
}) async {
239+
_log.finer(
240+
'Fetching distinct headquarter countries via aggregation with nameFilter: $nameFilter.',
241+
);
175242
try {
176243
final distinctCountries = await _getDistinctCountriesFromAggregation(
177244
repository: _sourceRepository,
178245
fieldName: 'headquarters',
246+
nameFilter: nameFilter,
179247
);
180-
_cachedHeadquarterCountries = _CacheEntry(
248+
final cacheKey = 'headquarters_${nameFilter ?? 'noFilter'}';
249+
_cachedHeadquarterCountries[cacheKey] = _CacheEntry(
181250
distinctCountries,
182251
DateTime.now().add(_cacheDuration),
183252
);
184253
_log.info(
185254
'Successfully fetched and cached ${distinctCountries.length} '
186-
'headquarter countries.',
255+
'headquarter countries for key: $cacheKey.',
187256
);
188257
return distinctCountries;
189258
} catch (e, s) {
190259
_log.severe(
191-
'Failed to fetch distinct headquarter countries via aggregation.',
260+
'Failed to fetch distinct headquarter countries via aggregation '
261+
'with nameFilter: $nameFilter.',
192262
e,
193263
s,
194264
);
@@ -202,29 +272,40 @@ class CountryService {
202272
/// - [repository]: The [DataRepository] to perform the aggregation on.
203273
/// - [fieldName]: The name of the field within the documents that contains
204274
/// the country object (e.g., 'eventCountry', 'headquarters').
275+
/// - [nameFilter]: An optional map containing a regex filter for the country name.
205276
///
206277
/// Throws [OperationFailedException] for internal errors during data fetch.
207-
Future<List<Country>> _getDistinctCountriesFromAggregation<T extends FeedItem>({
278+
Future<List<Country>>
279+
_getDistinctCountriesFromAggregation<T extends FeedItem>({
208280
required DataRepository<T> repository,
209281
required String fieldName,
282+
Map<String, dynamic>? nameFilter,
210283
}) async {
211-
_log.finer('Fetching distinct countries for field "$fieldName" via aggregation.');
284+
_log.finer(
285+
'Fetching distinct countries for field "$fieldName" via aggregation '
286+
'with nameFilter: $nameFilter.',
287+
);
212288
try {
213-
final pipeline = [
214-
{
215-
r'$match': {
216-
'status': ContentStatus.active.name,
217-
'$fieldName.id': {r'$exists': true},
218-
},
219-
},
220-
{
221-
r'$group': {
289+
final matchStage = <String, Object>{
290+
'status': ContentStatus.active.name,
291+
'$fieldName.id': <String, Object>{r'$exists': true},
292+
};
293+
294+
// Add name filter if provided
295+
if (nameFilter != null && nameFilter.isNotEmpty) {
296+
matchStage['$fieldName.name'] = nameFilter;
297+
}
298+
299+
final pipeline = <Map<String, Object>>[
300+
<String, Object>{r'$match': matchStage},
301+
<String, Object>{
302+
r'$group': <String, Object>{
222303
'_id': '\$$fieldName.id',
223-
'country': {r'$first': '\$$fieldName'},
304+
'country': <String, Object>{r'$first': '\$$fieldName'},
224305
},
225306
},
226-
{
227-
r'$replaceRoot': {'newRoot': r'$country'},
307+
<String, Object>{
308+
r'$replaceRoot': <String, Object>{'newRoot': r'$country'},
228309
},
229310
];
230311

@@ -238,12 +319,13 @@ class CountryService {
238319

239320
_log.info(
240321
'Successfully fetched ${distinctCountries.length} distinct countries '
241-
'for field "$fieldName".',
322+
'for field "$fieldName" with nameFilter: $nameFilter.',
242323
);
243324
return distinctCountries;
244325
} catch (e, s) {
245326
_log.severe(
246-
'Failed to fetch distinct countries for field "$fieldName".',
327+
'Failed to fetch distinct countries for field "$fieldName" '
328+
'with nameFilter: $nameFilter.',
247329
e,
248330
s,
249331
);

lib/src/services/database_seeding_service.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,11 @@ class DatabaseSeedingService {
118118
.collection('sources')
119119
.createIndex(keys: {'name': 'text'}, name: 'sources_text_index');
120120

121+
// Index for searching countries by name (case-insensitive friendly)
122+
await _db
123+
.collection('countries')
124+
.createIndex(keys: {'name': 1}, name: 'countries_name_index');
125+
121126
// Indexes for country aggregation queries
122127
await _db
123128
.collection('headlines')

0 commit comments

Comments
 (0)