Skip to content

Commit 3999310

Browse files
authored
Merge pull request #62 from flutter-news-app-full-source-code/feat/generic-db-migration-system
Feat/generic db migration system
2 parents 3f17b07 + 249841f commit 3999310

File tree

8 files changed

+386
-5
lines changed

8 files changed

+386
-5
lines changed

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,14 @@ Click on any category to explore.
9696
- **Secure & Flexible:** Manages all sensitive keys, API credentials, and environment-specific settings through a `.env` file, keeping your secrets out of the codebase.
9797
> **Your Advantage:** Deploy your application across different environments (local, staging, production) safely and efficiently.
9898
99+
---
100+
101+
### 🔄 Automated Database Migrations
102+
- **PR-Driven Schema Evolution:** Implements a robust, versioned database migration system that automatically applies schema changes to MongoDB on application startup.
103+
- **Idempotent & Generic:** Each migration is idempotent and designed to handle schema evolution for *any* model in the database, ensuring data consistency across deployments.
104+
- **Traceable Versioning:** Migrations are identified by their Pull Request merge date (`prDate` in `YYYYMMDDHHMMSS` format) for chronological execution, a concise `prSummary`, and a direct `prId` (GitHub PR ID) for full traceability.
105+
> **Your Advantage:** Say goodbye to manual database updates! Your application gracefully handles schema changes, providing a professional and reliable mechanism for evolving your data models without breaking existing data, with clear links to the originating code changes.
106+
99107
</details>
100108

101109
## 🔑 Licensing

