Skip to content

Commit 547f1b3

Browse files
committed
Record migration dependencies in history
- store declared dependencies as diagnostic migration metadata - preserve dependency order and optional-scope markers - prevent dependent migrations from bypassing migration plans - optimize dependency descriptors with readonly value semantics - update migration tests and documentation
1 parent b11243e commit 547f1b3

6 files changed

Lines changed: 170 additions & 7 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ Migrations are applied in the order in which they are registered within each sco
143143

144144
Dependencies may reference required scopes or optional scopes. An optional-scope dependency is ignored only when the scope is neither registered in the plan nor present in migration history. This prevents migrations from ignoring schema changes left by a module that was previously installed but is not currently loaded.
145145

146-
Applied migrations are recorded in the `__nuext_migrations` table with a global history identifier, scope-local sequence, application time, and optional version information. This preserves the complete migration chronology while allowing the application core and independently installed modules to maintain separate migration chains.
146+
Applied migrations are recorded in the `__nuext_migrations` table with a global history identifier, scope-local sequence, declared dependencies, application time, and optional version information. This preserves the complete migration chronology while allowing the application core and independently installed modules to maintain separate migration chains.
147147

148148
When a connection factory is used, the complete migration plan is executed within a synchronized transaction and is rolled back if any migration fails. A plan can also run within an existing `SQLiteDbContext`, allowing migrations and application operations to share the same caller-owned transaction.
149149

