Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1aada89ab5 | |||
| dbd569b3d6 | |||
| 5dd8342bf7 | |||
| 584d036a09 | |||
| 1da2dd8579 | |||
| b434383f7e | |||
| 3f1371e33a | |||
| 9c1da388d8 | |||
| 1edcafdebd | |||
| 93461fd35e | |||
| 52c9c9ab9e | |||
| 4020981eec | |||
| d7b7c2eb88 | |||
| 2b02374b19 | |||
| c630db2582 | |||
| a58b4d9279 | |||
| 1bab9c963d | |||
| 23297f76f3 | |||
| 6b68f099fa | |||
| 69e3ab6079 | |||
| b09777252b | |||
| 521ae31dce |
@@ -6,7 +6,6 @@ This file is the authoritative source for hrynco-ef workflow, delivery, and role
|
|||||||
|
|
||||||
- Command timeout: if a command runs longer than 10 minutes, report timeout and ask the user.
|
- Command timeout: if a command runs longer than 10 minutes, report timeout and ask the user.
|
||||||
- Validate required services before task work:
|
- Validate required services before task work:
|
||||||
- YouTrack authenticated access
|
|
||||||
- Gitea authenticated access and the `hrynco/hrynco-ef` repo
|
- Gitea authenticated access and the `hrynco/hrynco-ef` repo
|
||||||
- TeamCity authenticated access
|
- TeamCity authenticated access
|
||||||
- If any startup check fails, stop and ask the user.
|
- If any startup check fails, stop and ask the user.
|
||||||
@@ -30,7 +29,6 @@ Print one startup report line per check:
|
|||||||
Required report items:
|
Required report items:
|
||||||
|
|
||||||
- Workflow rules loaded
|
- Workflow rules loaded
|
||||||
- YouTrack auth + reachability
|
|
||||||
- Gitea auth + reachability
|
- Gitea auth + reachability
|
||||||
- Gitea resource access (`hrynco/hrynco-ef`)
|
- Gitea resource access (`hrynco/hrynco-ef`)
|
||||||
- TeamCity auth + reachability
|
- TeamCity auth + reachability
|
||||||
@@ -47,7 +45,7 @@ Required report items:
|
|||||||
- local `development` must match remote
|
- local `development` must match remote
|
||||||
- if not, fix that first
|
- if not, fix that first
|
||||||
- On task start:
|
- On task start:
|
||||||
- set YouTrack state to `In Progress`
|
- create a Gitea issue in `hrynco/hrynco-ef` if one does not exist
|
||||||
- create a feature branch from `development`
|
- create a feature branch from `development`
|
||||||
- name the branch after the task
|
- name the branch after the task
|
||||||
- Work only in the feature branch.
|
- Work only in the feature branch.
|
||||||
@@ -56,12 +54,12 @@ Required report items:
|
|||||||
|
|
||||||
### Issue lifecycle
|
### Issue lifecycle
|
||||||
|
|
||||||
1. Product analyst creates/refines the YouTrack task.
|
1. Product analyst creates/refines the Gitea issue.
|
||||||
2. Developer takes the task and starts implementation.
|
2. Developer takes the issue and starts implementation.
|
||||||
3. Developer finishes implementation and hands off to code review.
|
3. Developer finishes implementation and hands off to code review.
|
||||||
4. Code reviewer reviews and either passes or returns to developer.
|
4. Code reviewer reviews and either passes or returns to developer.
|
||||||
5. Repeat developer/reviewer cycles as needed.
|
5. Repeat developer/reviewer cycles as needed.
|
||||||
6. Maximum five review rounds total for the same unresolved task state.
|
6. Maximum five review rounds total for the same unresolved issue state.
|
||||||
7. After review round five, if still unresolved, return to product analysis.
|
7. After review round five, if still unresolved, return to product analysis.
|
||||||
8. Tester validates the implementation against acceptance criteria.
|
8. Tester validates the implementation against acceptance criteria.
|
||||||
9. Developer waits for user validation approval.
|
9. Developer waits for user validation approval.
|
||||||
@@ -69,7 +67,7 @@ Required report items:
|
|||||||
|
|
||||||
### Task rules
|
### Task rules
|
||||||
|
|
||||||
- Create the YouTrack task before development starts.
|
- Create the Gitea issue before development starts.
|
||||||
- Default assignee is AI unless the task flow requires another assignee.
|
- Default assignee is AI unless the task flow requires another assignee.
|
||||||
- Define scope and acceptance criteria.
|
- Define scope and acceptance criteria.
|
||||||
- Tasks must be independently deliverable and testable.
|
- Tasks must be independently deliverable and testable.
|
||||||
@@ -89,22 +87,17 @@ After explicit user validation approval:
|
|||||||
- push the feature branch
|
- push the feature branch
|
||||||
- create PR from feature branch to `development`
|
- create PR from feature branch to `development`
|
||||||
- merge that PR
|
- merge that PR
|
||||||
- create PR from `development` to `main`
|
- **do not merge to `main` unless a new NuGet package release is explicitly requested**
|
||||||
- merge that PR
|
- when a release is requested: create PR from `development` to `main`, merge, wait for TC `HrynCo / HrynCo.EF / publish` to finish successfully
|
||||||
- wait for TC `HrynCo / HrynCo.EF / publish` to finish successfully
|
- close the Gitea issue
|
||||||
- set the YouTrack task to `Done`
|
|
||||||
- calculate exact Spent Time from YouTrack timestamps only
|
|
||||||
- set the YouTrack Spent Time field
|
|
||||||
- switch back to `development`
|
- switch back to `development`
|
||||||
- pull latest `development`
|
- pull latest `development`
|
||||||
- ensure local `development` and `main` match their remotes
|
- ensure local `development` matches remote
|
||||||
- leave the repository in a clean end state
|
- leave the repository in a clean end state
|
||||||
|
|
||||||
### Spent Time
|
### Spent Time
|
||||||
|
|
||||||
- Do not estimate or infer Spent Time.
|
- Do not track spent time — this repo uses Gitea, not YouTrack.
|
||||||
- Use exact YouTrack creation and Done timestamps only.
|
|
||||||
- If exact timestamps are unavailable, stop and ask the user.
|
|
||||||
|
|
||||||
### Command output
|
### Command output
|
||||||
|
|
||||||
@@ -115,9 +108,9 @@ After explicit user validation approval:
|
|||||||
|
|
||||||
- Keep command output summaries concise.
|
- Keep command output summaries concise.
|
||||||
- Do not dump raw command output unless needed.
|
- Do not dump raw command output unless needed.
|
||||||
- Use direct links in YouTrack and implementation notes when stable links exist.
|
- Use direct links to Gitea issues and wiki pages in implementation notes when stable links exist.
|
||||||
- Use emojis intentionally for scanning, not mechanically.
|
- Use emojis intentionally for scanning, not mechanically.
|
||||||
- Commit messages must be Conventional Commit style, lowercase subject, short body, and final line `Ref: IT-<number>`.
|
- Commit messages must be Conventional Commit style, lowercase subject, short body.
|
||||||
|
|
||||||
## 6. Audio
|
## 6. Audio
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,14 @@
|
|||||||
<!-- HrynCo shared packages -->
|
<!-- HrynCo shared packages -->
|
||||||
<PackageVersion Include="HrynCo.Common" Version="1.0.0" />
|
<PackageVersion Include="HrynCo.Common" Version="1.0.0" />
|
||||||
<!-- Entity Framework Core -->
|
<!-- Entity Framework Core -->
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="9.0.5" />
|
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.7" />
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.5" />
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7" />
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.5" />
|
<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>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
@@ -10,7 +10,8 @@
|
|||||||
<Description>Abstract DAL contracts for HrynCo applications: entities, repository and unit-of-work interfaces.</Description>
|
<Description>Abstract DAL contracts for HrynCo applications: entities, repository and unit-of-work interfaces.</Description>
|
||||||
<PackageTags>hrynco dal abstract entity repository unitofwork</PackageTags>
|
<PackageTags>hrynco dal abstract entity repository unitofwork</PackageTags>
|
||||||
<RepositoryType>git</RepositoryType>
|
<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>
|
</PropertyGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,7 +40,12 @@ public abstract class BaseEfRepository<TDbContext, TEntity, TEntityId> :
|
|||||||
{
|
{
|
||||||
foreach (TEntity entity in entities)
|
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)
|
public async Task DeleteAsync(TEntityId id)
|
||||||
{
|
{
|
||||||
Delete(id);
|
TEntity? entity = await GetByIdAsync(id);
|
||||||
|
if (entity != null)
|
||||||
|
{
|
||||||
|
DoRemove(entity);
|
||||||
|
}
|
||||||
await DbContext.SaveChangesAsync();
|
await DbContext.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,11 +42,11 @@ public class EfUnitOfWork<TDbContext> : IUnitOfWork
|
|||||||
await tx.CommitAsync();
|
await tx.CommitAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch
|
catch (Exception original)
|
||||||
{
|
{
|
||||||
if (ownsTransaction)
|
if (ownsTransaction)
|
||||||
{
|
{
|
||||||
await tx.RollbackAsync();
|
await RollbackPreservingFailureAsync(tx, original);
|
||||||
}
|
}
|
||||||
|
|
||||||
throw;
|
throw;
|
||||||
@@ -55,7 +55,14 @@ public class EfUnitOfWork<TDbContext> : IUnitOfWork
|
|||||||
{
|
{
|
||||||
if (ownsTransaction)
|
if (ownsTransaction)
|
||||||
{
|
{
|
||||||
await tx.DisposeAsync();
|
try
|
||||||
|
{
|
||||||
|
await tx.DisposeAsync();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_currentTransaction = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -77,11 +84,11 @@ public class EfUnitOfWork<TDbContext> : IUnitOfWork
|
|||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
catch
|
catch (Exception original)
|
||||||
{
|
{
|
||||||
if (ownsTransaction)
|
if (ownsTransaction)
|
||||||
{
|
{
|
||||||
await tx.RollbackAsync();
|
await RollbackPreservingFailureAsync(tx, original);
|
||||||
}
|
}
|
||||||
|
|
||||||
throw;
|
throw;
|
||||||
@@ -90,11 +97,30 @@ public class EfUnitOfWork<TDbContext> : IUnitOfWork
|
|||||||
{
|
{
|
||||||
if (ownsTransaction)
|
if (ownsTransaction)
|
||||||
{
|
{
|
||||||
await tx.DisposeAsync();
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public ITransaction? GetCurrentTransaction()
|
public ITransaction? GetCurrentTransaction()
|
||||||
{
|
{
|
||||||
return _currentTransaction;
|
return _currentTransaction;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
<Description>Entity Framework Core base implementations for HrynCo applications: generic repository and unit-of-work.</Description>
|
<Description>Entity Framework Core base implementations for HrynCo applications: generic repository and unit-of-work.</Description>
|
||||||
<PackageTags>hrynco dal ef entityframework repository unitofwork</PackageTags>
|
<PackageTags>hrynco dal ef entityframework repository unitofwork</PackageTags>
|
||||||
<RepositoryType>git</RepositoryType>
|
<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>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -28,3 +28,4 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|
||||||
|
|||||||
@@ -4,12 +4,13 @@ Reusable Entity Framework Core base library for HrynCo applications.
|
|||||||
|
|
||||||
## Solution
|
## Solution
|
||||||
|
|
||||||
The solution (`hrynco-ef.slnx`) contains two projects:
|
The solution (`hrynco-ef.slnx`) contains the following projects:
|
||||||
|
|
||||||
| Project | Description |
|
| Project | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `HrynCo.DAL.Abstract` | Infrastructure-agnostic contracts: entities, repository interfaces, unit of work, transactions, pagination. No EF Core dependency. |
|
| `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` | 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.
|
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
|
## 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.Abstract` for contracts only (e.g. in domain/application layers).
|
||||||
Reference `HrynCo.DAL.EF` for the full EF Core implementation (infrastructure layer).
|
Reference `HrynCo.DAL.EF` for the full EF Core implementation (infrastructure layer).
|
||||||
|
|
||||||
@@ -202,6 +215,3 @@ services.AddScoped<IRepository<Product>, ProductRepository>();
|
|||||||
services.AddScoped<IUnitOfWork, EfUnitOfWork<YourDbContext>>();
|
services.AddScoped<IUnitOfWork, EfUnitOfWork<YourDbContext>>();
|
||||||
```
|
```
|
||||||
|
|
||||||
## Related
|
|
||||||
|
|
||||||
- [IT-642](https://yt.grynco.com.ua/issue/IT-642) — Extract `PagedResult` builder as reusable `IQueryable<T>` extension
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<Solution>
|
<Solution>
|
||||||
<Project Path="HrynCo.DAL.Abstract/HrynCo.DAL.Abstract.csproj" />
|
<Project Path="HrynCo.DAL.Abstract/HrynCo.DAL.Abstract.csproj" />
|
||||||
<Project Path="HrynCo.DAL.EF/HrynCo.DAL.EF.csproj" />
|
<Project Path="HrynCo.DAL.EF/HrynCo.DAL.EF.csproj" />
|
||||||
|
<Project Path="HrynCo.DAL.EF.Tests/HrynCo.DAL.EF.Tests.csproj" />
|
||||||
</Solution>
|
</Solution>
|
||||||
|
|||||||
Reference in New Issue
Block a user