From 34506ddf6ddb0ed016f9bef809e1d739446a7ae7 Mon Sep 17 00:00:00 2001 From: Valters Melnalksnis Date: Sun, 29 Sep 2024 13:43:06 +0300 Subject: [PATCH] fix(api): fix potential issues with concurrent updates --- docs/changelog.html | 4 ++ docs/sitemap.xml | 2 +- source/Gnomeshade.Data/AccountUnitOfWork.cs | 49 +++------------ .../AccountInCurrencyRepository.cs | 18 +----- .../Repositories/OwnershipRepository.cs | 13 +--- .../Repositories/ProjectRepository.cs | 8 +-- .../Repositories/Repository.cs | 51 ++++----------- .../Repositories/TransactionRepository.cs | 15 ++--- .../Gnomeshade.Data/TransactionUnitOfWork.cs | 63 +------------------ .../V1/Controllers/AccountsController.cs | 48 +++++++++----- .../V1/Controllers/CategoriesController.cs | 23 ++++--- .../Controllers/CounterpartiesController.cs | 28 ++++++--- .../V1/Controllers/LinksController.cs | 28 ++++++--- .../V1/Controllers/OwnersController.cs | 14 ++++- .../V1/Controllers/OwnershipsController.cs | 13 ++-- .../V1/Controllers/ProductsController.cs | 13 ++-- .../V1/Controllers/ProjectsController.cs | 26 +++++--- .../Controllers/TransactionItemController.cs | 17 ++--- .../V1/Controllers/TransactionsController.cs | 37 +++++++---- .../V1/Controllers/UnitsController.cs | 18 ++++-- source/Gnomeshade.WebApi/V1/CreatableBase.cs | 42 ++++++++++--- .../V2/Controllers/LoanPaymentsController.cs | 16 +++-- .../V2/Controllers/LoansController.cs | 16 +++-- .../Repositories/AccountRepositoryTests.cs | 60 ++++++++++-------- .../Repositories/CategoryRepositoryTests.cs | 9 ++- .../Repositories/ProductRepositoryTests.cs | 27 +++++--- .../Repositories/UnitRepositoryTests.cs | 23 ++++--- .../UnitsOfWork/TransactionUnitOfWorkTests.cs | 18 +++--- 28 files changed, 357 insertions(+), 342 deletions(-) diff --git a/docs/changelog.html b/docs/changelog.html index 41ee41ed9..ae69b6b76 100644 --- a/docs/changelog.html +++ b/docs/changelog.html @@ -93,6 +93,10 @@

Fixed

