12 Commits

Author SHA1 Message Date
agrynco 0cf66dd8fb Merge pull request 'docs(nuget): include package readmes' (#19) from docs/hrynco-18-nuget-readme into development 2026-10-15 14:44:34 +03:00
agrynco 2142e41d80 docs(nuget): include package readmes
Add package-specific NuGet readmes for both HrynCo DAL packages.

Ref: hrynco/hrynco-ef#18
2026-10-15 14:37:24 +03:00
agrynco dbd569b3d6 Merge pull request 'fix(transaction): retain both transaction failures (IT-1588)' (#16) from fix/IT-1588-preserve-transaction-errors into development 2026-10-15 14:27:57 +03:00
agrynco 5dd8342bf7 fix(transaction): retain both transaction failures
Keep the original action or commit failure visible alongside a failed rollback and cover owned transaction reuse.

Ref: IT-1588
2026-10-15 14:13:56 +03:00
Anatolii Grynchuk 1da2dd8579 fix: update RepositoryUrl in project files to correct domain 2026-05-06 22:41:21 +03:00
agrynco 3f1371e33a Merge pull request 'fix: use GetByIdAsync in DeleteAsync(TEntityId)' (#11) from IT-631-fix-delete-async-getbyid into development 2026-05-06 12:50:04 +03:00
Anatolii Grynchuk 9c1da388d8 fix: use GetByIdAsync in DeleteAsync(TEntityId)
Replaced sync GetById with await GetByIdAsync to avoid blocking the thread inside an async method.

Ref: #IT-631
2026-05-06 12:46:35 +03:00
agrynco 1edcafdebd Merge pull request 'fix: eliminate multi-save in batch Add and DeleteAsync(TEntityId)' (#10) from IT-631-fix-delete into development 2026-05-06 12:45:09 +03:00
Anatolii Grynchuk 93461fd35e fix: eliminate multi-save in batch Add and DeleteAsync(TEntityId)
- Add(TEntity[]) now passes save:false in the loop and calls SaveChanges once at the end
- DeleteAsync(TEntityId) now calls DoRemove directly instead of Delete(TEntityId) to avoid the double-save from the sync overload chain

Ref: #IT-631
2026-05-06 12:43:27 +03:00
Anatolii Grynchuk 4020981eec chore: upgrade ef core 8.0.15 -> 10.0.7
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-06 02:20:44 +03:00
Anatolii Grynchuk 2b02374b19 chore: downgrade ef core 9.0.5 -> 8.0.15
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-06 01:55:07 +03:00
agrynco c630db2582 Merge pull request 'docs: switch task management and wiki to gitea, merge to development only for non-release changes' (#7) from docs/gitea-workflow-agents into development 2026-05-06 01:29:15 +03:00
13 changed files with 374 additions and 16 deletions
+9 -3
View File
@@ -6,8 +6,14 @@
<!-- HrynCo shared packages -->
<PackageVersion Include="HrynCo.Common" Version="1.0.0" />
<!-- Entity Framework Core -->
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="9.0.5" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.5" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.5" />
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.7" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.7" />
<!-- Tests -->
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.7" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageVersion Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.5" />
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
</ItemGroup>
</Project>
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
@@ -6,11 +6,17 @@
<Nullable>enable</Nullable>
<RootNamespace>HrynCo.DAL.Abstract</RootNamespace>
<PackageId>HrynCo.DAL.Abstract</PackageId>
<PackageReadmeFile>README.md</PackageReadmeFile>
<Authors>HrynCo</Authors>
<Description>Abstract DAL contracts for HrynCo applications: entities, repository and unit-of-work interfaces.</Description>
<PackageTags>hrynco dal abstract entity repository unitofwork</PackageTags>
<RepositoryType>git</RepositoryType>
<RepositoryUrl>https://gitea.grynco.com.ua/hrynco/hrynco-ef.git</RepositoryUrl>
<RepositoryUrl>https://gitea.hrynco.com/hrynco/hrynco-ef.git</RepositoryUrl>
</PropertyGroup>
<ItemGroup>
<None Include="README.md" Pack="true" PackagePath="/" />
</ItemGroup>
</Project>
+5
View File
@@ -0,0 +1,5 @@
# HrynCo.DAL.Abstract
Infrastructure-independent contracts for entities, repositories, unit of work, transactions, and pagination. Reference this package from application layers that should not depend on Entity Framework Core.
Source and usage context: [hrynco-ef](https://gitea.hrynco.com/hrynco/hrynco-ef).
+213
View File
@@ -0,0 +1,213 @@
namespace HrynCo.DAL.EF.Tests;
using HrynCo.DAL.Abstract;
using Microsoft.EntityFrameworkCore;
using Xunit;
public sealed class EfUnitOfWorkTests
{
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task ActionFailure_ReportsBothExceptionsWhenRollbackFails(bool returnsResult)
{
var original = new InvalidOperationException("Action failed.");
var rollbackFailure = new InvalidOperationException("Transaction completed.");
var interceptor = new TransactionFailureInterceptor
{
RollbackException = rollbackFailure
};
await using DbContext context = await CreateContextAsync(interceptor);
var unitOfWork = new TestUnitOfWork(context);
AggregateException actual = await Assert.ThrowsAsync<AggregateException>(() =>
ExecuteAsync(unitOfWork, returnsResult, () => Task.FromException(original)));
Assert.Collection(actual.InnerExceptions,
error => Assert.Same(original, error),
error => Assert.Same(rollbackFailure, error));
Assert.Equal(1, interceptor.RollbackAttempts);
Assert.Null(unitOfWork.GetCurrentTransaction());
Assert.Null(context.Database.CurrentTransaction);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task CommitFailure_ReportsBothExceptionsWhenRollbackFails(bool returnsResult)
{
var original = new InvalidOperationException("Commit failed.");
var rollbackFailure = new InvalidOperationException("Transaction completed.");
var interceptor = new TransactionFailureInterceptor
{
CommitException = original,
RollbackException = rollbackFailure
};
await using DbContext context = await CreateContextAsync(interceptor);
var unitOfWork = new TestUnitOfWork(context);
AggregateException actual = await Assert.ThrowsAsync<AggregateException>(() =>
ExecuteAsync(unitOfWork, returnsResult, () => Task.CompletedTask));
Assert.Collection(actual.InnerExceptions,
error => Assert.Same(original, error),
error => Assert.Same(rollbackFailure, error));
Assert.Equal(1, interceptor.CommitAttempts);
Assert.Equal(1, interceptor.RollbackAttempts);
Assert.Null(unitOfWork.GetCurrentTransaction());
Assert.Null(context.Database.CurrentTransaction);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task CompletedTransaction_ReportsActionAndRollbackExceptions(bool returnsResult)
{
await using DbContext context = await CreateContextAsync();
var unitOfWork = new TestUnitOfWork(context);
var original = new InvalidOperationException("Action failed after the provider completed the transaction.");
AggregateException actual = await Assert.ThrowsAsync<AggregateException>(() =>
ExecuteAsync(unitOfWork, returnsResult, async () =>
{
await context.Database.CurrentTransaction!.CommitAsync();
throw original;
}));
Assert.Same(original, actual.InnerExceptions[0]);
Assert.IsType<InvalidOperationException>(actual.InnerExceptions[1]);
Assert.Null(unitOfWork.GetCurrentTransaction());
Assert.Null(context.Database.CurrentTransaction);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task ActionFailure_RollsBackWritesAndAllowsNextTransaction(bool returnsResult)
{
await using DbContext context = await CreateContextAsync();
var unitOfWork = new TestUnitOfWork(context);
var original = new InvalidOperationException("Action failed.");
Exception actual = await Assert.ThrowsAsync<InvalidOperationException>(() =>
ExecuteAsync(unitOfWork, returnsResult, async () =>
{
await context.Database.ExecuteSqlRawAsync("INSERT INTO records VALUES (1)");
throw original;
}));
Assert.Same(original, actual);
Assert.Equal(0, await CountRecordsAsync(context));
Assert.Null(unitOfWork.GetCurrentTransaction());
await ExecuteAsync(unitOfWork, returnsResult,
() => context.Database.ExecuteSqlRawAsync("INSERT INTO records VALUES (2)"));
Assert.Equal(1, await CountRecordsAsync(context));
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task Success_CommitsWritesAndCreatesFreshTransactionForNextCall(bool returnsResult)
{
var interceptor = new TransactionFailureInterceptor();
await using DbContext context = await CreateContextAsync(interceptor);
var unitOfWork = new TestUnitOfWork(context);
ITransaction? first = null;
await ExecuteAsync(unitOfWork, returnsResult, async () =>
{
first = unitOfWork.GetCurrentTransaction();
await context.Database.ExecuteSqlRawAsync("INSERT INTO records VALUES (1)");
});
Assert.Null(unitOfWork.GetCurrentTransaction());
Assert.Null(context.Database.CurrentTransaction);
await ExecuteAsync(unitOfWork, returnsResult, async () =>
{
Assert.NotSame(first, unitOfWork.GetCurrentTransaction());
await context.Database.ExecuteSqlRawAsync("INSERT INTO records VALUES (2)");
});
Assert.Equal(2, await CountRecordsAsync(context));
Assert.Equal(2, interceptor.CommitAttempts);
Assert.Equal(0, interceptor.RollbackAttempts);
Assert.Null(unitOfWork.GetCurrentTransaction());
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task CallerOwnedTransaction_SuccessLeavesLifecycleToCaller(bool returnsResult)
{
var interceptor = new TransactionFailureInterceptor();
await using DbContext context = await CreateContextAsync(interceptor);
var unitOfWork = new TestUnitOfWork(context);
await using ITransaction transaction = await unitOfWork.BeginTransactionAsync();
await ExecuteAsync(unitOfWork, returnsResult,
() => context.Database.ExecuteSqlRawAsync("INSERT INTO records VALUES (1)"));
Assert.Same(transaction, unitOfWork.GetCurrentTransaction());
Assert.NotNull(context.Database.CurrentTransaction);
Assert.Equal(0, interceptor.CommitAttempts);
Assert.Equal(0, interceptor.RollbackAttempts);
await transaction.RollbackAsync();
Assert.Equal(0, await CountRecordsAsync(context));
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task CallerOwnedTransaction_FailureLeavesLifecycleToCaller(bool returnsResult)
{
var interceptor = new TransactionFailureInterceptor();
await using DbContext context = await CreateContextAsync(interceptor);
var unitOfWork = new TestUnitOfWork(context);
await using ITransaction transaction = await unitOfWork.BeginTransactionAsync();
var original = new InvalidOperationException("Action failed.");
Exception actual = await Assert.ThrowsAsync<InvalidOperationException>(() =>
ExecuteAsync(unitOfWork, returnsResult, () => Task.FromException(original)));
Assert.Same(original, actual);
Assert.Same(transaction, unitOfWork.GetCurrentTransaction());
Assert.NotNull(context.Database.CurrentTransaction);
Assert.Equal(0, interceptor.CommitAttempts);
Assert.Equal(0, interceptor.RollbackAttempts);
await transaction.RollbackAsync();
}
private static async Task ExecuteAsync(TestUnitOfWork unitOfWork, bool returnsResult, Func<Task> action)
{
if (returnsResult)
{
int result = await unitOfWork.ExecuteInTransactionAsync(async () =>
{
await action();
return 42;
});
Assert.Equal(42, result);
return;
}
await unitOfWork.ExecuteInTransactionAsync(action);
}
private static async Task<DbContext> CreateContextAsync(TransactionFailureInterceptor? interceptor = null)
{
var options = new DbContextOptionsBuilder().UseSqlite("Data Source=:memory:");
if (interceptor is not null)
{
options.AddInterceptors(interceptor);
}
var context = new DbContext(options.Options);
await context.Database.OpenConnectionAsync();
await context.Database.ExecuteSqlRawAsync("CREATE TABLE records (id INTEGER NOT NULL)");
return context;
}
private static Task<int> CountRecordsAsync(DbContext context)
{
return context.Database.SqlQueryRaw<int>("SELECT COUNT(*) AS Value FROM records").SingleAsync();
}
}
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<ProjectReference Include="..\HrynCo.DAL.EF\HrynCo.DAL.EF.csproj" />
</ItemGroup>
</Project>
+6
View File
@@ -0,0 +1,6 @@
namespace HrynCo.DAL.EF.Tests;
using HrynCo.DAL.EF.Core;
using Microsoft.EntityFrameworkCore;
internal sealed class TestUnitOfWork(DbContext context) : EfUnitOfWork<DbContext>(context);
@@ -0,0 +1,42 @@
namespace HrynCo.DAL.EF.Tests;
using System.Data.Common;
using Microsoft.EntityFrameworkCore.Diagnostics;
internal sealed class TransactionFailureInterceptor : DbTransactionInterceptor
{
public Exception? CommitException { get; init; }
public Exception? RollbackException { get; init; }
public int CommitAttempts { get; private set; }
public int RollbackAttempts { get; private set; }
public override ValueTask<InterceptionResult> TransactionCommittingAsync(
DbTransaction transaction,
TransactionEventData eventData,
InterceptionResult result,
CancellationToken cancellationToken = default)
{
CommitAttempts++;
if (CommitException is not null)
{
throw CommitException;
}
return ValueTask.FromResult(result);
}
public override ValueTask<InterceptionResult> TransactionRollingBackAsync(
DbTransaction transaction,
TransactionEventData eventData,
InterceptionResult result,
CancellationToken cancellationToken = default)
{
RollbackAttempts++;
if (RollbackException is not null)
{
throw RollbackException;
}
return ValueTask.FromResult(result);
}
}
+11 -2
View File
@@ -40,7 +40,12 @@ public abstract class BaseEfRepository<TDbContext, TEntity, TEntityId> :
{
foreach (TEntity entity in entities)
{
Add(entity, save);
Add(entity, save: false);
}
if (save)
{
DbContext.SaveChanges();
}
}
@@ -84,7 +89,11 @@ public abstract class BaseEfRepository<TDbContext, TEntity, TEntityId> :
public async Task DeleteAsync(TEntityId id)
{
Delete(id);
TEntity? entity = await GetByIdAsync(id);
if (entity != null)
{
DoRemove(entity);
}
await DbContext.SaveChangesAsync();
}
+30 -4
View File
@@ -42,11 +42,11 @@ public class EfUnitOfWork<TDbContext> : IUnitOfWork
await tx.CommitAsync();
}
}
catch
catch (Exception original)
{
if (ownsTransaction)
{
await tx.RollbackAsync();
await RollbackPreservingFailureAsync(tx, original);
}
throw;
@@ -54,9 +54,16 @@ public class EfUnitOfWork<TDbContext> : IUnitOfWork
finally
{
if (ownsTransaction)
{
try
{
await tx.DisposeAsync();
}
finally
{
_currentTransaction = null;
}
}
}
}
@@ -77,11 +84,11 @@ public class EfUnitOfWork<TDbContext> : IUnitOfWork
return result;
}
catch
catch (Exception original)
{
if (ownsTransaction)
{
await tx.RollbackAsync();
await RollbackPreservingFailureAsync(tx, original);
}
throw;
@@ -89,9 +96,28 @@ public class EfUnitOfWork<TDbContext> : IUnitOfWork
finally
{
if (ownsTransaction)
{
try
{
await tx.DisposeAsync();
}
finally
{
_currentTransaction = null;
}
}
}
}
private static async Task RollbackPreservingFailureAsync(ITransaction transaction, Exception original)
{
try
{
await transaction.RollbackAsync();
}
catch (Exception rollbackFailure)
{
throw new AggregateException("The transaction and its rollback both failed.", original, rollbackFailure);
}
}
+8 -2
View File
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
@@ -6,13 +6,18 @@
<Nullable>enable</Nullable>
<RootNamespace>HrynCo.DAL.EF</RootNamespace>
<PackageId>HrynCo.DAL.EF</PackageId>
<PackageReadmeFile>README.md</PackageReadmeFile>
<Authors>HrynCo</Authors>
<Description>Entity Framework Core base implementations for HrynCo applications: generic repository and unit-of-work.</Description>
<PackageTags>hrynco dal ef entityframework repository unitofwork</PackageTags>
<RepositoryType>git</RepositoryType>
<RepositoryUrl>https://gitea.grynco.com.ua/hrynco/hrynco-ef.git</RepositoryUrl>
<RepositoryUrl>https://gitea.hrynco.com/hrynco/hrynco-ef.git</RepositoryUrl>
</PropertyGroup>
<ItemGroup>
<None Include="README.md" Pack="true" PackagePath="/" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\HrynCo.DAL.Abstract\HrynCo.DAL.Abstract.csproj" />
</ItemGroup>
@@ -28,3 +33,4 @@
</ItemGroup>
</Project>
+5
View File
@@ -0,0 +1,5 @@
# HrynCo.DAL.EF
Entity Framework Core implementations of the HrynCo.DAL.Abstract repository and unit-of-work contracts. Reference this package from the data-access layer alongside your EF Core database provider.
Source and usage context: [hrynco-ef](https://gitea.hrynco.com/hrynco/hrynco-ef).
+14 -1
View File
@@ -4,12 +4,13 @@ Reusable Entity Framework Core base library for HrynCo applications.
## Solution
The solution (`hrynco-ef.slnx`) contains two projects:
The solution (`hrynco-ef.slnx`) contains the following projects:
| Project | Description |
|---|---|
| `HrynCo.DAL.Abstract` | Infrastructure-agnostic contracts: entities, repository interfaces, unit of work, transactions, pagination. No EF Core dependency. |
| `HrynCo.DAL.EF` | Entity Framework Core implementations of the abstract contracts. Depends on `HrynCo.DAL.Abstract` and EF Core. |
| `HrynCo.DAL.EF.Tests` | Transaction lifecycle regression tests using SQLite in memory. |
The split allows consuming projects to reference only `HrynCo.DAL.Abstract` in domain/application layers, keeping those layers free of EF Core.
@@ -181,6 +182,18 @@ Entity Framework Core implementations of the abstract contracts.
## Usage
### Transaction ownership and failures
Both `ExecuteInTransactionAsync` overloads create, commit, and dispose a transaction when no transaction is already cached by the unit of work. After disposal, the cached reference is cleared, so a later call starts a fresh transaction.
If the action or commit fails, the owned transaction is rolled back. If rollback also fails (including when the provider reports a completed transaction), an `AggregateException` contains the original failure first and the rollback failure second. Disposal still runs.
When `BeginTransactionAsync` has already supplied a transaction to the caller, `ExecuteInTransactionAsync` runs the action inside that transaction and leaves commit, rollback, and disposal to the caller. Its cached reference is retained as before; the automatic cleanup above applies only to transactions owned by `ExecuteInTransactionAsync`.
Run the regression suite with `dotnet test hrynco-ef.slnx`.
### Package references
Reference `HrynCo.DAL.Abstract` for contracts only (e.g. in domain/application layers).
Reference `HrynCo.DAL.EF` for the full EF Core implementation (infrastructure layer).
+1
View File
@@ -1,4 +1,5 @@
<Solution>
<Project Path="HrynCo.DAL.Abstract/HrynCo.DAL.Abstract.csproj" />
<Project Path="HrynCo.DAL.EF/HrynCo.DAL.EF.csproj" />
<Project Path="HrynCo.DAL.EF.Tests/HrynCo.DAL.EF.Tests.csproj" />
</Solution>