@@ -48,42 +48,60 @@ class CountryService {
48
48
static const Duration _cacheDuration = Duration (hours: 1 );
49
49
50
50
// 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
+ {};
53
54
54
55
// 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 = {} ;
57
58
58
59
/// Retrieves a list of countries based on the provided filter.
59
60
///
60
61
/// Supports filtering by 'usage' to get countries that are either
61
62
/// '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) .
63
64
///
64
65
/// - [filter] : An optional map containing query parameters.
65
66
/// Expected keys:
66
67
/// - `'usage'` : String, can be 'eventCountry' or 'headquarters'.
68
+ /// - `'name'` : String, a full or partial country name for search.
67
69
///
68
70
/// Throws [BadRequestException] if an unsupported usage filter is provided.
69
71
/// Throws [OperationFailedException] for internal errors during data fetch.
70
72
Future <List <Country >> getCountries (Map <String , dynamic >? filter) async {
71
73
_log.info ('Fetching countries with filter: $filter ' );
72
74
73
75
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
+ }
74
83
75
84
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);
78
90
}
79
91
80
92
switch (usage) {
81
93
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);
84
99
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);
87
105
default :
88
106
_log.warning ('Unsupported country usage filter: "$usage "' );
89
107
throw BadRequestException (
@@ -94,15 +112,30 @@ class CountryService {
94
112
}
95
113
96
114
/// 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
+ );
99
123
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);
103
132
return response.items;
104
133
} 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
+ );
106
139
throw OperationFailedException ('Failed to retrieve all countries: $e ' );
107
140
}
108
141
}
@@ -112,56 +145,84 @@ class CountryService {
112
145
///
113
146
/// Uses MongoDB aggregation to efficiently get distinct country IDs
114
147
/// 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;
119
166
}
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;
125
168
}
126
169
127
170
/// Fetches a distinct list of countries that are referenced as
128
171
/// `headquarters` in sources.
129
172
///
130
173
/// Uses MongoDB aggregation to efficiently get distinct country IDs
131
174
/// 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;
137
185
}
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;
143
195
}
144
196
145
197
/// 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
+ );
148
206
try {
149
207
final distinctCountries = await _getDistinctCountriesFromAggregation (
150
208
repository: _headlineRepository,
151
209
fieldName: 'eventCountry' ,
210
+ nameFilter: nameFilter,
152
211
);
153
- _cachedEventCountries = _CacheEntry (
212
+ final cacheKey = 'eventCountry_${nameFilter ?? 'noFilter' }' ;
213
+ _cachedEventCountries[cacheKey] = _CacheEntry (
154
214
distinctCountries,
155
215
DateTime .now ().add (_cacheDuration),
156
216
);
157
217
_log.info (
158
218
'Successfully fetched and cached ${distinctCountries .length } '
159
- 'event countries.' ,
219
+ 'event countries for key: $ cacheKey .' ,
160
220
);
161
221
return distinctCountries;
162
222
} catch (e, s) {
163
223
_log.severe (
164
- 'Failed to fetch distinct event countries via aggregation.' ,
224
+ 'Failed to fetch distinct event countries via aggregation '
225
+ 'with nameFilter: $nameFilter .' ,
165
226
e,
166
227
s,
167
228
);
@@ -170,25 +231,34 @@ class CountryService {
170
231
}
171
232
172
233
/// 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
+ );
175
242
try {
176
243
final distinctCountries = await _getDistinctCountriesFromAggregation (
177
244
repository: _sourceRepository,
178
245
fieldName: 'headquarters' ,
246
+ nameFilter: nameFilter,
179
247
);
180
- _cachedHeadquarterCountries = _CacheEntry (
248
+ final cacheKey = 'headquarters_${nameFilter ?? 'noFilter' }' ;
249
+ _cachedHeadquarterCountries[cacheKey] = _CacheEntry (
181
250
distinctCountries,
182
251
DateTime .now ().add (_cacheDuration),
183
252
);
184
253
_log.info (
185
254
'Successfully fetched and cached ${distinctCountries .length } '
186
- 'headquarter countries.' ,
255
+ 'headquarter countries for key: $ cacheKey .' ,
187
256
);
188
257
return distinctCountries;
189
258
} catch (e, s) {
190
259
_log.severe (
191
- 'Failed to fetch distinct headquarter countries via aggregation.' ,
260
+ 'Failed to fetch distinct headquarter countries via aggregation '
261
+ 'with nameFilter: $nameFilter .' ,
192
262
e,
193
263
s,
194
264
);
@@ -202,29 +272,40 @@ class CountryService {
202
272
/// - [repository] : The [DataRepository] to perform the aggregation on.
203
273
/// - [fieldName] : The name of the field within the documents that contains
204
274
/// the country object (e.g., 'eventCountry', 'headquarters').
275
+ /// - [nameFilter] : An optional map containing a regex filter for the country name.
205
276
///
206
277
/// 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 >({
208
280
required DataRepository <T > repository,
209
281
required String fieldName,
282
+ Map <String , dynamic >? nameFilter,
210
283
}) 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
+ );
212
288
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 > {
222
303
'_id' : '\$ $fieldName .id' ,
223
- 'country' : {r'$first' : '\$ $fieldName ' },
304
+ 'country' : < String , Object > {r'$first' : '\$ $fieldName ' },
224
305
},
225
306
},
226
- {
227
- r'$replaceRoot' : {'newRoot' : r'$country' },
307
+ < String , Object > {
308
+ r'$replaceRoot' : < String , Object > {'newRoot' : r'$country' },
228
309
},
229
310
];
230
311
@@ -238,12 +319,13 @@ class CountryService {
238
319
239
320
_log.info (
240
321
'Successfully fetched ${distinctCountries .length } distinct countries '
241
- 'for field "$fieldName ".' ,
322
+ 'for field "$fieldName " with nameFilter: $ nameFilter .' ,
242
323
);
243
324
return distinctCountries;
244
325
} catch (e, s) {
245
326
_log.severe (
246
- 'Failed to fetch distinct countries for field "$fieldName ".' ,
327
+ 'Failed to fetch distinct countries for field "$fieldName " '
328
+ 'with nameFilter: $nameFilter .' ,
247
329
e,
248
330
s,
249
331
);
0 commit comments