Skip to content

Commit 0a9af73

Browse files
committed
Add database migration tests
Move WAL initialization to main method so that test dbs can be initialized as a single file for convenience. Correct nullability in MemorySearch data model.
1 parent c4e3e7e commit 0a9af73

File tree

12 files changed

+316
-2
lines changed

12 files changed

+316
-2
lines changed

.gitattributes

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# Auto detect text files and perform LF normalization
2+
* text=auto
3+
4+
*.sqlite binary

KnowledgeBaseServer.sln

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
1111
README.md = README.md
1212
.dockerignore = .dockerignore
1313
global.json = global.json
14+
.gitattributes = .gitattributes
1415
EndProjectSection
1516
EndProject
1617
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{2162A920-E15A-42C5-8CF0-E066D886D1B1}"
@@ -21,6 +22,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KnowledgeBaseServer", "src\
2122
EndProject
2223
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KnowledgeBaseServer.Tests", "tests\KnowledgeBaseServer.Tests\KnowledgeBaseServer.Tests.csproj", "{1BCEA64D-CF95-45B9-B5C1-47D0DDAF8CEF}"
2324
EndProject
25+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestDbGenerator", "tests\TestDbGenerator\TestDbGenerator.csproj", "{3E755914-EC27-4B64-AC40-3F86F544318F}"
26+
EndProject
2427
Global
2528
GlobalSection(SolutionConfigurationPlatforms) = preSolution
2629
Debug|Any CPU = Debug|Any CPU
@@ -29,6 +32,7 @@ Global
2932
GlobalSection(NestedProjects) = preSolution
3033
{504879A5-5047-4A35-A3B7-A15CC46B041D} = {2162A920-E15A-42C5-8CF0-E066D886D1B1}
3134
{1BCEA64D-CF95-45B9-B5C1-47D0DDAF8CEF} = {BEAF6947-664D-482B-A921-6A62C1FF6C7D}
35+
{3E755914-EC27-4B64-AC40-3F86F544318F} = {BEAF6947-664D-482B-A921-6A62C1FF6C7D}
3236
EndGlobalSection
3337
GlobalSection(ProjectConfigurationPlatforms) = postSolution
3438
{504879A5-5047-4A35-A3B7-A15CC46B041D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
@@ -39,5 +43,9 @@ Global
3943
{1BCEA64D-CF95-45B9-B5C1-47D0DDAF8CEF}.Debug|Any CPU.Build.0 = Debug|Any CPU
4044
{1BCEA64D-CF95-45B9-B5C1-47D0DDAF8CEF}.Release|Any CPU.ActiveCfg = Release|Any CPU
4145
{1BCEA64D-CF95-45B9-B5C1-47D0DDAF8CEF}.Release|Any CPU.Build.0 = Release|Any CPU
46+
{3E755914-EC27-4B64-AC40-3F86F544318F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
47+
{3E755914-EC27-4B64-AC40-3F86F544318F}.Debug|Any CPU.Build.0 = Debug|Any CPU
48+
{3E755914-EC27-4B64-AC40-3F86F544318F}.Release|Any CPU.ActiveCfg = Release|Any CPU
49+
{3E755914-EC27-4B64-AC40-3F86F544318F}.Release|Any CPU.Build.0 = Release|Any CPU
4250
EndGlobalSection
4351
EndGlobal

src/KnowledgeBaseServer/Migrator.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,9 @@ public static bool InitializeDatabase(ILoggerFactory loggerFactory, string path)
2828
Directory.CreateDirectory(directory);
2929
}
3030

31+
// Validate that we can create or connect to the database.
3132
using var connection = ConnectionString.Create(path).CreateConnection();
32-
connection.Execute("PRAGMA journal_mode=WAL;");
33+
connection.Open();
3334
return true;
3435
}
3536
catch (Exception e)

src/KnowledgeBaseServer/Program.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using System.IO;
33
using System.Text.Json;
44
using System.Threading;
5+
using Dapper;
56
using KnowledgeBaseServer;
67
using Microsoft.Extensions.DependencyInjection;
78
using Microsoft.Extensions.Hosting;
@@ -58,6 +59,12 @@
5859
return 1;
5960
}
6061

