20 Commits

Author SHA1 Message Date
Anatolii Grynchuk 584d036a09 Merge branch 'development' 2026-05-06 22:42:04 +03:00
Anatolii Grynchuk 1da2dd8579 fix: update RepositoryUrl in project files to correct domain 2026-05-06 22:41:21 +03:00
agrynco b434383f7e Merge pull request 'release: IT-631 fix EF repository base bugs' (#12) from development into main 2026-05-06 12:50:16 +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
agrynco 52c9c9ab9e Merge pull request 'chore: upgrade ef core 10.0.7' (#9) from development into main 2026-05-06 02:20:53 +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
agrynco d7b7c2eb88 Merge pull request 'chore: downgrade ef core 8.0.15' (#8) from development into main 2026-05-06 01:55:16 +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
Anatolii Grynchuk a58b4d9279 docs: switch task management and wiki to gitea, merge to development only for non-release changes
- remove YouTrack references from AGENTS.md
- use Gitea issues for task tracking
- merge to main only when a NuGet release is explicitly requested
- remove IT-642 link from README.md

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-06 01:29:01 +03:00
agrynco 1bab9c963d Merge pull request 'feat: rebuild base repository hierarchy, add readme and agents' (#6) from development into main 2026-05-06 01:16:30 +03:00
agrynco 23297f76f3 Merge pull request 'feat: rebuild base repository hierarchy, add readme and agents' (#5) from fix/base-db-context-utcnow into development 2026-05-06 01:16:19 +03:00
Anatolii Grynchuk 15c58522ef feat: rebuild base repository hierarchy, add readme and agents
- replace EfRepository/BaseDbContext/UtcValueConverter with BaseEfRepository and BaseRepository
- add IEfRepository interface hierarchy
- consolidate IEntity into Entity.cs, remove standalone IEntity.cs
- add PagedResult
- adjust all namespaces to HrynCo.DAL.Abstract / HrynCo.DAL.EF
- add README.md with solution overview, versioning rules, class diagram
- add AGENTS.md

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-06 01:15:59 +03:00
agrynco 6b68f099fa Merge pull request 'feat: expose UtcNow property on BaseDbContext' (#4) from development into main 2026-05-05 22:17:09 +03:00
agrynco 69e3ab6079 Merge pull request 'feat: expose UtcNow property on BaseDbContext' (#3) from fix/base-db-context-utcnow into development 2026-05-05 22:17:06 +03:00
Anatolii Grynchuk 4fac3ddba9 feat: expose UtcNow property on BaseDbContext
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-05 22:16:53 +03:00
agrynco b09777252b Merge pull request 'feat: add BaseDbContext, non-generic IEntity, fix Entity constructor' (#2) from development into main 2026-05-05 22:03:32 +03:00
18 changed files with 758 additions and 184 deletions
+160
View File
@@ -0,0 +1,160 @@
# hrynco-ef Agent Rules
This file is the authoritative source for hrynco-ef workflow, delivery, and role rules.
## 1. Startup
- Command timeout: if a command runs longer than 10 minutes, report timeout and ask the user.
- Validate required services before task work:
- Gitea authenticated access and the `hrynco/hrynco-ef` repo
- TeamCity authenticated access
- If any startup check fails, stop and ask the user.
### Operational cadence
- TeamCity polling: do not poll more often than once every 30 seconds.
- If no relevant build is visible after 60 seconds, warn once and continue waiting normally.
- Play the completion sound after multi-step work.
- Play the question sound before or with any blocking user question.
### Startup report
Print one startup report line per check:
- `✅` success, `⚠️` failure
- include a service emoji on every line
- use bright colors when supported
- after all checks, print `✅` or `⚠️` for initialization
Required report items:
- Workflow rules loaded
- Gitea auth + reachability
- Gitea resource access (`hrynco/hrynco-ef`)
- TeamCity auth + reachability
- Initialization
## 2. Workflow
- `AGENTS.md` is the source of truth; treat other docs as references only.
- Do not start a new task before the current one is complete.
- Default development branch: `development`.
- Before task work:
- be on local `development`
- local `development` must be clean
- 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
- create a feature branch from `development`
- name the branch after the task
- Work only in the feature branch.
- Keep diffs small, targeted, and consistent with existing style.
- Do not merge or publish during development.
### Issue lifecycle
1. Product analyst creates/refines the Gitea issue.
2. Developer takes the issue 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.
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.
10. Delivery and release manager handles commit, push, PRs, merge, and TeamCity publish validation.
### Task rules
- Create the Gitea issue 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.
## 3. Validation
- Build before finishing.
- Run relevant tests and checks.
- Verify no regressions.
- Do not guess about validation; use actual evidence.
## 4. Completion
After explicit user validation approval:
- commit staged changes
- 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
- switch back to `development`
- pull latest `development`
- ensure local `development` matches remote
- leave the repository in a clean end state
### Spent Time
- Do not track spent time — this repo uses Gitea, not YouTrack.
### Command output
- Keep command summaries concise.
- Do not dump raw output unless needed for diagnosis or explicitly requested.
## 5. Communication
- 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 emojis intentionally for scanning, not mechanically.
- Commit messages must be Conventional Commit style, lowercase subject, short body.
## 6. Audio
- Play `C:\Sounds\AgentSounds\warcraft_2_jobs_done.mp3` after multi-step work completes.
- Play `C:\Sounds\AgentSounds\peasantdeath.mp3` before or with any blocking user question.
- Use `System.Windows.Media.MediaPlayer` for MP3 playback on Windows.
## 7. Engineering
- This repo publishes two NuGet packages: `HrynCo.DAL.Abstract` and `HrynCo.DAL.EF`.
- Package versions are injected by TeamCity at build time via `Directory.Build.props`.
- Packages are pushed to nuget.org by the `HrynCo / HrynCo.EF / publish` TC build.
- Do not hardcode versions in `.csproj` files.
- `Directory.Packages.props` centralizes all dependency versions.
- Keep `HrynCo.DAL.Abstract` free of EF Core dependencies — it must remain infrastructure-agnostic.
- `HrynCo.DAL.EF` may depend on EF Core and `HrynCo.DAL.Abstract`.
## 8. Role rules
### Developer
- Implement end to end.
- Do not leave partial work.
- Do not refactor unrelated code.
- Validate before finishing.
### Code reviewer
- Focus on correctness, regressions, security, and missing tests.
- Report only meaningful findings.
- Do not modify code.
### Product analyst
- Make tasks clear, scoped, and executable.
- Avoid implementation assumptions unless necessary.
### Tester
- Validate happy path, failure path, and adjacent regressions.
- Report exact steps, actual result, expected result, and severity.
### Delivery and release manager
- Handle PR flow and publish validation.
- Do not trigger the publish build manually — it runs automatically on merge to `main`.
- Mark delivered only after TC publish build succeeds.
+3 -3
View File
@@ -6,8 +6,8 @@
<!-- 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" />
</ItemGroup> </ItemGroup>
</Project> </Project>
+47 -3
View File
@@ -1,9 +1,47 @@
namespace HrynCo.DAL.Abstract.Entities; namespace HrynCo.DAL.Abstract.Entities;
[Serializable] public interface IEntity
public abstract class Entity<TId> : IEntity<TId> where TId : struct
{ {
DateTimeOffset Created { get; set; }
object Id { get; set; }
DateTimeOffset? Updated { get; set; }
}
public interface IEntity<TId> : IEntity where TId : struct
{
new TId Id { get; set; }
}
[Serializable]
public class Entity<TId> : IEntity<TId> where TId : struct
{
protected Entity()
{
}
public Entity(TId id)
{
Id = id;
}
public TId Id { get; set; } public TId Id { get; set; }
object IEntity.Id
{
get => Id;
set
{
if (value is TId typedValue)
{
Id = typedValue;
}
else
{
throw new InvalidCastException($"Cannot cast value of type {value.GetType()} to {typeof(TId)}.");
}
}
}
public DateTimeOffset Created { get; set; } public DateTimeOffset Created { get; set; }
public DateTimeOffset? Updated { get; set; } public DateTimeOffset? Updated { get; set; }
} }
@@ -17,7 +55,13 @@ public abstract class Entity : Entity<Guid>
} }
protected Entity(Guid id) protected Entity(Guid id)
: base(id)
{ {
Id = id;
} }
} }
[Serializable]
public abstract class NamedEntity : Entity
{
public required string Name { get; set; }
}
-12
View File
@@ -1,12 +0,0 @@
namespace HrynCo.DAL.Abstract.Entities;
public interface IEntity
{
DateTimeOffset Created { get; set; }
DateTimeOffset? Updated { get; set; }
}
public interface IEntity<TId> : IEntity where TId : struct
{
TId Id { get; set; }
}
@@ -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>
+3 -1
View File
@@ -1,8 +1,10 @@
namespace HrynCo.DAL.Abstract; namespace HrynCo.DAL.Abstract;
using System.Threading;
using System.Threading.Tasks;
public interface IUnitOfWork public interface IUnitOfWork
{ {
Task SaveChangesAsync(CancellationToken cancellationToken = default);
Task<ITransaction> BeginTransactionAsync(CancellationToken cancellationToken = default); Task<ITransaction> BeginTransactionAsync(CancellationToken cancellationToken = default);
ITransaction? GetCurrentTransaction(); ITransaction? GetCurrentTransaction();
+9
View File
@@ -0,0 +1,9 @@
namespace HrynCo.DAL.Abstract;
public sealed class PagedResult<T>
{
public required IReadOnlyList<T> Items { get; init; }
public required int Page { get; init; }
public required int PageSize { get; init; }
public required int TotalCount { get; init; }
}
@@ -1,6 +0,0 @@
namespace HrynCo.DAL.EF.Converters;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
internal class UtcValueConverter()
: ValueConverter<DateTime, DateTime>(v => v, v => DateTime.SpecifyKind(v, DateTimeKind.Utc));
-74
View File
@@ -1,74 +0,0 @@
namespace HrynCo.DAL.EF.Core;
using HrynCo.Common;
using HrynCo.DAL.Abstract.Entities;
using HrynCo.DAL.EF.Converters;
using HrynCo.DAL.EF.Exceptions;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using Microsoft.EntityFrameworkCore.Metadata;
public abstract class BaseDbContext : DbContext
{
private readonly IClock _clock;
protected BaseDbContext(DbContextOptions options, IClock clock)
: base(options)
{
_clock = clock;
}
public override int SaveChanges()
{
ApplyTimestamps();
return base.SaveChanges();
}
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
ApplyTimestamps();
return await base.SaveChangesAsync(cancellationToken);
}
private void ApplyTimestamps()
{
DateTimeOffset now = _clock.UtcNow;
foreach (EntityEntry<IEntity> entry in ChangeTracker.Entries<IEntity>())
{
switch (entry.State)
{
case EntityState.Added:
entry.Entity.Created = now;
break;
case EntityState.Modified:
entry.Entity.Updated = now;
break;
case EntityState.Detached:
case EntityState.Unchanged:
case EntityState.Deleted:
break;
default:
throw new UnexpectedEntityStateException(entry.State);
}
}
}
protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
configurationBuilder.Properties<DateTime>().HaveConversion<UtcValueConverter>();
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
foreach (IMutableForeignKey relationship in modelBuilder.Model.GetEntityTypes()
.SelectMany(e => e.GetForeignKeys()))
{
relationship.DeleteBehavior = DeleteBehavior.Restrict;
}
modelBuilder.ApplyConfigurationsFromAssembly(GetType().Assembly);
base.OnModelCreating(modelBuilder);
}
}
+227
View File
@@ -0,0 +1,227 @@
namespace HrynCo.DAL.EF.Core;
using System.Diagnostics.CodeAnalysis;
using System.Linq.Expressions;
using HrynCo.DAL.Abstract.Entities;
using Microsoft.EntityFrameworkCore;
[SuppressMessage("Major Code Smell", "S2436:Reduce the number of generic parameters",
Justification = "Generic design is intentional and improves reusability")]
public abstract class BaseEfRepository<TDbContext, TEntity, TEntityId> :
IEfRepository<TEntity, TEntityId>
where TEntity : class, IEntity<TEntityId>
where TDbContext : DbContext
where TEntityId : struct
{
protected BaseEfRepository(TDbContext dbContext)
{
DbContext = dbContext;
DbSet = DbContext.Set<TEntity>();
}
public TDbContext DbContext { get; }
private DbSet<TEntity> DbSet { get; }
public TEntity Add(TEntity entity, bool save = true)
{
var entityEntry = DbSet.Add(entity);
TEntity addedEntity = entityEntry.Entity;
if (save)
{
DbContext.SaveChanges();
}
return addedEntity;
}
public void Add(TEntity[] entities, bool save = true)
{
foreach (TEntity entity in entities)
{
Add(entity, save: false);
}
if (save)
{
DbContext.SaveChanges();
}
}
public async Task<TEntity> AddAsync(TEntity entity, bool save = true)
{
var entityEntry = await DbSet.AddAsync(entity);
TEntity addedEntity = entityEntry.Entity;
if (save)
{
await DbContext.SaveChangesAsync();
}
return addedEntity;
}
public void Delete(IEnumerable<TEntityId> id)
{
foreach (TEntityId entityId in id)
{
Delete([GetById(entityId)!]);
}
}
public void Delete(TEntityId id)
{
Delete([id]);
}
public virtual void Delete(TEntity[] entities)
{
DoRemove(entities);
DbContext.SaveChanges();
}
public void Delete(TEntity entity)
{
DoRemove(entity);
DbContext.SaveChanges();
}
public async Task DeleteAsync(TEntityId id)
{
TEntity? entity = await GetByIdAsync(id);
if (entity != null)
{
DoRemove(entity);
}
await DbContext.SaveChangesAsync();
}
public async Task DeleteAsync(IEnumerable<TEntityId> id)
{
foreach (TEntityId entityId in id)
{
await DeleteAsync(entityId);
}
}
public async Task DeleteAsync(TEntity entity)
{
DoRemove(entity);
await DbContext.SaveChangesAsync();
}
public virtual async Task<TEntity?> GetByIdAsync(TEntityId id)
{
TEntity? entity = await DbSet.FindAsync(id);
return entity;
}
public async Task UpdateAsync(TEntity entity, bool save = true)
{
DbSet.Attach(entity);
DbContext.Entry(entity).State = EntityState.Modified;
if (save)
{
await DbContext.SaveChangesAsync();
}
}
public virtual void Update(TEntity entity, bool save = true)
{
DbSet.Attach(entity);
DbContext.Entry(entity).State = EntityState.Modified;
if (save)
{
DbContext.SaveChanges();
}
}
public async Task SaveChangesAsync()
{
await DbContext.SaveChangesAsync();
}
public IQueryable<TEntity> Get(
Expression<Func<TEntity, bool>>? filter = null,
Func<IQueryable<TEntity>, IOrderedQueryable<TEntity>>? orderBy = null,
string includeProperties = "")
{
IQueryable<TEntity> query = DbContext.Set<TEntity>();
if (filter != null)
{
query = query.Where(filter);
}
if (!string.IsNullOrWhiteSpace(includeProperties))
{
foreach (string includeProperty in includeProperties.Split(new[]
{
','
},
StringSplitOptions.RemoveEmptyEntries))
{
query = query.Include(includeProperty);
}
}
if (orderBy != null)
{
return orderBy(query).AsQueryable();
}
return query.AsQueryable();
}
public virtual IQueryable<TEntity> GetAll()
{
return DbContext.Set<TEntity>().AsQueryable();
}
public void RemoveRange(IEnumerable<TEntity> entities)
{
DbContext.Set<TEntity>().RemoveRange(entities);
}
public void Remove(TEntity entity)
{
DbContext.Set<TEntity>().Remove(entity);
}
public async Task<List<TEntity>> GetAllAsync()
{
return await DbContext.Set<TEntity>().ToListAsync();
}
public async Task<bool> Exists(TEntityId id)
{
return await DbContext.Set<TEntity>().AnyAsync(e => e.Id.Equals(id));
}
public virtual TEntity? GetById(TEntityId id)
{
return DbContext.Set<TEntity>().Find(id);
}
public void ClearChangeTracker()
{
DbContext.ChangeTracker.Clear();
}
protected virtual void DoRemove(TEntity[] entities)
{
foreach (TEntity entity in entities)
{
DoRemove(entity);
}
}
protected virtual void DoRemove(TEntity entity)
{
DbSet.Remove(entity);
}
}
+25
View File
@@ -0,0 +1,25 @@
namespace HrynCo.DAL.EF.Core;
using System.Diagnostics.CodeAnalysis;
using HrynCo.DAL.Abstract.Entities;
using Microsoft.EntityFrameworkCore;
[SuppressMessage("Major Code Smell", "S2436:Reduce the number of generic parameters",
Justification = "Generic design is intentional and improves reusability")]
public abstract class BaseRepository<TEfRepository, TDbContext, TEntity, TEntityId>
where TEntity : class, IEntity<TEntityId>
where TDbContext : DbContext
where TEfRepository : BaseEfRepository<TDbContext, TEntity, TEntityId>
where TEntityId : struct
{
private readonly Lazy<TEfRepository> _lazyEfRepository;
protected BaseRepository()
{
_lazyEfRepository = new Lazy<TEfRepository>(CreateEfRepository);
}
protected TEfRepository EfRepository => _lazyEfRepository.Value;
protected abstract TEfRepository CreateEfRepository();
}
-46
View File
@@ -1,46 +0,0 @@
using System.Linq.Expressions;
using Microsoft.EntityFrameworkCore;
namespace HrynCo.DAL.EF.Core;
public abstract class EfRepository<TDbContext, TEntity>
where TDbContext : DbContext
where TEntity : class
{
protected TDbContext DbContext { get; }
protected DbSet<TEntity> DbSet { get; }
protected EfRepository(TDbContext dbContext)
{
DbContext = dbContext;
DbSet = dbContext.Set<TEntity>();
}
protected async Task AddAsync(TEntity entity, CancellationToken ct = default)
{
await DbSet.AddAsync(entity, ct);
}
protected async Task AddRangeAsync(IEnumerable<TEntity> entities, CancellationToken ct = default)
{
await DbSet.AddRangeAsync(entities, ct);
}
protected void Update(TEntity entity)
{
DbSet.Update(entity);
}
protected void Delete(TEntity entity)
{
DbSet.Remove(entity);
}
protected void DeleteRange(IEnumerable<TEntity> entities)
{
DbSet.RemoveRange(entities);
}
protected Task<bool> ExistsAsync(Expression<Func<TEntity, bool>> predicate, CancellationToken ct = default) =>
DbSet.AnyAsync(predicate, ct);
}
+9 -9
View File
@@ -1,29 +1,29 @@
namespace HrynCo.DAL.EF.Core;
using HrynCo.DAL.Abstract; using HrynCo.DAL.Abstract;
using Microsoft.EntityFrameworkCore.Storage; using Microsoft.EntityFrameworkCore.Storage;
namespace HrynCo.DAL.EF.Core; public class EfTransactionAdapter : ITransaction
internal sealed class EfTransactionAdapter : ITransaction
{ {
private readonly IDbContextTransaction _transaction; private readonly IDbContextTransaction _efTransaction;
internal EfTransactionAdapter(IDbContextTransaction transaction) public EfTransactionAdapter(IDbContextTransaction efTransaction)
{ {
_transaction = transaction; _efTransaction = efTransaction;
} }
public Task CommitAsync(CancellationToken cancellationToken = default) public Task CommitAsync(CancellationToken cancellationToken = default)
{ {
return _transaction.CommitAsync(cancellationToken); return _efTransaction.CommitAsync(cancellationToken);
} }
public Task RollbackAsync(CancellationToken cancellationToken = default) public Task RollbackAsync(CancellationToken cancellationToken = default)
{ {
return _transaction.RollbackAsync(cancellationToken); return _efTransaction.RollbackAsync(cancellationToken);
} }
public ValueTask DisposeAsync() public ValueTask DisposeAsync()
{ {
return _transaction.DisposeAsync(); return _efTransaction.DisposeAsync();
} }
} }
+12 -15
View File
@@ -1,10 +1,10 @@
namespace HrynCo.DAL.EF.Core;
using HrynCo.DAL.Abstract; using HrynCo.DAL.Abstract;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage; using Microsoft.EntityFrameworkCore.Storage;
namespace HrynCo.DAL.EF.Core; public class EfUnitOfWork<TDbContext> : IUnitOfWork
public abstract class EfUnitOfWork<TDbContext> : IUnitOfWork
where TDbContext : DbContext where TDbContext : DbContext
{ {
private readonly TDbContext _context; private readonly TDbContext _context;
@@ -15,11 +15,6 @@ public abstract class EfUnitOfWork<TDbContext> : IUnitOfWork
_context = context; _context = context;
} }
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
{
return _context.SaveChangesAsync(cancellationToken);
}
public async Task<ITransaction> BeginTransactionAsync(CancellationToken cancellationToken = default) public async Task<ITransaction> BeginTransactionAsync(CancellationToken cancellationToken = default)
{ {
if (_currentTransaction != null) if (_currentTransaction != null)
@@ -27,13 +22,8 @@ public abstract class EfUnitOfWork<TDbContext> : IUnitOfWork
return _currentTransaction; return _currentTransaction;
} }
IDbContextTransaction tx = await _context.Database.BeginTransactionAsync(cancellationToken); IDbContextTransaction transaction = await _context.Database.BeginTransactionAsync(cancellationToken);
_currentTransaction = new EfTransactionAdapter(tx); _currentTransaction = new EfTransactionAdapter(transaction);
return _currentTransaction;
}
public ITransaction? GetCurrentTransaction()
{
return _currentTransaction; return _currentTransaction;
} }
@@ -41,6 +31,7 @@ public abstract class EfUnitOfWork<TDbContext> : IUnitOfWork
{ {
ITransaction? existing = GetCurrentTransaction(); ITransaction? existing = GetCurrentTransaction();
bool ownsTransaction = existing is null; bool ownsTransaction = existing is null;
ITransaction tx = existing ?? await BeginTransactionAsync(); ITransaction tx = existing ?? await BeginTransactionAsync();
try try
@@ -73,6 +64,7 @@ public abstract class EfUnitOfWork<TDbContext> : IUnitOfWork
{ {
ITransaction? existing = GetCurrentTransaction(); ITransaction? existing = GetCurrentTransaction();
bool ownsTransaction = existing is null; bool ownsTransaction = existing is null;
ITransaction tx = existing ?? await BeginTransactionAsync(); ITransaction tx = existing ?? await BeginTransactionAsync();
try try
@@ -102,4 +94,9 @@ public abstract class EfUnitOfWork<TDbContext> : IUnitOfWork
} }
} }
} }
public ITransaction? GetCurrentTransaction()
{
return _currentTransaction;
}
} }
+53
View File
@@ -0,0 +1,53 @@
namespace HrynCo.DAL.EF.Core;
using System.Linq.Expressions;
using HrynCo.DAL.Abstract.Entities;
public interface IEfRepository
{
void ClearChangeTracker();
}
public interface IEfRepository<TEntity, in TEntityId> : IEfRepository
where TEntity : IEntity<TEntityId> where TEntityId : struct
{
TEntity Add(TEntity entity, bool save = true);
void Add(TEntity[] entities, bool save = true);
Task<TEntity> AddAsync(TEntity entity, bool save = true);
void Delete(TEntity[] entities);
void Delete(TEntity entity);
void Delete(IEnumerable<TEntityId> id);
void Delete(TEntityId id);
Task DeleteAsync(TEntityId id);
Task DeleteAsync(IEnumerable<TEntityId> id);
Task DeleteAsync(TEntity entity);
IQueryable<TEntity> Get(
Expression<Func<TEntity, bool>>? filter = null,
Func<IQueryable<TEntity>, IOrderedQueryable<TEntity>>? orderBy = null,
string includeProperties = "");
IQueryable<TEntity> GetAll();
void RemoveRange(IEnumerable<TEntity> entities);
void Remove(TEntity entity);
Task<List<TEntity>> GetAllAsync();
Task<bool> Exists(TEntityId id);
TEntity? GetById(TEntityId id);
Task<TEntity?> GetByIdAsync(TEntityId id);
Task UpdateAsync(TEntity entity, bool save = true);
void Update(TEntity entity, bool save = true);
Task SaveChangesAsync();
}
public interface IEfRepository<TEntity> : IEfRepository<TEntity, int>
where TEntity : IEntity<int>
{
}
@@ -1,11 +0,0 @@
namespace HrynCo.DAL.EF.Exceptions;
using Microsoft.EntityFrameworkCore;
public sealed class UnexpectedEntityStateException : Exception
{
public UnexpectedEntityStateException(EntityState state)
: base($"Unexpected entity state: {state}")
{
}
}
+3 -2
View File
@@ -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>
+204
View File
@@ -0,0 +1,204 @@
# hrynco-ef
Reusable Entity Framework Core base library for HrynCo applications.
## Solution
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. |
The split allows consuming projects to reference only `HrynCo.DAL.Abstract` in domain/application layers, keeping those layers free of EF Core.
## Versioning
Versions are managed entirely on the TeamCity side — **do not set `<Version>` in `.csproj` files**.
At publish time, the TC `HrynCo / HrynCo.EF / publish` build:
1. Writes the current build number into `Directory.Build.props` as `<Version>%build.number%</Version>`.
2. Builds and packs both projects.
3. Pushes the resulting `.nupkg` files to nuget.org.
The build number follows the pattern `1.0.<counter>` (e.g. `1.0.6`, `1.0.7`, …). The counter increments automatically on each successful publish run. To release a new version, merge to `main` — the publish build triggers automatically.
To bump the major or minor version, update the build number pattern in TC: **HrynCo → HrynCo.EF → publish → Edit Configuration → General → Build number format**.
## Class diagram
```mermaid
classDiagram
namespace HrynCo_DAL_Abstract {
class IEntity {
<<interface>>
+object Id
+DateTimeOffset Created
+DateTimeOffset? Updated
}
class IEntityTId {
<<interface>>
+TId Id
}
class EntityTId {
<<abstract>>
+TId Id
+DateTimeOffset Created
+DateTimeOffset? Updated
}
class Entity {
<<abstract>>
+Guid Id
}
class NamedEntity {
<<abstract>>
+string Name
}
class IUnitOfWork {
<<interface>>
+BeginTransactionAsync() Task~ITransaction~
+GetCurrentTransaction() ITransaction?
+ExecuteInTransactionAsync(action) Task
+ExecuteInTransactionAsync~TResult~(action) Task~TResult~
}
class ITransaction {
<<interface>>
+CommitAsync() Task
+RollbackAsync() Task
+DisposeAsync() ValueTask
}
class PagedResultT {
<<sealed>>
+IReadOnlyList~T~ Items
+int Page
+int PageSize
+int TotalCount
}
}
namespace HrynCo_DAL_EF {
class IEfRepository {
<<interface>>
+ClearChangeTracker()
}
class IEfRepositoryTEntityTId {
<<interface>>
+Add(entity) TEntity
+AddAsync(entity) Task~TEntity~
+Delete(id)
+DeleteAsync(id) Task
+Get(filter, orderBy, includes) IQueryable~TEntity~
+GetAll() IQueryable~TEntity~
+GetAllAsync() Task~List~TEntity~~
+GetById(id) TEntity?
+GetByIdAsync(id) Task~TEntity?~
+Exists(id) Task~bool~
+UpdateAsync(entity) Task
+SaveChangesAsync() Task
}
class IEfRepositoryTEntity {
<<interface>>
}
class BaseEfRepositoryTDbContextTEntityTEntityId {
<<abstract>>
+TDbContext DbContext
+Add() TEntity
+AddAsync() Task~TEntity~
+Delete()
+DeleteAsync() Task
+Get() IQueryable~TEntity~
+GetAll() IQueryable~TEntity~
+GetAllAsync() Task~List~TEntity~~
+GetById() TEntity?
+GetByIdAsync() Task~TEntity?~
+Exists() Task~bool~
+Update()
+UpdateAsync() Task
+SaveChangesAsync() Task
#DoRemove()
}
class BaseRepositoryTEfRepositoryTDbContextTEntityTEntityId {
<<abstract>>
#EfRepository TEfRepository
#CreateEfRepository()* TEfRepository
}
class EfUnitOfWorkTDbContext {
+BeginTransactionAsync() Task~ITransaction~
+GetCurrentTransaction() ITransaction?
+ExecuteInTransactionAsync() Task
}
class EfTransactionAdapter {
+CommitAsync() Task
+RollbackAsync() Task
+DisposeAsync() ValueTask
}
}
IEntityTId --|> IEntity : extends
EntityTId ..|> IEntityTId : implements
Entity --|> EntityTId : extends (TId=Guid)
NamedEntity --|> Entity : extends
IEfRepositoryTEntityTId --|> IEfRepository : extends
IEfRepositoryTEntity --|> IEfRepositoryTEntityTId : extends (TId=int)
BaseEfRepositoryTDbContextTEntityTEntityId ..|> IEfRepositoryTEntityTId : implements
BaseRepositoryTEfRepositoryTDbContextTEntityTEntityId --> BaseEfRepositoryTDbContextTEntityTEntityId : uses (lazy)
EfUnitOfWorkTDbContext ..|> IUnitOfWork : implements
EfTransactionAdapter ..|> ITransaction : implements
EfUnitOfWorkTDbContext --> EfTransactionAdapter : creates
```
## Packages
### `HrynCo.DAL.Abstract`
Abstract DAL contracts — entities, repository interfaces, unit of work, transactions, and pagination.
| Type | Description |
|---|---|
| `IEntity` / `IEntity<TId>` | Base entity contracts |
| `Entity<TId>` / `Entity` | Base entity implementations with auto-generated `Id` |
| `NamedEntity` | Entity with a `Name` property |
| `IRepository<T>` | Generic async repository interface |
| `IUnitOfWork` | Unit of work interface with transaction support |
| `ITransaction` | Async transaction contract |
| `PagedResult<T>` | Pagination result wrapper |
### `HrynCo.DAL.EF`
Entity Framework Core implementations of the abstract contracts.
| Type | Description |
|---|---|
| `BaseRepository<T>` | Base repository with common CRUD operations |
| `BaseEfRepository<T>` | EF Core repository with `DbContext` access |
| `IEfRepository<T>` | EF-specific repository interface |
| `EfUnitOfWork` | EF Core unit of work implementation |
| `EfTransactionAdapter` | Adapts EF transactions to `ITransaction` |
## Usage
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).
```csharp
// 1. Define your entity
public class Product : Entity
{
public string Name { get; set; } = string.Empty;
}
// 2. Implement your repository
public class ProductRepository : BaseEfRepository<Product>
{
public ProductRepository(YourDbContext context) : base(context) { }
}
// 3. Register in DI
services.AddScoped<IRepository<Product>, ProductRepository>();
services.AddScoped<IUnitOfWork, EfUnitOfWork<YourDbContext>>();
```