lib/src/config/app_dependencies.dart

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@ import 'package:data_repository/data_repository.dart';
66
import 'package:email_repository/email_repository.dart';
77
import 'package:email_sendgrid/email_sendgrid.dart';
88
import 'package:flutter_news_app_api_server_full_source_code/src/config/environment_config.dart';
9+
import 'package:flutter_news_app_api_server_full_source_code/src/database/migrations/all_migrations.dart';
910
import 'package:flutter_news_app_api_server_full_source_code/src/rbac/permission_service.dart';
1011
import 'package:flutter_news_app_api_server_full_source_code/src/services/auth_service.dart';
1112
import 'package:flutter_news_app_api_server_full_source_code/src/services/auth_token_service.dart';
1213
import 'package:flutter_news_app_api_server_full_source_code/src/services/country_query_service.dart';
1314
import 'package:flutter_news_app_api_server_full_source_code/src/services/dashboard_summary_service.dart';
15+
import 'package:flutter_news_app_api_server_full_source_code/src/services/database_migration_service.dart';
1416
import 'package:flutter_news_app_api_server_full_source_code/src/services/database_seeding_service.dart';
1517
import 'package:flutter_news_app_api_server_full_source_code/src/services/default_user_preference_limit_service.dart';
1618
import 'package:flutter_news_app_api_server_full_source_code/src/services/jwt_auth_token_service.dart';
@@ -63,6 +65,7 @@ class AppDependencies {
6365
late final EmailRepository emailRepository;
6466

6567
// Services
68+
late final DatabaseMigrationService databaseMigrationService;
6669
late final TokenBlacklistService tokenBlacklistService;
6770
late final AuthTokenService authTokenService;
6871
late final VerificationCodeStorageService verificationCodeStorageService;
@@ -92,15 +95,26 @@ class AppDependencies {
9295
await _mongoDbConnectionManager.init(EnvironmentConfig.databaseUrl);
9396
_log.info('MongoDB connection established.');
9497

95-
// 2. Seed Database
98+
// 2. Initialize and Run Database Migrations
99+
databaseMigrationService = DatabaseMigrationService(
100+
db: _mongoDbConnectionManager.db,
101+
log: Logger('DatabaseMigrationService'),
102+
migrations:
103+
allMigrations, // From lib/src/database/migrations/all_migrations.dart
104+
);
105+
await databaseMigrationService.init();
106+
_log.info('Database migrations applied.');
107+
108+
// 3. Seed Database
109+
// This runs AFTER migrations to ensure the schema is up-to-date.
96110
final seedingService = DatabaseSeedingService(
97111
db: _mongoDbConnectionManager.db,
98112
log: Logger('DatabaseSeedingService'),
99113
);
100114
await seedingService.seedInitialData();
101115
_log.info('Database seeding complete.');
102116

103-
// 3. Initialize Data Clients (MongoDB implementation)
117+
// 4. Initialize Data Clients (MongoDB implementation)
104118
final headlineClient = DataMongodb<Headline>(
105119
connectionManager: _mongoDbConnectionManager,
106120
modelName: 'headlines',

lib/src/database/migration.dart

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import 'package:logging/logging.dart';
2+
import 'package:mongo_dart/mongo_dart.dart';
3+
4+
/// {@template migration}
5+
/// An abstract base class for defining database migration scripts.
6+
///
7+
/// Each concrete migration must extend this class and implement the [up] and
8+
/// [down] methods. Migrations are identified by a unique [prDate] string
9+
/// (following the `YYYYMMDDHHMMSS` format) and a [prSummary].
10+
///
11+
/// Implementations of [up] and [down] must be **idempotent**, meaning they
12+
/// can be safely run multiple times without causing errors or incorrect data.
13+
/// This is crucial for robust database schema evolution.
14+
/// {@endtemplate}
15+
abstract class Migration {
16+
/// {@macro migration}
17+
const Migration({
18+
required this.prDate,
19+
required this.prSummary,
20+
required this.prId,
21+
});
22+
23+
/// The merge date and time of the Pull Request that introduced this
24+
/// migration, in `YYYYMMDDHHMMSS` format (e.g., '20250924083500').
25+
///
26+
/// This serves as the unique, chronological identifier for the migration,
27+
/// ensuring that migrations are applied in the correct order.
28+
final String prDate;
29+
30+
/// A concise summary of the changes introduced by the Pull Request that
31+
/// this migration addresses.
32+
///
33+
/// This provides a human-readable description of the migration's purpose.
34+
final String prSummary;
35+
36+
/// The unique identifier of the GitHub Pull Request that introduced the
37+
/// schema changes addressed by this migration (e.g., '50').
38+
///
39+
/// This provides direct traceability, linking the database migration to the
40+
/// specific code changes on GitHub.
41+
final String prId;
42+
43+
/// Applies the migration, performing necessary schema changes or data
44+
/// transformations.
45+
///
46+
/// This method is executed when the migration is run. It receives the
47+
/// MongoDB [db] instance and a [Logger] for logging progress and errors.
48+
///
49+
/// Implementations **must** be idempotent.
50+
Future<void> up(Db db, Logger log);
51+
52+
/// Reverts the migration, undoing the changes made by the [up] method.
53+
///
54+
/// This method is executed when a migration needs to be rolled back. It
55+
/// receives the MongoDB [db] instance and a [Logger].
56+
///
57+
/// Implementations **must** be idempotent. While optional for simple
58+
/// forward-only migrations, providing a `down` method is a best practice
59+
/// for professional systems to enable rollback capabilities.
60+
Future<void> down(Db db, Logger log);
61+
}
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
// ignore_for_file: comment_references
2+
3+
import 'package:core/core.dart';
4+
import 'package:flutter_news_app_api_server_full_source_code/src/database/migration.dart';
5+
import 'package:logging/logging.dart';
6+
import 'package:mongo_dart/mongo_dart.dart';
7+
8+
/// {@template refactor_ad_config_to_role_based}
9+
/// A comprehensive migration to refactor the `adConfig` structure within
10+
/// `RemoteConfig` documents to a new role-based `visibleTo` map approach.
11+
///
12+
/// This migration addresses significant changes introduced by a PR (see
13+
/// [gitHubPullRequest]) that aimed to enhance flexibility and maintainability
14+
/// of ad configurations. It transforms old, role-specific ad frequency and
15+
/// placement fields into new `visibleTo` maps for `FeedAdConfiguration`,
16+
/// `ArticleAdConfiguration`, and `InterstitialAdConfiguration`.
17+
///
18+
/// The migration ensures that existing `RemoteConfig` documents are updated
19+
/// to conform to the latest model structure, preventing deserialization errors
20+
/// and enabling granular control over ad display for different user roles.
21+
/// {@endtemplate}
22+
class RefactorAdConfigToRoleBased extends Migration {
23+
/// {@macro refactor_ad_config_to_role_based}
24+
RefactorAdConfigToRoleBased()
25+
: super(
26+
prDate: '20250924084800',
27+
prSummary: 'Refactor adConfig to use role-based visibleTo maps',
28+
prId: '50',
29+
);
30+
31+
@override
32+
Future<void> up(Db db, Logger log) async {
33+
log.info(
34+
'Applying migration PR#$prId (Date: $prDate): $prSummary.',
35+
);
36+
37+
final remoteConfigCollection = db.collection('remote_configs');
38+
39+
// Define default FeedAdFrequencyConfig for roles
40+
const defaultGuestFeedAdFrequency = FeedAdFrequencyConfig(
41+
adFrequency: 5,
42+
adPlacementInterval: 3,
43+
);
44+
const defaultStandardUserFeedAdFrequency = FeedAdFrequencyConfig(
45+
adFrequency: 10,
46+
adPlacementInterval: 5,
47+
);
48+
// Define default InterstitialAdFrequencyConfig for roles
49+
const defaultGuestInterstitialAdFrequency = InterstitialAdFrequencyConfig(
50+
transitionsBeforeShowingInterstitialAds: 5,
51+
);
52+
const defaultStandardUserInterstitialAdFrequency =
53+
InterstitialAdFrequencyConfig(
54+
transitionsBeforeShowingInterstitialAds: 10,
55+
);
56+
57+
// Define default ArticleAdSlot visibility for roles
58+
final defaultArticleAdSlots = {
59+
InArticleAdSlotType.aboveArticleContinueReadingButton.name: true,
60+
InArticleAdSlotType.belowArticleContinueReadingButton.name: true,
61+
};
62+
63+
final result = await remoteConfigCollection.updateMany(
64+
// Find documents that still have the old structure (e.g., old frequency fields)
65+
where.exists(
66+
'adConfig.feedAdConfiguration.frequencyConfig.guestAdFrequency',
67+
),
68+
ModifierBuilder()
69+
// --- FeedAdConfiguration Transformation ---
70+
// Remove old frequencyConfig fields
71+
..unset('adConfig.feedAdConfiguration.frequencyConfig.guestAdFrequency')
72+
..unset(
73+
'adConfig.feedAdConfiguration.frequencyConfig.guestAdPlacementInterval',
74+
)
75+
..unset(
76+
'adConfig.feedAdConfiguration.frequencyConfig.authenticatedAdFrequency',
77+
)
78+
..unset(
79+
'adConfig.feedAdConfiguration.frequencyConfig.authenticatedAdPlacementInterval',
80+
)
81+
..unset(
82+
'adConfig.feedAdConfiguration.frequencyConfig.premiumAdFrequency',
83+
)
84+
..unset(
85+
'adConfig.feedAdConfiguration.frequencyConfig.premiumAdPlacementInterval',
86+
)
87+
// Set the new visibleTo map for FeedAdConfiguration
88+
..set(
89+
'adConfig.feedAdConfiguration.visibleTo',
90+
{
91+
AppUserRole.guestUser.name: defaultGuestFeedAdFrequency.toJson(),
92+
AppUserRole.standardUser.name: defaultStandardUserFeedAdFrequency
93+
.toJson(),
94+
},
95+
)
96+
// --- ArticleAdConfiguration Transformation ---
97+
// Remove old inArticleAdSlotConfigurations list
98+
..unset('adConfig.articleAdConfiguration.inArticleAdSlotConfigurations')
99+
// Set the new visibleTo map for ArticleAdConfiguration
100+
..set(
101+
'adConfig.articleAdConfiguration.visibleTo',
102+
{
103+
AppUserRole.guestUser.name: defaultArticleAdSlots,
104+
AppUserRole.standardUser.name: defaultArticleAdSlots,
105+
},
106+
)
107+
// --- InterstitialAdConfiguration Transformation ---
108+
// Remove old feedInterstitialAdFrequencyConfig fields
109+
..unset(
110+
'adConfig.interstitialAdConfiguration.feedInterstitialAdFrequencyConfig.guestTransitionsBeforeShowingInterstitialAds',
111+
)
112+
..unset(
113+
'adConfig.interstitialAdConfiguration.feedInterstitialAdFrequencyConfig.standardUserTransitionsBeforeShowingInterstitialAds',
114+
)
115+
..unset(
116+
'adConfig.interstitialAdConfiguration.feedInterstitialAdFrequencyConfig.premiumUserTransitionsBeforeShowingInterstitialAds',
117+
)
118+
// Set the new visibleTo map for InterstitialAdConfiguration
119+
..set(
120+
'adConfig.interstitialAdConfiguration.visibleTo',
121+
{
122+
AppUserRole.guestUser.name: defaultGuestInterstitialAdFrequency
123+
.toJson(),
124+
AppUserRole.standardUser.name:
125+
defaultStandardUserInterstitialAdFrequency.toJson(),
126+
},
127+
),
128+
);
129+
130+
log.info(
131+
'Updated ${result.nModified} remote_config documents '
132+
'to new role-based adConfig structure.',
133+
);
134+
}
135+
136+
@override
137+
Future<void> down(Db db, Logger log) async {
138+
log.warning(
139+
'Reverting migration: Revert adConfig to old structure '
140+
'(not recommended for production).',
141+
);
142+
// This down migration is complex and primarily for development/testing rollback.
143+
// Reverting to the old structure would require re-introducing the old fields
144+
// and potentially losing data if the new structure was used.
145+
// For simplicity in this example, we'll just unset the new fields.
146+
final result = await db
147+
.collection('remote_configs')
148+
.updateMany(
149+
where.exists('adConfig.feedAdConfiguration.visibleTo'),
150+
ModifierBuilder()
151+
..unset('adConfig.feedAdConfiguration.visibleTo')
152+
..unset('adConfig.articleAdConfiguration.visibleTo')
153+
..unset('adConfig.interstitialAdConfiguration.visibleTo'),
154+
);
155+
log.warning(
156+
'Reverted ${result.nModified} remote_config documents '
157+
'by unsetting new adConfig fields.',
158+
);
159+
}
160+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import 'package:flutter_news_app_api_server_full_source_code/src/database/migration.dart';
2+
import 'package:flutter_news_app_api_server_full_source_code/src/database/migrations/20250924084800__refactor_ad_config_to_role_based.dart';
3+
import 'package:flutter_news_app_api_server_full_source_code/src/services/database_migration_service.dart'
4+
show DatabaseMigrationService;
5+
6+
/// A central list of all database migrations to be applied.
7+
///
8+
/// New migration classes should be added to this list. The
9+
/// [DatabaseMigrationService] will automatically sort and apply them based on
10+
/// their `prDate` property.
11+
final List<Migration> allMigrations = [
12+
RefactorAdConfigToRoleBased(),
13+
];

lib/src/registry/model_registry.dart

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -459,4 +459,6 @@ typedef ModelRegistryMap = Map<String, ModelConfig<dynamic>>;
459459
/// This makes the `modelRegistry` map available for injection into the
460460
/// request context via `context.read<ModelRegistryMap>()`. It's primarily
461461
/// used by the middleware in `routes/api/v1/data/_middleware.dart`.
462-
final Middleware modelRegistryProvider = provider<ModelRegistryMap>((_) => modelRegistry);
462+
final Middleware modelRegistryProvider = provider<ModelRegistryMap>(
463+
(_) => modelRegistry,
464+
);

0 commit comments

Comments
 (0)