Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions Backend.Tests/Mocks/MongoDbContextMock.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
using System;
using System.Threading.Tasks;
using BackendFramework.Interfaces;
using MongoDB.Driver;

namespace Backend.Tests.Mocks;

public class MongoDbContextMock : IMongoDbContext
{
public IMongoDatabase Db => throw new NotSupportedException();
public Task<IMongoTransaction> BeginTransaction()
{
return Task.FromResult<IMongoTransaction>(new MongoTransactionMock());
}

private sealed class MongoTransactionMock : IMongoTransaction
{
public IClientSessionHandle Session => null!;

public Task CommitTransactionAsync()
{
return Task.CompletedTask;
}

public Task AbortTransactionAsync()
{
return Task.CompletedTask;
}

public void Dispose()
{
}
}
}
17 changes: 17 additions & 0 deletions Backend.Tests/Mocks/WordRepositoryMock.cs
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,23 @@ public Task<Word> Add(Word word)
return Task.FromResult(word);
}

public Task<Word> CreateAndDeleteFrontier(Word newWord, string oldWordId)
{
newWord.Id = Guid.NewGuid().ToString();
_words.Add(newWord.Clone());
_frontier.Add(newWord.Clone());
_frontier.RemoveAll(w => w.ProjectId == newWord.ProjectId && w.Id == oldWordId);
return Task.FromResult(newWord.Clone());
}

public Task<Word> AddAndDeleteFrontier(Word deletedWord, string wordId)
{
deletedWord.Id = Guid.NewGuid().ToString();
_words.Add(deletedWord.Clone());
_frontier.RemoveAll(w => w.ProjectId == deletedWord.ProjectId && w.Id == wordId);
return Task.FromResult(deletedWord.Clone());
}