‎src/System/Data/SQLite/SQLiteDbMigrationDependency.cs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
/// <summary>
44
/// Represents a dependency on a migration from another migration scope.
55
/// </summary>
6-
public sealed record SQLiteDbMigrationDependency
6+
public readonly record struct SQLiteDbMigrationDependency
77
{
88
/// <summary>
99
/// Initializes a new instance of the <see cref="SQLiteDbMigrationDependency"/> class.

‎src/System/Data/SQLite/SQLiteDbMigrationExtensions.cs‎

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,16 +54,21 @@ public static void MigrateScope(this IEnumerable<SQLiteDbMigration> migrations,
5454
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
5555
/// <exception cref="ArgumentNullException"><paramref name="migrations"/> or <paramref name="createConnection"/> is <see langword="null"/>.</exception>
5656
/// <exception cref="ArgumentException"><paramref name="scope"/> is <see langword="null"/>, empty, or consists only of white-space characters.</exception>
57+
/// <exception cref="DbMigrationException">A migration declares dependencies and must be executed through <see cref="SQLiteDbMigrationPlan"/>, or the migration configuration or applied history is invalid.</exception>
5758
public static void MigrateScope(this IEnumerable<SQLiteDbMigration> migrations, Func<SQLiteDbConnection> createConnection, string scope, string? version, CancellationToken cancellationToken = default)
5859
{
5960
ArgumentNullException.ThrowIfNull(migrations);
6061
ArgumentNullException.ThrowIfNull(createConnection);
6162
ArgumentException.ThrowIfNullOrWhiteSpace(scope);
6263
cancellationToken.ThrowIfCancellationRequested();
6364

65+
SQLiteDbMigration[] migrationArray = [.. migrations];
66+
67+
ValidateIndependentMigrations(migrationArray);
68+
6469
var executor = new SQLiteMigrationExecutor(createConnection);
6570
var history = new SQLiteMigrationHistory(scope);
66-
var migrator = new DbMigrator<SQLiteDbMigrationContext>(executor, history, migrations, version);
71+
var migrator = new DbMigrator<SQLiteDbMigrationContext>(executor, history, migrationArray, version);
6772
migrator.Migrate(cancellationToken);
6873
}
6974

@@ -128,16 +133,33 @@ public static void MigrateScope(this IEnumerable<SQLiteDbMigration> migrations,
128133
/// <remarks>
129134
/// The caller owns the database context and is responsible for committing or rolling back its transaction.
130135
/// </remarks>
136+
/// <exception cref="DbMigrationException">A migration declares dependencies and must be executed through <see cref="SQLiteDbMigrationPlan"/>, or the migration configuration or applied history is invalid.</exception>
131137
public static void MigrateScope(this IEnumerable<SQLiteDbMigration> migrations, SQLiteDbContext dbContext, string scope, string? version, CancellationToken cancellationToken = default)
132138
{
133139
ArgumentNullException.ThrowIfNull(migrations);
134140
ArgumentNullException.ThrowIfNull(dbContext);
135141
ArgumentException.ThrowIfNullOrWhiteSpace(scope);
136142
cancellationToken.ThrowIfCancellationRequested();
137143

144+
SQLiteDbMigration[] migrationArray = [.. migrations];
145+
146+
ValidateIndependentMigrations(migrationArray);
147+
138148
var executor = new SQLiteDbContextMigrationExecutor(dbContext);
139149
var history = new SQLiteMigrationHistory(scope);
140-
var migrator = new DbMigrator<SQLiteDbMigrationContext>(executor, history, migrations, version);
150+
var migrator = new DbMigrator<SQLiteDbMigrationContext>(executor, history, migrationArray, version);
141151
migrator.Migrate(cancellationToken);
142152
}
153+
154+
private static void ValidateIndependentMigrations(SQLiteDbMigration[] migrations)
155+
{
156+
for (var i = 0; i < migrations.Length; i++)
157+
{
158+
var migration = migrations[i];
159+
if (migration is { Dependencies.Count: > 0 })
160+
{
161+
throw new DbMigrationException($"Migration '{migration.Id}' declares dependencies and must be executed through '{nameof(SQLiteDbMigrationPlan)}'.");
162+
}
163+
}
164+
}
143165
}

‎src/System/Data/SQLite/SQLiteDbMigrationPlan.cs‎

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -178,12 +178,38 @@ private void ApplyMigration(SQLiteDbMigrationContext context, int scopeIndex, Sp
178178

179179
var appliedMigration = new AppliedDbMigration(migrationIndex + 1, migration.Id, DateTimeOffset.UtcNow, scope.Version);
180180

181-
scope.History.Add(context, appliedMigration);
181+
scope.History.Add(context, appliedMigration, FormatDependencies(migration.Dependencies));
182182
history.Add(scope.Name, appliedMigration);
183183

184184
nextMigrationIndexes[scopeIndex]++;
185185
}
186186

187+
private static string? FormatDependencies(IReadOnlyList<SQLiteDbMigrationDependency> dependencies)
188+
{
189+
if (dependencies.Count == 0)
190+
{
191+
return null;
192+
}
193+
194+
var result = new ValueStringBuilder(stackalloc char[SQLiteDbConnection.StackallocCharBufferSizeLimit]);
195+
string delimiter = string.Empty;
196+
for (var i = 0; i < dependencies.Count; i++)
197+
{
198+
result.Append(delimiter);
199+
delimiter = "; ";
200+
201+
var dependency = dependencies[i];
202+
result.Append(dependency.Scope);
203+
result.Append('/');
204+
result.Append(dependency.MigrationId);
205+
if (dependency.OptionalScope)
206+
{
207+
result.Append('?');
208+
}
209+
}
210+
return result.ToString();
211+
}
212+
187213
private bool AreDependenciesApplied(SQLiteDbMigration migration, SQLiteMigrationHistorySnapshot history)
188214
{
189215
var dependencies = migration.Dependencies;
@@ -348,14 +374,17 @@ private static void ValidateDependencies(string scope, SQLiteDbMigration migrati
348374

349375
for (var i = 0; i < dependencies.Count; i++)
350376
{
351-
var dependency = dependencies[i] ?? throw new DbMigrationException($"Dependency at index {i} of migration '{scope}/{migration.Id}' is null.");
377+
var dependency = dependencies[i];
352378

379+
if (string.IsNullOrWhiteSpace(dependency.Scope) || string.IsNullOrWhiteSpace(dependency.MigrationId))
380+
{
381+
throw new DbMigrationException($"Dependency at index {i} of migration '{scope}/{migration.Id}' is invalid.");
382+
}
353383
if (StringComparer.Ordinal.Equals(scope, dependency.Scope) &&
354384
StringComparer.Ordinal.Equals(migration.Id, dependency.MigrationId))
355385
{
356386
throw new DbMigrationException($"Migration '{scope}/{migration.Id}' cannot depend on itself.");
357387
}
358-
359388
if (!dependencyKeys.Add((dependency.Scope, dependency.MigrationId)))
360389
{
361390
throw new DbMigrationException($"Duplicate dependency '{dependency.Scope}/{dependency.MigrationId}' in migration '{scope}/{migration.Id}'.");

‎src/System/Data/SQLite/SQLiteMigrationHistory.cs‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ internal sealed class SQLiteMigrationHistory : IDbMigrationHistory<SQLiteDbMigra
1212
scope TEXT NOT NULL CHECK(scope <> ''),
1313
sequence INTEGER NOT NULL CHECK(sequence > 0),
1414
migration_id TEXT NOT NULL CHECK(migration_id <> ''),
15+
dependencies TEXT,
1516
version TEXT,
1617
applied_at INTEGER NOT NULL,
1718
UNIQUE(scope, sequence),
@@ -31,13 +32,15 @@ migration_id TEXT NOT NULL CHECK(migration_id <> ''),
3132
scope,
3233
sequence,
3334
migration_id,
35+
dependencies,
3436
version,
3537
applied_at
3638
)
3739
VALUES (
3840
@scope,
3941
@sequence,
4042
@migration_id,
43+
@dependencies,
4144
@version,
4245
@applied_at
4346
);
@@ -74,13 +77,15 @@ internal SQLiteMigrationHistory(string scope)
7477
_scope = scope;
7578
}
7679

80+
/// <inheritdoc/>
7781
public void EnsureCreated(SQLiteDbMigrationContext context)
7882
{
7983
ArgumentNullException.ThrowIfNull(context);
8084

8185
context.Connection.ExecuteNonQuery(CreateMigrationHistoryTableCommand, cancellationToken: context.CancellationToken);
8286
}
8387

88+
/// <inheritdoc/>
8489
public IReadOnlyList<AppliedDbMigration> GetAppliedMigrations(SQLiteDbMigrationContext context)
8590
{
8691
ArgumentNullException.ThrowIfNull(context);
@@ -111,7 +116,13 @@ static void Read(SQLiteDataReader reader, SQLiteMigrationHistorySnapshot history
111116
}
112117
}
113118

119+
/// <inheritdoc/>
114120
public void Add(SQLiteDbMigrationContext context, AppliedDbMigration migration)
121+
{
122+
Add(context, migration, null);
123+
}
124+
125+
internal void Add(SQLiteDbMigrationContext context, AppliedDbMigration migration, string? dependencies)
115126
{
116127
ArgumentNullException.ThrowIfNull(context);
117128
ArgumentNullException.ThrowIfNull(migration);
@@ -120,6 +131,7 @@ public void Add(SQLiteDbMigrationContext context, AppliedDbMigration migration)
120131
DbType.String.CreateInputParam("@scope", _scope),
121132
DbType.Int32.CreateInputParam("@sequence", migration.Sequence),
122133
DbType.String.CreateInputParam("@migration_id", migration.Id),
134+
DbType.String.CreateInputParam("@dependencies", dependencies),
123135
DbType.String.CreateInputParam("@version", migration.Version),
124136
DbType.Int64.CreateInputParam("@applied_at", migration.AppliedAt.ToUnixTimeMilliseconds()));
125137
}

‎tests/SQLiteDbMigrationTests.cs‎

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1657,6 +1657,92 @@ public void MigrationPlan_ScopeVersions_RecordsConfiguredVersions()
16571657
Assert.AreEqual("1.5.0", GetAppliedMigrationVersion(connection, 1, "module.video"));
16581658
}
16591659

1660+
[TestMethod]
1661+
public void MigrationPlan_Dependencies_RecordsDiagnosticInformation()
1662+
{
1663+
var coreMigration = new DelegateMigration("0001_Core", static _ => { });
1664+
1665+
var videoMigration = new DelegateMigration(
1666+
"0001_Video",
1667+
static _ => { },
1668+
[
1669+
new SQLiteDbMigrationDependency("core", "0001_Core"),
1670+
new SQLiteDbMigrationDependency("module.storage", "0002_Storage", optionalScope: true)
1671+
]);
1672+
1673+
var plan = new SQLiteDbMigrationPlan()
1674+
.AddScope("core", [coreMigration])
1675+
.AddScope("module.video", [videoMigration]);
1676+
1677+
plan.Migrate(CreateConnection, CancellationToken.None);
1678+
1679+
using var connection = CreateConnection();
1680+
Assert.IsNull(GetAppliedMigrationDependencies(connection, 1, "core"));
1681+
Assert.AreEqual("core/0001_Core; module.storage/0002_Storage?", GetAppliedMigrationDependencies(connection, 1, "module.video"));
1682+
}
1683+
1684+
[TestMethod]
1685+
public void Migrate_RecordsNullDependencies()
1686+
{
1687+
var migration = new DelegateMigration("0001_CreateSchema", static _ => { });
1688+
SQLiteDbMigration[] migrations =
1689+
[
1690+
migration
1691+
];
1692+
1693+
migrations.Migrate(CreateConnection, CancellationToken.None);
1694+
1695+
using var connection = CreateConnection();
1696+
Assert.IsNull(GetAppliedMigrationDependencies(connection, 1));
1697+
}
1698+
1699+
[TestMethod]
1700+
public void MigrationPlan_Dependencies_PreservesConfiguredOrder()
1701+
{
1702+
var migration1 = new DelegateMigration("0001_Core", static _ => { });
1703+
var migration2 = new DelegateMigration("0001_Storage", static _ => { });
1704+
var migration3 = new DelegateMigration(
1705+
"0001_Video",
1706+
static _ => { },
1707+
[
1708+
new SQLiteDbMigrationDependency("module.storage", "0001_Storage"),
1709+
new SQLiteDbMigrationDependency("core", "0001_Core")
1710+
]);
1711+
1712+
var plan = new SQLiteDbMigrationPlan()
1713+
.AddScope("core", [migration1])
1714+
.AddScope("module.storage", [migration2])
1715+
.AddScope("module.video", [migration3]);
1716+
1717+
plan.Migrate(CreateConnection, CancellationToken.None);
1718+
1719+
using var connection = CreateConnection();
1720+
Assert.AreEqual("module.storage/0001_Storage; core/0001_Core", GetAppliedMigrationDependencies(connection, 1, "module.video"));
1721+
}
1722+
1723+
[TestMethod]
1724+
public void MigrateScope_MigrationWithDependencies_ThrowsDbMigrationException()
1725+
{
1726+
var migration = new DelegateMigration(
1727+
"0001_Video",
1728+
static _ => { },
1729+
[
1730+
new SQLiteDbMigrationDependency("core", "0001_Core")
1731+
]);
1732+
SQLiteDbMigration[] migrations =
1733+
[
1734+
migration
1735+
];
1736+
1737+
Assert.ThrowsExactly<DbMigrationException>(() =>
1738+
migrations.MigrateScope(CreateConnection, "module.video", CancellationToken.None));
1739+
1740+
Assert.AreEqual(0, migration.ExecutionCount);
1741+
1742+
using var connection = CreateConnection();
1743+
Assert.IsFalse(connection.TableExists("__nuext_migrations", CancellationToken.None));
1744+
}
1745+
16601746
private SQLiteDbConnection CreateConnection()
16611747
{
16621748
return new SQLiteDbConnection(_connectionString);
@@ -1712,6 +1798,7 @@ CREATE TABLE __nuext_migrations (
17121798
scope TEXT NOT NULL CHECK(scope <> ''),
17131799
sequence INTEGER NOT NULL CHECK(sequence > 0),
17141800
migration_id TEXT NOT NULL CHECK(migration_id <> ''),
1801+
dependencies TEXT,
17151802
version TEXT,
17161803
applied_at INTEGER NOT NULL,
17171804
UNIQUE(scope, sequence),
@@ -1748,6 +1835,19 @@ FROM __nuext_migrations
17481835
return Convert.ToString(result)!;
17491836
}
17501837

1838+
private static string? GetAppliedMigrationDependencies(SQLiteDbConnection connection, int sequence, string scope = DefaultScope)
1839+
{
1840+
object? result = connection.ExecuteScalar(
1841+
"""
1842+
SELECT dependencies
1843+
FROM __nuext_migrations
1844+
WHERE scope = @scope AND sequence = @sequence;
1845+
""",
1846+
DbType.String.CreateInputParam("@scope", scope),
1847+
DbType.Int32.CreateInputParam("@sequence", sequence));
1848+
return result is null or DBNull ? null : Convert.ToString(result);
1849+
}
1850+
17511851
private static string? GetAppliedMigrationVersion(SQLiteDbConnection connection, int sequence, string scope = DefaultScope)
17521852
{
17531853
object? result = connection.ExecuteScalar(

0 commit comments

Comments
 (0)