Skip to content

Commit

Permalink
(CommunityToolkit#245) Added MongoDB Repository and tests. WIP.
Browse files Browse the repository at this point in the history
  • Loading branch information
adrianhall committed Feb 5, 2025
1 parent 16f8496 commit 6ed9c47
Show file tree
Hide file tree
Showing 17 changed files with 732 additions and 3 deletions.
14 changes: 14 additions & 0 deletions Datasync.Toolkit.sln
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{75F7
EndProject
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}"
EndProject
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}"
EndProject
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}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -154,6 +158,14 @@ Global
{A9967817-2A2C-4C6D-A133-967A6062E9B3}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A9967817-2A2C-4C6D-A133-967A6062E9B3}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A9967817-2A2C-4C6D-A133-967A6062E9B3}.Release|Any CPU.Build.0 = Release|Any CPU
{DC20ACF9-12E9-41D9-B672-CB5FD85548E9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{DC20ACF9-12E9-41D9-B672-CB5FD85548E9}.Debug|Any CPU.Build.0 = Debug|Any CPU
{DC20ACF9-12E9-41D9-B672-CB5FD85548E9}.Release|Any CPU.ActiveCfg = Release|Any CPU
{DC20ACF9-12E9-41D9-B672-CB5FD85548E9}.Release|Any CPU.Build.0 = Release|Any CPU
{4FC45D20-0BA9-484B-9040-641687659AF6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4FC45D20-0BA9-484B-9040-641687659AF6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4FC45D20-0BA9-484B-9040-641687659AF6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4FC45D20-0BA9-484B-9040-641687659AF6}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand All @@ -180,6 +192,8 @@ Global
{D3B72031-D4BD-44D3-973C-2752AB1570F6} = {84AD662A-4B9E-4E64-834D-72529FB7FCE5}
{2889E6B2-9CD1-437C-A43C-98CFAFF68B99} = {D59F1489-5D74-4F52-B78B-88037EAB2838}
{A9967817-2A2C-4C6D-A133-967A6062E9B3} = {75F709FD-8CC2-4558-A802-FE57086167C2}
{DC20ACF9-12E9-41D9-B672-CB5FD85548E9} = {84AD662A-4B9E-4E64-834D-72529FB7FCE5}
{4FC45D20-0BA9-484B-9040-641687659AF6} = {D59F1489-5D74-4F52-B78B-88037EAB2838}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {78A935E9-8F14-448A-BEDF-360FB742F14E}
Expand Down
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
<PackageVersion Include="Microsoft.OData.Core" Version="8.2.3" />
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="8.0.0" />
<PackageVersion Include="Microsoft.Spatial" Version="8.2.3" />
<PackageVersion Include="MongoDB.Driver" Version="3.1.0" />
<PackageVersion Include="NSubstitute" Version="5.3.0" />
<PackageVersion Include="NSwag.AspNetCore" Version="14.2.0" />
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.3" />
Expand Down
6 changes: 3 additions & 3 deletions infra/modules/cosmos-mongodb.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ targetScope = 'resourceGroup'

@minLength(1)
@description('The name of the test container to create')
param containerName string = 'Movies'
param collectionName string = 'movies'

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

resource collection 'Microsoft.DocumentDb/databaseAccounts/mongodbDatabases/collections@2022-05-15' = {
parent: database
name: containerName
name: collectionName
tags: tags
properties: {
resource: {
id: containerName
id: collectionName
shardKey: {
_id: 'Hash'
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Description>A repository for the server-side of the Datasync Toolkit that uses MongoDB for storage.</Description>
</PropertyGroup>

<ItemGroup>
<InternalsVisibleTo Include="CommunityToolkit.Datasync.Server.Test" />
<InternalsVisibleTo Include="CommunityToolkit.Datasync.Server.MongoDB.Test" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="MongoDB.Driver" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\CommunityToolkit.Datasync.Server.Abstractions\CommunityToolkit.Datasync.Server.Abstractions.csproj" />
</ItemGroup>
</Project>
166 changes: 166 additions & 0 deletions src/CommunityToolkit.Datasync.Server.MongoDB/MongoDBRepository.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.

using CommunityToolkit.Datasync.Server.Abstractions.Http;
using MongoDB.Bson;
using MongoDB.Driver;

namespace CommunityToolkit.Datasync.Server.MongoDB;

/// <summary>
/// A repository implementation that stored data in a LiteDB database.
/// </summary>
/// <typeparam name="TEntity">The entity type to store in the database.</typeparam>
public class MongoDBRepository<TEntity> : IRepository<TEntity> where TEntity : MongoTableData
{
/// <summary>
/// Creates a new <see cref="MongoDBRepository{TEntity}"/> using the provided MongoDB database.
/// </summary>
/// <remarks>
/// The collection name is based on the entity type.
/// </remarks>
/// <param name="database">The <see cref="IMongoDatabase"/> to use for storing entities.</param>
public MongoDBRepository(IMongoDatabase database) : this(database.GetCollection<TEntity>(typeof(TEntity).Name.ToLowerInvariant() + "s"))
{
}

/// <summary>
/// Creates a new <see cref="MongoDBRepository{TEntity}"/> using the provided database connection
/// and collection name.
/// </summary>
/// <param name="collection">The <see cref="IMongoCollection{TDocument}"/> to use for storing entities.</param>
public MongoDBRepository(IMongoCollection<TEntity> collection)
{
Collection = collection;
// TODO: Ensure that there is an index on the right properties.
}

/// <summary>
/// The collection within the LiteDb database that stores the entities.
/// </summary>
public virtual IMongoCollection<TEntity> Collection { get; }

/// <summary>
/// The mechanism by which an Id is generated when one is not provided.
/// </summary>
public Func<TEntity, string> IdGenerator { get; set; } = _ => Guid.NewGuid().ToString("N");

/// <summary>
/// The mechanism by which a new version byte array is generated.
/// </summary>
public Func<byte[]> VersionGenerator { get; set; } = () => Guid.NewGuid().ToByteArray();

/// <summary>
/// Updates the system properties for the provided entity on write.
/// </summary>
/// <param name="entity">The entity to update.</param>
protected void UpdateEntity(TEntity entity)
{
entity.UpdatedAt = DateTimeOffset.UtcNow;
entity.Version = VersionGenerator.Invoke();
}

/// <summary>
/// Checks that the provided ID is valid.
/// </summary>
/// <param name="id">The ID of the entity to check.</param>
/// <exception cref="HttpException">Thrown if the ID is not valid.</exception>
protected static void CheckIdIsValid(string id)
{
if (string.IsNullOrEmpty(id))
{
throw new HttpException(HttpStatusCodes.Status400BadRequest);
}
}

/// <summary>
/// Returns a filter definition for finding a single document.
/// </summary>
/// <param name="id">The ID of the document to find.</param>
/// <returns>The filter definition to find the document.</returns>
protected FilterDefinition<TEntity> GetFilterById(string id)
=> Builders<TEntity>.Filter.Eq(x => x.Id, id);

/// <summary>
/// Returns the document with the provided ID, or null if it doesn't exist.
/// </summary>
/// <param name="id">The ID of the document to find.</param>
/// <param name="cancellationToken">A <see cref="CancellationToken"/> to observe.</param>
/// <returns>The document, or null if not found.</returns>
protected async ValueTask<TEntity?> FindDocumentByIdAsync(string id, CancellationToken cancellationToken = default)
{
return await Collection.Find(GetFilterById(id)).FirstOrDefaultAsync(cancellationToken);
}

/// <inheritdoc/>
public virtual ValueTask<IQueryable<TEntity>> AsQueryableAsync(CancellationToken cancellationToken = default)
=> ValueTask.FromResult(Collection.AsQueryable());

/// <inheritdoc/>
public virtual async ValueTask CreateAsync(TEntity entity, CancellationToken cancellationToken = default)
{
if (string.IsNullOrEmpty(entity.Id))
{
entity.Id = IdGenerator.Invoke(entity);
}

TEntity? existingEntity = await FindDocumentByIdAsync(entity.Id, cancellationToken).ConfigureAwait(false);
if (existingEntity is not null)
{
throw new HttpException(HttpStatusCodes.Status409Conflict) { Payload = existingEntity };
}

UpdateEntity(entity);
await Collection.InsertOneAsync(entity, options: null, cancellationToken);
}

/// <inheritdoc/>
public virtual async ValueTask DeleteAsync(string id, byte[]? version = null, CancellationToken cancellationToken = default)
{
CheckIdIsValid(id);

TEntity storedEntity = await FindDocumentByIdAsync(id, cancellationToken).ConfigureAwait(false)
?? throw new HttpException(HttpStatusCodes.Status404NotFound);
if (version?.Length > 0 && !storedEntity.Version.SequenceEqual(version))
{
throw new HttpException(HttpStatusCodes.Status412PreconditionFailed) { Payload = storedEntity };
}

DeleteResult result = await Collection.DeleteOneAsync(GetFilterById(id), cancellationToken);
if (result.DeletedCount == 0)
{
throw new HttpException(HttpStatusCodes.Status404NotFound);
}
}

/// <inheritdoc/>
public virtual async ValueTask<TEntity> ReadAsync(string id, CancellationToken cancellationToken = default)
{
CheckIdIsValid(id);

return await FindDocumentByIdAsync(id, cancellationToken).ConfigureAwait(false)
?? throw new HttpException(HttpStatusCodes.Status404NotFound);
}

/// <inheritdoc/>
public virtual async ValueTask ReplaceAsync(TEntity entity, byte[]? version = null, CancellationToken cancellationToken = default)
{
CheckIdIsValid(entity.Id);

TEntity storedEntity = await FindDocumentByIdAsync(entity.Id, cancellationToken).ConfigureAwait(false)
?? throw new HttpException(HttpStatusCodes.Status404NotFound);
if (version?.Length > 0 && !storedEntity.Version.SequenceEqual(version))
{
throw new HttpException(HttpStatusCodes.Status412PreconditionFailed) { Payload = storedEntity };
}

UpdateEntity(entity);
ReplaceOptions<TEntity> options = new() { IsUpsert = false };
ReplaceOneResult result = await Collection.ReplaceOneAsync(GetFilterById(entity.Id), entity, options, cancellationToken);
if (result.IsModifiedCountAvailable && result.ModifiedCount == 0)
{
throw new HttpException(HttpStatusCodes.Status404NotFound);
}
}
}
32 changes: 32 additions & 0 deletions src/CommunityToolkit.Datasync.Server.MongoDB/MongoTableData.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.

using MongoDB.Bson.Serialization.Attributes;

namespace CommunityToolkit.Datasync.Server.MongoDB;

/// <summary>
/// An implementation of the <see cref="ITableData"/> interface for
/// handling entities in a MongoDB database.
/// </summary>
public class MongoTableData : ITableData
{
/// <inheritdoc />
[BsonId]
public string Id { get; set; } = string.Empty;

/// <inheritdoc />
public bool Deleted { get; set; } = false;

/// <inheritdoc />
public DateTimeOffset? UpdatedAt { get; set; } = DateTimeOffset.UnixEpoch;

/// <inheritdoc />
public byte[] Version { get; set; } = [];

/// <inheritdoc />
public bool Equals(ITableData? other)
=> other != null && Id == other.Id && Version.SequenceEqual(other.Version);
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\..\src\CommunityToolkit.Datasync.Server.MongoDB\CommunityToolkit.Datasync.Server.MongoDB.csproj" />
<ProjectReference Include="..\CommunityToolkit.Datasync.TestCommon\CommunityToolkit.Datasync.TestCommon.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.msbuild">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="xunit.runner.visualstudio">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.

using CommunityToolkit.Datasync.TestCommon;
using CommunityToolkit.Datasync.TestCommon.Databases;
using Microsoft.EntityFrameworkCore;
using MongoDB.Bson;
using MongoDB.Driver;
using Xunit;
using Xunit.Abstractions;

namespace CommunityToolkit.Datasync.Server.MongoDB.Test;

[ExcludeFromCodeCoverage]
public class CosmosMongoRepository_Tests(ITestOutputHelper output) : RepositoryTests<MongoDBMovie>(), IAsyncLifetime
{
#region Setup
private readonly Random random = new();
private List<MongoDBMovie> movies = [];

public async Task InitializeAsync()
{
if (!string.IsNullOrEmpty(ConnectionStrings.CosmosMongo))
{
Context = await MongoDBContext.CreateContextAsync(ConnectionStrings.CosmosMongo, output);
this.movies = await Context.Movies.Find(new BsonDocument()).ToListAsync();
}
}

public async Task DisposeAsync()
{
if (Context is not null)
{
await Context.DisposeAsync();
}
}

public MongoDBContext Context { get; set; }

protected override bool CanRunLiveTests() => !string.IsNullOrEmpty(ConnectionStrings.CosmosMongo);

protected override async Task<MongoDBMovie> GetEntityAsync(string id)
=> await Context.Movies.Find(Builders<MongoDBMovie>.Filter.Eq(x => x.Id, id)).FirstOrDefaultAsync();

protected override async Task<int> GetEntityCountAsync()
=> (int)(await Context.Movies.CountDocumentsAsync(Builders<MongoDBMovie>.Filter.Empty));

protected override Task<IRepository<MongoDBMovie>> GetPopulatedRepositoryAsync()
=> Task.FromResult<IRepository<MongoDBMovie>>(new MongoDBRepository<MongoDBMovie>(Context.Movies));

protected override Task<string> GetRandomEntityIdAsync(bool exists)
=> Task.FromResult(exists ? this.movies[this.random.Next(this.movies.Count)].Id : Guid.NewGuid().ToString());
#endregion
}
Loading

0 comments on commit 6ed9c47

Please sign in to comment.