62+
// Use WAL mode for real app databases (initialized here so that it does not affect test databases).
63+
using (var connection = connectionString.CreateConnection())
64+
{
65+
_ = connection.Execute("PRAGMA journal_mode=WAL;");
66+
}
67+
6168
using var cancellationTokenSource = new CancellationTokenSource();
6269
Console.CancelKeyPress += (_, eventArgs) =>
6370
{

tests/KnowledgeBaseServer.Tests/Data/MemorySearch.cs

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
using System;
2+
using System.Collections.Generic;
3+
using System.Data;
4+
using Dapper;
25

36
namespace KnowledgeBaseServer.Tests.Data;
47

@@ -8,5 +11,26 @@ public sealed class MemorySearch
811

912
public required string MemoryContent { get; init; }
1013

11-
public required string MemoryContext { get; init; }
14+
public string? MemoryContext { get; init; }
15+
}
16+
17+
public static partial class DbConnectionExtensions
18+
{
19+
public static IReadOnlyList<MemorySearch> GetMemorySearches(
20+
this IDbConnection connection,
21+
string? where = null,
22+
object? param = null
23+
)
24+
{
25+
var sql = """
26+
select memory_node_id, memory_content, memory_context
27+
from memory_search
28+
""";
29+
if (where is not null)
30+
{
31+
sql += "\n where " + where;
32+
}
33+
34+
return connection.Query<MemorySearch>(sql, param).AsList();
35+
}
1236
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
using System;
2+
using System.Data;
3+
using System.Diagnostics.CodeAnalysis;
4+
using Microsoft.Data.Sqlite;
5+
6+
namespace KnowledgeBaseServer.Tests.DatabaseMigrationTests;
7+
8+
[SuppressMessage(
9+
"Major Code Smell",
10+
"S3881:\"IDisposable\" should be implemented correctly",
11+
Justification = "Disposal is handled by XUnit, we don't need the full pattern"
12+
)]
13+
public abstract class MigrationTest : IDisposable
14+
{
15+
// We must hold a connection open for the lifetime of the test, otherwise the in-memory database will be destroyed.
16+
private readonly IDbConnection _connection;
17+
18+
protected MigrationTest(string version)
19+
{
20+
ConnectionString = new ConnectionString($"Data Source={version};Mode=Memory;Cache=Shared;Foreign Keys=True;");
21+
_connection = ConnectionString.CreateConnection();
22+
23+
using var migrationConnection = new SqliteConnection(
24+
$"Data Source=./DatabaseMigrationTests/databases/{version}.sqlite;"
25+
);
26+
migrationConnection.Open();
27+
migrationConnection.BackupDatabase((SqliteConnection)_connection);
28+
}
29+
30+
protected ConnectionString ConnectionString { get; }
31+
32+
/// <inheritdoc />
33+
public void Dispose() => _connection.Dispose();
34+
}
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Diagnostics.CodeAnalysis;
4+
using System.Linq;
5+
using Dapper;
6+
using KnowledgeBaseServer.Tests.Data;
7+
using Microsoft.Extensions.Logging;
8+
using Shouldly;
9+
using Xunit;
10+
11+
namespace KnowledgeBaseServer.Tests.DatabaseMigrationTests;
12+
13+
// ReSharper disable once InconsistentNaming
14+
public class MigrationTests_V0_1_0 : MigrationTest
15+
{
16+
private readonly ILoggerFactory _loggerFactory = LoggerFactory.Create(b =>
17+
b.AddConsole().SetMinimumLevel(LogLevel.Trace)
18+
);
19+
20+
/// <inheritdoc />
21+
public MigrationTests_V0_1_0()
22+
: base("v0.1.0") { }
23+
24+
[Fact(DisplayName = "v0.1.0 database should migrate to latest version")]
25+
public void ShouldMigrateToLatest()
26+
{
27+
// arrange
28+
// Query data in the original v0.1.0 format.
29+
List<TopicV010> originalTopics;
30+
List<MemoryContextV010> originalMemoryContexts;
31+
List<MemoryNodeV010> originalMemoryNodes;
32+
List<MemoryEdgeV010> originalMemoryEdges;
33+
List<MemorySearchV010> originalMemorySearches;
34+
35+
using (var arrangeConnection = ConnectionString.CreateConnection())
36+
{
37+
originalTopics = arrangeConnection.Query<TopicV010>("select * from topics").AsList();
38+
originalMemoryContexts = arrangeConnection
39+
.Query<MemoryContextV010>("select * from memory_contexts")
40+
.AsList();
41+
originalMemoryNodes = arrangeConnection.Query<MemoryNodeV010>("select * from memory_nodes").AsList();
42+
originalMemoryEdges = arrangeConnection.Query<MemoryEdgeV010>("select * from memory_edges").AsList();
43+
originalMemorySearches = arrangeConnection.Query<MemorySearchV010>("select * from memory_search").AsList();
44+
}
45+
46+
// act
47+
var result = Migrator.ApplyMigrations(_loggerFactory, ConnectionString);
48+
49+
// assert
50+
result.ShouldBeTrue();
51+
52+
// Validate that the data migrated correctly.
53+
using var connection = ConnectionString.CreateConnection();
54+
55+
var migratedTopics = connection.GetTopics();
56+
foreach (var originalTopic in originalTopics)
57+
{
58+
migratedTopics
59+
.FirstOrDefault(x => x.Id == originalTopic.Id)
60+
.ShouldNotBeNull()
61+
.ShouldSatisfyAllConditions(
62+
x => x.Created.ShouldBe(originalTopic.Created),
63+
x => x.Name.ShouldBe(originalTopic.Name)
64+
);
65+
}
66+
67+
var migratedMemoryContexts = connection.GetMemoryContexts();
68+
foreach (var originalMemoryContext in originalMemoryContexts)
69+
{
70+
migratedMemoryContexts
71+
.FirstOrDefault(x => x.Id == originalMemoryContext.Id)
72+
.ShouldNotBeNull()
73+
.ShouldSatisfyAllConditions(
74+
x => x.Created.ShouldBe(originalMemoryContext.Created),
75+
x => x.Value.ShouldBe(originalMemoryContext.Value)
76+
);
77+
}
78+
79+
var migratedMemoryNodes = connection.GetMemoryNodes();
80+
foreach (var originalMemoryNode in originalMemoryNodes)
81+
{
82+
migratedMemoryNodes
83+
.FirstOrDefault(x => x.Id == originalMemoryNode.Id)
84+
.ShouldNotBeNull()
85+
.ShouldSatisfyAllConditions(
86+
x => x.Created.ShouldBe(originalMemoryNode.Created),
87+
x => x.TopicId.ShouldBe(originalMemoryNode.TopicId),
88+
x => x.ContextId.ShouldBe(originalMemoryNode.ContextId),
89+
x => x.Content.ShouldBe(originalMemoryNode.Content),
90+
x => x.Outdated.ShouldBe(originalMemoryNode.Outdated),
91+
x => x.OutdatedReason.ShouldBe(originalMemoryNode.OutdatedReason)
92+
);
93+
}
94+
95+
// Validate that the data migrated correctly for memory edges.
96+
var migratedMemoryEdges = connection.GetMemoryEdges();
97+
foreach (var originalMemoryEdge in originalMemoryEdges)
98+
{
99+
migratedMemoryEdges
100+
.FirstOrDefault(x =>
101+
x.SourceMemoryNodeId == originalMemoryEdge.SourceMemoryNodeId
102+
&& x.TargetMemoryNodeId == originalMemoryEdge.TargetMemoryNodeId
103+
)
104+
.ShouldNotBeNull()
105+
.ShouldSatisfyAllConditions(x => x.Created.ShouldBe(originalMemoryEdge.Created));
106+
}
107+
108+
// Validate that the data migrated correctly for memory searches.
109+
var migratedMemorySearches = connection.GetMemorySearches();
110+
foreach (var originalMemorySearch in originalMemorySearches)
111+
{
112+
migratedMemorySearches
113+
.FirstOrDefault(x => x.MemoryNodeId == originalMemorySearch.MemoryNodeId)
114+
.ShouldNotBeNull()
115+
.ShouldSatisfyAllConditions(
116+
x => x.MemoryContent.ShouldBe(originalMemorySearch.MemoryContent),
117+
x => x.MemoryContext.ShouldBe(originalMemorySearch.MemoryContext)
118+
);
119+
}
120+
}
121+
122+
private sealed record TopicV010(Guid Id, DateTimeOffset Created, string Name);
123+
124+
private sealed record MemoryContextV010(Guid Id, DateTimeOffset Created, string Value);
125+
126+
private sealed record MemoryNodeV010(
127+
Guid Id,
128+
DateTimeOffset Created,
129+
Guid TopicId,
130+
Guid? ContextId,
131+
string Content,
132+
DateTimeOffset? Outdated,
133+
string? OutdatedReason
134+
);
135+
136+
private sealed record MemoryEdgeV010(Guid SourceMemoryNodeId, Guid TargetMemoryNodeId, DateTimeOffset Created);
137+
138+
// For some reason Dapper can't deserialize this correctly as a record.
139+
[SuppressMessage("Minor Code Smell", "S3459:Unassigned members should be removed")]
140+
[SuppressMessage("Major Code Smell", "S1144:Unused private types or members should be removed")]
141+
private sealed class MemorySearchV010
142+
{
143+
public required Guid MemoryNodeId { get; init; }
144+
145+
public required string MemoryContent { get; init; }
146+
147+
public string? MemoryContext { get; init; }
148+
}
149+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
After releasing a new version, add a new database and tests to ensure that future migrations upgrade it correctly.
2+
3+
1. Create the db using the TestDbGenerator app. From the project root run:
4+
```bash
5+
dotnet run --project src/TestDbGenerator/TestDbGenerator.csproj tests/KnowledgeBaseServer.Tests/DatabaseMigrationTests/databases/v{version}.sqlite
6+
```
7+
2. Add a new test class in this folder that subclasses `MigrationTest`.
8+
3. Define nested records/classes that represent the tables in the database, in its form for the release that was just created.
9+
4. Add a test method that does the following
10+
- Loads all data from the test database using those records.
11+
- Migrates the db to the latest version.
12+
- Uses the helper methods and data objects from the `Data` namespace to load the post-migration data.
13+
- Asserts that the pre-migration and post-migration data are equal.
14+
15+
As new migrations are added to the project, update the migration tests as necessary to validate that the data is correct
16+
after the latest migrations have been applied.
17+
Binary file not shown.

tests/KnowledgeBaseServer.Tests/KnowledgeBaseServer.Tests.csproj

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,10 @@
2222
<ProjectReference Include="..\..\src\KnowledgeBaseServer\KnowledgeBaseServer.csproj" />
2323
</ItemGroup>
2424

25+
<ItemGroup>
26+
<None Update="DatabaseMigrationTests\databases\*.sqlite">
27+
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
28+
</None>
29+
</ItemGroup>
30+
2531
</Project>

0 commit comments

Comments
 (0)