public Task<int> CountFrontierWordsWithDomain(string projectId, string domainId)
{
var count = _frontier.Count(
Expand Down
42 changes: 30 additions & 12 deletions Backend/Contexts/MongoDbContext.cs
Original file line number Diff line number Diff line change
@@ -1,28 +1,46 @@
using System;
using System.Diagnostics.CodeAnalysis;
using System.Threading.Tasks;
using BackendFramework.Interfaces;
using Microsoft.Extensions.Options;
using MongoDB.Driver;

namespace BackendFramework.Contexts
namespace BackendFramework.Contexts;

public class MongoDbContext : IMongoDbContext
{
[ExcludeFromCodeCoverage]
public class MongoDbContext : IMongoDbContext
public IMongoDatabase Db { get; }

public MongoDbContext(IOptions<Startup.Settings> options)
{
var client = new MongoClient(options.Value.ConnectionString);
Db = client.GetDatabase(options.Value.CombineDatabase);
}

public async Task<IMongoTransaction> BeginTransaction()
{
private MongoClient _mongoClient { get; }
var session = await Db.Client.StartSessionAsync();
session.StartTransaction();
return new MongoTransactionWrapper(session);
}

public IMongoDatabase Db { get; }
private class MongoTransactionWrapper(IClientSessionHandle session) : IMongoTransaction
{
private readonly IClientSessionHandle _session = session;

public IClientSessionHandle Session => _session;

public Task CommitTransactionAsync()
{
return _session.CommitTransactionAsync();
}

public MongoDbContext(IOptions<Startup.Settings> options)
public Task AbortTransactionAsync()
{
_mongoClient = new MongoClient(options.Value.ConnectionString);
Db = _mongoClient.GetDatabase(options.Value.CombineDatabase);
return _session.AbortTransactionAsync();
}

public void Dispose()
{
_mongoClient.Dispose();
GC.SuppressFinalize(this);
_session.Dispose();
}
}
}
18 changes: 13 additions & 5 deletions Backend/Interfaces/IMongoDbContext.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
using System;
using System.Threading.Tasks;
using MongoDB.Driver;

namespace BackendFramework.Interfaces
namespace BackendFramework.Interfaces;

public interface IMongoDbContext
{
IMongoDatabase Db { get; }
Task<IMongoTransaction> BeginTransaction();
}

public interface IMongoTransaction : IDisposable
{
public interface IMongoDbContext : IDisposable
{
IMongoDatabase Db { get; }
}
IClientSessionHandle Session { get; }
Task CommitTransactionAsync();
Task AbortTransactionAsync();
}
2 changes: 2 additions & 0 deletions Backend/Interfaces/IWordRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ public interface IWordRepository
Task<Word> Create(Word word);
Task<List<Word>> Create(List<Word> words);
Task<Word> Add(Word word);
Task<Word> CreateAndDeleteFrontier(Word newWord, string oldWordId);
Task<Word> AddAndDeleteFrontier(Word deletedWord, string wordId);
Task<bool> DeleteAllWords(string projectId);
Task<bool> DeleteAllFrontierWords(string projectId);
Task<bool> HasWords(string projectId);
Expand Down
106 changes: 99 additions & 7 deletions Backend/Repositories/WordRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ namespace BackendFramework.Repositories
[ExcludeFromCodeCoverage]
public class WordRepository(IMongoDbContext dbContext) : IWordRepository
{
private readonly IMongoDbContext _dbContext = dbContext;
private readonly IMongoCollection<Word> _frontier = dbContext.Db.GetCollection<Word>("FrontierCollection");
private readonly IMongoCollection<Word> _words = dbContext.Db.GetCollection<Word>("WordsCollection");

Expand Down Expand Up @@ -100,9 +101,19 @@ public async Task<bool> DeleteAllWords(string projectId)
var filterDef = new FilterDefinitionBuilder<Word>();
var filter = filterDef.Eq(x => x.ProjectId, projectId);

var deleted = await _words.DeleteManyAsync(filter);
await _frontier.DeleteManyAsync(filter);
return deleted.DeletedCount != 0;
using var transaction = await _dbContext.BeginTransaction();
try
{
var deleted = await _words.DeleteManyAsync(transaction.Session, filter);
await _frontier.DeleteManyAsync(transaction.Session, filter);
await transaction.CommitTransactionAsync();
return deleted.DeletedCount != 0;
}
catch
{
await transaction.AbortTransactionAsync();
throw;
}
}

/// <summary> Removes all <see cref="Word"/>s from the Frontier for specified <see cref="Project"/> </summary>
Expand Down Expand Up @@ -146,8 +157,18 @@ public async Task<Word> Create(Word word)
OtelService.StartActivityWithTag(otelTagName, "creating a word in WordsCollection and Frontier");

PopulateBlankWordTimes(word);
await _words.InsertOneAsync(word);
await AddFrontier(word);
using var transaction = await _dbContext.BeginTransaction();
try
{
await _words.InsertOneAsync(transaction.Session, word);
await _frontier.InsertOneAsync(transaction.Session, word);
await transaction.CommitTransactionAsync();
}
catch
{
await transaction.AbortTransactionAsync();
throw;
}
return word;
}

Expand All @@ -170,8 +191,18 @@ public async Task<List<Word>> Create(List<Word> words)
{
PopulateBlankWordTimes(w);
}
await _words.InsertManyAsync(words);
await AddFrontier(words);
using var transaction = await _dbContext.BeginTransaction();
try
{
await _words.InsertManyAsync(transaction.Session, words);
await _frontier.InsertManyAsync(transaction.Session, words);
await transaction.CommitTransactionAsync();
}
catch
{
await transaction.AbortTransactionAsync();
throw;
}
return words;
}

Expand All @@ -190,6 +221,67 @@ public async Task<Word> Add(Word word)
return word;
}

/// <summary>
/// Adds a new <see cref="Word"/> to WordsCollection and Frontier, and removes the old word from Frontier.
/// </summary>
/// <remarks>
/// If the Created or Modified time fields are blank, they will be automatically calculated using the current
/// time. This allows services to set or clear the values before creation to control these fields.
/// </remarks>
/// <returns> The new word created. </returns>
public async Task<Word> CreateAndDeleteFrontier(Word newWord, string oldWordId)
{
using var activity = OtelService.StartActivityWithTag(
otelTagName, "creating word in WordsCollection and Frontier, deleting old word from Frontier");

PopulateBlankWordTimes(newWord);
using var transaction = await _dbContext.BeginTransaction();
try
{
await _words.InsertOneAsync(transaction.Session, newWord);
await _frontier.InsertOneAsync(transaction.Session, newWord);
await _frontier.FindOneAndDeleteAsync(
transaction.Session, GetProjectWordFilter(newWord.ProjectId, oldWordId));
await transaction.CommitTransactionAsync();
}
catch
{
await transaction.AbortTransactionAsync();
throw;
}
return newWord;
}