Display of account/counterparty name in transaction overview in #1345 +
  • + Potential issues with concurrent updates in + #1393 +
  • diff --git a/docs/sitemap.xml b/docs/sitemap.xml index 143e0e2e6..be1292fef 100644 --- a/docs/sitemap.xml +++ b/docs/sitemap.xml @@ -14,6 +14,6 @@ https://www.gnomeshade.org/changelog - 2024-09-22 + 2024-09-29 diff --git a/source/Gnomeshade.Data/AccountUnitOfWork.cs b/source/Gnomeshade.Data/AccountUnitOfWork.cs index b9b834af5..a34427421 100644 --- a/source/Gnomeshade.Data/AccountUnitOfWork.cs +++ b/source/Gnomeshade.Data/AccountUnitOfWork.cs @@ -17,39 +17,24 @@ namespace Gnomeshade.Data; /// Performs bulk actions on account entities. public sealed class AccountUnitOfWork { - private readonly DbConnection _dbConnection; private readonly AccountRepository _repository; private readonly AccountInCurrencyRepository _inCurrencyRepository; private readonly CounterpartyRepository _counterpartyRepository; /// Initializes a new instance of the class. - /// The database connection for executing queries. /// The repository for managing accounts. /// The repository for managing accounts in currencies. /// The repository for managing counterparties. public AccountUnitOfWork( - DbConnection dbConnection, AccountRepository repository, AccountInCurrencyRepository inCurrencyRepository, CounterpartyRepository counterpartyRepository) { - _dbConnection = dbConnection; _repository = repository; _inCurrencyRepository = inCurrencyRepository; _counterpartyRepository = counterpartyRepository; } - /// Creates a new account with the currencies in . - /// The account to create. - /// The id of the created account. - public async Task AddAsync(AccountEntity account) - { - await using var dbTransaction = await _dbConnection.OpenAndBeginTransaction(); - var id = await AddAsync(account, dbTransaction).ConfigureAwait(false); - await dbTransaction.CommitAsync(); - return id; - } - /// Creates a new account with the currencies in . /// The account to create. /// The database transaction to use for queries. @@ -105,30 +90,9 @@ public async Task AddWithCounterpartyAsync(AccountEntity account, DbTransa /// Deletes the specified account and all its currencies. /// The account to delete. /// The id of the owner of the entity. + /// The database transaction to use for queries. /// A representing the asynchronous operation. - public async Task DeleteAsync(AccountEntity account, Guid userId) - { - await using var dbTransaction = await _dbConnection.OpenAndBeginTransaction(); - await DeleteAsync(account, userId, dbTransaction).ConfigureAwait(false); - await dbTransaction.CommitAsync(); - } - - /// Updates the specified account. - /// The account to update. - /// The user which modified the . - /// A representing the asynchronous operation. - public async Task UpdateAsync(AccountEntity account, UserEntity modifiedBy) - { - await using var dbTransaction = await _dbConnection.OpenAndBeginTransaction(); - if (await UpdateAsync(account, modifiedBy, dbTransaction) is not 1) - { - throw new InvalidOperationException("Failed to update account"); - } - - await dbTransaction.CommitAsync(); - } - - private async Task DeleteAsync(AccountEntity account, Guid userId, DbTransaction dbTransaction) + public async Task DeleteAsync(AccountEntity account, Guid userId, DbTransaction dbTransaction) { foreach (var currency in account.Currencies) { @@ -144,10 +108,15 @@ private async Task DeleteAsync(AccountEntity account, Guid userId, DbTransaction } } + /// Updates the specified account. + /// The account to update. + /// The user which modified the . + /// The database transaction to use for queries. + /// The number of affected rows. [MustUseReturnValue] - private Task UpdateAsync(AccountEntity account, UserEntity modifiedBy, DbTransaction dbTransaction) + public async Task UpdateAsync(AccountEntity account, UserEntity modifiedBy, DbTransaction dbTransaction) { account.ModifiedByUserId = modifiedBy.Id; - return _repository.UpdateAsync(account, dbTransaction); + return await _repository.UpdateAsync(account, dbTransaction); } } diff --git a/source/Gnomeshade.Data/Repositories/AccountInCurrencyRepository.cs b/source/Gnomeshade.Data/Repositories/AccountInCurrencyRepository.cs index 1bc67c9a9..392bcf7fa 100644 --- a/source/Gnomeshade.Data/Repositories/AccountInCurrencyRepository.cs +++ b/source/Gnomeshade.Data/Repositories/AccountInCurrencyRepository.cs @@ -15,16 +15,9 @@ namespace Gnomeshade.Data.Repositories; /// Database backed repository. -public sealed class AccountInCurrencyRepository : Repository +public sealed class AccountInCurrencyRepository(ILogger logger, DbConnection dbConnection) + : Repository(logger, dbConnection) { - /// Initializes a new instance of the class with a database connection. - /// Logger for logging in the specified category. - /// The database connection for executing queries. - public AccountInCurrencyRepository(ILogger logger, DbConnection dbConnection) - : base(logger, dbConnection) - { - } - /// protected override string DeleteSql => Queries.AccountInCurrency.Delete; @@ -48,16 +41,11 @@ public AccountInCurrencyRepository(ILogger logger, /// protected override string NotDeleted => "a.deleted_at IS NULL"; - public Task RestoreDeletedAsync(Guid id, Guid userId) - { - return DbConnection.ExecuteAsync(Queries.AccountInCurrency.RestoreDeleted, new { id, userId }); - } - public Task RestoreDeletedAsync(Guid id, Guid userId, DbTransaction dbTransaction) { return DbConnection.ExecuteAsync( Queries.AccountInCurrency.RestoreDeleted, new { id, userId }, - transaction: dbTransaction); + dbTransaction); } } diff --git a/source/Gnomeshade.Data/Repositories/OwnershipRepository.cs b/source/Gnomeshade.Data/Repositories/OwnershipRepository.cs index b50aa5fdd..2bbae3ee4 100644 --- a/source/Gnomeshade.Data/Repositories/OwnershipRepository.cs +++ b/source/Gnomeshade.Data/Repositories/OwnershipRepository.cs @@ -16,16 +16,9 @@ namespace Gnomeshade.Data.Repositories; /// Persistence store of . -public sealed class OwnershipRepository : Repository +public sealed class OwnershipRepository(ILogger logger, DbConnection dbConnection) + : Repository(logger, dbConnection) { - /// Initializes a new instance of the class. - /// Logger for logging in the specified category. - /// The database connection for executing queries. - public OwnershipRepository(ILogger logger, DbConnection dbConnection) - : base(logger, dbConnection) - { - } - /// protected override string DeleteSql => Queries.Ownership.Delete; @@ -56,6 +49,6 @@ public async Task AddDefaultAsync(Guid id, DbTransaction dbTransaction) var accessCommand = new CommandDefinition(text, null, dbTransaction); var accessId = await DbConnection.QuerySingleAsync(accessCommand); - await AddAsync(new() { Id = id, OwnerId = id, UserId = id, AccessId = accessId }); + await AddAsync(new() { Id = id, OwnerId = id, UserId = id, AccessId = accessId }, dbTransaction); } } diff --git a/source/Gnomeshade.Data/Repositories/ProjectRepository.cs b/source/Gnomeshade.Data/Repositories/ProjectRepository.cs index e7f1a022b..a2052ca42 100644 --- a/source/Gnomeshade.Data/Repositories/ProjectRepository.cs +++ b/source/Gnomeshade.Data/Repositories/ProjectRepository.cs @@ -44,7 +44,7 @@ public sealed class ProjectRepository(ILogger logger, DbConne /// protected override string SelectSql => Queries.Project.Select; - public Task AddPurchaseAsync(Guid id, Guid purchaseId, Guid userId) + public Task AddPurchaseAsync(Guid id, Guid purchaseId, Guid userId, DbTransaction dbTransaction) { const string sql = """ @@ -54,11 +54,11 @@ INSERT INTO project_purchases (CURRENT_TIMESTAMP, @userId, @id, @purchaseId); """; - var command = new CommandDefinition(sql, new { id, purchaseId, userId }); + var command = new CommandDefinition(sql, new { id, purchaseId, userId }, dbTransaction); return DbConnection.ExecuteAsync(command); } - public Task RemovePurchaseAsync(Guid id, Guid purchaseId, Guid userId) + public Task RemovePurchaseAsync(Guid id, Guid purchaseId, Guid userId, DbTransaction dbTransaction) { const string sql = """ @@ -67,7 +67,7 @@ DELETE FROM project_purchases AND project_purchases.purchase_id = @purchaseId; """; - var command = new CommandDefinition(sql, new { id, purchaseId, userId }); + var command = new CommandDefinition(sql, new { id, purchaseId, userId }, dbTransaction); return DbConnection.ExecuteAsync(command); } } diff --git a/source/Gnomeshade.Data/Repositories/Repository.cs b/source/Gnomeshade.Data/Repositories/Repository.cs index c75a3d6fb..cb7740a93 100644 --- a/source/Gnomeshade.Data/Repositories/Repository.cs +++ b/source/Gnomeshade.Data/Repositories/Repository.cs @@ -65,15 +65,6 @@ protected Repository(ILogger> logger, DbConnection dbConnect /// Gets the SQL for filtering out deleted rows. protected abstract string NotDeleted { get; } - /// Adds a new entity. - /// The entity to add. - /// The id of the created entity. - public Task AddAsync(TEntity entity) - { - Logger.AddingEntity(); - return DbConnection.QuerySingleAsync(InsertSql, entity); - } - /// Adds a new entity using the specified database transaction. /// The entity to add. /// The database transaction to use for the query. @@ -85,19 +76,6 @@ public Task AddAsync(TEntity entity, DbTransaction dbTransaction) return DbConnection.QuerySingleAsync(command); } - /// Deletes the entity with the specified id. - /// The id of the entity to delete. - /// The id of the user requesting access to the entity. - /// The number of affected rows. - [MustUseReturnValue] - public async Task DeleteAsync(Guid id, Guid userId) - { - Logger.DeletingEntity(id); - var count = await DbConnection.ExecuteAsync(DeleteSql, new { id, userId }); - Logger.DeletedRows(count); - return count; - } - /// Deletes the entity with the specified id using the specified database transaction. /// The id of the entity to delete. /// The id of the user requesting access to the entity. @@ -136,6 +114,15 @@ public Task> GetAsync(Guid userId, CancellationToken cancel cancellationToken: cancellationToken)); } + public Task> GetAsync(Guid userId, DbTransaction dbTransaction) + { + Logger.GetAll(); + return GetEntitiesAsync(new( + $"{SelectActiveSql} {GroupBy};", + new { userId, access = Read.ToParam() }, + dbTransaction)); + } + /// Gets an entity with the specified id. /// The id of the entity to get. /// The id of the user requesting access to the entity. @@ -165,7 +152,7 @@ public Task GetByIdAsync(Guid id, Guid userId, DbTransaction dbTransact } /// Searches for an entity with the specified id. - /// The id to to search by. + /// The id to search by. /// The id of the user requesting access to the entity. /// The access level to check. /// A to observe while waiting for the task to complete. @@ -184,7 +171,7 @@ public Task GetByIdAsync(Guid id, Guid userId, DbTransaction dbTransact } /// Searches for an entity with the specified id using the specified database transaction. - /// The id to to search by. + /// The id to search by. /// The id of the user requesting access to the entity. /// The database transaction to use for the query. /// The access level to check. @@ -203,7 +190,7 @@ public Task GetByIdAsync(Guid id, Guid userId, DbTransaction dbTransact } /// Searches for an entity with the specified id. - /// The id to to search by. + /// The id to search by. /// A to observe while waiting for the task to complete. /// The entity if one exists, otherwise . public Task FindByIdAsync(Guid id, CancellationToken cancellationToken = default) @@ -216,7 +203,7 @@ public Task GetByIdAsync(Guid id, Guid userId, DbTransaction dbTransact } /// Searches for an entity with the specified id. - /// The id to to search by. + /// The id to search by. /// The database transaction to use for the query. /// The entity if one exists, otherwise . public Task FindByIdAsync(Guid id, DbTransaction dbTransaction) @@ -228,18 +215,6 @@ public Task GetByIdAsync(Guid id, Guid userId, DbTransaction dbTransact dbTransaction)); } - /// Updates an existing entity with the specified id. - /// The entity to update. - /// The number of affected rows. - [MustUseReturnValue] - public async Task UpdateAsync(TEntity entity) - { - Logger.UpdatingEntity(); - var count = await DbConnection.ExecuteAsync(UpdateSql, entity); - Logger.UpdatedRows(count); - return count; - } - /// Updates an existing entity with the specified id using the specified database transaction. /// The entity to update. /// The database transaction to use for the query. diff --git a/source/Gnomeshade.Data/Repositories/TransactionRepository.cs b/source/Gnomeshade.Data/Repositories/TransactionRepository.cs index 9cf915424..234a27c87 100644 --- a/source/Gnomeshade.Data/Repositories/TransactionRepository.cs +++ b/source/Gnomeshade.Data/Repositories/TransactionRepository.cs @@ -24,16 +24,9 @@ namespace Gnomeshade.Data.Repositories; /// Database backed repository. -public sealed class TransactionRepository : Repository +public sealed class TransactionRepository(ILogger logger, DbConnection dbConnection) + : Repository(logger, dbConnection) { - /// Initializes a new instance of the class with a database connection. - /// Logger for logging in the specified category. - /// The database connection for executing queries. - public TransactionRepository(ILogger logger, DbConnection dbConnection) - : base(logger, dbConnection) - { - } - /// protected override string DeleteSql => Queries.Transaction.Delete; @@ -136,7 +129,7 @@ public async Task> GetAllLinksAsync( return entities.DistinctBy(entity => entity.Id); } - public Task AddLinkAsync(Guid id, Guid linkId, Guid userId) + public Task AddLinkAsync(Guid id, Guid linkId, Guid userId, DbTransaction dbTransaction) { const string sql = @" INSERT INTO transaction_links @@ -148,7 +141,7 @@ INSERT INTO transaction_links return DbConnection.ExecuteAsync(command); } - public Task RemoveLinkAsync(Guid id, Guid linkId, Guid userId) + public Task RemoveLinkAsync(Guid id, Guid linkId, Guid userId, DbTransaction dbTransaction) { const string sql = @" DELETE FROM transaction_links diff --git a/source/Gnomeshade.Data/TransactionUnitOfWork.cs b/source/Gnomeshade.Data/TransactionUnitOfWork.cs index de1c471f7..0122e08ee 100644 --- a/source/Gnomeshade.Data/TransactionUnitOfWork.cs +++ b/source/Gnomeshade.Data/TransactionUnitOfWork.cs @@ -9,36 +9,20 @@ using Gnomeshade.Data.Entities; using Gnomeshade.Data.Repositories; -using JetBrains.Annotations; - namespace Gnomeshade.Data; /// Transaction related actions spanning multiple entities. public sealed class TransactionUnitOfWork { - private readonly DbConnection _dbConnection; private readonly TransactionRepository _repository; /// Initializes a new instance of the class. - /// The database connection for executing queries. /// The repository for managing transactions. - public TransactionUnitOfWork(DbConnection dbConnection, TransactionRepository repository) + public TransactionUnitOfWork(TransactionRepository repository) { - _dbConnection = dbConnection; _repository = repository; } - /// Adds a new transaction with the specified items. - /// The transaction to create. - /// The id of the created transaction. - public async Task AddAsync(TransactionEntity transaction) - { - await using var dbTransaction = await _dbConnection.OpenAndBeginTransaction(); - var transactionId = await AddAsync(transaction, dbTransaction); - await dbTransaction.CommitAsync(); - return transactionId; - } - /// Adds a new transaction with the specified items. /// The transaction to create. /// The database transaction to use for the query. @@ -54,49 +38,4 @@ public async Task AddAsync(TransactionEntity transaction, DbTransaction db var transactionId = await _repository.AddAsync(transaction, dbTransaction).ConfigureAwait(false); return transactionId; } - - /// Deletes the specified transaction and all its items. - /// The transaction to delete. - /// The id of the owner of the entity. - /// A representing the asynchronous operation. - public async Task DeleteAsync(TransactionEntity transaction, Guid userId) - { - await using var dbTransaction = await _dbConnection.OpenAndBeginTransaction(); - if (await DeleteAsync(transaction, userId, dbTransaction) is not 1) - { - throw new InvalidOperationException("Failed to delete transaction"); - } - - await dbTransaction.CommitAsync(); - } - - /// - /// Updates the specified transaction and all its items. - /// - /// The transaction to update. - /// The user which modified the . - /// A representing the asynchronous operation. - public async Task UpdateAsync(TransactionEntity transaction, UserEntity modifiedBy) - { - await using var dbTransaction = await _dbConnection.OpenAndBeginTransaction(); - if (await UpdateAsync(transaction, modifiedBy, dbTransaction) is not 1) - { - throw new InvalidOperationException("Failed to update transaction"); - } - - await dbTransaction.CommitAsync(); - } - - [MustUseReturnValue] - private Task DeleteAsync(TransactionEntity transaction, Guid userId, DbTransaction dbTransaction) - { - return _repository.DeleteAsync(transaction.Id, userId, dbTransaction); - } - - [MustUseReturnValue] - private Task UpdateAsync(TransactionEntity transaction, UserEntity modifiedBy, DbTransaction dbTransaction) - { - transaction.ModifiedByUserId = modifiedBy.Id; - return _repository.UpdateAsync(transaction, dbTransaction); - } } diff --git a/source/Gnomeshade.WebApi/V1/Controllers/AccountsController.cs b/source/Gnomeshade.WebApi/V1/Controllers/AccountsController.cs index 108c0975d..85009c6c7 100644 --- a/source/Gnomeshade.WebApi/V1/Controllers/AccountsController.cs +++ b/source/Gnomeshade.WebApi/V1/Controllers/AccountsController.cs @@ -98,7 +98,10 @@ public override Task Put(Guid id, [FromBody] AccountCreation accou [ProducesStatus409Conflict] public async Task> AddCurrency(Guid id, [FromBody] AccountInCurrencyCreation currency) { - var account = await _repository.FindByIdAsync(id, ApplicationUser.Id); + var userId = ApplicationUser.Id; + await using var dbTransaction = await DbConnection.OpenAndBeginTransaction(); + + var account = await _repository.FindByIdAsync(id, userId, dbTransaction); if (account is null) { return NotFound(); @@ -116,12 +119,14 @@ public async Task> AddCurrency(Guid id, [FromBody] AccountInC var accountInCurrency = Mapper.Map(currency) with { OwnerId = account.OwnerId, - CreatedByUserId = ApplicationUser.Id, - ModifiedByUserId = ApplicationUser.Id, + CreatedByUserId = userId, + ModifiedByUserId = userId, AccountId = account.Id, }; - id = await _inCurrencyRepository.AddAsync(accountInCurrency); + id = await _inCurrencyRepository.AddAsync(accountInCurrency, dbTransaction); + + await dbTransaction.CommitAsync(); // todo should this point to account or account in currency? return CreatedAtAction(nameof(Get), new { id }, id); @@ -135,20 +140,22 @@ public async Task> AddCurrency(Guid id, [FromBody] AccountInC [ProducesStatus404NotFound] public async Task RemoveCurrency(Guid id, Guid currencyId) { + var userId = ApplicationUser.Id; await using var dbTransaction = await DbConnection.OpenAndBeginTransaction(); - var account = await _repository.FindByIdAsync(id, ApplicationUser.Id, dbTransaction, AccessLevel.Delete); + + var account = await _repository.FindByIdAsync(id, userId, dbTransaction, AccessLevel.Delete); if (account is null) { return NotFound(); } - var currency = await _inCurrencyRepository.FindByIdAsync(currencyId, ApplicationUser.Id, dbTransaction, AccessLevel.Delete); + var currency = await _inCurrencyRepository.FindByIdAsync(currencyId, userId, dbTransaction, AccessLevel.Delete); if (currency is null) { return NotFound(); } - var count = await _inCurrencyRepository.DeleteAsync(currencyId, ApplicationUser.Id, dbTransaction); + var count = await _inCurrencyRepository.DeleteAsync(currencyId, userId, dbTransaction); await dbTransaction.CommitAsync(); return count > 0 ? NoContent() @@ -174,9 +181,13 @@ public async Task>> Balance(Guid id, CancellationToke } /// - protected override async Task UpdateExistingAsync(Guid id, AccountCreation creation, UserEntity user) + protected override async Task UpdateExistingAsync( + Guid id, + AccountCreation creation, + UserEntity user, + DbTransaction dbTransaction) { - var conflictingResult = await GetConflictResult(creation, user, id); + var conflictingResult = await GetConflictResult(creation, user, dbTransaction, id); if (conflictingResult is not null) { return conflictingResult; @@ -184,14 +195,20 @@ protected override async Task UpdateExistingAsync(Guid id, Account var account = Mapper.Map(creation) with { Id = id }; - await _accountUnitOfWork.UpdateAsync(account, user); - return NoContent(); + var updatedCount = await _accountUnitOfWork.UpdateAsync(account, user, dbTransaction); + return updatedCount is 1 + ? NoContent() + : throw new InvalidOperationException("Failed to update account"); } /// - protected override async Task CreateNewAsync(Guid id, AccountCreation creation, UserEntity user) + protected override async Task CreateNewAsync( + Guid id, + AccountCreation creation, + UserEntity user, + DbTransaction dbTransaction) { - var conflictingResult = await GetConflictResult(creation, user); + var conflictingResult = await GetConflictResult(creation, user, dbTransaction); if (conflictingResult is not null) { return conflictingResult; @@ -204,17 +221,18 @@ protected override async Task CreateNewAsync(Guid id, AccountCreat ModifiedByUserId = user.Id, }; - _ = await _accountUnitOfWork.AddAsync(account); + _ = await _accountUnitOfWork.AddAsync(account, dbTransaction); return CreatedAtAction(nameof(Get), new { id }, id); } private async Task GetConflictResult( AccountCreation model, UserEntity user, + DbTransaction dbTransaction, Guid? existingAccountId = null) { var normalizedName = model.Name!.ToUpperInvariant(); - var conflictingAccount = await _repository.FindByNameAsync(normalizedName, user.Id); + var conflictingAccount = await _repository.FindByNameAsync(normalizedName, user.Id, dbTransaction); if (conflictingAccount is null || conflictingAccount.Id == existingAccountId || conflictingAccount.CounterpartyId != model.CounterpartyId) diff --git a/source/Gnomeshade.WebApi/V1/Controllers/CategoriesController.cs b/source/Gnomeshade.WebApi/V1/Controllers/CategoriesController.cs index 737236fb5..ad791abf5 100644 --- a/source/Gnomeshade.WebApi/V1/Controllers/CategoriesController.cs +++ b/source/Gnomeshade.WebApi/V1/Controllers/CategoriesController.cs @@ -10,7 +10,6 @@ using AutoMapper; -using Gnomeshade.Data; using Gnomeshade.Data.Entities; using Gnomeshade.Data.Repositories; using Gnomeshade.WebApi.Client; @@ -78,10 +77,12 @@ public override Task Delete(Guid id) => base.Delete(id); /// - protected override async Task UpdateExistingAsync(Guid id, CategoryCreation creation, UserEntity user) + protected override async Task UpdateExistingAsync( + Guid id, + CategoryCreation creation, + UserEntity user, + DbTransaction dbTransaction) { - await using var dbTransaction = await DbConnection.OpenAndBeginTransaction(); - var linkedProductId = await GetLinkedProductId(id, creation, user, dbTransaction); var category = Mapper.Map(creation) with { @@ -90,7 +91,7 @@ protected override async Task UpdateExistingAsync(Guid id, Categor LinkedProductId = linkedProductId, }; - if (await Repository.UpdateAsync(category) is not 1) + if (await Repository.UpdateAsync(category, dbTransaction) is not 1) { return StatusCode(Status403Forbidden); } @@ -100,16 +101,16 @@ protected override async Task UpdateExistingAsync(Guid id, Categor return StatusCode(Status403Forbidden); } - await dbTransaction.CommitAsync(); - return NoContent(); } /// - protected override async Task CreateNewAsync(Guid id, CategoryCreation creation, UserEntity user) + protected override async Task CreateNewAsync( + Guid id, + CategoryCreation creation, + UserEntity user, + DbTransaction dbTransaction) { - await using var dbTransaction = await DbConnection.OpenAndBeginTransaction(); - var conflictingCategory = await Repository.FindByNameAsync(creation.Name, user.Id, dbTransaction); if (conflictingCategory is not null) { @@ -134,8 +135,6 @@ protected override async Task CreateNewAsync(Guid id, CategoryCrea return StatusCode(Status403Forbidden); } - await dbTransaction.CommitAsync(); - return CreatedAtAction(nameof(Get), new { id }, id); } diff --git a/source/Gnomeshade.WebApi/V1/Controllers/CounterpartiesController.cs b/source/Gnomeshade.WebApi/V1/Controllers/CounterpartiesController.cs index e37c06999..4cc33aa5b 100644 --- a/source/Gnomeshade.WebApi/V1/Controllers/CounterpartiesController.cs +++ b/source/Gnomeshade.WebApi/V1/Controllers/CounterpartiesController.cs @@ -85,9 +85,13 @@ public async Task Merge(Guid targetId, Guid sourceId) } /// - protected override async Task UpdateExistingAsync(Guid id, CounterpartyCreation creation, UserEntity user) + protected override async Task UpdateExistingAsync( + Guid id, + CounterpartyCreation creation, + UserEntity user, + DbTransaction dbTransaction) { - var conflictResult = await GetConflictResult(creation, user, id); + var conflictResult = await GetConflictResult(creation, user, dbTransaction, id); if (conflictResult is not null) { return conflictResult; @@ -99,7 +103,7 @@ protected override async Task UpdateExistingAsync(Guid id, Counter ModifiedByUserId = user.Id, }; - return await Repository.UpdateAsync(counterparty) switch + return await Repository.UpdateAsync(counterparty, dbTransaction) switch { 1 => NoContent(), _ => StatusCode(Status403Forbidden), @@ -107,9 +111,13 @@ protected override async Task UpdateExistingAsync(Guid id, Counter } /// - protected override async Task CreateNewAsync(Guid id, CounterpartyCreation creation, UserEntity user) + protected override async Task CreateNewAsync( + Guid id, + CounterpartyCreation creation, + UserEntity user, + DbTransaction dbTransaction) { - var conflictResult = await GetConflictResult(creation, user); + var conflictResult = await GetConflictResult(creation, user, dbTransaction); if (conflictResult is not null) { return conflictResult; @@ -122,13 +130,17 @@ protected override async Task CreateNewAsync(Guid id, Counterparty ModifiedByUserId = user.Id, }; - _ = await Repository.AddAsync(counterparty); + _ = await Repository.AddAsync(counterparty, dbTransaction); return CreatedAtAction(nameof(Get), new { id }, id); } - private async Task GetConflictResult(CounterpartyCreation model, UserEntity user, Guid? existingId = null) + private async Task GetConflictResult( + CounterpartyCreation model, + UserEntity user, + DbTransaction dbTransaction, + Guid? existingId = null) { - var conflictingCounterparty = await Repository.FindByNameAsync(model.Name!, user.Id); + var conflictingCounterparty = await Repository.FindByNameAsync(model.Name!, user.Id, dbTransaction); if (conflictingCounterparty is null || conflictingCounterparty.Id == existingId) { return null; diff --git a/source/Gnomeshade.WebApi/V1/Controllers/LinksController.cs b/source/Gnomeshade.WebApi/V1/Controllers/LinksController.cs index b5e0b24eb..b2714d276 100644 --- a/source/Gnomeshade.WebApi/V1/Controllers/LinksController.cs +++ b/source/Gnomeshade.WebApi/V1/Controllers/LinksController.cs @@ -53,9 +53,13 @@ public override Task Delete(Guid id) => base.Delete(id); /// - protected override async Task UpdateExistingAsync(Guid id, LinkCreation creation, UserEntity user) + protected override async Task UpdateExistingAsync( + Guid id, + LinkCreation creation, + UserEntity user, + DbTransaction dbTransaction) { - var conflictResult = await GetConflictResult(creation, user); + var conflictResult = await GetConflictResult(creation, user, dbTransaction, id); if (conflictResult is not null) { return conflictResult; @@ -69,7 +73,7 @@ protected override async Task UpdateExistingAsync(Guid id, LinkCre Uri = creation.Uri!.ToString(), }; - return await Repository.UpdateAsync(linkToCreate) switch + return await Repository.UpdateAsync(linkToCreate, dbTransaction) switch { 1 => NoContent(), _ => StatusCode(Status403Forbidden), @@ -77,9 +81,13 @@ protected override async Task UpdateExistingAsync(Guid id, LinkCre } /// - protected override async Task CreateNewAsync(Guid id, LinkCreation creation, UserEntity user) + protected override async Task CreateNewAsync( + Guid id, + LinkCreation creation, + UserEntity user, + DbTransaction dbTransaction) { - var conflictResult = await GetConflictResult(creation, user); + var conflictResult = await GetConflictResult(creation, user, dbTransaction); if (conflictResult is not null) { return conflictResult; @@ -94,13 +102,17 @@ protected override async Task CreateNewAsync(Guid id, LinkCreation Uri = creation.Uri!.ToString(), }; - _ = await Repository.AddAsync(link); + _ = await Repository.AddAsync(link, dbTransaction); return CreatedAtAction(nameof(Get), new { id }, id); } - private async Task GetConflictResult(LinkCreation creation, UserEntity user, Guid? existingId = null) + private async Task GetConflictResult( + LinkCreation creation, + UserEntity user, + DbTransaction dbTransaction, + Guid? existingId = null) { - var links = await Repository.GetAsync(user.Id); + var links = await Repository.GetAsync(user.Id, dbTransaction); var conflictingLink = links.FirstOrDefault(link => link.Uri == creation.Uri?.ToString()); if (conflictingLink is null || conflictingLink.Id == existingId) { diff --git a/source/Gnomeshade.WebApi/V1/Controllers/OwnersController.cs b/source/Gnomeshade.WebApi/V1/Controllers/OwnersController.cs index f621040ca..78489960b 100644 --- a/source/Gnomeshade.WebApi/V1/Controllers/OwnersController.cs +++ b/source/Gnomeshade.WebApi/V1/Controllers/OwnersController.cs @@ -48,14 +48,22 @@ public override Task Delete(Guid id) => base.Delete(id); /// - protected override Task UpdateExistingAsync(Guid id, OwnerCreation creation, UserEntity user) + protected override Task UpdateExistingAsync( + Guid id, + OwnerCreation creation, + UserEntity user, + DbTransaction dbTransaction) { // todo return Task.FromResult(NoContent()); } /// - protected override async Task CreateNewAsync(Guid id, OwnerCreation creation, UserEntity user) + protected override async Task CreateNewAsync( + Guid id, + OwnerCreation creation, + UserEntity user, + DbTransaction dbTransaction) { var owner = Mapper.Map(creation) with { @@ -63,7 +71,7 @@ protected override async Task CreateNewAsync(Guid id, OwnerCreatio CreatedByUserId = ApplicationUser.Id, }; - await Repository.AddAsync(owner); + await Repository.AddAsync(owner, dbTransaction); return CreatedAtAction(nameof(Get), new { id }, id); } } diff --git a/source/Gnomeshade.WebApi/V1/Controllers/OwnershipsController.cs b/source/Gnomeshade.WebApi/V1/Controllers/OwnershipsController.cs index 90093e2d3..984d4cdc4 100644 --- a/source/Gnomeshade.WebApi/V1/Controllers/OwnershipsController.cs +++ b/source/Gnomeshade.WebApi/V1/Controllers/OwnershipsController.cs @@ -50,10 +50,11 @@ public override Task Delete(Guid id) => protected override async Task UpdateExistingAsync( Guid id, OwnershipCreation creation, - UserEntity user) + UserEntity user, + DbTransaction dbTransaction) { var ownership = Mapper.Map(creation) with { Id = id }; - return await Repository.UpdateAsync(ownership) switch + return await Repository.UpdateAsync(ownership, dbTransaction) switch { 1 => NoContent(), _ => StatusCode(Status403Forbidden), @@ -61,10 +62,14 @@ protected override async Task UpdateExistingAsync( } /// - protected override async Task CreateNewAsync(Guid id, OwnershipCreation creation, UserEntity user) + protected override async Task CreateNewAsync( + Guid id, + OwnershipCreation creation, + UserEntity user, + DbTransaction dbTransaction) { var ownership = Mapper.Map(creation) with { Id = id }; - await Repository.AddAsync(ownership); + await Repository.AddAsync(ownership, dbTransaction); return CreatedAtAction(nameof(Get), new { id }, id); } } diff --git a/source/Gnomeshade.WebApi/V1/Controllers/ProductsController.cs b/source/Gnomeshade.WebApi/V1/Controllers/ProductsController.cs index 3ac4de831..4a2bf3644 100644 --- a/source/Gnomeshade.WebApi/V1/Controllers/ProductsController.cs +++ b/source/Gnomeshade.WebApi/V1/Controllers/ProductsController.cs @@ -87,7 +87,8 @@ public async Task>> Purchases(Guid id, CancellationT protected override async Task UpdateExistingAsync( Guid id, ProductCreation creation, - UserEntity user) + UserEntity user, + DbTransaction dbTransaction) { var product = Mapper.Map(creation) with { @@ -95,7 +96,7 @@ protected override async Task UpdateExistingAsync( ModifiedByUserId = user.Id, }; - return await Repository.UpdateAsync(product) switch + return await Repository.UpdateAsync(product, dbTransaction) switch { 1 => NoContent(), _ => StatusCode(Status403Forbidden), @@ -103,7 +104,11 @@ protected override async Task UpdateExistingAsync( } /// - protected override async Task CreateNewAsync(Guid id, ProductCreation creation, UserEntity user) + protected override async Task CreateNewAsync( + Guid id, + ProductCreation creation, + UserEntity user, + DbTransaction dbTransaction) { var conflictingProduct = await Repository.FindByNameAsync(creation.Name!, user.Id); if (conflictingProduct is not null) @@ -121,7 +126,7 @@ protected override async Task CreateNewAsync(Guid id, ProductCreat ModifiedByUserId = user.Id, }; - _ = await Repository.AddAsync(product); + _ = await Repository.AddAsync(product, dbTransaction); return CreatedAtAction(nameof(Get), new { id }, id); } } diff --git a/source/Gnomeshade.WebApi/V1/Controllers/ProjectsController.cs b/source/Gnomeshade.WebApi/V1/Controllers/ProjectsController.cs index 580bac546..4240e667d 100644 --- a/source/Gnomeshade.WebApi/V1/Controllers/ProjectsController.cs +++ b/source/Gnomeshade.WebApi/V1/Controllers/ProjectsController.cs @@ -11,6 +11,7 @@ using AutoMapper; +using Gnomeshade.Data; using Gnomeshade.Data.Entities; using Gnomeshade.Data.Repositories; using Gnomeshade.WebApi.Client; @@ -108,14 +109,16 @@ public async Task>> Purchases(Guid id, CancellationT public async Task AddPurchase(Guid id, Guid purchaseId) { var userId = ApplicationUser.Id; + await using var dbTransaction = await DbConnection.OpenAndBeginTransaction(); - var project = await Repository.FindByIdAsync(id, userId, AccessLevel.Write); + var project = await Repository.FindByIdAsync(id, userId, dbTransaction, AccessLevel.Write); if (project is null) { return NotFound(); } - await Repository.AddPurchaseAsync(id, purchaseId, userId); + await Repository.AddPurchaseAsync(id, purchaseId, userId, dbTransaction); + await dbTransaction.CommitAsync(); return NoContent(); } @@ -127,14 +130,16 @@ public async Task AddPurchase(Guid id, Guid purchaseId) public async Task RemovePurchase(Guid id, Guid purchaseId) { var userId = ApplicationUser.Id; + await using var dbTransaction = await DbConnection.OpenAndBeginTransaction(); - var project = await Repository.FindByIdAsync(id, userId, AccessLevel.Write); + var project = await Repository.FindByIdAsync(id, userId, dbTransaction, AccessLevel.Write); if (project is null) { return NotFound(); } - await Repository.RemovePurchaseAsync(id, purchaseId, userId); + await Repository.RemovePurchaseAsync(id, purchaseId, userId, dbTransaction); + await dbTransaction.CommitAsync(); return NoContent(); } @@ -142,7 +147,8 @@ public async Task RemovePurchase(Guid id, Guid purchaseId) protected override async Task UpdateExistingAsync( Guid id, ProjectCreation creation, - UserEntity user) + UserEntity user, + DbTransaction dbTransaction) { var project = new ProjectEntity { @@ -153,7 +159,7 @@ protected override async Task UpdateExistingAsync( ParentProjectId = creation.ParentProjectId, }; - return await Repository.UpdateAsync(project) switch + return await Repository.UpdateAsync(project, dbTransaction) switch { 1 => NoContent(), _ => StatusCode(Status403Forbidden), @@ -161,7 +167,11 @@ protected override async Task UpdateExistingAsync( } /// - protected override async Task CreateNewAsync(Guid id, ProjectCreation creation, UserEntity user) + protected override async Task CreateNewAsync( + Guid id, + ProjectCreation creation, + UserEntity user, + DbTransaction dbTransaction) { var conflictingProject = await Repository.FindByNameAsync(creation.Name, user.Id); if (conflictingProject is not null) @@ -182,7 +192,7 @@ protected override async Task CreateNewAsync(Guid id, ProjectCreat ParentProjectId = creation.ParentProjectId, }; - _ = await Repository.AddAsync(project); + _ = await Repository.AddAsync(project, dbTransaction); return CreatedAtAction(nameof(Get), new { id }, id); } } diff --git a/source/Gnomeshade.WebApi/V1/Controllers/TransactionItemController.cs b/source/Gnomeshade.WebApi/V1/Controllers/TransactionItemController.cs index df290c26e..702ec2fe3 100644 --- a/source/Gnomeshade.WebApi/V1/Controllers/TransactionItemController.cs +++ b/source/Gnomeshade.WebApi/V1/Controllers/TransactionItemController.cs @@ -8,7 +8,6 @@ using AutoMapper; -using Gnomeshade.Data; using Gnomeshade.Data.Entities; using Gnomeshade.Data.Entities.Abstractions; using Gnomeshade.Data.Repositories; @@ -50,9 +49,12 @@ protected TransactionItemController( } /// - protected sealed override async Task UpdateExistingAsync(Guid id, TItemCreation creation, UserEntity user) + protected sealed override async Task UpdateExistingAsync( + Guid id, + TItemCreation creation, + UserEntity user, + DbTransaction dbTransaction) { - await using var dbTransaction = await DbConnection.OpenAndBeginTransaction(); var conflictingResult = await FindConflictingTransaction(creation.TransactionId!.Value, user.Id, dbTransaction); if (conflictingResult is not null) { @@ -71,14 +73,16 @@ protected sealed override async Task UpdateExistingAsync(Guid id, return StatusCode(Status403Forbidden); } - await dbTransaction.CommitAsync(); return NoContent(); } /// - protected sealed override async Task CreateNewAsync(Guid id, TItemCreation creation, UserEntity user) + protected sealed override async Task CreateNewAsync( + Guid id, + TItemCreation creation, + UserEntity user, + DbTransaction dbTransaction) { - await using var dbTransaction = await DbConnection.OpenAndBeginTransaction(); var conflictingResult = await FindConflictingTransaction(creation.TransactionId!.Value, user.Id, dbTransaction); if (conflictingResult is not null) { @@ -93,7 +97,6 @@ protected sealed override async Task CreateNewAsync(Guid id, TItem }; await Repository.AddAsync(entity, dbTransaction); - await dbTransaction.CommitAsync(); return CreatedAtAction("Get", new { id }, null); } diff --git a/source/Gnomeshade.WebApi/V1/Controllers/TransactionsController.cs b/source/Gnomeshade.WebApi/V1/Controllers/TransactionsController.cs index 8ca2c4162..c84a1f4b1 100644 --- a/source/Gnomeshade.WebApi/V1/Controllers/TransactionsController.cs +++ b/source/Gnomeshade.WebApi/V1/Controllers/TransactionsController.cs @@ -38,13 +38,11 @@ public sealed class TransactionsController : CreatableBaseInitializes a new instance of the class. /// The repository for performing CRUD operations on . - /// Unit of work for managing transactions and all related entities. /// Repository entity and API model mapper. /// Persistence store for . /// Persistence store for . @@ -54,7 +52,6 @@ public sealed class TransactionsController : CreatableBaseDatabase connection for transaction management. public TransactionsController( TransactionRepository repository, - TransactionUnitOfWork unitOfWork, Mapper mapper, TransferRepository transferRepository, PurchaseRepository purchaseRepository, @@ -66,7 +63,6 @@ public TransactionsController( DbConnection dbConnection) : base(mapper, repository, dbConnection) { - _unitOfWork = unitOfWork; _transferRepository = transferRepository; _purchaseRepository = purchaseRepository; _loanRepository = loanRepository; @@ -173,13 +169,17 @@ public async Task> GetLinks(Guid transactionId, CancellationToken can [ProducesStatus404NotFound] public async Task AddLink(Guid transactionId, Guid id) { - var transaction = await Repository.FindByIdAsync(transactionId, ApplicationUser.Id); + var userId = ApplicationUser.Id; + await using var dbTransaction = await DbConnection.OpenAndBeginTransaction(); + + var transaction = await Repository.FindByIdAsync(transactionId, userId, dbTransaction); if (transaction is null) { return NotFound(); } - await Repository.AddLinkAsync(transactionId, id, ApplicationUser.Id); + await Repository.AddLinkAsync(transactionId, id, userId, dbTransaction); + await dbTransaction.CommitAsync(); return NoContent(); } @@ -190,13 +190,17 @@ public async Task AddLink(Guid transactionId, Guid id) [ProducesStatus404NotFound] public async Task RemoveLink(Guid transactionId, Guid id) { - var transaction = await Repository.FindByIdAsync(transactionId, ApplicationUser.Id); + var userId = ApplicationUser.Id; + await using var dbTransaction = await DbConnection.OpenAndBeginTransaction(); + + var transaction = await Repository.FindByIdAsync(transactionId, userId); if (transaction is null) { return NotFound(); } - await Repository.RemoveLinkAsync(transactionId, id, ApplicationUser.Id); + await Repository.RemoveLinkAsync(transactionId, id, userId, dbTransaction); + await dbTransaction.CommitAsync(); return NoContent(); } @@ -310,7 +314,8 @@ public async Task RemoveRelated(Guid id, Guid relatedId) protected override async Task UpdateExistingAsync( Guid id, TransactionCreation creation, - UserEntity user) + UserEntity user, + DbTransaction dbTransaction) { var transaction = Mapper.Map(creation) with { @@ -318,12 +323,18 @@ protected override async Task UpdateExistingAsync( ModifiedByUserId = user.Id, }; - await _unitOfWork.UpdateAsync(transaction, user); - return NoContent(); + var updatedCount = await Repository.UpdateAsync(transaction, dbTransaction); + return updatedCount is 1 + ? NoContent() + : throw new InvalidOperationException("Failed to update transaction"); } /// - protected override async Task CreateNewAsync(Guid id, TransactionCreation creation, UserEntity user) + protected override async Task CreateNewAsync( + Guid id, + TransactionCreation creation, + UserEntity user, + DbTransaction dbTransaction) { var transaction = Mapper.Map(creation) with { @@ -335,7 +346,7 @@ protected override async Task CreateNewAsync(Guid id, TransactionC ReconciledByUserId = creation.ReconciledAt is null ? null : user.Id, }; - var transactionId = await _unitOfWork.AddAsync(transaction); + var transactionId = await Repository.AddAsync(transaction, dbTransaction); return CreatedAtAction(nameof(Get), new { id = transactionId }, id); } diff --git a/source/Gnomeshade.WebApi/V1/Controllers/UnitsController.cs b/source/Gnomeshade.WebApi/V1/Controllers/UnitsController.cs index 175e0f3fb..e1b3869ef 100644 --- a/source/Gnomeshade.WebApi/V1/Controllers/UnitsController.cs +++ b/source/Gnomeshade.WebApi/V1/Controllers/UnitsController.cs @@ -55,7 +55,11 @@ public override Task Put(Guid id, [FromBody, BindRequired] UnitCre base.Put(id, unit); /// - protected override async Task UpdateExistingAsync(Guid id, UnitCreation creation, UserEntity user) + protected override async Task UpdateExistingAsync( + Guid id, + UnitCreation creation, + UserEntity user, + DbTransaction dbTransaction) { var unit = Mapper.Map(creation) with { @@ -63,7 +67,7 @@ protected override async Task UpdateExistingAsync(Guid id, UnitCre ModifiedByUserId = user.Id, }; - return await Repository.UpdateAsync(unit) switch + return await Repository.UpdateAsync(unit, dbTransaction) switch { 1 => NoContent(), _ => StatusCode(Status403Forbidden), @@ -71,9 +75,13 @@ protected override async Task UpdateExistingAsync(Guid id, UnitCre } /// - protected override async Task CreateNewAsync(Guid id, UnitCreation creation, UserEntity user) + protected override async Task CreateNewAsync( + Guid id, + UnitCreation creation, + UserEntity user, + DbTransaction dbTransaction) { - var conflictingUnit = await Repository.FindByNameAsync(creation.Name!, user.Id); + var conflictingUnit = await Repository.FindByNameAsync(creation.Name!, user.Id, dbTransaction); if (conflictingUnit is not null) { return Problem( @@ -89,7 +97,7 @@ protected override async Task CreateNewAsync(Guid id, UnitCreation ModifiedByUserId = user.Id, }; - _ = await Repository.AddAsync(unit); + _ = await Repository.AddAsync(unit, dbTransaction); return CreatedAtAction(nameof(Get), new { id }, id); } } diff --git a/source/Gnomeshade.WebApi/V1/CreatableBase.cs b/source/Gnomeshade.WebApi/V1/CreatableBase.cs index 896c93070..7ae2acc01 100644 --- a/source/Gnomeshade.WebApi/V1/CreatableBase.cs +++ b/source/Gnomeshade.WebApi/V1/CreatableBase.cs @@ -79,10 +79,15 @@ public virtual Task> Get(Guid id, CancellationToken cancell [HttpPost] [ProducesResponseType(Status201Created)] [ProducesStatus409Conflict] - public virtual Task Post([FromBody] TCreation creation) + public virtual async Task Post([FromBody] TCreation creation) { - creation = creation with { OwnerId = creation.OwnerId ?? ApplicationUser.Id }; - return CreateNewAsync(Guid.NewGuid(), creation, ApplicationUser); + var user = ApplicationUser; + creation = creation with { OwnerId = creation.OwnerId ?? user.Id }; + + await using var dbTransaction = await DbConnection.OpenAndBeginTransaction(); + var result = await CreateNewAsync(Guid.NewGuid(), creation, user, dbTransaction); + await dbTransaction.CommitAsync(); + return result; } /// Creates a new entity or replaces an existing one, if one exists with the specified id. @@ -96,20 +101,27 @@ public virtual Task Post([FromBody] TCreation creation) [ProducesStatus409Conflict] public virtual async Task Put(Guid id, [FromBody] TCreation creation) { - creation = creation with { OwnerId = creation.OwnerId ?? ApplicationUser.Id }; + var user = ApplicationUser; + creation = creation with { OwnerId = creation.OwnerId ?? user.Id }; - var existingEntity = await Repository.FindByIdAsync(id, ApplicationUser.Id, AccessLevel.Write); + await using var dbTransaction = await DbConnection.OpenAndBeginTransaction(); + var existingEntity = await Repository.FindByIdAsync(id, user.Id, dbTransaction, AccessLevel.Write); if (existingEntity is not null) { - return await UpdateExistingAsync(id, creation, ApplicationUser); + var result = await UpdateExistingAsync(id, creation, user, dbTransaction); + await dbTransaction.CommitAsync(); + return result; } - var conflictingEntity = await Repository.FindByIdAsync(id); + var conflictingEntity = await Repository.FindByIdAsync(id, dbTransaction); if (conflictingEntity is null) { - return await CreateNewAsync(id, creation, ApplicationUser); + var result = await CreateNewAsync(id, creation, user, dbTransaction); + await dbTransaction.CommitAsync(); + return result; } + await dbTransaction.RollbackAsync(); return StatusCode(Status403Forbidden); } @@ -144,13 +156,23 @@ public virtual async Task Delete(Guid id) /// The id of the entity to update. /// The details to update. /// The current user. + /// The database transaction to use for queries. /// An action result. - protected abstract Task UpdateExistingAsync(Guid id, TCreation creation, UserEntity user); + protected abstract Task UpdateExistingAsync( + Guid id, + TCreation creation, + UserEntity user, + DbTransaction dbTransaction); /// Creates a new with details from . /// The id of the entity to create. /// The details from which to create the entity. /// The current user. + /// The database transaction to use for queries. /// An action result. - protected abstract Task CreateNewAsync(Guid id, TCreation creation, UserEntity user); + protected abstract Task CreateNewAsync( + Guid id, + TCreation creation, + UserEntity user, + DbTransaction dbTransaction); } diff --git a/source/Gnomeshade.WebApi/V2/Controllers/LoanPaymentsController.cs b/source/Gnomeshade.WebApi/V2/Controllers/LoanPaymentsController.cs index 63886cd80..aea4848f0 100644 --- a/source/Gnomeshade.WebApi/V2/Controllers/LoanPaymentsController.cs +++ b/source/Gnomeshade.WebApi/V2/Controllers/LoanPaymentsController.cs @@ -60,7 +60,11 @@ public override Task Delete(Guid id) => base.Delete(id); /// - protected override async Task UpdateExistingAsync(Guid id, LoanPaymentCreation creation, UserEntity user) + protected override async Task UpdateExistingAsync( + Guid id, + LoanPaymentCreation creation, + UserEntity user, + DbTransaction dbTransaction) { var payment = Mapper.Map(creation) with { @@ -68,7 +72,7 @@ protected override async Task UpdateExistingAsync(Guid id, LoanPay ModifiedByUserId = user.Id, }; - return await Repository.UpdateAsync(payment) switch + return await Repository.UpdateAsync(payment, dbTransaction) switch { 1 => NoContent(), _ => StatusCode(Status403Forbidden), @@ -76,7 +80,11 @@ protected override async Task UpdateExistingAsync(Guid id, LoanPay } /// - protected override async Task CreateNewAsync(Guid id, LoanPaymentCreation creation, UserEntity user) + protected override async Task CreateNewAsync( + Guid id, + LoanPaymentCreation creation, + UserEntity user, + DbTransaction dbTransaction) { var loan = Mapper.Map(creation) with { @@ -85,7 +93,7 @@ protected override async Task CreateNewAsync(Guid id, LoanPaymentC ModifiedByUserId = user.Id, }; - await Repository.AddAsync(loan); + await Repository.AddAsync(loan, dbTransaction); return CreatedAtAction(nameof(Get), new { id }, id); } } diff --git a/source/Gnomeshade.WebApi/V2/Controllers/LoansController.cs b/source/Gnomeshade.WebApi/V2/Controllers/LoansController.cs index 8bb0017e3..3fcfa8092 100644 --- a/source/Gnomeshade.WebApi/V2/Controllers/LoansController.cs +++ b/source/Gnomeshade.WebApi/V2/Controllers/LoansController.cs @@ -83,7 +83,11 @@ public async Task> GetLoanPayments(Guid id, CancellationToken } /// - protected override async Task UpdateExistingAsync(Guid id, LoanCreation creation, UserEntity user) + protected override async Task UpdateExistingAsync( + Guid id, + LoanCreation creation, + UserEntity user, + DbTransaction dbTransaction) { var loan = Mapper.Map(creation) with { @@ -91,7 +95,7 @@ protected override async Task UpdateExistingAsync(Guid id, LoanCre ModifiedByUserId = user.Id, }; - return await Repository.UpdateAsync(loan) switch + return await Repository.UpdateAsync(loan, dbTransaction) switch { 1 => NoContent(), _ => StatusCode(Status403Forbidden), @@ -99,7 +103,11 @@ protected override async Task UpdateExistingAsync(Guid id, LoanCre } /// - protected override async Task CreateNewAsync(Guid id, LoanCreation creation, UserEntity user) + protected override async Task CreateNewAsync( + Guid id, + LoanCreation creation, + UserEntity user, + DbTransaction dbTransaction) { var loan = Mapper.Map(creation) with { @@ -108,7 +116,7 @@ protected override async Task CreateNewAsync(Guid id, LoanCreation ModifiedByUserId = user.Id, }; - await Repository.AddAsync(loan); + await Repository.AddAsync(loan, dbTransaction); return CreatedAtAction(nameof(Get), new { id }, id); } } diff --git a/tests/Gnomeshade.Data.Tests.Integration/Repositories/AccountRepositoryTests.cs b/tests/Gnomeshade.Data.Tests.Integration/Repositories/AccountRepositoryTests.cs index a6faf1c76..391ca1785 100644 --- a/tests/Gnomeshade.Data.Tests.Integration/Repositories/AccountRepositoryTests.cs +++ b/tests/Gnomeshade.Data.Tests.Integration/Repositories/AccountRepositoryTests.cs @@ -34,7 +34,7 @@ public async Task SetUpAsync() _counterpartyRepository = new(NullLogger.Instance, _dbConnection); _repository = new(NullLogger.Instance, _dbConnection); _inCurrencyRepository = new(NullLogger.Instance, _dbConnection); - _unitOfWork = new(_dbConnection, _repository, _inCurrencyRepository, new(NullLogger.Instance, _dbConnection)); + _unitOfWork = new(_repository, _inCurrencyRepository, new(NullLogger.Instance, _dbConnection)); } [Test] @@ -43,9 +43,11 @@ public async Task AddAsync_WithTransaction() var currencies = await EntityFactory.GetCurrenciesAsync(); var preferredCurrency = currencies.First(); + await using var dbTransaction = await _dbConnection.OpenAndBeginTransaction(); + var counterParty = new CounterpartyFaker(TestUser.Id).Generate(); - var counterPartyId = await _counterpartyRepository.AddAsync(counterParty); - counterParty = await _counterpartyRepository.GetByIdAsync(counterPartyId, TestUser.Id); + var counterPartyId = await _counterpartyRepository.AddAsync(counterParty, dbTransaction); + counterParty = await _counterpartyRepository.GetByIdAsync(counterPartyId, TestUser.Id, dbTransaction); var accountFaker = new AccountFaker(TestUser, counterParty, preferredCurrency); var account = accountFaker.Generate(); @@ -54,12 +56,12 @@ public async Task AddAsync_WithTransaction() account.Currencies.Add(new() { CurrencyId = currency.Id }); } - var accountId = await _unitOfWork.AddAsync(account); + var accountId = await _unitOfWork.AddAsync(account, dbTransaction); - var getAccount = await _repository.GetByIdAsync(accountId, TestUser.Id); - var findAccount = await _repository.FindByIdAsync(getAccount.Id, TestUser.Id); - var findByNameAccount = await _repository.FindByNameAsync(getAccount.NormalizedName, TestUser.Id); - var accounts = await _repository.GetAsync(TestUser.Id); + var getAccount = await _repository.GetByIdAsync(accountId, TestUser.Id, dbTransaction); + var findAccount = await _repository.FindByIdAsync(getAccount.Id, TestUser.Id, dbTransaction); + var findByNameAccount = await _repository.FindByNameAsync(getAccount.NormalizedName, TestUser.Id, dbTransaction); + var accounts = await _repository.GetAsync(TestUser.Id, dbTransaction); var expectedAccount = account with { @@ -78,8 +80,8 @@ public async Task AddAsync_WithTransaction() accounts.Should().ContainSingle().Which.Should().BeEquivalentTo(expectedAccount, Options); var firstAccountInCurrency = getAccount.Currencies.First(); - var getAccountInCurrency = await _inCurrencyRepository.GetByIdAsync(firstAccountInCurrency.Id, TestUser.Id); - var findAccountInCurrency = await _inCurrencyRepository.FindByIdAsync(getAccountInCurrency.Id, TestUser.Id); + var getAccountInCurrency = await _inCurrencyRepository.GetByIdAsync(firstAccountInCurrency.Id, TestUser.Id, dbTransaction); + var findAccountInCurrency = await _inCurrencyRepository.FindByIdAsync(getAccountInCurrency.Id, TestUser.Id, dbTransaction); var expectedAccountInCurrency = firstAccountInCurrency with { @@ -92,20 +94,24 @@ public async Task AddAsync_WithTransaction() getAccountInCurrency.Should().BeEquivalentTo(expectedAccountInCurrency); findAccountInCurrency.Should().BeEquivalentTo(expectedAccountInCurrency); - (await _inCurrencyRepository.DeleteAsync(firstAccountInCurrency.Id, TestUser.Id)).Should().Be(1); - var deleted = await _inCurrencyRepository.FindByIdAsync(firstAccountInCurrency.Id, TestUser.Id); + (await _inCurrencyRepository.DeleteAsync(firstAccountInCurrency.Id, TestUser.Id, dbTransaction)).Should().Be(1); + var deleted = await _inCurrencyRepository.FindByIdAsync(firstAccountInCurrency.Id, TestUser.Id, dbTransaction); deleted.Should().BeNull(); - await _inCurrencyRepository.RestoreDeletedAsync(getAccountInCurrency.Id, TestUser.Id); + await _inCurrencyRepository.RestoreDeletedAsync(getAccountInCurrency.Id, TestUser.Id, dbTransaction); + await dbTransaction.CommitAsync(); + var restored = await _inCurrencyRepository.GetByIdAsync(firstAccountInCurrency.Id, TestUser.Id); restored.Should().BeEquivalentTo(getAccountInCurrency, options => options.Excluding(a => a.ModifiedAt)); - (await FluentActions.Awaiting(() => _inCurrencyRepository.AddAsync(firstAccountInCurrency)) + // ReSharper disable once AccessToDisposedClosure + (await FluentActions.Awaiting(() => _inCurrencyRepository.AddAsync(firstAccountInCurrency, dbTransaction)) .Should() .ThrowAsync()) .Which.Message.Should().Contain("duplicate key value violates unique constraint"); - await _unitOfWork.DeleteAsync(getAccount, TestUser.Id); + await using var transaction = await _dbConnection.OpenAndBeginTransaction(); + await _unitOfWork.DeleteAsync(getAccount, TestUser.Id, transaction); } [Test] @@ -114,28 +120,32 @@ public async Task FindByName_ShouldIgnoreDeleted() var currencies = await EntityFactory.GetCurrenciesAsync(); var preferredCurrency = currencies.First(); + await using var dbTransaction = await _dbConnection.OpenAndBeginTransaction(); + var counterParty = new CounterpartyFaker(TestUser.Id).Generate(); - var counterPartyId = await _counterpartyRepository.AddAsync(counterParty); - counterParty = await _counterpartyRepository.GetByIdAsync(counterPartyId, TestUser.Id); + var counterPartyId = await _counterpartyRepository.AddAsync(counterParty, dbTransaction); + counterParty = await _counterpartyRepository.GetByIdAsync(counterPartyId, TestUser.Id, dbTransaction); var accountFaker = new AccountFaker(TestUser, counterParty, preferredCurrency); var account = accountFaker.Generate(); - var accountId = await _unitOfWork.AddAsync(account); + var accountId = await _unitOfWork.AddAsync(account, dbTransaction); - var firstAccount = await _repository.GetByIdAsync(accountId, TestUser.Id); + var firstAccount = await _repository.GetByIdAsync(accountId, TestUser.Id, dbTransaction); var firstAccountName = firstAccount.Name; - (await _repository.DeleteAsync(accountId, TestUser.Id)).Should().Be(1); - firstAccount = await _repository.FindByIdAsync(accountId, TestUser.Id); + (await _repository.DeleteAsync(accountId, TestUser.Id, dbTransaction)).Should().Be(1); + firstAccount = await _repository.FindByIdAsync(accountId, TestUser.Id, dbTransaction); firstAccount.Should().BeNull(); counterParty = new CounterpartyFaker(TestUser.Id).Generate(); - counterPartyId = await _counterpartyRepository.AddAsync(counterParty); + counterPartyId = await _counterpartyRepository.AddAsync(counterParty, dbTransaction); account = account with { Id = Guid.NewGuid(), CounterpartyId = counterPartyId }; - accountId = await _unitOfWork.AddAsync(account); - var secondAccount = await _repository.GetByIdAsync(accountId, TestUser.Id); + accountId = await _unitOfWork.AddAsync(account, dbTransaction); + var secondAccount = await _repository.GetByIdAsync(accountId, TestUser.Id, dbTransaction); secondAccount.Name.Should().Be(firstAccountName); - var secondAccountByName = await _repository.FindByNameAsync(secondAccount.Name, TestUser.Id); + var secondAccountByName = await _repository.FindByNameAsync(secondAccount.Name, TestUser.Id, dbTransaction); secondAccountByName.Should().BeEquivalentTo(secondAccount, Options); + + await dbTransaction.CommitAsync(); } private static EquivalencyAssertionOptions Options( diff --git a/tests/Gnomeshade.Data.Tests.Integration/Repositories/CategoryRepositoryTests.cs b/tests/Gnomeshade.Data.Tests.Integration/Repositories/CategoryRepositoryTests.cs index 7cd942472..4ca2e88e0 100644 --- a/tests/Gnomeshade.Data.Tests.Integration/Repositories/CategoryRepositoryTests.cs +++ b/tests/Gnomeshade.Data.Tests.Integration/Repositories/CategoryRepositoryTests.cs @@ -33,14 +33,17 @@ public async Task AddGet() var tag = tagFaker.Generate(); var childTag = tagFaker.GenerateUnique(tag); - var tagId = await _repository.AddAsync(tag); + await using var dbTransaction = await _dbConnection.OpenAndBeginTransaction(); + var tagId = await _repository.AddAsync(tag, dbTransaction); - (await _repository.GetAsync(TestUser.Id)) + (await _repository.GetAsync(TestUser.Id, dbTransaction)) .Should() .ContainSingle() .Which.Id.Should() .Be(tagId); - _ = await _repository.AddAsync(childTag); + _ = await _repository.AddAsync(childTag, dbTransaction); + + await dbTransaction.CommitAsync(); } } diff --git a/tests/Gnomeshade.Data.Tests.Integration/Repositories/ProductRepositoryTests.cs b/tests/Gnomeshade.Data.Tests.Integration/Repositories/ProductRepositoryTests.cs index 7a797d068..1b1029d6b 100644 --- a/tests/Gnomeshade.Data.Tests.Integration/Repositories/ProductRepositoryTests.cs +++ b/tests/Gnomeshade.Data.Tests.Integration/Repositories/ProductRepositoryTests.cs @@ -31,10 +31,13 @@ public async Task AddGetDelete_WithoutTransaction() { var productToAdd = new ProductFaker(TestUser).Generate(); - var id = await _repository.AddAsync(productToAdd); - var getProduct = await _repository.GetByIdAsync(id, TestUser.Id); - var findProduct = await _repository.FindByIdAsync(getProduct.Id, TestUser.Id); - var allProducts = await _repository.GetAsync(TestUser.Id); + await using var firstTransaction = await _dbConnection.BeginTransactionAsync(); + var id = await _repository.AddAsync(productToAdd, firstTransaction); + + var getProduct = await _repository.GetByIdAsync(id, TestUser.Id, firstTransaction); + var findProduct = await _repository.FindByIdAsync(getProduct.Id, TestUser.Id, firstTransaction); + var allProducts = await _repository.GetAsync(TestUser.Id, firstTransaction); + await firstTransaction.CommitAsync(); var expectedProduct = productToAdd with { @@ -47,9 +50,10 @@ public async Task AddGetDelete_WithoutTransaction() findProduct.Should().BeEquivalentTo(expectedProduct); allProducts.Should().ContainSingle().Which.Should().BeEquivalentTo(expectedProduct); + await using var secondTransaction = await _dbConnection.BeginTransactionAsync(); var productToUpdate = getProduct with { Sku = "123", Description = "Foo" }; - (await _repository.UpdateAsync(productToUpdate)).Should().Be(1); - var updatedProduct = await _repository.GetByIdAsync(productToUpdate.Id, TestUser.Id); + (await _repository.UpdateAsync(productToUpdate, secondTransaction)).Should().Be(1); + var updatedProduct = await _repository.GetByIdAsync(productToUpdate.Id, TestUser.Id, secondTransaction); using (new AssertionScope()) { @@ -61,7 +65,8 @@ public async Task AddGetDelete_WithoutTransaction() updatedProduct.Description.Should().Be("Foo"); } - (await _repository.DeleteAsync(id, TestUser.Id)).Should().Be(1); + (await _repository.DeleteAsync(id, TestUser.Id, secondTransaction)).Should().Be(1); + await secondTransaction.CommitAsync(); var afterDelete = await _repository.FindByIdAsync(id, TestUser.Id); afterDelete.Should().BeNull(); @@ -74,9 +79,8 @@ public async Task AddGetDelete_WithTransaction() await using var dbTransaction = await _dbConnection.BeginTransactionAsync(); var id = await _repository.AddAsync(productToAdd, dbTransaction); - await dbTransaction.CommitAsync(); - var getProduct = await _repository.GetByIdAsync(id, TestUser.Id); + var getProduct = await _repository.GetByIdAsync(id, TestUser.Id, dbTransaction); var expectedProduct = productToAdd with { Id = id, @@ -85,6 +89,9 @@ public async Task AddGetDelete_WithTransaction() }; getProduct.Should().BeEquivalentTo(expectedProduct); - (await _repository.DeleteAsync(id, TestUser.Id)).Should().Be(1); + + (await _repository.DeleteAsync(id, TestUser.Id, dbTransaction)).Should().Be(1); + + await dbTransaction.CommitAsync(); } } diff --git a/tests/Gnomeshade.Data.Tests.Integration/Repositories/UnitRepositoryTests.cs b/tests/Gnomeshade.Data.Tests.Integration/Repositories/UnitRepositoryTests.cs index 3ea6ad68f..5130183e6 100644 --- a/tests/Gnomeshade.Data.Tests.Integration/Repositories/UnitRepositoryTests.cs +++ b/tests/Gnomeshade.Data.Tests.Integration/Repositories/UnitRepositoryTests.cs @@ -31,10 +31,12 @@ public async Task AddGetDelete_WithoutTransaction() { var unitToAdd = new UnitFaker(TestUser).Generate(); - var id = await _repository.AddAsync(unitToAdd); - var getUnit = await _repository.GetByIdAsync(id, TestUser.Id); - var findUnit = await _repository.FindByIdAsync(getUnit.Id, TestUser.Id); - var allUnits = await _repository.GetAsync(TestUser.Id); + await using var dbTransaction = await _dbConnection.OpenAndBeginTransaction(); + var id = await _repository.AddAsync(unitToAdd, dbTransaction); + + var getUnit = await _repository.GetByIdAsync(id, TestUser.Id, dbTransaction); + var findUnit = await _repository.FindByIdAsync(getUnit.Id, TestUser.Id, dbTransaction); + var allUnits = await _repository.GetAsync(TestUser.Id, dbTransaction); var expectedUnit = unitToAdd with { @@ -50,7 +52,8 @@ public async Task AddGetDelete_WithoutTransaction() allUnits.Should().ContainSingle(unit => unit.Id == id).Which.Should().BeEquivalentTo(expectedUnit); } - (await _repository.DeleteAsync(id, TestUser.Id)).Should().Be(1); + (await _repository.DeleteAsync(id, TestUser.Id, dbTransaction)).Should().Be(1); + await dbTransaction.CommitAsync(); var afterDelete = await _repository.FindByIdAsync(id, TestUser.Id); afterDelete.Should().BeNull(); @@ -74,9 +77,7 @@ public async Task AddGetDelete_WithTransaction() var childUnitId = await _repository.AddAsync(childUnit, dbTransaction); - await dbTransaction.CommitAsync(); - - var getUnit = await _repository.GetByIdAsync(childUnitId, TestUser.Id); + var getUnit = await _repository.GetByIdAsync(childUnitId, TestUser.Id, dbTransaction); var expectedUnit = childUnit with { Id = childUnitId, @@ -87,7 +88,9 @@ public async Task AddGetDelete_WithTransaction() getUnit.Should().BeEquivalentTo(expectedUnit); - (await _repository.DeleteAsync(childUnitId, TestUser.Id)).Should().Be(1); - (await _repository.DeleteAsync(parentUnitId, TestUser.Id)).Should().Be(1); + (await _repository.DeleteAsync(childUnitId, TestUser.Id, dbTransaction)).Should().Be(1); + (await _repository.DeleteAsync(parentUnitId, TestUser.Id, dbTransaction)).Should().Be(1); + + await dbTransaction.CommitAsync(); } } diff --git a/tests/Gnomeshade.Data.Tests.Integration/UnitsOfWork/TransactionUnitOfWorkTests.cs b/tests/Gnomeshade.Data.Tests.Integration/UnitsOfWork/TransactionUnitOfWorkTests.cs index 60747d74b..400984cba 100644 --- a/tests/Gnomeshade.Data.Tests.Integration/UnitsOfWork/TransactionUnitOfWorkTests.cs +++ b/tests/Gnomeshade.Data.Tests.Integration/UnitsOfWork/TransactionUnitOfWorkTests.cs @@ -26,26 +26,28 @@ public async Task OneTimeSetupAsync() { _dbConnection = await CreateConnectionAsync(); _repository = new(NullLogger.Instance, _dbConnection); - _unitOfWork = new(_dbConnection, _repository); + _unitOfWork = new(_repository); } [Test] public async Task AddGetDelete_WithoutTransaction() { var transactionToAdd = new TransactionFaker(TestUser).Generate(); + await using var dbTransaction = await _dbConnection.BeginTransactionAsync(); - var transactionId = await _unitOfWork.AddAsync(transactionToAdd); + var transactionId = await _unitOfWork.AddAsync(transactionToAdd, dbTransaction); - var getTransaction = await _repository.GetByIdAsync(transactionId, TestUser.Id); - var findTransaction = await _repository.FindByIdAsync(getTransaction.Id, TestUser.Id); - await using var dbTransaction = await _dbConnection.BeginTransactionAsync(); - await dbTransaction.CommitAsync(); - var allTransactions = await _repository.GetAsync(TestUser.Id); + var getTransaction = await _repository.GetByIdAsync(transactionId, TestUser.Id, dbTransaction); + var findTransaction = await _repository.FindByIdAsync(getTransaction.Id, TestUser.Id, dbTransaction); + var allTransactions = await _repository.GetAsync(TestUser.Id, dbTransaction); findTransaction.Should().BeEquivalentTo(getTransaction, Options); allTransactions.Should().ContainSingle().Which.Should().BeEquivalentTo(getTransaction, Options); - await _unitOfWork.DeleteAsync(getTransaction, TestUser.Id); + var count = await _repository.DeleteAsync(transactionId, TestUser.Id, dbTransaction); + count.Should().Be(1); + + await dbTransaction.CommitAsync(); } private static EquivalencyAssertionOptions Options(