From bda22641021ec05aa4dee5ead0c39c9da92f6a42 Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 22 Aug 2025 18:47:41 +0100 Subject: [PATCH 01/13] build(dependencies): add collection package - Add collection dependency version 1.19.1 to pubspec.yaml --- pubspec.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/pubspec.yaml b/pubspec.yaml index aba528b..6a05a55 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -7,6 +7,7 @@ environment: sdk: ^3.9.0 dependencies: + collection: ^1.19.1 core: git: url: https://github.com/flutter-news-app-full-source-code/core.git From ae6923337df78200b3686fa88bdd0e6248d63a83 Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 22 Aug 2025 18:48:02 +0100 Subject: [PATCH 02/13] feat(service): implement CountryQueryService for complex country data queries - Add CountryQueryService to handle advanced queries on country data - Implement caching mechanism with TTL for frequently requested queries - Support filtering by active sources, headlines, and text search - Build MongoDB aggregation pipeline based on query parameters - Implement pagination and sorting for query results - Add methods for cache cleanup and service disposal --- lib/src/services/country_query_service.dart | 310 ++++++++++++++++++++ 1 file changed, 310 insertions(+) create mode 100644 lib/src/services/country_query_service.dart diff --git a/lib/src/services/country_query_service.dart b/lib/src/services/country_query_service.dart new file mode 100644 index 0000000..0413866 --- /dev/null +++ b/lib/src/services/country_query_service.dart @@ -0,0 +1,310 @@ +import 'dart:async'; +import 'dart:collection'; // Added for SplayTreeMap +import 'dart:convert'; + +import 'package:collection/collection.dart'; +import 'package:core/core.dart'; +import 'package:data_repository/data_repository.dart'; +import 'package:logging/logging.dart'; +import 'package:mongo_dart/mongo_dart.dart'; + +/// {@template country_query_service} +/// A service responsible for executing complex queries on country data, +/// including filtering by active sources and headlines, and supporting +/// compound filters with text search. +/// +/// This service also implements robust in-memory caching with a configurable +/// Time-To-Live (TTL) to optimize performance for frequently requested queries. +/// {@endtemplate} +class CountryQueryService { + /// {@macro country_query_service} + CountryQueryService({ + required DataRepository headlineRepository, + required DataRepository sourceRepository, + required DataRepository countryRepository, + required Logger log, + Duration cacheDuration = const Duration(minutes: 15), + }) : _headlineRepository = headlineRepository, + _sourceRepository = sourceRepository, + _countryRepository = countryRepository, + _log = log, + _cacheDuration = cacheDuration { + _cleanupTimer = Timer.periodic(const Duration(minutes: 5), (_) { + _cleanupCache(); + }); + _log.info('CountryQueryService initialized with cache duration: $cacheDuration'); + } + + final DataRepository _headlineRepository; + final DataRepository _sourceRepository; + final DataRepository _countryRepository; + final Logger _log; + final Duration _cacheDuration; + + final Map data, DateTime expiry})> + _cache = {}; + Timer? _cleanupTimer; + bool _isDisposed = false; + + /// Retrieves a paginated list of countries based on the provided filters, + /// including special filters for active sources and headlines, and text search. + /// + /// This method supports compound filtering by combining `q` (text search), + /// `hasActiveSources`, `hasActiveHeadlines`, and other standard filters. + /// Results are cached to improve performance. + /// + /// - [filter]: A map containing query conditions. Special keys like + /// `hasActiveSources` and `hasActiveHeadlines` trigger aggregation logic. + /// The `q` key triggers a text search on country names. + /// - [pagination]: Optional pagination parameters. + /// - [sort]: Optional sorting options. + /// + /// Throws [OperationFailedException] for unexpected errors during query + /// execution or cache operations. + Future> getFilteredCountries({ + required Map filter, + PaginationOptions? pagination, + List? sort, + }) async { + if (_isDisposed) { + _log.warning('Attempted to query on disposed service.'); + throw const OperationFailedException('Service is disposed.'); + } + + final cacheKey = _generateCacheKey(filter, pagination, sort); + final cachedEntry = _cache[cacheKey]; + + if (cachedEntry != null && DateTime.now().isBefore(cachedEntry.expiry)) { + _log.finer('Returning cached result for key: $cacheKey'); + return cachedEntry.data; + } + + _log.info('Executing new query for countries with filter: $filter'); + try { + final pipeline = _buildAggregationPipeline(filter, pagination, sort); + final aggregationResult = await _countryRepository.aggregate( + pipeline: pipeline, + ); + + // MongoDB aggregation returns a list of maps. We need to convert these + // back into Country objects. + final List countries = aggregationResult + .map((json) => Country.fromJson(json)) + .toList(); + + // For aggregation queries, pagination and hasMore need to be handled + // manually if not directly supported by the aggregation stages. + // For simplicity, we'll assume the aggregation pipeline handles limit/skip + // and we'll determine hasMore based on if we fetched more than the limit. + final int limit = pagination?.limit ?? countries.length; + final bool hasMore = countries.length > limit; + final List paginatedCountries = + countries.take(limit).toList(); + + final response = PaginatedResponse( + items: paginatedCountries, + cursor: null, // Aggregation doesn't typically return a cursor directly + hasMore: hasMore, + ); + + _cache[cacheKey] = (data: response, expiry: DateTime.now().add(_cacheDuration)); + _log.finer('Cached new result for key: $cacheKey'); + + return response; + } on HttpException { + rethrow; // Propagate known HTTP exceptions + } catch (e, s) { + _log.severe('Error fetching filtered countries: $e', e, s); + throw OperationFailedException( + 'Failed to retrieve filtered countries: $e', + ); + } + } + + /// Builds the MongoDB aggregation pipeline based on the provided filters. + List> _buildAggregationPipeline( + Map filter, + PaginationOptions? pagination, + List? sort, + ) { + final pipeline = >[]; + final compoundMatchStages = >[]; + + // --- Stage 1: Initial Match for active status (if applicable) --- + // All countries should be active by default for these queries + compoundMatchStages.add({ + 'status': ContentStatus.active.name, + }); + + // --- Stage 2: Handle `hasActiveSources` filter --- + if (filter['hasActiveSources'] == true) { + pipeline.add({ + r'$lookup': { + 'from': 'sources', + 'localField': '_id', + 'foreignField': 'headquarters._id', + 'as': 'matchingSources', + }, + }); + pipeline.add({ + r'$match': { + 'matchingSources': {r'$ne': []}, // Ensure there's at least one source + 'matchingSources.status': ContentStatus.active.name, + }, + }); + } + + // --- Stage 3: Handle `hasActiveHeadlines` filter --- + if (filter['hasActiveHeadlines'] == true) { + pipeline.add({ + r'$lookup': { + 'from': 'headlines', + 'localField': r'_id', + 'foreignField': r'eventCountry._id', + 'as': 'matchingHeadlines', + }, + }); + pipeline.add({ + r'$match': { + 'matchingHeadlines': {r'$ne': []}, // Ensure there's at least one headline + 'matchingHeadlines.status': ContentStatus.active.name, + }, + }); + } + + // --- Stage 4: Handle `q` (text search) filter --- + final qValue = filter['q']; + if (qValue is String && qValue.isNotEmpty) { + compoundMatchStages.add({ + r'$text': {r'$search': qValue}, + }); + } + + // --- Stage 5: Handle other standard filters --- + filter.forEach((key, value) { + if (key != 'q' && key != 'hasActiveSources' && key != 'hasActiveHeadlines') { + compoundMatchStages.add({key: value}); + } + }); + + // Combine all compound match stages + if (compoundMatchStages.isNotEmpty) { + pipeline.add({r'$match': {r'$and': compoundMatchStages}}); + } + + // --- Stage 6: Project to original Country structure and ensure uniqueness --- + // After lookups and matches, we might have duplicate countries if they + // matched multiple sources/headlines. We need to group them back to unique countries. + pipeline.add({ + r'$group': { + r'_id': r'$_id', // Group by the original country ID + 'doc': {r'$first': r'$$ROOT'}, // Take the first full document + }, + }); + pipeline.add({ + r'$replaceRoot': { + 'newRoot': r'$doc', // Replace root with the original document + }, + }); + + // --- Stage 7: Sorting --- + if (sort != null && sort.isNotEmpty) { + final sortStage = {}; + for (final option in sort) { + sortStage[option.field] = option.order == SortOrder.asc ? 1 : -1; + } + pipeline.add({r'$sort': sortStage}); + } + + // --- Stage 8: Pagination (Skip and Limit) --- + if (pagination?.cursor != null) { + // For cursor-based pagination, we'd typically need a more complex + // aggregation that sorts by the cursor field and then skips. + // For simplicity, this example assumes offset-based pagination or + // that the client handles cursor logic. + _log.warning( + 'Cursor-based pagination is not fully implemented for aggregation ' + 'queries in CountryQueryService. Only limit/skip is supported.', + ); + } + if (pagination?.limit != null) { + // Fetch one more than the limit to determine 'hasMore' + pipeline.add({r'$limit': pagination!.limit! + 1}); + } + + // Project to match the Country model's JSON structure if necessary + // (e.g., if _id was used, map it back to id) + pipeline.add({ + r'$project': { + r'_id': 0, // Exclude _id + 'id': {r'$toString': r'$_id'}, // Map _id back to id + 'isoCode': r'$isoCode', + 'name': r'$name', + 'flagUrl': r'$flagUrl', + 'createdAt': r'$createdAt', + 'updatedAt': r'$updatedAt', + 'status': r'$status', + // Ensure other fields are projected if they were modified or needed + }, + }); + + return pipeline; + } + + /// Generates a unique cache key from the query parameters. + String _generateCacheKey( + Map filter, + PaginationOptions? pagination, + List? sort, + ) { + final sortedFilter = SplayTreeMap.from(filter); + final List? sortedSort; + if (sort != null) { + sortedSort = List.from(sort) + ..sort((a, b) => a.field.compareTo(b.field)); + } else { + sortedSort = null; + } + + final keyData = { + 'filter': sortedFilter, + 'pagination': { + 'cursor': pagination?.cursor, + 'limit': pagination?.limit, + }, + 'sort': sortedSort?.map((s) => '${s.field}:${s.order.name}').toList(), + }; + return json.encode(keyData); + } + + /// Cleans up expired entries from the in-memory cache. + void _cleanupCache() { + if (_isDisposed) return; + + final now = DateTime.now(); + final expiredKeys = []; + + _cache.forEach((key, value) { + if (now.isAfter(value.expiry)) { + expiredKeys.add(key); + } + }); + + if (expiredKeys.isNotEmpty) { + expiredKeys.forEach(_cache.remove); + _log.info('Cleaned up ${expiredKeys.length} expired cache entries.'); + } else { + _log.finer('Cache cleanup ran, no expired entries found.'); + } + } + + /// Disposes of resources, specifically the periodic cache cleanup timer. + void dispose() { + if (!_isDisposed) { + _isDisposed = true; + _cleanupTimer?.cancel(); + _cache.clear(); + _log.info('CountryQueryService disposed.'); + } + } +} From 55d2855cd3c43f179460d0797903884a915617f9 Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 22 Aug 2025 18:49:11 +0100 Subject: [PATCH 03/13] feat(services): add country query service - Implement CountryQueryService for efficient country-based queries - Add service initialization in AppDependencies class - Include service disposal in AppDependencies dispose method - Set default cache duration to 15 minutes --- lib/src/config/app_dependencies.dart | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/lib/src/config/app_dependencies.dart b/lib/src/config/app_dependencies.dart index d766b6e..68be8dd 100644 --- a/lib/src/config/app_dependencies.dart +++ b/lib/src/config/app_dependencies.dart @@ -13,6 +13,7 @@ import 'package:flutter_news_app_api_server_full_source_code/src/services/dashbo import 'package:flutter_news_app_api_server_full_source_code/src/services/database_seeding_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/default_user_preference_limit_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/jwt_auth_token_service.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/services/country_query_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/mongodb_rate_limit_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/mongodb_token_blacklist_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/mongodb_verification_code_storage_service.dart'; @@ -69,6 +70,7 @@ class AppDependencies { late final PermissionService permissionService; late final UserPreferenceLimitService userPreferenceLimitService; late final RateLimitService rateLimitService; + late final CountryQueryService countryQueryService; /// Initializes all application dependencies. /// @@ -238,6 +240,13 @@ class AppDependencies { connectionManager: _mongoDbConnectionManager, log: Logger('MongoDbRateLimitService'), ); + countryQueryService = CountryQueryService( + headlineRepository: headlineRepository, + sourceRepository: sourceRepository, + countryRepository: countryRepository, + log: Logger('CountryQueryService'), + cacheDuration: const Duration(minutes: 15), // Default cache duration + ); _isInitialized = true; _log.info('Application dependencies initialized successfully.'); @@ -255,6 +264,7 @@ class AppDependencies { await _mongoDbConnectionManager.close(); tokenBlacklistService.dispose(); rateLimitService.dispose(); + countryQueryService.dispose(); // Dispose the new service _isInitialized = false; _log.info('Application dependencies disposed.'); } From b659fb8a1c4db7921e5ec302b332c65a8cfb2aea Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 22 Aug 2025 18:49:21 +0100 Subject: [PATCH 04/13] feat(country): implement special filtering for country data operation - Add special filter handling for 'hasActiveSources' and 'hasActiveHeadlines' filters - Integrate CountryQueryService for filtered country queries - Maintain standard readAll behavior for other cases --- lib/src/registry/data_operation_registry.dart | 28 +++++++++++++++---- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/lib/src/registry/data_operation_registry.dart b/lib/src/registry/data_operation_registry.dart index caf2371..690eaff 100644 --- a/lib/src/registry/data_operation_registry.dart +++ b/lib/src/registry/data_operation_registry.dart @@ -1,7 +1,9 @@ import 'package:core/core.dart'; import 'package:dart_frog/dart_frog.dart'; import 'package:data_repository/data_repository.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/config/app_dependencies.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/middlewares/ownership_check_middleware.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/services/country_query_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/dashboard_summary_service.dart'; // --- Typedefs for Data Operations --- @@ -128,12 +130,26 @@ class DataOperationRegistry { sort: s, pagination: p, ), - 'country': (c, uid, f, s, p) => c.read>().readAll( - userId: uid, - filter: f, - sort: s, - pagination: p, - ), + 'country': (c, uid, f, s, p) async { + final countryQueryService = AppDependencies.instance.countryQueryService; + // Check for special filters that require aggregation + if (f != null && + (f.containsKey('hasActiveSources') || + f.containsKey('hasActiveHeadlines'))) { + return countryQueryService.getFilteredCountries( + filter: f, + pagination: p, + sort: s, + ); + } + // Fallback to standard readAll if no special filters are present + return c.read>().readAll( + userId: uid, + filter: f, + sort: s, + pagination: p, + ); + }, 'language': (c, uid, f, s, p) => c .read>() .readAll(userId: uid, filter: f, sort: s, pagination: p), From 6a221806e5c01417995c17ff838452e2f74a55da Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 22 Aug 2025 18:54:49 +0100 Subject: [PATCH 05/13] style: format misc --- analysis_options.yaml | 1 + lib/src/config/app_dependencies.dart | 4 +- lib/src/registry/data_operation_registry.dart | 4 +- lib/src/services/country_query_service.dart | 70 +++++++++---------- 4 files changed, 37 insertions(+), 42 deletions(-) diff --git a/analysis_options.yaml b/analysis_options.yaml index 12a262c..803eb63 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -10,6 +10,7 @@ analyzer: avoid_catching_errors: ignore document_ignores: ignore one_member_abstracts: ignore + cascade_invocations: ignore exclude: - build/** linter: diff --git a/lib/src/config/app_dependencies.dart b/lib/src/config/app_dependencies.dart index 68be8dd..647eb44 100644 --- a/lib/src/config/app_dependencies.dart +++ b/lib/src/config/app_dependencies.dart @@ -9,11 +9,11 @@ import 'package:flutter_news_app_api_server_full_source_code/src/config/environm import 'package:flutter_news_app_api_server_full_source_code/src/rbac/permission_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/auth_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/auth_token_service.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/services/country_query_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/dashboard_summary_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/database_seeding_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/default_user_preference_limit_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/jwt_auth_token_service.dart'; -import 'package:flutter_news_app_api_server_full_source_code/src/services/country_query_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/mongodb_rate_limit_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/mongodb_token_blacklist_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/mongodb_verification_code_storage_service.dart'; @@ -241,8 +241,6 @@ class AppDependencies { log: Logger('MongoDbRateLimitService'), ); countryQueryService = CountryQueryService( - headlineRepository: headlineRepository, - sourceRepository: sourceRepository, countryRepository: countryRepository, log: Logger('CountryQueryService'), cacheDuration: const Duration(minutes: 15), // Default cache duration diff --git a/lib/src/registry/data_operation_registry.dart b/lib/src/registry/data_operation_registry.dart index 690eaff..3c1bf3e 100644 --- a/lib/src/registry/data_operation_registry.dart +++ b/lib/src/registry/data_operation_registry.dart @@ -3,7 +3,6 @@ import 'package:dart_frog/dart_frog.dart'; import 'package:data_repository/data_repository.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/config/app_dependencies.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/middlewares/ownership_check_middleware.dart'; -import 'package:flutter_news_app_api_server_full_source_code/src/services/country_query_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/dashboard_summary_service.dart'; // --- Typedefs for Data Operations --- @@ -131,7 +130,8 @@ class DataOperationRegistry { pagination: p, ), 'country': (c, uid, f, s, p) async { - final countryQueryService = AppDependencies.instance.countryQueryService; + final countryQueryService = + AppDependencies.instance.countryQueryService; // Check for special filters that require aggregation if (f != null && (f.containsKey('hasActiveSources') || diff --git a/lib/src/services/country_query_service.dart b/lib/src/services/country_query_service.dart index 0413866..df599f5 100644 --- a/lib/src/services/country_query_service.dart +++ b/lib/src/services/country_query_service.dart @@ -2,11 +2,9 @@ import 'dart:async'; import 'dart:collection'; // Added for SplayTreeMap import 'dart:convert'; -import 'package:collection/collection.dart'; import 'package:core/core.dart'; import 'package:data_repository/data_repository.dart'; import 'package:logging/logging.dart'; -import 'package:mongo_dart/mongo_dart.dart'; /// {@template country_query_service} /// A service responsible for executing complex queries on country data, @@ -19,30 +17,25 @@ import 'package:mongo_dart/mongo_dart.dart'; class CountryQueryService { /// {@macro country_query_service} CountryQueryService({ - required DataRepository headlineRepository, - required DataRepository sourceRepository, required DataRepository countryRepository, required Logger log, Duration cacheDuration = const Duration(minutes: 15), - }) : _headlineRepository = headlineRepository, - _sourceRepository = sourceRepository, - _countryRepository = countryRepository, - _log = log, - _cacheDuration = cacheDuration { + }) : _countryRepository = countryRepository, + _log = log, + _cacheDuration = cacheDuration { _cleanupTimer = Timer.periodic(const Duration(minutes: 5), (_) { _cleanupCache(); }); - _log.info('CountryQueryService initialized with cache duration: $cacheDuration'); + _log.info( + 'CountryQueryService initialized with cache duration: $cacheDuration', + ); } - - final DataRepository _headlineRepository; - final DataRepository _sourceRepository; final DataRepository _countryRepository; final Logger _log; final Duration _cacheDuration; final Map data, DateTime expiry})> - _cache = {}; + _cache = {}; Timer? _cleanupTimer; bool _isDisposed = false; @@ -88,18 +81,15 @@ class CountryQueryService { // MongoDB aggregation returns a list of maps. We need to convert these // back into Country objects. - final List countries = aggregationResult - .map((json) => Country.fromJson(json)) - .toList(); + final countries = aggregationResult.map(Country.fromJson).toList(); // For aggregation queries, pagination and hasMore need to be handled // manually if not directly supported by the aggregation stages. // For simplicity, we'll assume the aggregation pipeline handles limit/skip // and we'll determine hasMore based on if we fetched more than the limit. - final int limit = pagination?.limit ?? countries.length; - final bool hasMore = countries.length > limit; - final List paginatedCountries = - countries.take(limit).toList(); + final limit = pagination?.limit ?? countries.length; + final hasMore = countries.length > limit; + final paginatedCountries = countries.take(limit).toList(); final response = PaginatedResponse( items: paginatedCountries, @@ -107,7 +97,10 @@ class CountryQueryService { hasMore: hasMore, ); - _cache[cacheKey] = (data: response, expiry: DateTime.now().add(_cacheDuration)); + _cache[cacheKey] = ( + data: response, + expiry: DateTime.now().add(_cacheDuration), + ); _log.finer('Cached new result for key: $cacheKey'); return response; @@ -132,9 +125,7 @@ class CountryQueryService { // --- Stage 1: Initial Match for active status (if applicable) --- // All countries should be active by default for these queries - compoundMatchStages.add({ - 'status': ContentStatus.active.name, - }); + compoundMatchStages.add({'status': ContentStatus.active.name}); // --- Stage 2: Handle `hasActiveSources` filter --- if (filter['hasActiveSources'] == true) { @@ -148,7 +139,9 @@ class CountryQueryService { }); pipeline.add({ r'$match': { - 'matchingSources': {r'$ne': []}, // Ensure there's at least one source + 'matchingSources': { + r'$ne': [], + }, // Ensure there's at least one source 'matchingSources.status': ContentStatus.active.name, }, }); @@ -159,14 +152,16 @@ class CountryQueryService { pipeline.add({ r'$lookup': { 'from': 'headlines', - 'localField': r'_id', - 'foreignField': r'eventCountry._id', + 'localField': '_id', + 'foreignField': 'eventCountry._id', 'as': 'matchingHeadlines', }, }); pipeline.add({ r'$match': { - 'matchingHeadlines': {r'$ne': []}, // Ensure there's at least one headline + 'matchingHeadlines': { + r'$ne': [], + }, // Ensure there's at least one headline 'matchingHeadlines.status': ContentStatus.active.name, }, }); @@ -182,14 +177,18 @@ class CountryQueryService { // --- Stage 5: Handle other standard filters --- filter.forEach((key, value) { - if (key != 'q' && key != 'hasActiveSources' && key != 'hasActiveHeadlines') { + if (key != 'q' && + key != 'hasActiveSources' && + key != 'hasActiveHeadlines') { compoundMatchStages.add({key: value}); } }); // Combine all compound match stages if (compoundMatchStages.isNotEmpty) { - pipeline.add({r'$match': {r'$and': compoundMatchStages}}); + pipeline.add({ + r'$match': {r'$and': compoundMatchStages}, + }); } // --- Stage 6: Project to original Country structure and ensure uniqueness --- @@ -197,7 +196,7 @@ class CountryQueryService { // matched multiple sources/headlines. We need to group them back to unique countries. pipeline.add({ r'$group': { - r'_id': r'$_id', // Group by the original country ID + '_id': r'$_id', // Group by the original country ID 'doc': {r'$first': r'$$ROOT'}, // Take the first full document }, }); @@ -236,7 +235,7 @@ class CountryQueryService { // (e.g., if _id was used, map it back to id) pipeline.add({ r'$project': { - r'_id': 0, // Exclude _id + '_id': 0, // Exclude _id 'id': {r'$toString': r'$_id'}, // Map _id back to id 'isoCode': r'$isoCode', 'name': r'$name', @@ -268,10 +267,7 @@ class CountryQueryService { final keyData = { 'filter': sortedFilter, - 'pagination': { - 'cursor': pagination?.cursor, - 'limit': pagination?.limit, - }, + 'pagination': {'cursor': pagination?.cursor, 'limit': pagination?.limit}, 'sort': sortedSort?.map((s) => '${s.field}:${s.order.name}').toList(), }; return json.encode(keyData); From 697cf267dea7ae25a96dc320b1c605d4dcca64ec Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 22 Aug 2025 19:49:47 +0100 Subject: [PATCH 06/13] perf(country): optimize database queries and caching - Refactor country queries to use sub-pipelines for filtering active sources and headlines, improving efficiency. - Enhance caching by generating canonical cache keys that represent query parameters in a consistent and unique manner. - These changes reduce database load and improve the performance and scalability of country-related operations. --- lib/src/services/country_query_service.dart | 51 +++++++++++++++------ 1 file changed, 38 insertions(+), 13 deletions(-) diff --git a/lib/src/services/country_query_service.dart b/lib/src/services/country_query_service.dart index df599f5..6bee0c9 100644 --- a/lib/src/services/country_query_service.dart +++ b/lib/src/services/country_query_service.dart @@ -129,40 +129,52 @@ class CountryQueryService { // --- Stage 2: Handle `hasActiveSources` filter --- if (filter['hasActiveSources'] == true) { + // This lookup uses a sub-pipeline to filter for active sources *before* + // joining, which is more efficient than a post-join match. pipeline.add({ r'$lookup': { 'from': 'sources', - 'localField': '_id', - 'foreignField': 'headquarters._id', + 'let': {'countryId': r'$_id'}, + 'pipeline': [ + { + r'$match': { + r'$expr': {r'$eq': [r'$headquarters._id', r'$$countryId']}, + 'status': ContentStatus.active.name, + } + } + ], 'as': 'matchingSources', }, }); pipeline.add({ r'$match': { - 'matchingSources': { - r'$ne': [], - }, // Ensure there's at least one source - 'matchingSources.status': ContentStatus.active.name, + 'matchingSources': {r'$ne': []}, }, }); } // --- Stage 3: Handle `hasActiveHeadlines` filter --- if (filter['hasActiveHeadlines'] == true) { + // This lookup uses a sub-pipeline to filter for active headlines *before* + // joining, which is more efficient than a post-join match. pipeline.add({ r'$lookup': { 'from': 'headlines', - 'localField': '_id', - 'foreignField': 'eventCountry._id', + 'let': {'countryId': r'$_id'}, + 'pipeline': [ + { + r'$match': { + r'$expr': {r'$eq': [r'$eventCountry._id', r'$$countryId']}, + 'status': ContentStatus.active.name, + } + } + ], 'as': 'matchingHeadlines', }, }); pipeline.add({ r'$match': { - 'matchingHeadlines': { - r'$ne': [], - }, // Ensure there's at least one headline - 'matchingHeadlines.status': ContentStatus.active.name, + 'matchingHeadlines': {r'$ne': []}, }, }); } @@ -250,7 +262,20 @@ class CountryQueryService { return pipeline; } - /// Generates a unique cache key from the query parameters. + /// Generates a unique, canonical cache key from the query parameters. + /// + /// A canonical key is essential for effective caching. If two different + /// sets of parameters represent the same logical query (e.g., filters in a + /// different order), they must produce the exact same cache key. + /// + /// This implementation achieves this by: + /// 1. Using a [SplayTreeMap] for the `filter` map, which automatically + /// sorts the filters by their keys. + /// 2. Sorting the `sort` options by their field names. + /// 3. Combining these sorted structures with pagination details into a + /// standard map. + /// 4. Encoding the final map into a JSON string, which serves as the + /// reliable and unique cache key. String _generateCacheKey( Map filter, PaginationOptions? pagination, From f3242e67b2248ff7ab032c84d1003ea5b07dbebc Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 22 Aug 2025 19:51:33 +0100 Subject: [PATCH 07/13] feat(config): add country service cache duration configuration - Introduce new environment configuration parameter for country service cache duration - Default cache duration is set to 15 minutes if not specified or parsing fails - New method `countryServiceCacheDuration` added to `EnvironmentConfig` class --- lib/src/config/environment_config.dart | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/lib/src/config/environment_config.dart b/lib/src/config/environment_config.dart index f78e1f6..71babc7 100644 --- a/lib/src/config/environment_config.dart +++ b/lib/src/config/environment_config.dart @@ -172,4 +172,14 @@ abstract final class EnvironmentConfig { int.tryParse(_env['RATE_LIMIT_DATA_API_WINDOW_MINUTES'] ?? '60') ?? 60; return Duration(minutes: minutes); } + + /// Retrieves the cache duration in minutes for the CountryQueryService. + /// + /// The value is read from the `COUNTRY_SERVICE_CACHE_MINUTES` environment + /// variable. Defaults to 15 minutes if not set or if parsing fails. + static Duration get countryServiceCacheDuration { + final minutes = + int.tryParse(_env['COUNTRY_SERVICE_CACHE_MINUTES'] ?? '15') ?? 15; + return Duration(minutes: minutes); + } } From 507acf3246d67634ec653550d48cc15061ed9dd7 Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 22 Aug 2025 19:51:50 +0100 Subject: [PATCH 08/13] docs(env): add configuration for country service cache duration - Add new optional environment variable COUNTRY_SERVICE_CACHE_MINUTES - Specifies the cache duration for the CountryQueryService in minutes - Defaults to 15 minutes if not specified --- .env.example | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.env.example b/.env.example index 171a774..a1a8dd8 100644 --- a/.env.example +++ b/.env.example @@ -52,3 +52,7 @@ # OPTIONAL: Window for the /data API endpoints, in minutes. # RATE_LIMIT_DATA_API_WINDOW_MINUTES=60 + +# OPTIONAL: The cache duration for the CountryQueryService, in minutes. +# Defaults to 15 minutes if not specified. +# COUNTRY_SERVICE_CACHE_MINUTES=15 From 450f9a43906b1d3636954e3004a44a42659f1860 Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 22 Aug 2025 19:54:00 +0100 Subject: [PATCH 09/13] feat(config): make country service cache duration configurable - Replace hardcoded cache duration with environment configuration value - Allow cache duration to be set via EnvironmentConfig.countryServiceCacheDuration --- lib/src/config/app_dependencies.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/config/app_dependencies.dart b/lib/src/config/app_dependencies.dart index 647eb44..2667118 100644 --- a/lib/src/config/app_dependencies.dart +++ b/lib/src/config/app_dependencies.dart @@ -243,7 +243,7 @@ class AppDependencies { countryQueryService = CountryQueryService( countryRepository: countryRepository, log: Logger('CountryQueryService'), - cacheDuration: const Duration(minutes: 15), // Default cache duration + cacheDuration: EnvironmentConfig.countryServiceCacheDuration, ); _isInitialized = true; From 0a978b17909df4c113fe46f69578a23e3094c9ab Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 22 Aug 2025 19:57:30 +0100 Subject: [PATCH 10/13] refactor(country): inject CountryQueryService via provider - Remove direct dependency on AppDependencies for CountryQueryService - Inject CountryQueryService using Dart Frog provider - Update country data operation to use injected service for complex queries - Add CountryQueryService to middleware providers --- lib/src/registry/data_operation_registry.dart | 20 +++++++++---------- routes/_middleware.dart | 6 ++++++ 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/lib/src/registry/data_operation_registry.dart b/lib/src/registry/data_operation_registry.dart index 3c1bf3e..8739722 100644 --- a/lib/src/registry/data_operation_registry.dart +++ b/lib/src/registry/data_operation_registry.dart @@ -1,8 +1,8 @@ import 'package:core/core.dart'; import 'package:dart_frog/dart_frog.dart'; import 'package:data_repository/data_repository.dart'; -import 'package:flutter_news_app_api_server_full_source_code/src/config/app_dependencies.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/middlewares/ownership_check_middleware.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/services/country_query_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/dashboard_summary_service.dart'; // --- Typedefs for Data Operations --- @@ -130,25 +130,25 @@ class DataOperationRegistry { pagination: p, ), 'country': (c, uid, f, s, p) async { - final countryQueryService = - AppDependencies.instance.countryQueryService; - // Check for special filters that require aggregation + // Check for special filters that require aggregation. if (f != null && (f.containsKey('hasActiveSources') || f.containsKey('hasActiveHeadlines'))) { + // Use the injected CountryQueryService for complex queries. + final countryQueryService = c.read(); return countryQueryService.getFilteredCountries( filter: f, pagination: p, sort: s, ); } - // Fallback to standard readAll if no special filters are present + // Fallback to standard readAll if no special filters are present. return c.read>().readAll( - userId: uid, - filter: f, - sort: s, - pagination: p, - ); + userId: uid, + filter: f, + sort: s, + pagination: p, + ); }, 'language': (c, uid, f, s, p) => c .read>() diff --git a/routes/_middleware.dart b/routes/_middleware.dart index 38b0e44..b08b7a3 100644 --- a/routes/_middleware.dart +++ b/routes/_middleware.dart @@ -10,6 +10,7 @@ import 'package:flutter_news_app_api_server_full_source_code/src/registry/data_o import 'package:flutter_news_app_api_server_full_source_code/src/registry/model_registry.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/auth_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/auth_token_service.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/services/country_query_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/dashboard_summary_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/rate_limit_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/token_blacklist_service.dart'; @@ -151,6 +152,11 @@ Handler middleware(Handler handler) { ), ) .use(provider((_) => deps.rateLimitService)) + .use( + provider( + (_) => deps.countryQueryService, + ), + ) .call(context); }; }); From 39eea59cd98ef51cd07e236373d4741f1dd600e4 Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 22 Aug 2025 19:58:00 +0100 Subject: [PATCH 11/13] style: misc --- lib/src/registry/data_operation_registry.dart | 10 +++++----- lib/src/services/country_query_service.dart | 16 ++++++++++------ routes/_middleware.dart | 4 +--- 3 files changed, 16 insertions(+), 14 deletions(-) diff --git a/lib/src/registry/data_operation_registry.dart b/lib/src/registry/data_operation_registry.dart index 8739722..9f45053 100644 --- a/lib/src/registry/data_operation_registry.dart +++ b/lib/src/registry/data_operation_registry.dart @@ -144,11 +144,11 @@ class DataOperationRegistry { } // Fallback to standard readAll if no special filters are present. return c.read>().readAll( - userId: uid, - filter: f, - sort: s, - pagination: p, - ); + userId: uid, + filter: f, + sort: s, + pagination: p, + ); }, 'language': (c, uid, f, s, p) => c .read>() diff --git a/lib/src/services/country_query_service.dart b/lib/src/services/country_query_service.dart index 6bee0c9..1ec9748 100644 --- a/lib/src/services/country_query_service.dart +++ b/lib/src/services/country_query_service.dart @@ -138,10 +138,12 @@ class CountryQueryService { 'pipeline': [ { r'$match': { - r'$expr': {r'$eq': [r'$headquarters._id', r'$$countryId']}, + r'$expr': { + r'$eq': [r'$headquarters._id', r'$$countryId'], + }, 'status': ContentStatus.active.name, - } - } + }, + }, ], 'as': 'matchingSources', }, @@ -164,10 +166,12 @@ class CountryQueryService { 'pipeline': [ { r'$match': { - r'$expr': {r'$eq': [r'$eventCountry._id', r'$$countryId']}, + r'$expr': { + r'$eq': [r'$eventCountry._id', r'$$countryId'], + }, 'status': ContentStatus.active.name, - } - } + }, + }, ], 'as': 'matchingHeadlines', }, diff --git a/routes/_middleware.dart b/routes/_middleware.dart index b08b7a3..51acc21 100644 --- a/routes/_middleware.dart +++ b/routes/_middleware.dart @@ -153,9 +153,7 @@ Handler middleware(Handler handler) { ) .use(provider((_) => deps.rateLimitService)) .use( - provider( - (_) => deps.countryQueryService, - ), + provider((_) => deps.countryQueryService), ) .call(context); }; From 644ac68860a3239123c1dce286b402b4f1992dce Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 22 Aug 2025 20:06:04 +0100 Subject: [PATCH 12/13] refactor(country): optimize country query pipeline and cache management - Reorganize match stages for better efficiency - Implement text search and standard filter handling in initial match stage - Simplify and reorder aggregation pipeline stages - Improve cache cleanup mechanism --- lib/src/services/country_query_service.dart | 65 +++++++++++---------- 1 file changed, 33 insertions(+), 32 deletions(-) diff --git a/lib/src/services/country_query_service.dart b/lib/src/services/country_query_service.dart index 1ec9748..cc9ccc8 100644 --- a/lib/src/services/country_query_service.dart +++ b/lib/src/services/country_query_service.dart @@ -20,9 +20,9 @@ class CountryQueryService { required DataRepository countryRepository, required Logger log, Duration cacheDuration = const Duration(minutes: 15), - }) : _countryRepository = countryRepository, - _log = log, - _cacheDuration = cacheDuration { + }) : _countryRepository = countryRepository, + _log = log, + _cacheDuration = cacheDuration { _cleanupTimer = Timer.periodic(const Duration(minutes: 5), (_) { _cleanupCache(); }); @@ -35,7 +35,7 @@ class CountryQueryService { final Duration _cacheDuration; final Map data, DateTime expiry})> - _cache = {}; + _cache = {}; Timer? _cleanupTimer; bool _isDisposed = false; @@ -123,10 +123,34 @@ class CountryQueryService { final pipeline = >[]; final compoundMatchStages = >[]; - // --- Stage 1: Initial Match for active status (if applicable) --- + // --- Stage 1: Initial Match for active status, text search, and other filters --- // All countries should be active by default for these queries compoundMatchStages.add({'status': ContentStatus.active.name}); + // Handle `q` (text search) filter + final qValue = filter['q']; + if (qValue is String && qValue.isNotEmpty) { + compoundMatchStages.add({ + r'$text': {r'$search': qValue}, + }); + } + + // Handle other standard filters + filter.forEach((key, value) { + if (key != 'q' && + key != 'hasActiveSources' && + key != 'hasActiveHeadlines') { + compoundMatchStages.add({key: value}); + } + }); + + // Combine all compound match stages and add to pipeline first for efficiency + if (compoundMatchStages.isNotEmpty) { + pipeline.add({ + r'$match': {r'$and': compoundMatchStages}, + }); + } + // --- Stage 2: Handle `hasActiveSources` filter --- if (filter['hasActiveSources'] == true) { // This lookup uses a sub-pipeline to filter for active sources *before* @@ -183,31 +207,7 @@ class CountryQueryService { }); } - // --- Stage 4: Handle `q` (text search) filter --- - final qValue = filter['q']; - if (qValue is String && qValue.isNotEmpty) { - compoundMatchStages.add({ - r'$text': {r'$search': qValue}, - }); - } - - // --- Stage 5: Handle other standard filters --- - filter.forEach((key, value) { - if (key != 'q' && - key != 'hasActiveSources' && - key != 'hasActiveHeadlines') { - compoundMatchStages.add({key: value}); - } - }); - - // Combine all compound match stages - if (compoundMatchStages.isNotEmpty) { - pipeline.add({ - r'$match': {r'$and': compoundMatchStages}, - }); - } - - // --- Stage 6: Project to original Country structure and ensure uniqueness --- + // --- Stage 4: Project to original Country structure and ensure uniqueness --- // After lookups and matches, we might have duplicate countries if they // matched multiple sources/headlines. We need to group them back to unique countries. pipeline.add({ @@ -222,7 +222,7 @@ class CountryQueryService { }, }); - // --- Stage 7: Sorting --- + // --- Stage 5: Sorting --- if (sort != null && sort.isNotEmpty) { final sortStage = {}; for (final option in sort) { @@ -231,7 +231,7 @@ class CountryQueryService { pipeline.add({r'$sort': sortStage}); } - // --- Stage 8: Pagination (Skip and Limit) --- + // --- Stage 6: Pagination (Skip and Limit) --- if (pagination?.cursor != null) { // For cursor-based pagination, we'd typically need a more complex // aggregation that sorts by the cursor field and then skips. @@ -247,6 +247,7 @@ class CountryQueryService { pipeline.add({r'$limit': pagination!.limit! + 1}); } + // --- Stage 7: Final Projection --- // Project to match the Country model's JSON structure if necessary // (e.g., if _id was used, map it back to id) pipeline.add({ From 04d30c65a3222868d5f5abfc4fdf741be8b696c5 Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 22 Aug 2025 20:12:03 +0100 Subject: [PATCH 13/13] refactor(country): optimize aggregation pipeline in getActiveCountries - Remove unnecessary grouping stage for uniqueness - Adjust stage numbering accordingly - Update comments for clarity - Add explanation for final projection stage --- lib/src/services/country_query_service.dart | 28 ++++++--------------- 1 file changed, 7 insertions(+), 21 deletions(-) diff --git a/lib/src/services/country_query_service.dart b/lib/src/services/country_query_service.dart index cc9ccc8..1af081c 100644 --- a/lib/src/services/country_query_service.dart +++ b/lib/src/services/country_query_service.dart @@ -207,22 +207,7 @@ class CountryQueryService { }); } - // --- Stage 4: Project to original Country structure and ensure uniqueness --- - // After lookups and matches, we might have duplicate countries if they - // matched multiple sources/headlines. We need to group them back to unique countries. - pipeline.add({ - r'$group': { - '_id': r'$_id', // Group by the original country ID - 'doc': {r'$first': r'$$ROOT'}, // Take the first full document - }, - }); - pipeline.add({ - r'$replaceRoot': { - 'newRoot': r'$doc', // Replace root with the original document - }, - }); - - // --- Stage 5: Sorting --- + // --- Stage 4: Sorting --- if (sort != null && sort.isNotEmpty) { final sortStage = {}; for (final option in sort) { @@ -231,7 +216,7 @@ class CountryQueryService { pipeline.add({r'$sort': sortStage}); } - // --- Stage 6: Pagination (Skip and Limit) --- + // --- Stage 5: Pagination (Skip and Limit) --- if (pagination?.cursor != null) { // For cursor-based pagination, we'd typically need a more complex // aggregation that sorts by the cursor field and then skips. @@ -247,9 +232,11 @@ class CountryQueryService { pipeline.add({r'$limit': pagination!.limit! + 1}); } - // --- Stage 7: Final Projection --- - // Project to match the Country model's JSON structure if necessary - // (e.g., if _id was used, map it back to id) + // --- Stage 6: Final Projection --- + // Project to match the Country model's JSON structure. + // The $lookup stages add fields ('matchingSources', 'matchingHeadlines') + // that are not part of the Country model, so we project only the fields + // that are part of the model to ensure clean deserialization. pipeline.add({ r'$project': { '_id': 0, // Exclude _id @@ -260,7 +247,6 @@ class CountryQueryService { 'createdAt': r'$createdAt', 'updatedAt': r'$updatedAt', 'status': r'$status', - // Ensure other fields are projected if they were modified or needed }, });