3 Commits

14 changed files with 38 additions and 386 deletions
+19 -12
View File
@@ -6,6 +6,7 @@ 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.
- Validate required services before task work:
- YouTrack authenticated access
- Gitea authenticated access and the `hrynco/hrynco-ef` repo
- TeamCity authenticated access
- If any startup check fails, stop and ask the user.
@@ -29,6 +30,7 @@ Print one startup report line per check:
Required report items:
- Workflow rules loaded
- YouTrack auth + reachability
- Gitea auth + reachability
- Gitea resource access (`hrynco/hrynco-ef`)
- TeamCity auth + reachability
@@ -45,7 +47,7 @@ Required report items:
- local `development` must match remote
- if not, fix that first
- On task start:
- create a Gitea issue in `hrynco/hrynco-ef` if one does not exist
- set YouTrack state to `In Progress`
- create a feature branch from `development`
- name the branch after the task
- Work only in the feature branch.
@@ -54,12 +56,12 @@ Required report items:
### Issue lifecycle
1. Product analyst creates/refines the Gitea issue.
2. Developer takes the issue and starts implementation.
1. Product analyst creates/refines the YouTrack task.
2. Developer takes the task and starts implementation.
3. Developer finishes implementation and hands off to code review.
4. Code reviewer reviews and either passes or returns to developer.
5. Repeat developer/reviewer cycles as needed.
6. Maximum five review rounds total for the same unresolved issue state.
6. Maximum five review rounds total for the same unresolved task state.
7. After review round five, if still unresolved, return to product analysis.
8. Tester validates the implementation against acceptance criteria.
9. Developer waits for user validation approval.
@@ -67,7 +69,7 @@ Required report items:
### Task rules
- Create the Gitea issue before development starts.
- Create the YouTrack task before development starts.
- Default assignee is AI unless the task flow requires another assignee.
- Define scope and acceptance criteria.
- Tasks must be independently deliverable and testable.
@@ -87,17 +89,22 @@ After explicit user validation approval:
- push the feature branch
- create PR from feature branch to `development`
- merge that PR
- **do not merge to `main` unless a new NuGet package release is explicitly requested**
- when a release is requested: create PR from `development` to `main`, merge, wait for TC `HrynCo / HrynCo.EF / publish` to finish successfully
- close the Gitea issue
- create PR from `development` to `main`
- merge that PR
- wait for TC `HrynCo / HrynCo.EF / publish` to finish successfully
- set the YouTrack task to `Done`
- calculate exact Spent Time from YouTrack timestamps only
- set the YouTrack Spent Time field
- switch back to `development`
- pull latest `development`
- ensure local `development` matches remote
- ensure local `development` and `main` match their remotes
- leave the repository in a clean end state
### Spent Time
- Do not track spent time — this repo uses Gitea, not YouTrack.
- Do not estimate or infer Spent Time.
- Use exact YouTrack creation and Done timestamps only.
- If exact timestamps are unavailable, stop and ask the user.
### Command output
@@ -108,9 +115,9 @@ After explicit user validation approval:
- Keep command output summaries concise.
- Do not dump raw command output unless needed.
- Use direct links to Gitea issues and wiki pages in implementation notes when stable links exist.
- Use direct links in YouTrack and implementation notes when stable links exist.
- Use emojis intentionally for scanning, not mechanically.
- Commit messages must be Conventional Commit style, lowercase subject, short body.
- Commit messages must be Conventional Commit style, lowercase subject, short body, and final line `Ref: IT-<number>`.
## 6. Audio
+3 -9
View File
@@ -6,14 +6,8 @@
<!-- HrynCo shared packages -->
<PackageVersion Include="HrynCo.Common" Version="1.0.0" />
<!-- Entity Framework Core -->
<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" />
<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" />
</ItemGroup>
</Project>
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
@@ -6,17 +6,11 @@
<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.hrynco.com/hrynco/hrynco-ef.git</RepositoryUrl>
<RepositoryUrl>https://gitea.grynco.com.ua/hrynco/hrynco-ef.git</RepositoryUrl>
</PropertyGroup>
<ItemGroup>
<None Include="README.md" Pack="true" PackagePath="/" />
</ItemGroup>
</Project>
-5
View File
@@ -1,5 +0,0 @@
# 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
@@ -1,213 +0,0 @@
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();
}
}
@@ -1,20 +0,0 @@
<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
@@ -1,6 +0,0 @@
namespace HrynCo.DAL.EF.Tests;
using HrynCo.DAL.EF.Core;
using Microsoft.EntityFrameworkCore;
internal sealed class TestUnitOfWork(DbContext context) : EfUnitOfWork<DbContext>(context);
@@ -1,42 +0,0 @@
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);
}
}
+2 -11
View File
@@ -40,12 +40,7 @@ public abstract class BaseEfRepository<TDbContext, TEntity, TEntityId> :
{
foreach (TEntity entity in entities)
{
Add(entity, save: false);
}
if (save)
{
DbContext.SaveChanges();
Add(entity, save);
}
}
@@ -89,11 +84,7 @@ public abstract class BaseEfRepository<TDbContext, TEntity, TEntityId> :
public async Task DeleteAsync(TEntityId id)
{
TEntity? entity = await GetByIdAsync(id);
if (entity != null)
{
DoRemove(entity);
}
Delete(id);
await DbContext.SaveChangesAsync();
}
+6 -32
View File
@@ -42,11 +42,11 @@ public class EfUnitOfWork<TDbContext> : IUnitOfWork
await tx.CommitAsync();
}
}
catch (Exception original)
catch
{
if (ownsTransaction)
{
await RollbackPreservingFailureAsync(tx, original);
await tx.RollbackAsync();
}
throw;
@@ -55,14 +55,7 @@ public class EfUnitOfWork<TDbContext> : IUnitOfWork
{
if (ownsTransaction)
{
try
{
await tx.DisposeAsync();
}
finally
{
_currentTransaction = null;
}
await tx.DisposeAsync();
}
}
}
@@ -84,11 +77,11 @@ public class EfUnitOfWork<TDbContext> : IUnitOfWork
return result;
}
catch (Exception original)
catch
{
if (ownsTransaction)
{
await RollbackPreservingFailureAsync(tx, original);
await tx.RollbackAsync();
}
throw;
@@ -97,30 +90,11 @@ public class EfUnitOfWork<TDbContext> : IUnitOfWork
{
if (ownsTransaction)
{
try
{
await tx.DisposeAsync();
}
finally
{
_currentTransaction = null;
}
await tx.DisposeAsync();
}
}
}
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()
{
return _currentTransaction;
+2 -8
View File
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
@@ -6,18 +6,13 @@
<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.hrynco.com/hrynco/hrynco-ef.git</RepositoryUrl>
<RepositoryUrl>https://gitea.grynco.com.ua/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>
@@ -33,4 +28,3 @@
</ItemGroup>
</Project>
-5
View File
@@ -1,5 +0,0 @@
# 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).
+4 -14
View File
@@ -4,13 +4,12 @@ Reusable Entity Framework Core base library for HrynCo applications.
## Solution
The solution (`hrynco-ef.slnx`) contains the following projects:
The solution (`hrynco-ef.slnx`) contains two 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.
@@ -182,18 +181,6 @@ 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).
@@ -215,3 +202,6 @@ services.AddScoped<IRepository<Product>, ProductRepository>();
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
View File
@@ -1,5 +1,4 @@
<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>