Skip to content

Commit 6ed9c47

Browse files
author
Adrian Hall
committed
(#245) Added MongoDB Repository and tests. WIP.
1 parent 16f8496 commit 6ed9c47

File tree

17 files changed

+732
-3
lines changed

17 files changed

+732
-3
lines changed

Datasync.Toolkit.sln

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{75F7
6464
EndProject
6565
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sample.Datasync.Server", "samples\datasync-server\src\Sample.Datasync.Server\Sample.Datasync.Server.csproj", "{A9967817-2A2C-4C6D-A133-967A6062E9B3}"
6666
EndProject
67+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CommunityToolkit.Datasync.Server.MongoDB", "src\CommunityToolkit.Datasync.Server.MongoDB\CommunityToolkit.Datasync.Server.MongoDB.csproj", "{DC20ACF9-12E9-41D9-B672-CB5FD85548E9}"
68+
EndProject
69+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CommunityToolkit.Datasync.Server.MongoDB.Test", "tests\CommunityToolkit.Datasync.Server.MongoDB.Test\CommunityToolkit.Datasync.Server.MongoDB.Test.csproj", "{4FC45D20-0BA9-484B-9040-641687659AF6}"
70+
EndProject
6771
Global
6872
GlobalSection(SolutionConfigurationPlatforms) = preSolution
6973
Debug|Any CPU = Debug|Any CPU
@@ -154,6 +158,14 @@ Global
154158
{A9967817-2A2C-4C6D-A133-967A6062E9B3}.Debug|Any CPU.Build.0 = Debug|Any CPU
155159
{A9967817-2A2C-4C6D-A133-967A6062E9B3}.Release|Any CPU.ActiveCfg = Release|Any CPU
156160
{A9967817-2A2C-4C6D-A133-967A6062E9B3}.Release|Any CPU.Build.0 = Release|Any CPU
161+
{DC20ACF9-12E9-41D9-B672-CB5FD85548E9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
162+
{DC20ACF9-12E9-41D9-B672-CB5FD85548E9}.Debug|Any CPU.Build.0 = Debug|Any CPU
163+
{DC20ACF9-12E9-41D9-B672-CB5FD85548E9}.Release|Any CPU.ActiveCfg = Release|Any CPU
164+
{DC20ACF9-12E9-41D9-B672-CB5FD85548E9}.Release|Any CPU.Build.0 = Release|Any CPU
165+
{4FC45D20-0BA9-484B-9040-641687659AF6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
166+
{4FC45D20-0BA9-484B-9040-641687659AF6}.Debug|Any CPU.Build.0 = Debug|Any CPU
167+
{4FC45D20-0BA9-484B-9040-641687659AF6}.Release|Any CPU.ActiveCfg = Release|Any CPU
168+
{4FC45D20-0BA9-484B-9040-641687659AF6}.Release|Any CPU.Build.0 = Release|Any CPU
157169
EndGlobalSection
158170
GlobalSection(SolutionProperties) = preSolution
159171
HideSolutionNode = FALSE
@@ -180,6 +192,8 @@ Global
180192
{D3B72031-D4BD-44D3-973C-2752AB1570F6} = {84AD662A-4B9E-4E64-834D-72529FB7FCE5}
181193
{2889E6B2-9CD1-437C-A43C-98CFAFF68B99} = {D59F1489-5D74-4F52-B78B-88037EAB2838}
182194
{A9967817-2A2C-4C6D-A133-967A6062E9B3} = {75F709FD-8CC2-4558-A802-FE57086167C2}
195+
{DC20ACF9-12E9-41D9-B672-CB5FD85548E9} = {84AD662A-4B9E-4E64-834D-72529FB7FCE5}
196+
{4FC45D20-0BA9-484B-9040-641687659AF6} = {D59F1489-5D74-4F52-B78B-88037EAB2838}
183197
EndGlobalSection
184198
GlobalSection(ExtensibilityGlobals) = postSolution
185199
SolutionGuid = {78A935E9-8F14-448A-BEDF-360FB742F14E}

Directory.Packages.props

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
<PackageVersion Include="Microsoft.OData.Core" Version="8.2.3" />
2525
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="8.0.0" />
2626
<PackageVersion Include="Microsoft.Spatial" Version="8.2.3" />
27+
<PackageVersion Include="MongoDB.Driver" Version="3.1.0" />
2728
<PackageVersion Include="NSubstitute" Version="5.3.0" />
2829
<PackageVersion Include="NSwag.AspNetCore" Version="14.2.0" />
2930
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.3" />

infra/modules/cosmos-mongodb.bicep

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ targetScope = 'resourceGroup'
22

33
@minLength(1)
44
@description('The name of the test container to create')
5-
param containerName string = 'Movies'
5+
param collectionName string = 'movies'
66

77
@minLength(1)
88
@description('The name of the test database to create')
@@ -149,11 +149,11 @@ resource database 'Microsoft.DocumentDB/databaseAccounts/mongodbDatabases@2022-0
149149

150150
resource collection 'Microsoft.DocumentDb/databaseAccounts/mongodbDatabases/collections@2022-05-15' = {
151151
parent: database
152-
name: containerName
152+
name: collectionName
153153
tags: tags
154154
properties: {
155155
resource: {
156-
id: containerName
156+
id: collectionName
157157
shardKey: {
158158
_id: 'Hash'
159159
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
<PropertyGroup>
3+
<Description>A repository for the server-side of the Datasync Toolkit that uses MongoDB for storage.</Description>
4+
</PropertyGroup>
5+
6+
<ItemGroup>
7+
<InternalsVisibleTo Include="CommunityToolkit.Datasync.Server.Test" />
8+
<InternalsVisibleTo Include="CommunityToolkit.Datasync.Server.MongoDB.Test" />
9+
</ItemGroup>
10+
11+
<ItemGroup>
12+
<PackageReference Include="MongoDB.Driver" />
13+
</ItemGroup>
14+
15+
<ItemGroup>
16+
<ProjectReference Include="..\CommunityToolkit.Datasync.Server.Abstractions\CommunityToolkit.Datasync.Server.Abstractions.csproj" />
17+
</ItemGroup>
18+
</Project>
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for more information.
4+
5+
using CommunityToolkit.Datasync.Server.Abstractions.Http;
6+
using MongoDB.Bson;
7+
using MongoDB.Driver;
8+
9+
namespace CommunityToolkit.Datasync.Server.MongoDB;
10+
11+
/// <summary>
12+
/// A repository implementation that stored data in a LiteDB database.
13+
/// </summary>
14+
/// <typeparam name="TEntity">The entity type to store in the database.</typeparam>
15+
public class MongoDBRepository<TEntity> : IRepository<TEntity> where TEntity : MongoTableData
16+
{
17+
/// <summary>
18+
/// Creates a new <see cref="MongoDBRepository{TEntity}"/> using the provided MongoDB database.
19+
/// </summary>
20+
/// <remarks>
21+
/// The collection name is based on the entity type.
22+
/// </remarks>
23+
/// <param name="database">The <see cref="IMongoDatabase"/> to use for storing entities.</param>
24+
public MongoDBRepository(IMongoDatabase database) : this(database.GetCollection<TEntity>(typeof(TEntity).Name.ToLowerInvariant() + "s"))
25+
{
26+
}
27+
28+
/// <summary>
29+
/// Creates a new <see cref="MongoDBRepository{TEntity}"/> using the provided database connection
30+
/// and collection name.
31+
/// </summary>
32+
/// <param name="collection">The <see cref="IMongoCollection{TDocument}"/> to use for storing entities.</param>
33+
public MongoDBRepository(IMongoCollection<TEntity> collection)
34+
{
35+
Collection = collection;
36+
// TODO: Ensure that there is an index on the right properties.
37+
}
38+
39+
/// <summary>
40+
/// The collection within the LiteDb database that stores the entities.
41+
/// </summary>
42+
public virtual IMongoCollection<TEntity> Collection { get; }
43+
44+
/// <summary>
45+
/// The mechanism by which an Id is generated when one is not provided.
46+
/// </summary>
47+
public Func<TEntity, string> IdGenerator { get; set; } = _ => Guid.NewGuid().ToString("N");
48+
49+
/// <summary>
50+
/// The mechanism by which a new version byte array is generated.
51+
/// </summary>
52+
public Func<byte[]> VersionGenerator { get; set; } = () => Guid.NewGuid().ToByteArray();
53+
54+
/// <summary>
55+
/// Updates the system properties for the provided entity on write.
56+
/// </summary>
57+
/// <param name="entity">The entity to update.</param>
58+
protected void UpdateEntity(TEntity entity)
59+
{
60+
entity.UpdatedAt = DateTimeOffset.UtcNow;
61+
entity.Version = VersionGenerator.Invoke();
62+
}
63+
64+
/// <summary>
65+
/// Checks that the provided ID is valid.
66+
/// </summary>
67+
/// <param name="id">The ID of the entity to check.</param>
68+
/// <exception cref="HttpException">Thrown if the ID is not valid.</exception>
69+
protected static void CheckIdIsValid(string id)
70+
{
71+
if (string.IsNullOrEmpty(id))
72+
{
73+
throw new HttpException(HttpStatusCodes.Status400BadRequest);
74+
}
75+
}
76+
77+
/// <summary>
78+
/// Returns a filter definition for finding a single document.
79+
/// </summary>
80+
/// <param name="id">The ID of the document to find.</param>
81+
/// <returns>The filter definition to find the document.</returns>
82+
protected FilterDefinition<TEntity> GetFilterById(string id)
83+
=> Builders<TEntity>.Filter.Eq(x => x.Id, id);
84+
85+
/// <summary>
86+
/// Returns the document with the provided ID, or null if it doesn't exist.
87+
/// </summary>
88+
/// <param name="id">The ID of the document to find.</param>
89+
/// <param name="cancellationToken">A <see cref="CancellationToken"/> to observe.</param>
90+
/// <returns>The document, or null if not found.</returns>
91+
protected async ValueTask<TEntity?> FindDocumentByIdAsync(string id, CancellationToken cancellationToken = default)
92+
{
93+
return await Collection.Find(GetFilterById(id)).FirstOrDefaultAsync(cancellationToken);
94+
}
95+
96+
/// <inheritdoc/>
97+
public virtual ValueTask<IQueryable<TEntity>> AsQueryableAsync(CancellationToken cancellationToken = default)
98+
=> ValueTask.FromResult(Collection.AsQueryable());
99+
100+
/// <inheritdoc/>
101+
public virtual async ValueTask CreateAsync(TEntity entity, CancellationToken cancellationToken = default)
102+
{
103+
if (string.IsNullOrEmpty(entity.Id))
104+
{
105+
entity.Id = IdGenerator.Invoke(entity);
106+
}
107+
108+
TEntity? existingEntity = await FindDocumentByIdAsync(entity.Id, cancellationToken).ConfigureAwait(false);
109+
if (existingEntity is not null)
110+
{
111+
throw new HttpException(HttpStatusCodes.Status409Conflict) { Payload = existingEntity };
112+
}
113+
114+
UpdateEntity(entity);
115+
await Collection.InsertOneAsync(entity, options: null, cancellationToken);
116+
}
117+
118+
/// <inheritdoc/>
119+
public virtual async ValueTask DeleteAsync(string id, byte[]? version = null, CancellationToken cancellationToken = default)
120+
{
121+
CheckIdIsValid(id);
122+
123+
TEntity storedEntity = await FindDocumentByIdAsync(id, cancellationToken).ConfigureAwait(false)
124+
?? throw new HttpException(HttpStatusCodes.Status404NotFound);
125+
if (version?.Length > 0 && !storedEntity.Version.SequenceEqual(version))
126+
{
127+
throw new HttpException(HttpStatusCodes.Status412PreconditionFailed) { Payload = storedEntity };
128+
}
129+
130+
DeleteResult result = await Collection.DeleteOneAsync(GetFilterById(id), cancellationToken);
131+
if (result.DeletedCount == 0)
132+
{
133+
throw new HttpException(HttpStatusCodes.Status404NotFound);
134+
}
135+
}
136+
137+
/// <inheritdoc/>
138+
public virtual async ValueTask<TEntity> ReadAsync(string id, CancellationToken cancellationToken = default)
139+
{
140+
CheckIdIsValid(id);
141+
142+
return await FindDocumentByIdAsync(id, cancellationToken).ConfigureAwait(false)
143+
?? throw new HttpException(HttpStatusCodes.Status404NotFound);
144+
}
145+
146+
/// <inheritdoc/>
147+
public virtual async ValueTask ReplaceAsync(TEntity entity, byte[]? version = null, CancellationToken cancellationToken = default)
148+
{
149+
CheckIdIsValid(entity.Id);
150+
151+
TEntity storedEntity = await FindDocumentByIdAsync(entity.Id, cancellationToken).ConfigureAwait(false)
152+
?? throw new HttpException(HttpStatusCodes.Status404NotFound);
153+
if (version?.Length > 0 && !storedEntity.Version.SequenceEqual(version))
154+
{
155+
throw new HttpException(HttpStatusCodes.Status412PreconditionFailed) { Payload = storedEntity };
156+
}
157+
158+
UpdateEntity(entity);
159+
ReplaceOptions<TEntity> options = new() { IsUpsert = false };
160+
ReplaceOneResult result = await Collection.ReplaceOneAsync(GetFilterById(entity.Id), entity, options, cancellationToken);
161+
if (result.IsModifiedCountAvailable && result.ModifiedCount == 0)
162+
{
163+
throw new HttpException(HttpStatusCodes.Status404NotFound);
164+
}
165+
}
166+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for more information.
4+
5+
using MongoDB.Bson.Serialization.Attributes;
6+
7+
namespace CommunityToolkit.Datasync.Server.MongoDB;
8+
9+
/// <summary>
10+
/// An implementation of the <see cref="ITableData"/> interface for
11+
/// handling entities in a MongoDB database.
12+
/// </summary>
13+
public class MongoTableData : ITableData
14+
{
15+
/// <inheritdoc />
16+
[BsonId]
17+
public string Id { get; set; } = string.Empty;
18+
19+
/// <inheritdoc />
20+
public bool Deleted { get; set; } = false;
21+
22+
/// <inheritdoc />
23+
public DateTimeOffset? UpdatedAt { get; set; } = DateTimeOffset.UnixEpoch;
24+
25+
/// <inheritdoc />
26+
public byte[] Version { get; set; } = [];
27+
28+
/// <inheritdoc />
29+
public bool Equals(ITableData? other)
30+
=> other != null && Id == other.Id && Version.SequenceEqual(other.Version);
31+
}
32+
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
<ItemGroup>
3+
<ProjectReference Include="..\..\src\CommunityToolkit.Datasync.Server.MongoDB\CommunityToolkit.Datasync.Server.MongoDB.csproj" />
4+
<ProjectReference Include="..\CommunityToolkit.Datasync.TestCommon\CommunityToolkit.Datasync.TestCommon.csproj" />
5+
</ItemGroup>
6+
<ItemGroup>
7+
<PackageReference Include="coverlet.collector">
8+
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
9+
<PrivateAssets>all</PrivateAssets>
10+
</PackageReference>
11+
<PackageReference Include="coverlet.msbuild">
12+
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
13+
<PrivateAssets>all</PrivateAssets>
14+
</PackageReference>
15+
<PackageReference Include="xunit.runner.visualstudio">
16+
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
17+
<PrivateAssets>all</PrivateAssets>
18+
</PackageReference>
19+
</ItemGroup>
20+
</Project>
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for more information.
4+
5+
using CommunityToolkit.Datasync.TestCommon;
6+
using CommunityToolkit.Datasync.TestCommon.Databases;
7+
using Microsoft.EntityFrameworkCore;
8+
using MongoDB.Bson;
9+
using MongoDB.Driver;
10+
using Xunit;
11+
using Xunit.Abstractions;
12+
13+
namespace CommunityToolkit.Datasync.Server.MongoDB.Test;
14+
15+
[ExcludeFromCodeCoverage]
16+
public class CosmosMongoRepository_Tests(ITestOutputHelper output) : RepositoryTests<MongoDBMovie>(), IAsyncLifetime
17+
{
18+
#region Setup
19+
private readonly Random random = new();
20+
private List<MongoDBMovie> movies = [];
21+
22+
public async Task InitializeAsync()
23+
{
24+
if (!string.IsNullOrEmpty(ConnectionStrings.CosmosMongo))
25+
{
26+
Context = await MongoDBContext.CreateContextAsync(ConnectionStrings.CosmosMongo, output);
27+
this.movies = await Context.Movies.Find(new BsonDocument()).ToListAsync();
28+
}
29+
}
30+
31+
public async Task DisposeAsync()
32+
{
33+
if (Context is not null)
34+
{
35+
await Context.DisposeAsync();
36+
}
37+
}
38+
39+
public MongoDBContext Context { get; set; }
40+
41+
protected override bool CanRunLiveTests() => !string.IsNullOrEmpty(ConnectionStrings.CosmosMongo);
42+
43+
protected override async Task<MongoDBMovie> GetEntityAsync(string id)
44+
=> await Context.Movies.Find(Builders<MongoDBMovie>.Filter.Eq(x => x.Id, id)).FirstOrDefaultAsync();
45+
46+
protected override async Task<int> GetEntityCountAsync()
47+
=> (int)(await Context.Movies.CountDocumentsAsync(Builders<MongoDBMovie>.Filter.Empty));
48+
49+
protected override Task<IRepository<MongoDBMovie>> GetPopulatedRepositoryAsync()
50+
=> Task.FromResult<IRepository<MongoDBMovie>>(new MongoDBRepository<MongoDBMovie>(Context.Movies));
51+
52+
protected override Task<string> GetRandomEntityIdAsync(bool exists)
53+
=> Task.FromResult(exists ? this.movies[this.random.Next(this.movies.Count)].Id : Guid.NewGuid().ToString());
54+
#endregion
55+
}

0 commit comments

Comments
 (0)