/// <summary>
/// Adds a <see cref="Word"/> only to the WordsCollection and removes a word from the Frontier.
/// </summary>
/// <remarks>
/// If the Created or Modified time fields are blank, they will be automatically calculated using the current
/// time. This allows services to set or clear the values before creation to control these fields.
/// </remarks>
/// <returns> The word added. </returns>
public async Task<Word> AddAndDeleteFrontier(Word deletedWord, string wordId)
{
using var activity = OtelService.StartActivityWithTag(
otelTagName, "adding word to WordsCollection, deleting word from Frontier");

PopulateBlankWordTimes(deletedWord);
using var transaction = await _dbContext.BeginTransaction();
try
{
await _words.InsertOneAsync(transaction.Session, deletedWord);
await _frontier.FindOneAndDeleteAsync(
transaction.Session, GetProjectWordFilter(deletedWord.ProjectId, wordId));
await transaction.CommitTransactionAsync();
}
catch
{
await transaction.AbortTransactionAsync();
throw;
}
return deletedWord;
}

/// <summary> Checks if Words collection for specified <see cref="Project"/> has any words. </summary>
public async Task<bool> HasWords(string projectId)
{
Expand Down
19 changes: 2 additions & 17 deletions Backend/Services/WordService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,6 @@ public async Task<List<Word>> Create(string userId, List<Word> words)
return await _wordRepo.Create(words.Select(w => PrepEditedData(userId, w)).ToList());
}

/// <summary> Adds a new word with updated edited data. </summary>
/// <returns> The added word </returns>
private async Task<Word> Add(string userId, Word word)
{
return await _wordRepo.Add(PrepEditedData(userId, word));
}

/// <summary> Removes audio with specified fileName from a Frontier word </summary>
/// <returns> Updated word, or null if not found </returns>
public async Task<Word?> DeleteAudio(string projectId, string userId, string wordId, string fileName)
Expand Down Expand Up @@ -85,10 +78,7 @@ private async Task<Word> Add(string userId, Word word)
word.Accessibility = Status.Deleted;
word.History.Add(wordId);

var deletedWord = await Add(userId, word);

// Don't remove the Frontier word until the copy is successfully stored as deleted.
await _wordRepo.DeleteFrontier(projectId, wordId);
var deletedWord = await _wordRepo.AddAndDeleteFrontier(PrepEditedData(userId, word), wordId);

return deletedWord.Id;
}
Expand Down Expand Up @@ -156,12 +146,7 @@ public async Task<bool> RestoreFrontierWords(string projectId, List<string> word
// only keep UsingCitationForm true if the Vernacular hasn't changed.
word.UsingCitationForm &= word.Vernacular == oldWord.Vernacular;

var newWord = await Create(userId, word);

// Don't remove the old Frontier word until the new word is successfully created.
await _wordRepo.DeleteFrontier(word.ProjectId, oldWordId);

return newWord;
return await _wordRepo.CreateAndDeleteFrontier(PrepEditedData(userId, word), oldWordId);
}

/// <summary> Checks if a word being added is a duplicate of a preexisting word. </summary>
Expand Down
3 changes: 2 additions & 1 deletion Backend/Startup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -350,7 +350,8 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env, IHostApp

// If an admin user has been created via the command line, treat that as a single action and shut the
// server down so the calling script knows it's been completed successfully or unsuccessfully.
var userRepo = app.ApplicationServices.GetService<IUserRepository>();
using var startupScope = app.ApplicationServices.CreateAsyncScope();
var userRepo = startupScope.ServiceProvider.GetService<IUserRepository>();
if (userRepo is not null && CreateAdminUser(userRepo))
{
_logger.LogInformation("Stopping application");
Expand Down
Loading